@statewalker/webrun-http-browser 0.3.4 → 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.
@@ -1,19 +1,38 @@
1
1
  import type { HttpHandler } from "@statewalker/webrun-http-streams";
2
+ import { serializeError } from "@statewalker/webrun-streams";
2
3
  import { callChannel, handleChannelCalls } from "../core/data-calls.js";
3
4
  import type { MessageTarget } from "../core/message-target.js";
4
5
  import { newRegistry } from "../core/registry.js";
6
+ import {
7
+ awaitActiveServiceWorker,
8
+ DEFAULT_SERVICE_WORKER_TIMEOUT,
9
+ } from "../core/service-worker-control.js";
5
10
  import { handleHttpRequests, sendHttpRequest } from "../http/http-send-recieve.js";
6
11
 
7
12
  export * from "./split-service-url.js";
8
13
 
14
+ /**
15
+ * The URL of this module, kept in a variable on purpose. Bundlers (Vite
16
+ * among them) rewrite every literal `new URL("<path>", import.meta.url)` into
17
+ * an emitted asset at build time, before tree-shaking — so the defaults below
18
+ * made every Vite consumer of this entry emit a dead copy of the package's
19
+ * own `dist/index.js` (`"../"` resolves to the package, hence to its `main`).
20
+ * Resolving against a variable is the same URL at run time and invisible to
21
+ * that transform.
22
+ */
23
+ const moduleUrl: string = import.meta.url;
24
+
9
25
  /**
10
26
  * Returns a MessagePort that transparently bridges messages to/from the
11
- * ServiceWorker controlling this page.
27
+ * page's ServiceWorker: the one controlling the page, or — when the page is
28
+ * not controlled (a hard reload, or a page Firefox left uncontrolled) — the
29
+ * active worker of `registration`, which answers messages all the same.
12
30
  */
