├── public ├── favicon.ico ├── icons │ ├── favicon.ico │ ├── apple-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon-96x96.png │ ├── ms-icon-144x144.png │ ├── ms-icon-150x150.png │ ├── ms-icon-310x310.png │ ├── ms-icon-70x70.png │ ├── apple-icon-57x57.png │ ├── apple-icon-60x60.png │ ├── apple-icon-72x72.png │ ├── apple-icon-76x76.png │ ├── android-icon-144x144.png │ ├── android-icon-192x192.png │ ├── android-icon-36x36.png │ ├── android-icon-48x48.png │ ├── android-icon-72x72.png │ ├── android-icon-96x96.png │ ├── apple-icon-114x114.png │ ├── apple-icon-120x120.png │ ├── apple-icon-144x144.png │ ├── apple-icon-152x152.png │ ├── apple-icon-180x180.png │ └── apple-icon-precomposed.png └── manifest.webmanifest ├── .yarnrc.yml ├── .gitignore ├── scripts ├── utils │ ├── response.js │ ├── config.js │ ├── request.js │ └── handle-request.js ├── plugins │ ├── side-effects.js │ ├── routes-module.js │ └── entry-module.js ├── service-worker.internal.js └── build.js ├── app ├── database.js ├── routes │ ├── _index.jsx │ ├── selection.jsx │ ├── _app.jsx │ └── _app.flights.jsx ├── entry.client.jsx ├── root.jsx ├── entry.worker.js └── entry.server.jsx ├── remix.config.js ├── tsconfig.json ├── LICENSE ├── package.json └── README.md /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scandinavianairlines/remix-workers-poc/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scandinavianairlines/remix-workers-poc/HEAD/public/icons/favicon.ico -------------------------------------------------------------------------------- /public/icons/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scandinavianairlines/remix-workers-poc/HEAD/public/icons/apple-icon.png -------------------------------------------------------------------------------- /public/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scandinavianairlines/remix-workers-poc/HEAD/public/icons/favicon-16x16.png -------------------------------------------------------------------------------- /public/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scandinavianairlines/remix-workers-poc/HEAD/public/icons/favicon-32x32.png -------------------------------------------------------------------------------- /public/icons/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scandinavianairlines/remix-workers-poc/HEAD/public/icons/favicon-96x96.png -------------------------------------------------------------------------------- /public/icons/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scandinavianairlines/remix-workers-poc/HEAD/public/icons/ms-icon-144x144.png -------------------------------------------------------------------------------- /public/icons/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scandinavianairlines/remix-workers-poc/HEAD/public/icons/ms-icon-150x150.png -------------------------------------------------------------------------------- /public/icons/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scandinavianairlines/remix-workers-poc/HEAD/public/icons/ms-icon-310x310.png -------------------------------------------------------------------------------- /public/icons/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scandinavianairlines/remix-workers-poc/HEAD/public/icons/ms-icon-70x70.png -------------------------------------------------------------------------------- /public/icons/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scandinavianairlines/remix-workers-poc/HEAD/public/icons/apple-icon-57x57.png -------------------------------------------------------------------------------- /public/icons/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scandinavianairlines/remix-workers-poc/HEAD/public/icons/apple-icon-60x60.png -------------------------------------------------------------------------------- /public/icons/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scandinavianairlines/remix-workers-poc/HEAD/public/icons/apple-icon-72x72.png -------------------------------------------------------------------------------- /public/icons/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scandinavianairlines/remix-workers-poc/HEAD/public/icons/apple-icon-76x76.png -------------------------------------------------------------------------------- /public/icons/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scandinavianairlines/remix-workers-poc/HEAD/public/icons/android-icon-144x144.png -------------------------------------------------------------------------------- /public/icons/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scandinavianairlines/remix-workers-poc/HEAD/public/icons/android-icon-192x192.png -------------------------------------------------------------------------------- /public/icons/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scandinavianairlines/remix-workers-poc/HEAD/public/icons/android-icon-36x36.png -------------------------------------------------------------------------------- /public/icons/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scandinavianairlines/remix-workers-poc/HEAD/public/icons/android-icon-48x48.png -------------------------------------------------------------------------------- /public/icons/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scandinavianairlines/remix-workers-poc/HEAD/public/icons/android-icon-72x72.png -------------------------------------------------------------------------------- /public/icons/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scandinavianairlines/remix-workers-poc/HEAD/public/icons/android-icon-96x96.png -------------------------------------------------------------------------------- /public/icons/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scandinavianairlines/remix-workers-poc/HEAD/public/icons/apple-icon-114x114.png -------------------------------------------------------------------------------- /public/icons/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scandinavianairlines/remix-workers-poc/HEAD/public/icons/apple-icon-120x120.png -------------------------------------------------------------------------------- /public/icons/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scandinavianairlines/remix-workers-poc/HEAD/public/icons/apple-icon-144x144.png -------------------------------------------------------------------------------- /public/icons/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scandinavianairlines/remix-workers-poc/HEAD/public/icons/apple-icon-152x152.png -------------------------------------------------------------------------------- /public/icons/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scandinavianairlines/remix-workers-poc/HEAD/public/icons/apple-icon-180x180.png -------------------------------------------------------------------------------- /public/icons/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scandinavianairlines/remix-workers-poc/HEAD/public/icons/apple-icon-precomposed.png -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | plugins: 4 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs 5 | spec: "@yarnpkg/plugin-interactive-tools" 6 | 7 | yarnPath: .yarn/releases/yarn-3.6.1.cjs 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | 4 | /.cache 5 | build/ 6 | public/build 7 | .env 8 | # Yarn Integrity file & Yarn 2 9 | .yarn/* 10 | !.yarn/patches 11 | !.yarn/releases 12 | !.yarn/plugins 13 | !.yarn/sdks 14 | !.yarn/versions 15 | .pnp.* 16 | -------------------------------------------------------------------------------- /scripts/utils/response.js: -------------------------------------------------------------------------------- 1 | import { json } from "@remix-run/server-runtime/dist/responses.js"; 2 | 3 | export function errorResponseToJson(errorResponse) { 4 | return json(errorResponse.error || new Error("Unexpected Server Error"), { 5 | status: errorResponse.status, 6 | statusText: errorResponse.statusText, 7 | headers: { 8 | "X-Remix-Error": "yes", 9 | }, 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /app/database.js: -------------------------------------------------------------------------------- 1 | import Dexie from "dexie"; 2 | 3 | const DATABASE_NAME = "client-state"; 4 | const STORES = { 5 | flights: "flightId", 6 | selections: "++id", 7 | }; 8 | 9 | function createStorageRepository() { 10 | const db = new Dexie(DATABASE_NAME, { autoOpen: true }); 11 | db.version(1).stores(STORES); 12 | 13 | return { 14 | flights: db.flights, 15 | selections: db.selections, 16 | }; 17 | } 18 | 19 | export default createStorageRepository; 20 | -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@remix-run/dev').AppConfig} */ 2 | module.exports = { 3 | ignoredRouteFiles: ["**/.*"], 4 | // appDirectory: "app", 5 | // assetsBuildDirectory: "public/build", 6 | // serverBuildPath: "build/index.js", 7 | // publicPath: "/build/", 8 | serverDependenciesToBundle: [/^@remix-pwa\/sw/], 9 | serverModuleFormat: "cjs", 10 | future: { 11 | v2_dev: true, 12 | v2_errorBoundary: true, 13 | v2_headers: true, 14 | v2_meta: true, 15 | v2_normalizeFormMethod: true, 16 | v2_routeConvention: true, 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /app/routes/_index.jsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@remix-run/react"; 2 | 3 | export const meta = () => { 4 | return [ 5 | { title: "Worker actions & loaders" }, 6 | { name: "description", content: "Progressive web apps proof of concept" }, 7 | ]; 8 | }; 9 | 10 | export default function Index() { 11 | return ( 12 |
13 |

