@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.
- package/README.md +164 -6
- package/dist/core/data-channels.d.ts.map +1 -1
- package/dist/core/deadline.d.ts +7 -0
- package/dist/core/deadline.d.ts.map +1 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/message-target.d.ts +1 -15
- package/dist/core/message-target.d.ts.map +1 -1
- package/dist/core/service-worker-control.d.ts +72 -0
- package/dist/core/service-worker-control.d.ts.map +1 -0
- package/dist/http/http-send-recieve.d.ts +27 -14
- package/dist/http/http-send-recieve.d.ts.map +1 -1
- package/dist/index.js +485 -167
- package/dist/relay/index-sw.d.ts.map +1 -1
- package/dist/relay/index.d.ts +23 -6
- package/dist/relay/index.d.ts.map +1 -1
- package/dist/relay-sw.js +864 -65
- package/dist/sw/sw-dispatcher.d.ts +22 -0
- package/dist/sw/sw-dispatcher.d.ts.map +1 -1
- package/dist/sw-worker.js +869 -64
- package/dist/sw.js +1096 -106
- package/package.json +8 -3
- package/src/core/data-channels.ts +42 -4
- package/src/core/deadline.ts +31 -0
- package/src/core/index.ts +1 -0
- package/src/core/message-target.ts +8 -18
- package/src/core/service-worker-control.ts +243 -0
- package/src/http/http-send-recieve.ts +27 -14
- package/src/relay/index-sw.ts +5 -0
- package/src/relay/index.ts +65 -47
- package/src/sw/sw-dispatcher.ts +98 -46
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statewalker/webrun-http-browser",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "ServiceWorker-based HTTP server for browsers, with relay and same-origin dispatch modes",
|
|
@@ -42,15 +42,19 @@
|
|
|
42
42
|
],
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"idb-keyval": "^6.3.0",
|
|
45
|
-
"@statewalker/webrun-streams": "0.
|
|
46
|
-
"@statewalker/webrun-
|
|
45
|
+
"@statewalker/webrun-http-streams": "0.2.2",
|
|
46
|
+
"@statewalker/webrun-streams": "0.2.0",
|
|
47
|
+
"@statewalker/webrun-rpc": "0.4.0"
|
|
47
48
|
},
|
|
48
49
|
"devDependencies": {
|
|
50
|
+
"@biomejs/biome": "^2.5.8",
|
|
49
51
|
"@types/node": "^26.2.0",
|
|
50
52
|
"http-server": "^14.1.1",
|
|
53
|
+
"playwright": "^1.62.1",
|
|
51
54
|
"rimraf": "^6.1.3",
|
|
52
55
|
"rolldown": "^1.2.4",
|
|
53
56
|
"typescript": "^7.0.2",
|
|
57
|
+
"vite": "^8.2.1",
|
|
54
58
|
"vitest": "^4.1.10"
|
|
55
59
|
},
|
|
56
60
|
"sideEffects": false,
|
|
@@ -60,6 +64,7 @@
|
|
|
60
64
|
"scripts": {
|
|
61
65
|
"build": "rimraf dist && rolldown -c && tsc --emitDeclarationOnly --declaration",
|
|
62
66
|
"test": "vitest run",
|
|
67
|
+
"test:browser": "pnpm run build && vitest run --config vitest.browser.config.ts",
|
|
63
68
|
"lint": "biome check src tests",
|
|
64
69
|
"serve": "http-server -p 5173 -c-1 -s .",
|
|
65
70
|
"example:same-origin": "pnpm run build && http-server -p 5173 -c-1 -o /public/index.html .",
|
|
@@ -125,11 +125,18 @@ export async function* sendStream<T>(
|
|
|
125
125
|
communicationPort.postMessage({ type: "START_CALL", params }, [messageChannel.port2]);
|
|
126
126
|
|
|
127
127
|
const channel = newStreamChannel<T>(messageChannel.port1);
|
|
128
|
+
let drained = false;
|
|
128
129
|
try {
|
|
129
130
|
await channel.start();
|
|
130
131
|
void channel.sendAll(input);
|
|
131
132
|
yield* channel.recieveAll();
|
|
133
|
+
drained = true;
|
|
132
134
|
} finally {
|
|
135
|
+
// A caller that stops early — an aborted `fetch`, a cancelled response
|
|
136
|
+
// body, a `break` — has to TELL the peer. Closing a `MessagePort` does not
|
|
137
|
+
// notify the other end, so without this the handler on the far side keeps
|
|
138
|
+
// producing into a port nobody reads, for the life of the page.
|
|
139
|
+
if (!drained) channel.cancel();
|
|
133
140
|
await channel.close();
|
|
134
141
|
}
|
|
135
142
|
}
|
|
@@ -168,26 +175,57 @@ export function handleStreams<T>(
|
|
|
168
175
|
interface StreamChannel<T> {
|
|
169
176
|
start(): Promise<void>;
|
|
170
177
|
close(): Promise<void>;
|
|
178
|
+
/** Tell the peer we have stopped reading, so it can release its producer. */
|
|
179
|
+
cancel(): void;
|
|
171
180
|
recieveAll(): AsyncGenerator<T, void, unknown>;
|
|
172
181
|
sendAll(it: AsyncIterable<T>): Promise<void>;
|
|
173
182
|
}
|
|
174
183
|
|
|
184
|
+
/** A data chunk, or — with `cancel` — the peer saying it has stopped reading. */
|
|
185
|
+
type StreamMessage<T> = { done?: boolean; value?: T; error?: unknown; cancel?: boolean };
|
|
186
|
+
|
|
175
187
|
function newStreamChannel<T>(port: MessageTarget): StreamChannel<T> {
|
|
176
|
-
type DataListener = (msg:
|
|
188
|
+
type DataListener = (msg: StreamMessage<T>) => Promise<boolean>;
|
|
177
189
|
let listeners: DataListener[] = [];
|
|
178
190
|
let iterators: AsyncIterable<T>[] = [];
|
|
179
191
|
|
|
180
|
-
const notifyAll = async (data:
|
|
192
|
+
const notifyAll = async (data: StreamMessage<T>) => {
|
|
181
193
|
for (const listener of listeners) await listener(data);
|
|
182
194
|
};
|
|
183
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Release whatever we are sending. NOT awaited: `.return()` on an async
|
|
198
|
+
* generator parked awaiting its own source is queued behind that pending
|
|
199
|
+
* `next()`, so awaiting it here would block the message handler — and the
|
|
200
|
+
* chunk that would unblock it can only arrive through that same handler.
|
|
201
|
+
*/
|
|
202
|
+
const cancelOutgoing = (): void => {
|
|
203
|
+
for (const it of [...iterators]) {
|
|
204
|
+
const iterable = it as AsyncIterable<T> & { return?: () => unknown };
|
|
205
|
+
void Promise.resolve(iterable.return?.()).catch(() => {});
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
184
209
|
const channel = newInvokationChannel({
|
|
185
210
|
port,
|
|
186
|
-
handler: (data: unknown) =>
|
|
211
|
+
handler: (data: unknown) => {
|
|
212
|
+
const message = data as StreamMessage<T>;
|
|
213
|
+
if (message?.cancel) {
|
|
214
|
+
cancelOutgoing();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
return notifyAll(message);
|
|
218
|
+
},
|
|
187
219
|
});
|
|
188
220
|
|
|
189
221
|
const start = () => channel.start();
|
|
190
222
|
|
|
223
|
+
const cancel = (): void => {
|
|
224
|
+
// Best effort by design: the port may already be gone, and a peer that
|
|
225
|
+
// never hears this is no worse off than before the message existed.
|
|
226
|
+
void channel.invoke({ cancel: true }).catch(() => {});
|
|
227
|
+
};
|
|
228
|
+
|
|
191
229
|
const close = async () => {
|
|
192
230
|
await notifyAll({ done: true });
|
|
193
231
|
for (const it of [...iterators]) {
|
|
@@ -220,5 +258,5 @@ function newStreamChannel<T>(port: MessageTarget): StreamChannel<T> {
|
|
|
220
258
|
);
|
|
221
259
|
}
|
|
222
260
|
|
|
223
|
-
return { start, close, recieveAll, sendAll };
|
|
261
|
+
return { start, close, cancel, recieveAll, sendAll };
|
|
224
262
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settles like `promise` if it settles before `deadline` (a `Date.now()`
|
|
3
|
+
* value). Otherwise calls `onTimeout`: an `Error` it returns rejects, any
|
|
4
|
+
* other value resolves. Internal; not re-exported.
|
|
5
|
+
*/
|
|
6
|
+
export function withDeadline<T, F>(
|
|
7
|
+
deadline: number,
|
|
8
|
+
promise: Promise<T>,
|
|
9
|
+
onTimeout: () => F,
|
|
10
|
+
): Promise<T | (F extends Error ? never : F)> {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const timer = setTimeout(
|
|
13
|
+
() => {
|
|
14
|
+
const outcome = onTimeout();
|
|
15
|
+
if (outcome instanceof Error) reject(outcome);
|
|
16
|
+
else resolve(outcome as F extends Error ? never : F);
|
|
17
|
+
},
|
|
18
|
+
Math.max(0, deadline - Date.now()),
|
|
19
|
+
);
|
|
20
|
+
promise.then(
|
|
21
|
+
(value) => {
|
|
22
|
+
clearTimeout(timer);
|
|
23
|
+
resolve(value);
|
|
24
|
+
},
|
|
25
|
+
(error) => {
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
reject(error);
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
}
|
package/src/core/index.ts
CHANGED
|
@@ -1,18 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/** An object we can post messages to (with optional transferable list). */
|
|
11
|
-
export interface MessageSink {
|
|
12
|
-
postMessage(message: unknown, transfer?: Transferable[]): void;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** Full-duplex message target: both sends and receives. */
|
|
16
|
-
export interface MessageTarget extends MessageSource, MessageSink {
|
|
17
|
-
close?(): void | Promise<void>;
|
|
18
|
-
}
|
|
1
|
+
// Re-exported from @statewalker/webrun-rpc, which owns the port layer, so this
|
|
2
|
+
// package's five internal importers and its public API are unchanged.
|
|
3
|
+
export type {
|
|
4
|
+
MessageListener,
|
|
5
|
+
MessageSink,
|
|
6
|
+
MessageSource,
|
|
7
|
+
MessageTarget,
|
|
8
|
+
} from "@statewalker/webrun-rpc";
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/// <reference lib="webworker" />
|
|
2
|
+
|
|
3
|
+
import { callChannel, handleChannelCalls } from "./data-calls.js";
|
|
4
|
+
import { withDeadline } from "./deadline.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* How long, by default, the page waits for its ServiceWorker to activate and
|
|
8
|
+
* to take control before giving up. Generous: a first install downloads and
|
|
9
|
+
* evaluates the worker script, which on a slow link takes seconds.
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULT_SERVICE_WORKER_TIMEOUT = 30_000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* How long the page waits for `controllerchange` once the worker has
|
|
15
|
+
* answered the `CLAIM` request. `clients.claim()` resolves only after the
|
|
16
|
+
* browser has queued that event, so this is a grace period for delivery, not
|
|
17
|
+
* a second budget.
|
|
18
|
+
*/
|
|
19
|
+
const CLAIM_GRACE_MS = 1_000;
|
|
20
|
+
|
|
21
|
+
/** Channel call a page sends to ask its ServiceWorker to `clients.claim()` it. */
|
|
22
|
+
export const CLAIM_CALL = "CLAIM";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* - `activation-timeout`: the worker did not activate in time.
|
|
26
|
+
* - `uncontrolled`: the worker is active but the page is not controlled by it.
|
|
27
|
+
* - `unresponsive`: the page is controlled, but the worker did not answer the
|
|
28
|
+
* adapter's handshake in time.
|
|
29
|
+
*/
|
|
30
|
+
export type ServiceWorkerControlFailure = "activation-timeout" | "uncontrolled" | "unresponsive";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Why a page could not get a working ServiceWorker. `reason` says which wait
|
|
34
|
+
* failed; check it (or `name`) rather than `instanceof`, because each of this
|
|
35
|
+
* package's bundles carries its own copy of this class.
|
|
36
|
+
*/
|
|
37
|
+
export class ServiceWorkerControlError extends Error {
|
|
38
|
+
readonly reason: ServiceWorkerControlFailure;
|
|
39
|
+
constructor(reason: ServiceWorkerControlFailure, message: string) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = "ServiceWorkerControlError";
|
|
42
|
+
this.reason = reason;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface AwaitServiceWorkerOptions {
|
|
47
|
+
/** Upper bound for the whole wait, in ms. Default `DEFAULT_SERVICE_WORKER_TIMEOUT`. */
|
|
48
|
+
timeout?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface AwaitServiceWorkerControlOptions extends AwaitServiceWorkerOptions {
|
|
52
|
+
/**
|
|
53
|
+
* When the page is still uncontrolled after asking the worker to claim it,
|
|
54
|
+
* reload the page once instead of rejecting. A normal reload is a
|
|
55
|
+
* navigation, and navigations are controlled. Guarded by `sessionStorage`
|
|
56
|
+
* so it never loops: if the reloaded page is uncontrolled too, it rejects.
|
|
57
|
+
* Default `false`.
|
|
58
|
+
*/
|
|
59
|
+
reloadIfUncontrolled?: boolean;
|
|
60
|
+
/** For tests. Default `navigator.serviceWorker`. */
|
|
61
|
+
container?: ServiceWorkerContainer;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolves with the registration's worker once it is `activated`. Rejects
|
|
66
|
+
* with a `ServiceWorkerControlError` (`reason: "activation-timeout"`) if that
|
|
67
|
+
* takes longer than `timeout` — an install that throws or never finishes
|
|
68
|
+
* would otherwise leave the caller waiting forever.
|
|
69
|
+
*/
|
|
70
|
+
export async function awaitActiveServiceWorker(
|
|
71
|
+
registration: ServiceWorkerRegistration,
|
|
72
|
+
{ timeout = DEFAULT_SERVICE_WORKER_TIMEOUT }: AwaitServiceWorkerOptions = {},
|
|
73
|
+
): Promise<ServiceWorker> {
|
|
74
|
+
const deadline = Date.now() + timeout;
|
|
75
|
+
return await withDeadline(deadline, waitForActivated(registration), () => {
|
|
76
|
+
const worker = registration.installing ?? registration.waiting ?? registration.active;
|
|
77
|
+
return new ServiceWorkerControlError(
|
|
78
|
+
"activation-timeout",
|
|
79
|
+
`ServiceWorker ${scriptUrl(worker)} (scope ${registration.scope}) did not activate within ` +
|
|
80
|
+
`${timeout} ms` +
|
|
81
|
+
(worker ? `; it is "${worker.state}"` : "") +
|
|
82
|
+
". Check the worker script for errors during install (DevTools → Application → " +
|
|
83
|
+
"Service Workers), or raise the `timeout` option.",
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resolves with the ServiceWorker that controls this page, once `registration`
|
|
90
|
+
* has an activated worker and the page is controlled by it.
|
|
91
|
+
*
|
|
92
|
+
* A page can stay uncontrolled while its worker is active, and then no
|
|
93
|
+
* `controllerchange` ever fires on its own:
|
|
94
|
+
* - a hard reload (Ctrl+Shift+R) bypasses the worker for that load, and the
|
|
95
|
+
* worker's `clients.claim()` already ran when it activated;
|
|
96
|
+
* - Firefox can leave a page loaded while the worker is running uncontrolled.
|
|
97
|
+
*
|
|
98
|
+
* So when the page is uncontrolled, this asks the active worker to claim it
|
|
99
|
+
* again (a `CLAIM` channel call — this package's workers answer it) and waits
|
|
100
|
+
* for `controllerchange`. Every wait is bounded by `timeout`. If control never
|
|
101
|
+
* comes it reloads once (`reloadIfUncontrolled`) or rejects with a
|
|
102
|
+
* `ServiceWorkerControlError` (`reason: "uncontrolled"`) that says what
|
|
103
|
+
* happened and what to do.
|
|
104
|
+
*/
|
|
105
|
+
export async function awaitServiceWorkerControl(
|
|
106
|
+
registration: ServiceWorkerRegistration,
|
|
107
|
+
{
|
|
108
|
+
timeout = DEFAULT_SERVICE_WORKER_TIMEOUT,
|
|
109
|
+
reloadIfUncontrolled = false,
|
|
110
|
+
container = navigator.serviceWorker,
|
|
111
|
+
}: AwaitServiceWorkerControlOptions = {},
|
|
112
|
+
): Promise<ServiceWorker> {
|
|
113
|
+
const deadline = Date.now() + timeout;
|
|
114
|
+
// Listen before anything else, so a `controllerchange` that lands while we
|
|
115
|
+
// wait for activation is not missed.
|
|
116
|
+
const controlled = waitForController(container);
|
|
117
|
+
try {
|
|
118
|
+
const active = await awaitActiveServiceWorker(registration, { timeout });
|
|
119
|
+
let controller = container.controller;
|
|
120
|
+
if (!controller) {
|
|
121
|
+
controller = await withDeadline(
|
|
122
|
+
deadline,
|
|
123
|
+
(async () => {
|
|
124
|
+
// Answered only once `clients.claim()` has resolved; by then the
|
|
125
|
+
// browser has queued `controllerchange` — or declined to.
|
|
126
|
+
await Promise.race([callChannel(active, CLAIM_CALL, {}), controlled.promise]);
|
|
127
|
+
return await Promise.race([controlled.promise, delay(CLAIM_GRACE_MS, null)]);
|
|
128
|
+
})(),
|
|
129
|
+
() => null,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (controller) {
|
|
133
|
+
forgetReload(registration);
|
|
134
|
+
return controller;
|
|
135
|
+
}
|
|
136
|
+
if (reloadIfUncontrolled && markReload(registration)) {
|
|
137
|
+
location.reload();
|
|
138
|
+
// The page is going away; settling now would only race the unload.
|
|
139
|
+
return await new Promise<never>(() => {});
|
|
140
|
+
}
|
|
141
|
+
forgetReload(registration);
|
|
142
|
+
throw new ServiceWorkerControlError(
|
|
143
|
+
"uncontrolled",
|
|
144
|
+
`This page is not controlled by its ServiceWorker ${scriptUrl(active)} ` +
|
|
145
|
+
`(scope ${registration.scope}), although the worker is active, and the worker did not ` +
|
|
146
|
+
`take control when asked (clients.claim()) within ${timeout} ms. ` +
|
|
147
|
+
"This happens after a hard reload (Ctrl+Shift+R / Cmd+Shift+R), which bypasses " +
|
|
148
|
+
"ServiceWorkers for that load, and in Firefox for some pages opened while the worker " +
|
|
149
|
+
"was already running. Requests from this page would not reach the worker. " +
|
|
150
|
+
"Reload the page normally, pass `reloadIfUncontrolled: true` to do that automatically, " +
|
|
151
|
+
"check that the page is inside the worker's scope, and that the worker answers the " +
|
|
152
|
+
`"${CLAIM_CALL}" request (this package's workers do).`,
|
|
153
|
+
);
|
|
154
|
+
} finally {
|
|
155
|
+
controlled.cancel();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* ServiceWorker side: answers the page's `CLAIM` request with
|
|
161
|
+
* `clients.claim()`, which takes over every uncontrolled client in scope.
|
|
162
|
+
* Returns a function that stops answering.
|
|
163
|
+
*/
|
|
164
|
+
export function handleClaimRequests(self: ServiceWorkerGlobalScope): () => void {
|
|
165
|
+
return handleChannelCalls(self, CLAIM_CALL, (event) => {
|
|
166
|
+
const claimed = self.clients.claim().then(() => true);
|
|
167
|
+
(event as unknown as Partial<ExtendableMessageEvent>).waitUntil?.(claimed);
|
|
168
|
+
return claimed;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function waitForActivated(registration: ServiceWorkerRegistration): Promise<ServiceWorker> {
|
|
173
|
+
return new Promise((resolve) => {
|
|
174
|
+
const watched = new Set<ServiceWorker>();
|
|
175
|
+
const check = () => {
|
|
176
|
+
const active = registration.active;
|
|
177
|
+
if (active?.state === "activated") {
|
|
178
|
+
registration.removeEventListener("updatefound", watch);
|
|
179
|
+
for (const worker of watched) worker.removeEventListener("statechange", check);
|
|
180
|
+
resolve(active);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
watch();
|
|
184
|
+
};
|
|
185
|
+
function watch() {
|
|
186
|
+
for (const worker of [registration.installing, registration.waiting, registration.active]) {
|
|
187
|
+
if (!worker || watched.has(worker)) continue;
|
|
188
|
+
watched.add(worker);
|
|
189
|
+
worker.addEventListener("statechange", check);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
registration.addEventListener("updatefound", watch);
|
|
193
|
+
check();
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function waitForController(container: ServiceWorkerContainer): {
|
|
198
|
+
promise: Promise<ServiceWorker>;
|
|
199
|
+
cancel: () => void;
|
|
200
|
+
} {
|
|
201
|
+
let cancel = () => {};
|
|
202
|
+
const promise = new Promise<ServiceWorker>((resolve) => {
|
|
203
|
+
const onChange = () => {
|
|
204
|
+
if (!container.controller) return;
|
|
205
|
+
cancel();
|
|
206
|
+
resolve(container.controller);
|
|
207
|
+
};
|
|
208
|
+
cancel = () => container.removeEventListener("controllerchange", onChange);
|
|
209
|
+
container.addEventListener("controllerchange", onChange);
|
|
210
|
+
});
|
|
211
|
+
return { promise, cancel };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function delay<T>(ms: number, value: T): Promise<T> {
|
|
215
|
+
return new Promise((resolve) => setTimeout(() => resolve(value), ms));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function scriptUrl(worker: ServiceWorker | null | undefined): string {
|
|
219
|
+
return worker?.scriptURL ? `"${worker.scriptURL}"` : "(no worker)";
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function reloadKey(registration: ServiceWorkerRegistration): string {
|
|
223
|
+
return `webrun-http-browser:reloaded-uncontrolled:${registration.scope}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Records the reload about to happen. `false` if one already happened, or if it cannot be recorded. */
|
|
227
|
+
function markReload(registration: ServiceWorkerRegistration): boolean {
|
|
228
|
+
try {
|
|
229
|
+
const key = reloadKey(registration);
|
|
230
|
+
if (sessionStorage.getItem(key)) return false;
|
|
231
|
+
sessionStorage.setItem(key, "1");
|
|
232
|
+
return true;
|
|
233
|
+
} catch {
|
|
234
|
+
// No storage, no loop guard: reloading could then repeat forever.
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function forgetReload(registration: ServiceWorkerRegistration): void {
|
|
240
|
+
try {
|
|
241
|
+
sessionStorage.removeItem(reloadKey(registration));
|
|
242
|
+
} catch {}
|
|
243
|
+
}
|
|
@@ -39,13 +39,21 @@ async function httpFromIterator<Options>(
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* `
|
|
48
|
-
* `
|
|
42
|
+
* Serve an `HttpHandler` over a `MessageTarget`, using this package's own
|
|
43
|
+
* `handleStreams` transport.
|
|
44
|
+
*
|
|
45
|
+
* @deprecated Prefer the port stack in `@statewalker/webrun-rpc`: open a port
|
|
46
|
+
* (`multiplexPort` over one pipe, or `transferPortMux` where the platform can
|
|
47
|
+
* transfer a real `MessagePort`), turn it into a `Duplex` with
|
|
48
|
+
* `serveDuplexOverPort`, and serve HTTP on that with
|
|
49
|
+
* `httpServe(handler, options)` from `@statewalker/webrun-http-streams`.
|
|
50
|
+
*
|
|
51
|
+
* That path has backpressure; **this one does not.** `sendStream`'s chunk
|
|
52
|
+
* sender discards the promise it is given, so a fast producer over a slow
|
|
53
|
+
* consumer accumulates without bound. It also has no per-stream timeout and no
|
|
54
|
+
* chunking to a transport's message ceiling.
|
|
55
|
+
*
|
|
56
|
+
* Kept for existing ServiceWorker setups built on the `MessageTarget` surface.
|
|
49
57
|
*/
|
|
50
58
|
export function handleHttpRequests(
|
|
51
59
|
communicationPort: MessageTarget,
|
|
@@ -60,13 +68,18 @@ export function handleHttpRequests(
|
|
|
60
68
|
}
|
|
61
69
|
|
|
62
70
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* `
|
|
71
|
+
* Ship a `Request` over a `MessageTarget` and await the `Response`, using this
|
|
72
|
+
* package's own `sendStream` transport.
|
|
73
|
+
*
|
|
74
|
+
* @deprecated Prefer the port stack in `@statewalker/webrun-rpc`: open a port
|
|
75
|
+
* (`multiplexPort` over one pipe, or `transferPortMux` where the platform can
|
|
76
|
+
* transfer a real `MessagePort`), turn it into a `Duplex` with
|
|
77
|
+
* `duplexOverPort`, and drive HTTP over it with `httpFetch` from
|
|
78
|
+
* `@statewalker/webrun-http-streams`.
|
|
79
|
+
*
|
|
80
|
+
* Same caveat as {@link handleHttpRequests}: the transport underneath this
|
|
81
|
+
* helper has no backpressure, no per-stream timeout, and no chunking to a
|
|
82
|
+
* transport's message ceiling. Kept for existing ServiceWorker setups.
|
|
70
83
|
*/
|
|
71
84
|
export async function sendHttpRequest(
|
|
72
85
|
communicationPort: MessageTarget,
|
package/src/relay/index-sw.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { HttpError } from "@statewalker/webrun-http-streams";
|
|
|
2
2
|
import { get, set } from "idb-keyval";
|
|
3
3
|
import { callChannel, handleChannelCalls } from "../core/data-calls.js";
|
|
4
4
|
import { newRegistry } from "../core/registry.js";
|
|
5
|
+
import { handleClaimRequests } from "../core/service-worker-control.js";
|
|
5
6
|
import { sendHttpRequest } from "../http/http-send-recieve.js";
|
|
6
7
|
import { splitServiceUrl } from "./split-service-url.js";
|
|
7
8
|
|
|
@@ -27,6 +28,10 @@ export function startRelayServiceWorker(self: ServiceWorkerGlobalScope): () => v
|
|
|
27
28
|
|
|
28
29
|
const clientsRegistry = newClientsRegistry({ self });
|
|
29
30
|
|
|
31
|
+
// Pages bridge to `registration.active` when uncontrolled, so they do not
|
|
32
|
+
// need this; it is here so any page of this origin can ask for control.
|
|
33
|
+
register(handleClaimRequests(self));
|
|
34
|
+
|
|
30
35
|
register(
|
|
31
36
|
handleChannelCalls(self, "REGISTER", async (event, data) => {
|
|
32
37
|
const source = event.source as Client | null;
|