four-stroke 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,3 +24,5 @@ jobs:
24
24
  - run: pnpm tsc --noEmit
25
25
 
26
26
  - run: pnpm knip
27
+
28
+ - run: pnpm test
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "four-stroke",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Simple Cloudflare Worker SSR SPA framework",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -0,0 +1,67 @@
1
+ // For apps whose data requests only work once a service worker at `/auth-service-worker.js`
2
+ // Has rewritten them, adding credentials for example. A page is uncontrolled on the first
3
+ // Visit in a browser, while the worker is still installing, and after a hard reload, and
4
+ // Its requests then bypass the worker; these helpers make them wait until it controls the
5
+ // Page. The worker should call `clients.claim()` on `activate` and on a `CLAIM_MESSAGE`.
6
+
7
+ export const SERVICE_WORKER_URL = "/auth-service-worker.js",
8
+ CLAIM_MESSAGE = "claim",
9
+ CONTROL_TIMEOUT_MS = 10_000;
10
+
11
+ export interface WorkerContainer {
12
+ readonly controller: unknown;
13
+ readonly ready: Promise<{ readonly active: { postMessage: (message: unknown) => void } | null }>;
14
+ register: (url: string, options: { type: "module" }) => Promise<unknown>;
15
+ addEventListener: (type: "controllerchange", listener: () => void) => void;
16
+ removeEventListener: (type: "controllerchange", listener: () => void) => void;
17
+ }
18
+
19
+ /**
20
+ * Resolves true once a service worker controls the page, or false when none does within
21
+ * `timeoutMs`, so a browser without service workers fails its requests rather than
22
+ * hanging. Registering is idempotent, so every app sharing the worker's scope can call
23
+ * this without knowing whether another app registered it first.
24
+ */
25
+ export const whenControlled = async (
26
+ container: WorkerContainer | undefined,
27
+ timeoutMs = CONTROL_TIMEOUT_MS,
28
+ ): Promise<boolean> => {
29
+ if (!container) return false;
30
+ if (container.controller) return true;
31
+
32
+ let onChange: (() => void) | undefined, timer: ReturnType<typeof setTimeout> | undefined;
33
+ const changed = new Promise<boolean>((resolve) => {
34
+ onChange = () => resolve(true);
35
+ container.addEventListener("controllerchange", onChange);
36
+ }),
37
+ timedOut = new Promise<boolean>((resolve) => {
38
+ timer = setTimeout(() => resolve(false), timeoutMs);
39
+ }),
40
+ claimed = (async () => {
41
+ await container.register(SERVICE_WORKER_URL, { type: "module" });
42
+ const registration = await container.ready;
43
+ if (container.controller) return true;
44
+ // An active worker claims pages only when it activates, so one that was already
45
+ // Active when this page loaded is asked to claim it now.
46
+ registration.active?.postMessage(CLAIM_MESSAGE);
47
+ return changed;
48
+ })().catch(() => false);
49
+
50
+ try {
51
+ return await Promise.race([changed, claimed, timedOut]);
52
+ } finally {
53
+ if (timer !== undefined) clearTimeout(timer);
54
+ if (onChange) container.removeEventListener("controllerchange", onChange);
55
+ }
56
+ };
57
+
58
+ let controlled: Promise<boolean> | null = null;
59
+
60
+ /** A drop-in `fetch` that waits once per page for the worker, then fetches. */
61
+ export const controlledFetch: typeof fetch = async (input, init) => {
62
+ controlled ??= whenControlled(
63
+ typeof navigator === "undefined" ? undefined : navigator.serviceWorker,
64
+ );
65
+ if (!(await controlled)) controlled = null;
66
+ return fetch(input, init);
67
+ };
@@ -0,0 +1,79 @@
1
+ import { CLAIM_MESSAGE, SERVICE_WORKER_URL, whenControlled } from "../src/util/service-worker";
2
+
3
+ import type { WorkerContainer } from "../src/util/service-worker";
4
+
5
+ interface FakeContainer extends WorkerContainer {
6
+ controller: unknown;
7
+ messages: unknown[];
8
+ registered: string[];
9
+ listeners: number;
10
+ takeControl: () => void;
11
+ }
12
+
13
+ const fakeContainer = ({
14
+ controlled = false,
15
+ active = true,
16
+ registerFails = false,
17
+ }: { controlled?: boolean; active?: boolean; registerFails?: boolean } = {}): FakeContainer => {
18
+ const listeners = new Set<() => void>(),
19
+ container: FakeContainer = {
20
+ controller: controlled ? {} : null,
21
+ messages: [],
22
+ registered: [],
23
+ get listeners() {
24
+ return listeners.size;
25
+ },
26
+ ready: Promise.resolve({
27
+ active: active
28
+ ? { postMessage: (message: unknown) => container.messages.push(message) }
29
+ : null,
30
+ }),
31
+ register: async (url) => {
32
+ if (registerFails) throw new Error("refused");
33
+ container.registered.push(url);
34
+ },
35
+ addEventListener: (_type, listener) => listeners.add(listener),
36
+ removeEventListener: (_type, listener) => listeners.delete(listener),
37
+ takeControl: () => {
38
+ container.controller = {};
39
+ for (const listener of listeners) listener();
40
+ },
41
+ };
42
+ return container;
43
+ };
44
+
45
+ describe("waiting for the auth service worker", () => {
46
+ it("resolves at once when the page is already controlled", async () => {
47
+ const container = fakeContainer({ controlled: true });
48
+ await expect(whenControlled(container)).resolves.toBe(true);
49
+ expect(container.registered).toEqual([]);
50
+ });
51
+
52
+ it("registers the worker and asks it to claim an uncontrolled page", async () => {
53
+ const container = fakeContainer(),
54
+ result = whenControlled(container);
55
+ await vi.waitFor(() => expect(container.messages).toEqual([CLAIM_MESSAGE]));
56
+ expect(container.registered).toEqual([SERVICE_WORKER_URL]);
57
+ container.takeControl();
58
+ await expect(result).resolves.toBe(true);
59
+ expect(container.listeners).toBe(0);
60
+ });
61
+
62
+ it("resolves when the worker takes control while it is still installing", async () => {
63
+ const container = fakeContainer({ active: false }),
64
+ result = whenControlled(container);
65
+ container.takeControl();
66
+ await expect(result).resolves.toBe(true);
67
+ });
68
+
69
+ it("gives up after the timeout when no worker takes control", async () => {
70
+ const container = fakeContainer();
71
+ await expect(whenControlled(container, 10)).resolves.toBe(false);
72
+ expect(container.listeners).toBe(0);
73
+ });
74
+
75
+ it("gives up at once when registration is refused or unsupported", async () => {
76
+ await expect(whenControlled(fakeContainer({ registerFails: true }))).resolves.toBe(false);
77
+ await expect(whenControlled(undefined)).resolves.toBe(false);
78
+ });
79
+ });
package/tsconfig.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "erasableSyntaxOnly": true,
4
4
  "esModuleInterop": true,
5
5
  "isolatedModules": true,
6
- "lib": ["ESNext"],
6
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
7
7
  "module": "ESNext",
8
8
  "moduleResolution": "bundler",
9
9
  "noUncheckedIndexedAccess": true,
@@ -0,0 +1,3 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({ test: { globals: true } });