Remix PWA - Worker actions & loaders

14 | 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /app/routes/selection.jsx: -------------------------------------------------------------------------------- 1 | import { json } from "@remix-run/router"; 2 | import { useLoaderData } from "@remix-run/react"; 3 | 4 | export async function loader() { 5 | return json({ selections: [] }); 6 | } 7 | 8 | export async function workerLoader({ context }) { 9 | const { database } = context; 10 | const selections = await database.selections.toArray(); 11 | return json({ selections }); 12 | } 13 | 14 | export default function SelectionPage() { 15 | const { selections } = useLoaderData(); 16 | return ( 17 |
18 |

Here is your selection

19 | {JSON.stringify(selections, 2, null)} 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "remix.env.d.ts", 4 | "**/*.js", 5 | "**/*.jsx" 6 | ], 7 | "compilerOptions": { 8 | "lib": [ 9 | "DOM", 10 | "DOM.Iterable", 11 | "ES2019" 12 | ], 13 | "isolatedModules": true, 14 | "esModuleInterop": true, 15 | "jsx": "react-jsx", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "target": "ES2019", 19 | "strict": true, 20 | "allowJs": true, 21 | "forceConsistentCasingInFileNames": true, 22 | "baseUrl": ".", 23 | "paths": { 24 | "~/*": [ 25 | "./app/*" 26 | ] 27 | }, 28 | // Remix takes care of building everything in `remix build`. 29 | "noEmit": true 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/entry.client.jsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle hydrating your app on the client for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.client 5 | */ 6 | 7 | import { RemixBrowser } from "@remix-run/react"; 8 | import { startTransition, StrictMode } from "react"; 9 | import { hydrateRoot } from "react-dom/client"; 10 | import { loadServiceWorker } from "@remix-pwa/sw"; 11 | 12 | startTransition(() => { 13 | hydrateRoot( 14 | document, 15 | 16 | 17 | 18 | ); 19 | }); 20 | 21 | loadServiceWorker({ 22 | scope: "/", 23 | serviceWorkerUrl: "/service-worker.js", 24 | }); 25 | -------------------------------------------------------------------------------- /app/routes/_app.jsx: -------------------------------------------------------------------------------- 1 | import { json } from "@remix-run/node"; 2 | import { Outlet, useLoaderData } from "@remix-run/react"; 3 | import { CacheFirst } from "@remix-pwa/sw/lib/strategy/cacheFirst"; 4 | 5 | const strategy = new CacheFirst({ 6 | cacheName: "app-loader", 7 | }); 8 | 9 | export function loader() { 10 | return json({ 11 | user: { 12 | email: "email@provider.co", 13 | name: "Scandinavian", 14 | }, 15 | }); 16 | } 17 | 18 | export function workerLoader({ context }) { 19 | // The strategy needs the original untouch request. 20 | return strategy.handle(context.event.request); 21 | } 22 | 23 | export default function AppLayout() { 24 | const { user } = useLoaderData(); 25 | return ( 26 |
27 |
28 |

{user.email}

29 |

{user.name}

30 |
31 | 32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /app/root.jsx: -------------------------------------------------------------------------------- 1 | import { useSWEffect } from "@remix-pwa/sw"; 2 | import { cssBundleHref } from "@remix-run/css-bundle"; 3 | 4 | import { 5 | Links, 6 | LiveReload, 7 | Meta, 8 | Outlet, 9 | Scripts, 10 | ScrollRestoration, 11 | } from "@remix-run/react"; 12 | 13 | export const links = () => [ 14 | ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), 15 | ]; 16 | 17 | export default function App() { 18 | useSWEffect(); 19 | return ( 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /scripts/plugins/side-effects.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const FILTER_REGEX = /\?user$/; 3 | 4 | /** 5 | * @param {import('../utils/config').ResolvedWorkerConfig} config 6 | * @returns {import('esbuild').Plugin} Esbuild plugin 7 | */ 8 | function sideEffectsPlugin() { 9 | /** 10 | * @param {import('esbuild').PluginBuild} build 11 | */ 12 | async function setup(build) { 13 | /** @type {(args: import('esbuild').OnResolveArgs) => import('esbuild').OnResolveResult} */ 14 | const onResolve = ({ path }) => ({ 15 | path: path.replace(FILTER_REGEX, ""), 16 | sideEffects: true, 17 | }); 18 | /** @type {(args: import('esbuild').OnResolveArgs) => Promise} */ 19 | const onLoad = async () => ({ loader: "js" }); 20 | 21 | build.onResolve({ filter: FILTER_REGEX }, onResolve); 22 | build.onLoad({ filter: FILTER_REGEX }, onLoad); 23 | } 24 | 25 | return { 26 | name: "sw-side-effects", 27 | setup, 28 | }; 29 | } 30 | 31 | module.exports = sideEffectsPlugin; 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Scandinavian Airline Systems 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "sideEffects": false, 4 | "scripts": { 5 | "build": "run-s build:*", 6 | "dev": "run-p 'dev:*'", 7 | "start": "remix-serve build", 8 | "typecheck": "tsc", 9 | "build:remix": "cross-env NODE_ENV=production remix build", 10 | "build:worker": "NODE_ENV=production node scripts/build.js", 11 | "dev:worker": "NODE_ENV=development node scripts/build.js --watch", 12 | "dev:remix": "cross-env NODE_ENV=development remix dev" 13 | }, 14 | "dependencies": { 15 | "@remix-pwa/sw": "^1.1.2", 16 | "@remix-run/css-bundle": "^1.19.1", 17 | "@remix-run/node": "^1.19.1", 18 | "@remix-run/react": "^1.19.1", 19 | "@remix-run/router": "^1.7.2", 20 | "@remix-run/serve": "^1.19.1", 21 | "cross-env": "^7.0.3", 22 | "dexie": "^3.2.4", 23 | "dotenv": "^16.3.1", 24 | "isbot": "^3.6.13", 25 | "npm-run-all": "^4.1.5", 26 | "react": "^18.2.0", 27 | "react-dom": "^18.2.0" 28 | }, 29 | "devDependencies": { 30 | "@remix-run/dev": "^1.19.1", 31 | "@remix-run/eslint-config": "^1.19.1", 32 | "@types/react": "^18.2.18", 33 | "@types/react-dom": "^18.2.7", 34 | "esbuild": "^0.18.17", 35 | "esbuild-plugins-node-modules-polyfill": "^1.3.0", 36 | "eslint": "^8.46.0", 37 | "minimist": "^1.2.8", 38 | "typescript": "^5.1.6" 39 | }, 40 | "engines": { 41 | "node": ">=18.0.0" 42 | }, 43 | "packageManager": "yarn@3.6.1" 44 | } 45 | -------------------------------------------------------------------------------- /public/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "PWA", 3 | "name": "Remix PWA", 4 | "start_url": "/", 5 | "display": "standalone", 6 | "background_color": "#d3d7dd", 7 | "theme_color": "#c34138", 8 | "shortcuts": [ 9 | { 10 | "name": "Homepage", 11 | "url": "/", 12 | "icons": [ 13 | { 14 | "src": "/icons/android-icon-96x96.png", 15 | "sizes": "96x96", 16 | "type": "image/png", 17 | "purpose": "any monochrome" 18 | } 19 | ] 20 | } 21 | ], 22 | "icons": [ 23 | { 24 | "src": "/icons/android-icon-36x36.png", 25 | "sizes": "36x36", 26 | "type": "image/png", 27 | "density": "0.75" 28 | }, 29 | { 30 | "src": "/icons/android-icon-48x48.png", 31 | "sizes": "48x48", 32 | "type": "image/png", 33 | "density": "1.0" 34 | }, 35 | { 36 | "src": "/icons/android-icon-72x72.png", 37 | "sizes": "72x72", 38 | "type": "image/png", 39 | "density": "1.5" 40 | }, 41 | { 42 | "src": "/icons/android-icon-96x96.png", 43 | "sizes": "96x96", 44 | "type": "image/png", 45 | "density": "2.0" 46 | }, 47 | { 48 | "src": "/icons/android-icon-144x144.png", 49 | "sizes": "144x144", 50 | "type": "image/png", 51 | "density": "3.0" 52 | }, 53 | { 54 | "src": "/icons/android-icon-192x192.png", 55 | "sizes": "192x192", 56 | "type": "image/png", 57 | "density": "4.0" 58 | } 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /scripts/utils/config.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const { 3 | readConfig: _readConfig, 4 | findConfig, 5 | } = require("@remix-run/dev/dist/config"); 6 | const path = require("path"); 7 | 8 | const EXTENSIONS = [".js", ".mjs", ".cjs"]; 9 | 10 | /** 11 | * @typedef {import('@remix-run/dev').AppConfig & { worker: string, workerName: string, workerMinify: boolean, workerBuildDirectory: string }} WorkerConfig 12 | */ 13 | /** 14 | * @typedef {import('@remix-run/dev').ResolvedRemixConfig & { worker?: string, workerName?: string, workerMinify?: boolean, workerBuildDirectory?: string }} ResolvedWorkerConfig 15 | */ 16 | 17 | /** 18 | * Reads the remix.config.js file and returns the config object. 19 | * @param {string} remixRoot The path to the remix.config.js file. 20 | * @param {import('@remix-run/dev/dist/config/serverModes').ServerMode} mode The server mode. 21 | * @returns {Promise} 22 | */ 23 | async function readConfig(remixRoot, mode) { 24 | const remixConfig = await _readConfig(remixRoot, mode); 25 | /** @type {WorkerConfig} */ 26 | const workerConfig = require(findConfig( 27 | remixRoot, 28 | "remix.config", 29 | EXTENSIONS 30 | )); 31 | 32 | return { 33 | ...remixConfig, 34 | worker: 35 | workerConfig.worker ?? `${remixConfig.appDirectory}/entry.worker.js`, 36 | workerName: workerConfig.workerName ?? "service-worker", 37 | workerMinify: workerConfig.workerMinify ?? false, 38 | workerBuildDirectory: 39 | workerConfig.workerBuildDirectory ?? path.resolve("./public"), 40 | }; 41 | } 42 | 43 | module.exports = { readConfig }; 44 | -------------------------------------------------------------------------------- /scripts/service-worker.internal.js: -------------------------------------------------------------------------------- 1 | import * as build from "@remix-pwa/build/magic"; 2 | import { handleRequest } from "./utils/handle-request"; 3 | 4 | // NOTE: Inject a `serverFetch` and the original `event` in the context. 5 | function createContext(event) { 6 | const request = event.request.clone(); 7 | // getLoadContext is a function exported by the `entry.worker.js` 8 | const context = build.entry.module.getLoadContext?.(event) || {}; 9 | return { 10 | event, 11 | fetchFromServer: (req = request) => fetch(req), 12 | // NOTE: we want the user to override the above properties if needed. 13 | ...context, 14 | }; 15 | } 16 | 17 | // if the user export a `defaultFetchHandler` inside the entry.worker.js, we use that one as default handler 18 | const defaultHandler = 19 | build.entry.module.defaultFetchHandler || 20 | ((event) => fetch(event.request.clone())); 21 | // if the user export a `handleError` inside the entry.worker.js, we use that one as default handler 22 | const defaultErrorHandler = 23 | build.entry.module.handleError || 24 | ((error, { request }) => { 25 | if (!request.signal.aborted) { 26 | console.error(error); 27 | } 28 | }); 29 | 30 | self.addEventListener( 31 | "fetch", 32 | /** 33 | * @param {FetchEvent} event 34 | * @returns {Promise} 35 | */ 36 | (event) => { 37 | const response = handleRequest({ 38 | event, 39 | routes: build.routes, 40 | defaultHandler, 41 | errorHandler: defaultErrorHandler, 42 | loadContext: createContext(event), 43 | }); 44 | return event.respondWith(response); 45 | } 46 | ); 47 | -------------------------------------------------------------------------------- /scripts/utils/request.js: -------------------------------------------------------------------------------- 1 | import { isMethod } from "@remix-pwa/sw/lib/fetch/fetch.js"; 2 | 3 | export function clone(obj) { 4 | const init = {}; 5 | for (const property in obj) { 6 | init[property] = obj[property]; 7 | } 8 | return init; 9 | } 10 | 11 | export function getURLParams(request) { 12 | return Object.fromEntries(new URL(request.url).searchParams.entries()); 13 | } 14 | 15 | export function stripIndexParam(request) { 16 | let url = new URL(request.url); 17 | let indexValues = url.searchParams.getAll("index"); 18 | url.searchParams.delete("index"); 19 | let indexValuesToKeep = []; 20 | for (let indexValue of indexValues) { 21 | if (indexValue) { 22 | indexValuesToKeep.push(indexValue); 23 | } 24 | } 25 | for (let toKeep of indexValuesToKeep) { 26 | url.searchParams.append("index", toKeep); 27 | } 28 | 29 | return new Request(url.href, { ...clone(request), duplex: "half" }); 30 | } 31 | 32 | export function stripDataParam(request) { 33 | let url = new URL(request.url); 34 | url.searchParams.delete("_data"); 35 | const init = {}; 36 | for (const property in request) { 37 | init[property] = request[property]; 38 | } 39 | 40 | return new Request(url.href, { ...clone(request), duplex: "half" }); 41 | } 42 | 43 | export function createArgumentsFrom({ event, loadContext }) { 44 | const request = stripDataParam(stripIndexParam(event.request.clone())); 45 | const params = getURLParams(request); 46 | 47 | return { 48 | request, 49 | params, 50 | context: loadContext, 51 | }; 52 | } 53 | 54 | export function isActionRequest(request) { 55 | const url = new URL(request.url); 56 | return ( 57 | isMethod(request, ["post", "delete", "put", "patch"]) && 58 | url.searchParams.get("_data") 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /scripts/plugins/routes-module.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const { 3 | getRouteModuleExports, 4 | } = require("@remix-run/dev/dist/compiler/utils/routeExports"); 5 | const FILTER_REGEX = /\?worker$/; 6 | const NAMESPACE = "routes-module"; 7 | 8 | /** 9 | * @param {import('../utils/config').ResolvedWorkerConfig} config 10 | * @returns {import('esbuild').Plugin} Esbuild plugin 11 | */ 12 | function routesModulesPlugin(config) { 13 | /** 14 | * @param {import('esbuild').PluginBuild} build 15 | */ 16 | async function setup(build) { 17 | const routesByFile = Object.keys(config.routes).reduce((map, key) => { 18 | const route = config.routes[key]; 19 | map.set(route.file, route); 20 | return map; 21 | }, new Map()); 22 | /** @type {(args: import('esbuild').OnResolveArgs) => import('esbuild').OnResolveResult} */ 23 | const onResolve = ({ path }) => ({ path, namespace: NAMESPACE }); 24 | /** @type {(args: import('esbuild').OnResolveArgs) => Promise} */ 25 | const onLoad = async ({ path }) => { 26 | const file = path.replace(/\?worker$/, ""); 27 | const route = routesByFile.get(file); 28 | const sourceExports = await getRouteModuleExports(config, route.id); 29 | const theExports = sourceExports.filter( 30 | (exp) => exp === "workerAction" || exp === "workerLoader" 31 | ); 32 | 33 | let contents = "module.exports = {};"; 34 | if (theExports.length !== 0) { 35 | const spec = `{ ${theExports.join(", ")} }`; 36 | contents = `export ${spec} from ${JSON.stringify(`./${file}`)}; 37 | export const hasWorkerAction = ${theExports.includes("workerAction")}; 38 | export const hasWorkerLoader = ${theExports.includes( 39 | "workerLoader" 40 | )}`; 41 | } 42 | return { 43 | contents: contents, 44 | resolveDir: config.appDirectory, 45 | loader: "js", 46 | }; 47 | }; 48 | 49 | build.onResolve({ filter: FILTER_REGEX }, onResolve); 50 | build.onLoad({ filter: FILTER_REGEX, namespace: NAMESPACE }, onLoad); 51 | } 52 | 53 | return { 54 | name: "sw-routes-modules", 55 | setup, 56 | }; 57 | } 58 | 59 | module.exports = routesModulesPlugin; 60 | -------------------------------------------------------------------------------- /scripts/plugins/entry-module.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const FILTER_REGEX = /@remix-pwa\/build\/magic$/; 3 | const NAMESPACE = "entry-module"; 4 | 5 | /** 6 | * Creates a string representation of the routes to be imported 7 | * @param {Array} routes 8 | * @returns {string} 9 | */ 10 | function createRouteImports(routes) { 11 | return routes 12 | .map( 13 | (route, index) => `import * as route${index} from '${route.file}?worker'` 14 | ) 15 | .join(";\n"); 16 | } 17 | 18 | /** 19 | * Creates a string representation of each route item. 20 | * @param {Array} routes 21 | * @returns {string} 22 | */ 23 | function createRouteList(routes) { 24 | return routes 25 | .map( 26 | (route, index) => 27 | `{ file: "${route.file}", path: "${route.path}", module: route${index}, id: "${route.id}", parentId: "${route.parentId}", }` 28 | ) 29 | .join(",\n"); 30 | } 31 | 32 | /** 33 | * @param {import('../utils/config').ResolvedWorkerConfig} config 34 | * @returns {import('esbuild').Plugin} Esbuild plugin 35 | */ 36 | function entryModulePlugin(config) { 37 | /** 38 | * @param {import('esbuild').PluginBuild} build 39 | */ 40 | function setup(build) { 41 | /** @type {(args: import('esbuild').OnResolveArgs) => import('esbuild').OnResolveResult} */ 42 | const onResolve = ({ path }) => ({ path, namespace: NAMESPACE }); 43 | /** @type {(args: import('esbuild').OnResolveArgs) => import('esbuild').OnResolveResult} */ 44 | const onLoad = () => { 45 | const routes = Object.values(config.routes); 46 | const contents = ` 47 | ${createRouteImports(routes, config.appDirectory)} 48 | 49 | export const routes = [ 50 | ${createRouteList(routes)} 51 | ]; 52 | 53 | import * as entryWorker from '${config.worker}?user'; 54 | export const entry = { module: entryWorker }; 55 | `; 56 | 57 | return { 58 | contents, 59 | resolveDir: config.appDirectory, 60 | loader: "js", 61 | }; 62 | }; 63 | 64 | build.onResolve({ filter: FILTER_REGEX }, onResolve); 65 | build.onLoad({ filter: FILTER_REGEX, namespace: NAMESPACE }, onLoad); 66 | } 67 | 68 | return { 69 | name: "sw-entry-module", 70 | setup, 71 | }; 72 | } 73 | 74 | module.exports = entryModulePlugin; 75 | -------------------------------------------------------------------------------- /app/entry.worker.js: -------------------------------------------------------------------------------- 1 | /// 2 | // NOTE: if we import from @remix-pwa/sw, the bundle will be too big ass is not tree-shakable apparently 3 | import { PrecacheHandler } from "@remix-pwa/sw/lib/message/precacheHandler.js"; 4 | import { NetworkFirst } from "@remix-pwa/sw/lib/strategy/networkFirst.js"; 5 | import { CacheFirst } from "@remix-pwa/sw/lib/strategy/cacheFirst.js"; 6 | import { matchRequest } from "@remix-pwa/sw/lib/fetch/match.js"; 7 | import createStorageRepository from "./database.js"; 8 | 9 | const PAGES = "page-cache"; 10 | const DATA = "data-cache"; 11 | const ASSETS = "assets-cache"; 12 | 13 | const precacheHandler = new PrecacheHandler({ 14 | dataCacheName: DATA, 15 | documentCacheName: PAGES, 16 | assetCacheName: ASSETS, 17 | }); 18 | 19 | const documentHandler = new NetworkFirst({ 20 | cacheName: PAGES, 21 | }); 22 | 23 | const loadersHandler = new NetworkFirst({ 24 | cacheName: DATA, 25 | }); 26 | 27 | const assetsHandler = new CacheFirst({ 28 | cacheName: ASSETS, 29 | }); 30 | 31 | /** 32 | * The load context works same as in Remix. The return values of this function will be injected in the worker action/loader. 33 | * @param {FetchEvent} [event] The fetch event request. 34 | * @returns {object} the context object. 35 | */ 36 | export const getLoadContext = () => { 37 | const stores = createStorageRepository(); 38 | return { 39 | database: stores, 40 | }; 41 | }; 42 | 43 | // The default fetch event handler will be invoke if the route is not matched by any of the worker action/loader. 44 | export const defaultFetchHandler = ({ context, request }) => { 45 | const type = matchRequest(request); 46 | 47 | if (type === "assets") { 48 | return assetsHandler.handle(request); 49 | } 50 | 51 | if (type === "loader") { 52 | return loadersHandler.handle(request); 53 | } 54 | 55 | if (type === "document") { 56 | return documentHandler.handle(request); 57 | } 58 | 59 | return context.fetchFromServer(); 60 | }; 61 | 62 | self.addEventListener("install", (event) => { 63 | event.waitUntil(self.skipWaiting()); 64 | }); 65 | 66 | self.addEventListener("activate", (event) => { 67 | event.waitUntil(self.clients.claim()); 68 | }); 69 | 70 | self.addEventListener("message", (event) => { 71 | event.waitUntil(precacheHandler.handle(event)); 72 | }); 73 | 74 | // NOTE: If the user declares something like the above, then all the interceptors 75 | // functionality won't work any more and must be managed by the end user. 76 | // self.addEventListener("fetch", (event) => { 77 | // console.log("entry"); 78 | // event.respondWith(defaultFetchHandler(event)); 79 | // }); 80 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const esbuild = require("esbuild"); 3 | const minimist = require("minimist"); 4 | const { readConfig } = require("./utils/config.js"); 5 | const { 6 | emptyModulesPlugin, 7 | } = require("@remix-run/dev/dist/compiler/plugins/emptyModules"); 8 | const path = require("path"); 9 | const entryModulePlugin = require("./plugins/entry-module.js"); 10 | const routesModulesPlugin = require("./plugins/routes-module.js"); 11 | const sideEffectsPlugin = require("./plugins/side-effects.js"); 12 | const { NODE_ENV } = process.env; 13 | const TIME_LABEL = "💿 Built in"; 14 | const MODE = NODE_ENV === "production" ? "production" : "development"; 15 | const { watch } = minimist(process.argv.slice(2)); 16 | 17 | /** 18 | * Creates the esbuild config object. 19 | * @param {import('./utils/config.js').ResolvedWorkerConfig} config 20 | * @returns {import("esbuild").BuildOptions} 21 | */ 22 | function createEsbuildConfig(config) { 23 | return { 24 | entryPoints: { 25 | [config.workerName]: "./scripts/service-worker.internal.js", 26 | }, 27 | globalName: "remix", 28 | outdir: config.workerBuildDirectory, 29 | platform: "browser", 30 | format: "esm", 31 | bundle: true, 32 | logLevel: "error", 33 | splitting: true, 34 | sourcemap: false, 35 | // As pointed out by https://github.com/evanw/esbuild/issues/2440, when tsconfig is set to 36 | // `undefined`, esbuild will keep looking for a tsconfig.json recursively up. This unwanted 37 | // behavior can only be avoided by creating an empty tsconfig file in the root directory. 38 | // tsconfig: ctx.config.tsconfigPath, 39 | mainFields: ["browser", "module", "main"], 40 | treeShaking: true, 41 | minify: config.workerMinify, 42 | chunkNames: "_shared/sw/[name]-[hash]", 43 | plugins: [ 44 | // nodeModulesPolyfillPlugin(), 45 | emptyModulesPlugin({ config }, /\.server(\.[jt]sx?)?$/), 46 | // assuming that we dont need react at all in the worker (we dont want to SWSR for now at least) 47 | emptyModulesPlugin({ config }, /^react(-dom)?(\/.*)?$/, { 48 | includeNodeModules: true, 49 | }), 50 | // TODO we need to see if we need this for the responses helpers 51 | emptyModulesPlugin( 52 | { config }, 53 | /^@remix-run\/(deno|cloudflare|node)(\/.*)?$/, 54 | { includeNodeModules: true } 55 | ), 56 | // This plugin will generate a list of routes based on the remix `flatRoutes` output and inject them in the bundled `service-worker`. 57 | entryModulePlugin(config), 58 | // for each route imported with`?worker` suffix this plugin will only keep the `workerAction` and `workerLoader` exports 59 | routesModulesPlugin(config), 60 | // we need to tag the user entry.worker as sideEffect so esbuild will not remove it 61 | sideEffectsPlugin(), 62 | ], 63 | supported: { 64 | "import-meta": true, 65 | }, 66 | }; 67 | } 68 | 69 | readConfig(path.resolve("./"), "production").then((remixConfig) => { 70 | console.time(TIME_LABEL); 71 | // @TODO: Support for multiple entry.worker.js files. 72 | // We should run the esbuild for each entry.worker.js file. 73 | esbuild 74 | .context({ 75 | ...createEsbuildConfig(remixConfig), 76 | metafile: true, 77 | write: true, 78 | }) 79 | .then((context) => { 80 | console.log(`Building service-worker app in ${MODE} mode`); 81 | return context 82 | .watch() 83 | .then(() => { 84 | console.timeEnd(TIME_LABEL); 85 | if (!watch) { 86 | return context.dispose(); 87 | } 88 | console.log("Watching for changes in the service-worker"); 89 | }) 90 | .catch(console.error); 91 | }) 92 | .catch((error) => { 93 | console.error(error); 94 | process.exit(1); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /app/entry.server.jsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle generating the HTTP Response for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.server 5 | */ 6 | 7 | import { PassThrough } from "node:stream"; 8 | 9 | import { Response } from "@remix-run/node"; 10 | import { RemixServer } from "@remix-run/react"; 11 | import isbot from "isbot"; 12 | import { renderToPipeableStream } from "react-dom/server"; 13 | 14 | const ABORT_DELAY = 5_000; 15 | 16 | export default function handleRequest( 17 | request, 18 | responseStatusCode, 19 | responseHeaders, 20 | remixContext, 21 | loadContext 22 | ) { 23 | return isbot(request.headers.get("user-agent")) 24 | ? handleBotRequest( 25 | request, 26 | responseStatusCode, 27 | responseHeaders, 28 | remixContext 29 | ) 30 | : handleBrowserRequest( 31 | request, 32 | responseStatusCode, 33 | responseHeaders, 34 | remixContext 35 | ); 36 | } 37 | 38 | function handleBotRequest( 39 | request, 40 | responseStatusCode, 41 | responseHeaders, 42 | remixContext 43 | ) { 44 | return new Promise((resolve, reject) => { 45 | let shellRendered = false; 46 | const { pipe, abort } = renderToPipeableStream( 47 | , 52 | 53 | { 54 | onAllReady() { 55 | shellRendered = true; 56 | const body = new PassThrough(); 57 | 58 | responseHeaders.set("Content-Type", "text/html"); 59 | 60 | resolve( 61 | new Response(body, { 62 | headers: responseHeaders, 63 | status: responseStatusCode, 64 | }) 65 | ); 66 | 67 | pipe(body); 68 | }, 69 | onShellError(error) { 70 | reject(error); 71 | }, 72 | onError(error) { 73 | responseStatusCode = 500; 74 | // Log streaming rendering errors from inside the shell. Don't log 75 | // errors encountered during initial shell rendering since they'll 76 | // reject and get logged in handleDocumentRequest. 77 | if (shellRendered) { 78 | console.error(error); 79 | } 80 | }, 81 | } 82 | ); 83 | 84 | setTimeout(abort, ABORT_DELAY); 85 | }); 86 | } 87 | 88 | function handleBrowserRequest( 89 | request, 90 | responseStatusCode, 91 | responseHeaders, 92 | remixContext 93 | ) { 94 | return new Promise((resolve, reject) => { 95 | let shellRendered = false; 96 | const { pipe, abort } = renderToPipeableStream( 97 | , 102 | 103 | { 104 | onShellReady() { 105 | shellRendered = true; 106 | const body = new PassThrough(); 107 | 108 | responseHeaders.set("Content-Type", "text/html"); 109 | 110 | resolve( 111 | new Response(body, { 112 | headers: responseHeaders, 113 | status: responseStatusCode, 114 | }) 115 | ); 116 | 117 | pipe(body); 118 | }, 119 | onShellError(error) { 120 | reject(error); 121 | }, 122 | onError(error) { 123 | responseStatusCode = 500; 124 | // Log streaming rendering errors from inside the shell. Don't log 125 | // errors encountered during initial shell rendering since they'll 126 | // reject and get logged in handleDocumentRequest. 127 | if (shellRendered) { 128 | console.error(error); 129 | } 130 | }, 131 | } 132 | ); 133 | 134 | setTimeout(abort, ABORT_DELAY); 135 | }); 136 | } 137 | -------------------------------------------------------------------------------- /app/routes/_app.flights.jsx: -------------------------------------------------------------------------------- 1 | import { redirect, defer, json } from "@remix-run/router"; 2 | import { useLoaderData, Form, useNavigation, Await } from "@remix-run/react"; 3 | import { Suspense } from "react"; 4 | 5 | export function loader() { 6 | return json({ 7 | flights: [ 8 | { 9 | date: "2023-07-01T23:30:00", 10 | arrival: "EZE", 11 | departure: "ARN", 12 | flightId: 1, 13 | flightNumber: "SK0000 - server", 14 | }, 15 | { 16 | date: "2023-07-06T07:35:00", 17 | arrival: "ARN", 18 | departure: "EZE", 19 | flightId: 2, 20 | flightNumber: "SK0001 - server", 21 | }, 22 | ], 23 | }); 24 | } 25 | 26 | /** 27 | * @param {import('@remix-run/node').ActionArgs} args 28 | */ 29 | export const workerAction = async ({ request, context }) => { 30 | const formData = await request.formData(); 31 | const { database, fetchFromServer } = context; 32 | 33 | try { 34 | // Send action to server 35 | fetchFromServer(); 36 | // Save selection in client 37 | await database.selections.add(Object.fromEntries(formData.entries())); 38 | return redirect("/selection"); 39 | } catch (error) { 40 | throw json({ message: "Something went wrong", error }, 500); 41 | } 42 | }; 43 | 44 | /** 45 | * @param {import('@remix-run/node').LoaderArgs} args 46 | */ 47 | export const workerLoader = async ({ context }) => { 48 | try { 49 | const { fetchFromServer, database } = context; 50 | const [serverResult, clientResult] = await Promise.allSettled([ 51 | // NOTE: If the user decides to use the server loader, must use the `context.event.request` object instead of `request`. 52 | // This is because we strip the `_data` and `index` from the request object just to follow what Remix does. 53 | // Doing fetch(context.event.request) is the same as using `fetchFromServer()`. 54 | fetchFromServer() 55 | .then((response) => response.json()) 56 | .then(({ flights }) => flights), 57 | database.flights.toArray(), 58 | ]); 59 | const flights = serverResult.value || clientResult.value; 60 | 61 | if (serverResult.value) { 62 | await database.flights.bulkPut( 63 | flights.map((f) => ({ 64 | ...f, 65 | flightNumber: `${f.flightNumber.split("-")[0].trim()} - client`, 66 | })) 67 | ); 68 | } 69 | 70 | // can't use same `json` here because is only for node 71 | return defer({ flights }); 72 | } catch (error) { 73 | console.error(error); 74 | throw json({ message: "Something went wrong", error }, 500); 75 | } 76 | }; 77 | 78 | export async function action({ request }) { 79 | const formData = await request.formData(); 80 | const flight = formData.get("flightId"); 81 | 82 | console.log(flight, "here is the flight id"); 83 | 84 | return redirect("/server-redirect"); 85 | } 86 | 87 | export default function FlightsRoute() { 88 | const { flights } = useLoaderData(); 89 | const navigation = useNavigation(); 90 | const loading = navigation.state !== "idle"; 91 | 92 | return ( 93 |
94 |

Flights

95 | 96 |
97 |
98 | Loading more data...

}> 99 | 100 | {(flights) => 101 | flights && ( 102 |
103 | {flights.map((flight, index) => ( 104 |
105 | 113 |
114 | ))} 115 |
116 | ) 117 | } 118 |
119 |
120 | 123 |
124 |
125 |
126 | ); 127 | } 128 | -------------------------------------------------------------------------------- /scripts/utils/handle-request.js: -------------------------------------------------------------------------------- 1 | import { 2 | isDeferredData, 3 | isRedirectStatusCode, 4 | redirect, 5 | createDeferredReadableStream, 6 | isRedirectResponse, 7 | isResponse, 8 | json, 9 | } from "@remix-run/server-runtime/dist/responses.js"; 10 | import { isRouteErrorResponse } from "@remix-run/router"; 11 | import { isLoaderRequest } from "@remix-pwa/sw/lib/fetch/match.js"; 12 | import { 13 | createArgumentsFrom, 14 | getURLParams, 15 | isActionRequest, 16 | } from "./request.js"; 17 | import { errorResponseToJson } from "./response.js"; 18 | 19 | export function handleRequest({ 20 | routes, 21 | event, 22 | defaultHandler, 23 | errorHandler, 24 | loadContext, 25 | }) { 26 | const url = new URL(event.request.url); 27 | const _data = url.searchParams.get("_data"); 28 | const route = routes.find((route) => route.id === _data); 29 | 30 | try { 31 | if (isLoaderRequest(event.request) && route.module?.workerLoader) { 32 | return handleLoader({ 33 | event, 34 | loader: route.module.workerLoader, 35 | routeId: route.id, 36 | loadContext, 37 | }).then(responseHandler); 38 | } 39 | 40 | if (isActionRequest(event.request) && route.module?.workerAction) { 41 | return handleAction({ 42 | event, 43 | action: route.module.workerAction, 44 | routeId: route.id, 45 | loadContext, 46 | }).then(responseHandler); 47 | } 48 | } catch (error) { 49 | const handler = (error) => 50 | errorHandler(error, createArgumentsFrom({ event, loadContext })); 51 | return _errorHandler({ error, handler }); 52 | } 53 | 54 | return defaultHandler({ 55 | request: event.request, 56 | params: getURLParams(event.request), 57 | context: loadContext, 58 | }); 59 | } 60 | 61 | async function handleLoader({ loader, event, routeId, loadContext }) { 62 | const _arguments = createArgumentsFrom({ event, loadContext }); 63 | const result = await loader(_arguments); 64 | 65 | if (result === undefined) { 66 | throw new Error( 67 | `You defined a loader for route "${routeId}" but didn't return ` + 68 | `anything from your \`loader\` function. Please return a value or \`null\`.` 69 | ); 70 | } 71 | 72 | if (isDeferredData(result)) { 73 | if (result.init && isRedirectStatusCode(result.init.status || 200)) { 74 | return redirect( 75 | new Headers(result.init.headers).get("Location"), 76 | result.init 77 | ); 78 | } 79 | 80 | const body = createDeferredReadableStream( 81 | result, 82 | event.request.signal, 83 | "production" 84 | ); 85 | const init = result.init || {}; 86 | const headers = new Headers(init.headers); 87 | headers.set("Content-Type", "text/remix-deferred"); 88 | init.headers = headers; 89 | 90 | return new Response(body, init); 91 | } 92 | 93 | return isResponse(result) ? result : json(result); 94 | } 95 | 96 | async function handleAction({ action, event, routeId, loadContext }) { 97 | const _arguments = createArgumentsFrom({ event, loadContext }); 98 | const result = await action(_arguments); 99 | 100 | if (result === undefined) { 101 | throw new Error( 102 | `You defined an action for route "${routeId}" but didn't return ` + 103 | `anything from your \`action\` function. Please return a value or \`null\`.` 104 | ); 105 | } 106 | 107 | return isResponse(result) ? result : json(result); 108 | } 109 | 110 | /** 111 | * Takes an data route error and returns remix expected json response 112 | */ 113 | function _errorHandler({ error, handler: handleError }) { 114 | if (isResponse(error)) { 115 | error.headers.set("X-Remix-Catch", "yes"); 116 | return error; 117 | } 118 | 119 | if (isRouteErrorResponse(error)) { 120 | if (error.error) { 121 | handleError(error.error); 122 | } 123 | return errorResponseToJson(error, serverMode); 124 | } 125 | 126 | let errorInstance = 127 | error instanceof Error ? error : new Error("Unexpected Server Error"); 128 | handleError(errorInstance); 129 | return json(serializeError(errorInstance, serverMode), { 130 | status: 500, 131 | headers: { 132 | "X-Remix-Error": "yes", 133 | }, 134 | }); 135 | } 136 | 137 | /** 138 | * takes a response and returns a new response with the remix expected headers and status 139 | */ 140 | function responseHandler(response) { 141 | if (isRedirectResponse(response)) { 142 | // We don't have any way to prevent a fetch request from following 143 | // redirects. So we use the `X-Remix-Redirect` header to indicate the 144 | // next URL, and then "follow" the redirect manually on the client. 145 | let headers = new Headers(response.headers); 146 | headers.set("X-Remix-Redirect", headers.get("Location")); 147 | headers.set("X-Remix-Status", response.status); 148 | headers.delete("Location"); 149 | if (response.headers.get("Set-Cookie") !== null) { 150 | headers.set("X-Remix-Revalidate", "yes"); 151 | } 152 | 153 | return new Response(null, { 154 | status: 204, 155 | headers, 156 | }); 157 | } 158 | 159 | // Mark all successful responses with a header so we can identify in-flight 160 | // network errors that are missing this header 161 | !response.headers.has("X-Remix-Response") && 162 | response.headers.set("X-Remix-Response", "yes"); 163 | return response; 164 | } 165 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Remix worker actions and loaders 2 | 3 | The main purpose of this repository is to show how to use Remix's [actions](https://remix.run/docs/en/main/guides/data-writes) and [loaders](https://remix.run/docs/en/main/guides/data-loading) in a _service worker_. 4 | The idea comes from finding a way to use all the Remix features and capabilities totally offline. 5 | 6 | ## How does it work? 7 | 8 | Following the same principles as the _Remix actions and loaders_, where the _loaders_ and _actions_ functions are exported from a route file and then a **compiler** process them to include them only in the server bundle and register the routes automatically. 9 | 10 | ### Compiler 11 | 12 | We created a compiler that mimics the _remix compiler_, but for a _service worker_. The compiler is a [esbuild](https://esbuild.github.io/) node script that uses the _remix config manifest_ to know all the routes files and compile only the exported `workerActions` and `workerLoader` function from each route file. In that way, the compiler creates a `routes` list with all information needed to "register a route" and inject it in the _service worker_. 13 | We held an interal `scripts/service-worker.js` file that is the compiler entry point and have the logic to listen to a **fetch event** and match the request with the right route `workerAction` or `workerLoader` function. 14 | Finally, the compiler bundles everything needed for the _service worker_ in a single file and writes it to the condigure `workerBuildDirectory` in the `remix.config.js` file. 15 | 16 | ### Worker actions and loaders 17 | 18 | The _worker actions_ and _worker loaders_ are the same as the _Remix actions_ and _Remix loaders_, but with a different name to easily identify the context where they are running. Both follows the same principles as in a normal _action_ or _loader_, recives the `request` object and returns a `response` object. The only difference is that the _worker actions_ and _worker loaders_ are executed in the _service worker_ thread and not in a Node.js server. 19 | 20 | **workerAction** 21 | 22 | ```js 23 | export function workerAction({ request, params, context }: ActionArgs): Promise | Response | Promise | AppData 24 | ``` 25 | 26 | **workerLoader** 27 | 28 | ```js 29 | export function workerLoader({ request, params, context }: LoaderArgs): Promise | Response | Promise | AppData 30 | ``` 31 | 32 | ### Context 33 | 34 | The `context` object is the same as in a normal [Remix app](https://remix.run/docs/en/1.19.1/route/loader#contex), but with some extra properties: 35 | 36 | - **context.event**: The _fetch event_ that was triggered. 37 | - **context.fetchFromServer**: A function that can be used to perform the original request. 38 | 39 | The _compiler_ also supports a way to create your own context that will be pass to all _worker actions_ and _worker loaders_. A `getLoadContext` function should be exported from the application service worker file and will be called in each fetch event. 40 | 41 | **getLoadContext** 42 | 43 | ```js 44 | export function getLoadContext(event: FetchEvent): AppLoadContext 45 | ``` 46 | 47 | ### Default event handler 48 | 49 | The _compiler_ also supports a way to create a default event handler that will be called if no _worker action_ or _worker loader_ matches the request. A `defaultEventHandler` function should be exported from the application service worker file. 50 | 51 | **defaultFetchHandler** 52 | 53 | ```js 54 | export function defaultFetchHandler({ request, params, context }: LoaderArgs): Promise | Response 55 | ``` 56 | 57 | If no `defaultFetchHandler` is exported, the _compiler_ will use a default one that will try to fetch the request from the server. 58 | 59 | ### Error handler 60 | 61 | The _compiler_ also supports a way to create a error handler that will be called if an error is thrown in a _worker action_ or _worker loader_. A `errorHandler` function should be exported from the application service worker file. 62 | 63 | **handleError** 64 | 65 | ```js 66 | export function handleError(error: Error, { request, params, context }: LoaderArgs | ActionArgs)): void 67 | ``` 68 | 69 | If no `errorHandler` is exported, the _compiler_ will use a default one that will log the error to the console. 70 | 71 | ### Configuration 72 | 73 | The _service worker_ can be configured in the `remix.config.js` file. The following options are available: 74 | 75 | - **worker**: The path to the _service worker_ file. Defaults to `app/entry.worker.js`. 76 | - **workerName**: The name of the _service worker_ output file without the extension. Defaults to `service-worker`. 77 | - **workerMinify**: Whether to minify the _service worker_ file. Defaults to `false`. 78 | - **workerBuildDirectory**: The directory to build the _service worker_ file in. Defaults to `public/`. 79 | 80 | ## Considerations 81 | 82 | - The compiler removes all dependencies related to `react`, `react-dom` and any `@remix-run/*` packages intended to be used in an specific environment like `cloudflare`, `node`, `deno`, etc 83 | - Remix methods like `json`, `defer` and `redirect` needs to be imported from `@remix-run/router` otherwise it won't work. 84 | - ⚠️ The first render (if you are online and don't have any cache around), will always go to the server. There is no way to intercept this request as the _service worker_ is not activated yet. 85 | - ⚠️ The _service worker_ runs on a background thread and can only [access Server Worker API's](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API). 86 | 87 | ## Project setup 88 | 89 | This project was bootstrapped with [Remix](https://remix.run), uses [Yarn](https://yarnpkg.com) as its package manager and [Node.js](https://nodejs.org) as its runtime. 90 | 91 | ### Install dependencies 92 | 93 | From your terminal: 94 | 95 | ```sh 96 | yarn install 97 | ``` 98 | 99 | ### Start development mode 100 | 101 | From your terminal: 102 | 103 | ```sh 104 | yarn dev 105 | ``` 106 | 107 | This starts your app in development mode with a built-in _service worker_, rebuilding assets on file changes. It uses two node scripts: `dev:remix` and `dev:worker`. 108 | 109 | - **`dev:remix`** starts the Remix development server, which serves your app at `localhost:3000`. 110 | - **`dev:worker`** builds your _service worker_ and watches for changes to your app's assets. 111 | 112 | ### Build for production 113 | 114 | From your terminal: 115 | 116 | ```sh 117 | yarn build 118 | ``` 119 | 120 | This builds your app for production, including your _service worker_. 121 | 122 | --- 123 | 124 | Created by the [Airline Digitalization Team](mailto:airlinedigitalization@flysas.com). 125 | 126 | ![SAS](https://user-images.githubusercontent.com/850110/180438296-f79396e1-cb77-4f82-93e0-1d5bf5bea6a1.svg 'SAS') 127 | --------------------------------------------------------------------------------