13
- export function newServiceWorkerPort(): MessagePort {
31
+ export function newServiceWorkerPort(registration?: ServiceWorkerRegistration): MessagePort {
14
32
  const channel = new MessageChannel();
15
33
  channel.port1.onmessage = (event) => {
16
- navigator.serviceWorker.controller?.postMessage(event.data, [...event.ports]);
34
+ const worker = navigator.serviceWorker.controller ?? registration?.active;
35
+ worker?.postMessage(event.data, [...event.ports]);
17
36
  };
18
37
  navigator.serviceWorker.addEventListener("message", (event) => {
19
38
  channel.port1.postMessage(event.data, [...event.ports]);
@@ -25,51 +44,38 @@ export interface InitServiceWorkerOptions {
25
44
  swUrl: string;
26
45
  scopeUrl?: string;
27
46
  type?: WorkerType;
47
+ /**
48
+ * Upper bound, in ms, for the wait for the worker to activate; past it the
49
+ * promise rejects with a `ServiceWorkerControlError`. Default
50
+ * `DEFAULT_SERVICE_WORKER_TIMEOUT` (30 s).
51
+ */
52
+ timeout?: number;
28
53
  }
29
54
 
30
55
  /**
31
- * Registers a ServiceWorker and resolves once it's activated and controlling the page.
56
+ * Registers a ServiceWorker and resolves with it once it is activated: the
57
+ * worker controlling the page, or the registration's active worker when the
58
+ * page is not controlled. Messaging works either way, which is all the relay
59
+ * needs; nothing here waits for control, because an uncontrolled page (hard
60
+ * reload; Firefox) may never get it. Rejects with a
61
+ * `ServiceWorkerControlError` if activation takes longer than `timeout`.
32
62
  */
33
- export async function initServiceWorker({
63
+ export async function initServiceWorker(options: InitServiceWorkerOptions): Promise<ServiceWorker> {
64
+ return (await registerServiceWorker(options)).worker;
65
+ }
66
+
67
+ async function registerServiceWorker({
34
68
  swUrl,
35
69
  scopeUrl,
36
70
  type,
37
- }: InitServiceWorkerOptions): Promise<ServiceWorker> {
38
- await navigator.serviceWorker.register(swUrl, { type, scope: scopeUrl });
39
- const worker = await getServiceWorkerController();
40
- await awaitServiceWorkerActivation(worker);
41
- return worker;
42
- }
43
-
44
- function getServiceWorkerController(): Promise<ServiceWorker> {
45
- return new Promise((resolve) => {
46
- const container = navigator.serviceWorker;
47
- if (container.controller) {
48
- resolve(container.controller);
49
- return;
50
- }
51
- const onChange = () => {
52
- if (!container.controller) return;
53
- resolve(container.controller);
54
- container.removeEventListener("controllerchange", onChange);
55
- };
56
- container.addEventListener("controllerchange", onChange);
57
- });
58
- }
59
-
60
- function awaitServiceWorkerActivation(worker: ServiceWorker): Promise<void> {
61
- return new Promise((resolve) => {
62
- if (worker.state === "activated") {
63
- resolve();
64
- return;
65
- }
66
- const onStateChange = () => {
67
- if (worker.state !== "activated") return;
68
- worker.removeEventListener("statechange", onStateChange);
69
- resolve();
70
- };
71
- worker.addEventListener("statechange", onStateChange);
72
- });
71
+ timeout = DEFAULT_SERVICE_WORKER_TIMEOUT,
72
+ }: InitServiceWorkerOptions): Promise<{
73
+ registration: ServiceWorkerRegistration;
74
+ worker: ServiceWorker;
75
+ }> {
76
+ const registration = await navigator.serviceWorker.register(swUrl, { type, scope: scopeUrl });
77
+ const active = await awaitActiveServiceWorker(registration, { timeout });
78
+ return { registration, worker: navigator.serviceWorker.controller ?? active };
73
79
  }
74
80
 
75
81
  export interface ServiceOptions {
@@ -111,16 +117,21 @@ export async function callHttpService(
111
117
  export interface RelayWindowHandlerOptions {
112
118
  swUrl?: string;
113
119
  scopeUrl?: string;
120
+ /** Passed to `initServiceWorker`: how long to wait for the relay worker to activate. */
121
+ timeout?: number;
114
122
  }
115
123
 
116
124
  /**
117
125
  * Returns a `window.onmessage` handler for use inside the relay iframe:
118
126
  * it accepts a CONNECT message, starts the relay ServiceWorker, and bridges
119
- * the parent's MessagePort with the SW.
127
+ * the parent's MessagePort with the SW. If the worker cannot be started, every
128
+ * call the parent makes on that port is answered with the error, so the
129
+ * parent's `initHttpService` / `callHttpService` reject instead of waiting.
120
130
  */
121
131
  export function getRelayWindowMessageHandler({
122
- swUrl = `${new URL("./index-sw.js", import.meta.url)}`,
123
- scopeUrl = `${new URL("../", import.meta.url)}`,
132
+ swUrl = `${new URL("./index-sw.js", moduleUrl)}`,
133
+ scopeUrl = `${new URL("../", moduleUrl)}`,
134
+ timeout,
124
135
  }: RelayWindowHandlerOptions = {}): (ev: MessageEvent) => Promise<void> {
125
136
  let externalPort: MessagePort | undefined;
126
137
  return async (ev) => {
@@ -132,8 +143,15 @@ export function getRelayWindowMessageHandler({
132
143
  return;
133
144
  }
134
145
  externalPort = newExternalPort;
135
- await initServiceWorker({ swUrl, scopeUrl });
136
- const serviceWorkerPort = newServiceWorkerPort();
146
+ let registration: ServiceWorkerRegistration;
147
+ try {
148
+ ({ registration } = await registerServiceWorker({ swUrl, scopeUrl, timeout }));
149
+ } catch (error) {
150
+ const serialized = serializeError(error);
151
+ externalPort.onmessage = (event) => event.ports[0]?.postMessage({ error: serialized });
152
+ throw error;
153
+ }
154
+ const serviceWorkerPort = newServiceWorkerPort(registration);
137
155
  serviceWorkerPort.onmessage = (event) => {
138
156
  externalPort?.postMessage(event.data, [...event.ports]);
139
157
  };
@@ -160,7 +178,7 @@ export interface RemoteRelayChannel {
160
178
  * returns the port to be used with `initHttpService` / `callHttpService`.
161
179
  */
162
180
  export async function newRemoteRelayChannel({
163
- baseUrl = new URL("../public-relay/", import.meta.url),
181
+ baseUrl = new URL("../public-relay/", moduleUrl),
164
182
  url = new URL("relay.html", baseUrl),
165
183
  container = document.body,
166
184
  }: RemoteRelayChannelOptions = {}): Promise<RemoteRelayChannel> {
@@ -1,12 +1,32 @@
1
1
  import { get, set } from "idb-keyval";
2
2
  import { callChannel, handleChannelCalls } from "../core/data-calls.js";
3
+ import { withDeadline } from "../core/deadline.js";
3
4
  import { newRegistry } from "../core/registry.js";
5
+ import {
6
+ awaitServiceWorkerControl,
7
+ DEFAULT_SERVICE_WORKER_TIMEOUT,
8
+ handleClaimRequests,
9
+ ServiceWorkerControlError,
10
+ } from "../core/service-worker-control.js";
4
11
 
5
12
  export interface SwPortHandlerOptions {
6
13
  key: string;
7
14
  scope?: string;
8
15
  serviceWorkerUrl?: string;
9
16
  bindPort: (port: MessagePort) => void | Promise<void>;
17
+ /**
18
+ * Upper bound, in ms, for `start()`'s wait for the worker to activate, take
19
+ * control of the page and answer the handshake. `start()` rejects with a
20
+ * `ServiceWorkerControlError` past it instead of waiting forever.
21
+ * Default `DEFAULT_SERVICE_WORKER_TIMEOUT` (30 s).
22
+ */
23
+ timeout?: number;
24
+ /**
25
+ * If the page is still uncontrolled once the worker is active — after a
26
+ * hard reload, say — and the worker does not take it over when asked,
27
+ * reload the page once instead of rejecting. Default `false`.
28
+ */
29
+ reloadIfUncontrolled?: boolean;
10
30
  }
11
31
 
12
32
  interface ChannelInfo {
@@ -47,7 +67,12 @@ export class SwPortHandler {
47
67
  get serviceWorkerUrl(): string {
48
68
  if (!this._serviceWorkerUrl) {
49
69
  const url = this.options.serviceWorkerUrl
50
- ? new URL(this.options.serviceWorkerUrl)
70
+ ? // A worker url is relative to the document that registers it, so it
71
+ // is resolved against `location.href` like any other url a page
72
+ // writes. Without the base, the root-relative form callers actually
73
+ // use — `"/sw-worker.js"` — threw a bare `Invalid URL` naming
74
+ // neither the option nor the value.
75
+ resolveWorkerUrl(this.options.serviceWorkerUrl)
51
76
  : new URL("./index-sw.js", this.rootUrl);
52
77
  this._serviceWorkerUrl = `${url}`;
53
78
  }
@@ -79,6 +104,15 @@ export class SwPortHandler {
79
104
  return { key: this.key };
80
105
  }
81
106
 
107
+ /**
108
+ * Registers the worker and waits until it is activated and controls this
109
+ * page, then opens the port to it. The page must be controlled, because
110
+ * only a controlled page's `fetch()` reaches the worker. When the page
111
+ * loaded uncontrolled (a hard reload does that), the worker is asked to
112
+ * claim it. Rejects with a `ServiceWorkerControlError` when that fails or
113
+ * `timeout` passes (`reason` says which step); a later `start()` tries
114
+ * again.
115
+ */
82
116
  async start(): Promise<void> {
83
117
  if (!this._registrationPromise) {
84
118
  this._registrationPromise = (async () => {
@@ -92,20 +126,44 @@ export class SwPortHandler {
92
126
  });
93
127
  register(() => registration.unregister());
94
128
 
95
- register(
96
- handleChannelCalls(
97
- navigator.serviceWorker,
98
- "UPDATE_COMMUNICATION_PORT",
99
- async (_event, _params, port: MessagePort) => {
100
- await this._setCommunicationPort(port);
101
- return this._getRegistrationInfo();
102
- },
103
- ),
129
+ const stopListening = handleChannelCalls(
130
+ navigator.serviceWorker,
131
+ "UPDATE_COMMUNICATION_PORT",
132
+ async (_event, _params, port: MessagePort) => {
133
+ await this._setCommunicationPort(port);
134
+ return this._getRegistrationInfo();
135
+ },
104
136
  );
105
-
106
- this._serviceWorker = await getServiceWorkerController();
107
- await awaitServiceWorkerActivation(this._serviceWorker);
108
- await this._updateCommunicationChannel();
137
+ register(stopListening);
138
+
139
+ const timeout = this.options.timeout ?? DEFAULT_SERVICE_WORKER_TIMEOUT;
140
+ const deadline = Date.now() + timeout;
141
+ try {
142
+ this._serviceWorker = await awaitServiceWorkerControl(registration, {
143
+ timeout,
144
+ reloadIfUncontrolled: this.options.reloadIfUncontrolled,
145
+ });
146
+ await withDeadline(
147
+ deadline,
148
+ this._updateCommunicationChannel(),
149
+ () =>
150
+ new ServiceWorkerControlError(
151
+ "unresponsive",
152
+ `ServiceWorker "${this._serviceWorker?.scriptURL}" controls this page but did ` +
153
+ `not answer the UPDATE_COMMUNICATION_PORT handshake within ${timeout} ms. ` +
154
+ "Check that the worker script runs this package's same-origin dispatcher " +
155
+ "(it importScripts `sw-worker.js`, or calls `startHttpDispatcher`).",
156
+ ),
157
+ );
158
+ } catch (error) {
159
+ // Forget this attempt so a later start() retries. Stop listening,
160
+ // but keep the registration: the worker is fine — it is this page
161
+ // that is not controlled, and other pages may be using the worker.
162
+ stopListening();
163
+ this._cleanupRegistrations = undefined;
164
+ this._registrationPromise = undefined;
165
+ throw error;
166
+ }
109
167
  })();
110
168
  }
111
169
  return this._registrationPromise;
@@ -124,37 +182,6 @@ export class SwPortHandler {
124
182
  }
125
183
  }
126
184
 
127
- function getServiceWorkerController(): Promise<ServiceWorker> {
128
- return new Promise((resolve) => {
129
- const container = navigator.serviceWorker;
130
- if (container.controller) {
131
- resolve(container.controller);
132
- return;
133
- }
134
- const onChange = () => {
135
- if (!container.controller) return;
136
- resolve(container.controller);
137
- container.removeEventListener("controllerchange", onChange);
138
- };
139
- container.addEventListener("controllerchange", onChange);
140
- });
141
- }
142
-
143
- function awaitServiceWorkerActivation(worker: ServiceWorker): Promise<void> {
144
- return new Promise((resolve) => {
145
- if (worker.state === "activated") {
146
- resolve();
147
- return;
148
- }
149
- const onStateChange = () => {
150
- if (worker.state !== "activated") return;
151
- worker.removeEventListener("statechange", onStateChange);
152
- resolve();
153
- };
154
- worker.addEventListener("statechange", onStateChange);
155
- });
156
- }
157
-
158
185
  export interface SwPortDispatcherOptions {
159
186
  self: ServiceWorkerGlobalScope;
160
187
  log?: (...args: unknown[]) => void;
@@ -216,7 +243,8 @@ export class SwPortDispatcher {
216
243
  }
217
244
 
218
245
  start(): void {
219
- this._cleanup = handleChannelCalls(
246
+ const stopClaims = handleClaimRequests(this.self);
247
+ const stopPortUpdates = handleChannelCalls(
220
248
  this.self,
221
249
  "UPDATE_COMMUNICATION_PORT",
222
250
  async (event, channelInfo, port: MessagePort) => {
@@ -227,6 +255,10 @@ export class SwPortDispatcher {
227
255
  return { ...(channelInfo as ChannelInfo) };
228
256
  },
229
257
  );
258
+ this._cleanup = () => {
259
+ stopClaims();
260
+ stopPortUpdates();
261
+ };
230
262
 
231
263
  this.self.addEventListener("install", (event) => {
232
264
  this.log("Skip waiting on install.", event);
@@ -305,3 +337,23 @@ export class SwPortDispatcher {
305
337
  return index;
306
338
  }
307
339
  }
340
+
341
+ /**
342
+ * Resolve a `serviceWorkerUrl` option the way a page would: relative to the
343
+ * current document. Absolute urls pass through untouched. A url that cannot
344
+ * be resolved is reported with the option name and the offending value,
345
+ * because the raw `TypeError: Failed to construct 'URL': Invalid URL` says
346
+ * neither.
347
+ */
348
+ function resolveWorkerUrl(serviceWorkerUrl: string): URL {
349
+ const base = globalThis.location?.href;
350
+ try {
351
+ return new URL(serviceWorkerUrl, base);
352
+ } catch (error) {
353
+ throw new Error(
354
+ `Invalid serviceWorkerUrl: ${JSON.stringify(serviceWorkerUrl)}` +
355
+ (base ? ` (relative to ${base})` : " (no document to resolve it against)"),
356
+ { cause: error },
357
+ );
358
+ }
359
+ }