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 |
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 | 
127 |
--------------------------------------------------------------------------------