@statewalker/webrun-http-browser 0.6.5 → 0.7.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 +302 -52
- package/dist/core/data-calls.d.ts +17 -0
- package/dist/core/data-calls.d.ts.map +1 -1
- package/dist/core/service-worker-control.d.ts +3 -1
- package/dist/core/service-worker-control.d.ts.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3906 -265
- package/dist/link/errors.d.ts +2 -0
- package/dist/link/errors.d.ts.map +1 -0
- package/dist/link/http-link.d.ts +58 -0
- package/dist/link/http-link.d.ts.map +1 -0
- package/dist/link/index.d.ts +4 -0
- package/dist/link/index.d.ts.map +1 -0
- package/dist/link/locks.d.ts +39 -0
- package/dist/link/locks.d.ts.map +1 -0
- package/dist/link/rendezvous.d.ts +21 -0
- package/dist/link/rendezvous.d.ts.map +1 -0
- package/dist/relay/index-sw.d.ts +12 -2
- package/dist/relay/index-sw.d.ts.map +1 -1
- package/dist/relay/index.d.ts +66 -8
- package/dist/relay/index.d.ts.map +1 -1
- package/dist/relay-sw.js +1160 -55
- package/dist/relay-worker.js +1161 -55
- package/dist/sw/handler-endpoint.d.ts +81 -0
- package/dist/sw/handler-endpoint.d.ts.map +1 -0
- package/dist/sw/handler-table.d.ts +53 -0
- package/dist/sw/handler-table.d.ts.map +1 -0
- package/dist/sw/http-sw-dispatcher.d.ts +162 -21
- package/dist/sw/http-sw-dispatcher.d.ts.map +1 -1
- package/dist/sw/index.d.ts +5 -0
- package/dist/sw/index.d.ts.map +1 -1
- package/dist/sw/protocol.d.ts +115 -0
- package/dist/sw/protocol.d.ts.map +1 -0
- package/dist/sw/sw-dispatcher.d.ts.map +1 -1
- package/dist/sw/sw-http-adapter.d.ts +199 -0
- package/dist/sw/sw-http-adapter.d.ts.map +1 -0
- package/dist/sw/sw-websocket.d.ts +16 -0
- package/dist/sw/sw-websocket.d.ts.map +1 -0
- package/dist/sw/worker-service.d.ts +45 -0
- package/dist/sw/worker-service.d.ts.map +1 -0
- package/dist/sw/worker-url.d.ts +9 -0
- package/dist/sw/worker-url.d.ts.map +1 -0
- package/dist/sw-worker.js +2075 -210
- package/dist/sw.d.ts +2 -0
- package/dist/sw.d.ts.map +1 -1
- package/dist/sw.js +3901 -531
- package/dist/worker.d.ts +10 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +3507 -0
- package/dist/ws/client.d.ts +73 -0
- package/dist/ws/client.d.ts.map +1 -0
- package/dist/ws/frames.d.ts +68 -0
- package/dist/ws/frames.d.ts.map +1 -0
- package/dist/ws/index.d.ts +6 -0
- package/dist/ws/index.d.ts.map +1 -0
- package/dist/ws/server.d.ts +53 -0
- package/dist/ws/server.d.ts.map +1 -0
- package/dist/ws/socket-core.d.ts +91 -0
- package/dist/ws/socket-core.d.ts.map +1 -0
- package/dist/ws/upgrade.d.ts +93 -0
- package/dist/ws/upgrade.d.ts.map +1 -0
- package/package.json +10 -5
- package/src/core/data-calls.ts +52 -2
- package/src/core/service-worker-control.ts +7 -1
- package/src/index.ts +3 -0
- package/src/link/errors.ts +3 -0
- package/src/link/http-link.ts +222 -0
- package/src/link/index.ts +3 -0
- package/src/link/locks.ts +97 -0
- package/src/link/rendezvous.ts +60 -0
- package/src/relay/index-sw.ts +84 -12
- package/src/relay/index.ts +203 -27
- package/src/sw/handler-endpoint.ts +278 -0
- package/src/sw/handler-table.ts +130 -0
- package/src/sw/http-sw-dispatcher.ts +649 -90
- package/src/sw/index.ts +5 -0
- package/src/sw/protocol.ts +103 -0
- package/src/sw/sw-dispatcher.ts +1 -20
- package/src/sw/sw-http-adapter.ts +613 -0
- package/src/sw/sw-websocket.ts +43 -0
- package/src/sw/worker-service.ts +133 -0
- package/src/sw/worker-url.ts +19 -0
- package/src/sw-worker.ts +8 -3
- package/src/sw.ts +2 -0
- package/src/worker.ts +9 -0
- package/src/ws/client.ts +346 -0
- package/src/ws/frames.ts +119 -0
- package/src/ws/index.ts +11 -0
- package/src/ws/server.ts +132 -0
- package/src/ws/socket-core.ts +251 -0
- package/src/ws/upgrade.ts +200 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { fetchOverDuplex, serveFetchOverDuplex } from "@statewalker/webrun-http-streams";
|
|
2
|
+
import { connect, overPipe, serve, structuredCodec } from "@statewalker/webrun-rpc";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The version of the HTTP-over-`MessagePort` protocol this package speaks,
|
|
6
|
+
* sent in every hand-shake that sets up a link. Version 1 was this package's
|
|
7
|
+
* own `handleStreams` / `sendStream` (no backpressure); version 2 is
|
|
8
|
+
* webrun-rpc's `connect` / `serve` over `overPipe` with webrun-http-streams'
|
|
9
|
+
* `fetchOverDuplex` / `serveFetchOverDuplex`: one port per link, calls
|
|
10
|
+
* multiplexed on it, each call a stream with a one-chunk window in both
|
|
11
|
+
* directions, and cancellation that reaches the handler.
|
|
12
|
+
*/
|
|
13
|
+
export const HTTP_LINK_PROTOCOL = "webrun-http-link/2";
|
|
14
|
+
|
|
15
|
+
export interface HttpLinkOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Per-stream inactivity timeout in ms (webrun-rpc's): a stream with no chunk
|
|
18
|
+
* in either direction for that long fails. Unset — the default — means
|
|
19
|
+
* none: a slow consumer is throttled, never failed.
|
|
20
|
+
*/
|
|
21
|
+
streamTimeout?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The calling end of a link: sends requests, gets responses. */
|
|
25
|
+
export interface HttpLink {
|
|
26
|
+
/** Sends `request` and resolves with the response, whose body streams. */
|
|
27
|
+
send(request: Request): Promise<Response>;
|
|
28
|
+
/**
|
|
29
|
+
* Requests whose response has not finished yet: still waiting for the head,
|
|
30
|
+
* or with a body still streaming.
|
|
31
|
+
*/
|
|
32
|
+
readonly inflight: number;
|
|
33
|
+
/** Resolves when `inflight` falls to 0. */
|
|
34
|
+
whenIdle(): Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* Closes the link. Requests in flight fail with `reason` (default: the link
|
|
37
|
+
* was closed) instead of hanging.
|
|
38
|
+
*/
|
|
39
|
+
close(reason?: Error): Promise<void>;
|
|
40
|
+
readonly closed: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Why a request failed when its link went away under it: the far side (the
|
|
45
|
+
* handler, or the ServiceWorker) was lost. The request may or may not have
|
|
46
|
+
* been applied.
|
|
47
|
+
*/
|
|
48
|
+
export class LinkLostError extends Error {
|
|
49
|
+
constructor(message = "The link was lost while this request was in flight") {
|
|
50
|
+
super(`${message}; it may or may not have been applied.`);
|
|
51
|
+
this.name = "LinkLostError";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Posted on a link's port by the calling end when it closes the link, so the
|
|
57
|
+
* answering end can release it: a `MessagePort` never reports that its other
|
|
58
|
+
* end is gone.
|
|
59
|
+
*/
|
|
60
|
+
const LINK_CLOSE = "webrun-http:link-close";
|
|
61
|
+
|
|
62
|
+
/** Opens the calling end of a link on `port` (one port per link). */
|
|
63
|
+
export async function connectHttpLink(
|
|
64
|
+
port: MessagePort,
|
|
65
|
+
{ streamTimeout }: HttpLinkOptions = {},
|
|
66
|
+
): Promise<HttpLink> {
|
|
67
|
+
const { call, close } = await connect({
|
|
68
|
+
mux: overPipe(port, { codec: structuredCodec, side: "initiator" }),
|
|
69
|
+
timeout: streamTimeout,
|
|
70
|
+
});
|
|
71
|
+
return trackedLink(
|
|
72
|
+
(request) => fetchOverDuplex(call, request),
|
|
73
|
+
async () => {
|
|
74
|
+
try {
|
|
75
|
+
port.postMessage({ type: LINK_CLOSE });
|
|
76
|
+
} catch {}
|
|
77
|
+
await close();
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Serves `handler` on the answering end of a link. Resolves with a teardown
|
|
84
|
+
* that abandons the requests still running (their handlers see the
|
|
85
|
+
* cancellation) and releases the port.
|
|
86
|
+
*/
|
|
87
|
+
export async function serveHttpLink(
|
|
88
|
+
port: MessagePort,
|
|
89
|
+
handler: (request: Request) => Response | Promise<Response>,
|
|
90
|
+
{ streamTimeout }: HttpLinkOptions = {},
|
|
91
|
+
): Promise<() => Promise<void>> {
|
|
92
|
+
const teardown = await serve(
|
|
93
|
+
{
|
|
94
|
+
mux: overPipe(port, { codec: structuredCodec, side: "responder" }),
|
|
95
|
+
timeout: streamTimeout,
|
|
96
|
+
},
|
|
97
|
+
serveFetchOverDuplex(async (request) => await handler(request)),
|
|
98
|
+
);
|
|
99
|
+
const onClose = (event: MessageEvent) => {
|
|
100
|
+
if ((event.data as { type?: unknown } | null)?.type === LINK_CLOSE) void close();
|
|
101
|
+
};
|
|
102
|
+
const close = async () => {
|
|
103
|
+
port.removeEventListener("message", onClose);
|
|
104
|
+
await teardown();
|
|
105
|
+
};
|
|
106
|
+
port.addEventListener("message", onClose);
|
|
107
|
+
return close;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Wraps a `send` so the link knows its requests in flight (for draining and
|
|
112
|
+
* load-based routing) and can fail them all at once when it is closed.
|
|
113
|
+
*/
|
|
114
|
+
export function trackedLink(
|
|
115
|
+
send: (request: Request) => Promise<Response>,
|
|
116
|
+
release: () => Promise<void> | void,
|
|
117
|
+
): HttpLink {
|
|
118
|
+
let inflight = 0;
|
|
119
|
+
let closed = false;
|
|
120
|
+
let lostReason: Error | undefined;
|
|
121
|
+
const failers = new Set<(error: Error) => void>();
|
|
122
|
+
const idleWaiters: Array<() => void> = [];
|
|
123
|
+
|
|
124
|
+
const done = () => {
|
|
125
|
+
inflight--;
|
|
126
|
+
if (inflight === 0) {
|
|
127
|
+
for (const resolve of idleWaiters.splice(0)) resolve();
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const link: HttpLink = {
|
|
132
|
+
get inflight() {
|
|
133
|
+
return inflight;
|
|
134
|
+
},
|
|
135
|
+
get closed() {
|
|
136
|
+
return closed;
|
|
137
|
+
},
|
|
138
|
+
whenIdle() {
|
|
139
|
+
return inflight === 0 ? Promise.resolve() : new Promise((r) => idleWaiters.push(r));
|
|
140
|
+
},
|
|
141
|
+
async send(request) {
|
|
142
|
+
if (closed) throw lostReason ?? new LinkLostError("The link is closed");
|
|
143
|
+
inflight++;
|
|
144
|
+
let finished = false;
|
|
145
|
+
const finish = () => {
|
|
146
|
+
if (finished) return;
|
|
147
|
+
finished = true;
|
|
148
|
+
failers.delete(fail);
|
|
149
|
+
done();
|
|
150
|
+
};
|
|
151
|
+
let fail: (error: Error) => void = () => {};
|
|
152
|
+
const lost = new Promise<never>((_, reject) => {
|
|
153
|
+
fail = reject;
|
|
154
|
+
});
|
|
155
|
+
failers.add(fail);
|
|
156
|
+
lost.catch(() => {});
|
|
157
|
+
let response: Response;
|
|
158
|
+
try {
|
|
159
|
+
response = await Promise.race([send(request), lost]);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
finish();
|
|
162
|
+
throw lostReason ?? error;
|
|
163
|
+
}
|
|
164
|
+
if (!response.body) {
|
|
165
|
+
finish();
|
|
166
|
+
return response;
|
|
167
|
+
}
|
|
168
|
+
return new Response(
|
|
169
|
+
guardBody(response.body, lost, () => lostReason, finish),
|
|
170
|
+
{
|
|
171
|
+
status: response.status,
|
|
172
|
+
statusText: response.statusText,
|
|
173
|
+
headers: response.headers,
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
},
|
|
177
|
+
async close(reason) {
|
|
178
|
+
if (closed) return;
|
|
179
|
+
closed = true;
|
|
180
|
+
lostReason = reason ?? new LinkLostError("The link was closed");
|
|
181
|
+
for (const fail of [...failers]) fail(lostReason);
|
|
182
|
+
try {
|
|
183
|
+
await release();
|
|
184
|
+
} catch {}
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
return link;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* A response body that reports its end (done, cancelled or failed) and fails
|
|
192
|
+
* with the link's reason when the link is lost mid-stream.
|
|
193
|
+
*/
|
|
194
|
+
function guardBody(
|
|
195
|
+
body: ReadableStream<Uint8Array>,
|
|
196
|
+
lost: Promise<never>,
|
|
197
|
+
reason: () => Error | undefined,
|
|
198
|
+
finish: () => void,
|
|
199
|
+
): ReadableStream<Uint8Array> {
|
|
200
|
+
const reader = body.getReader();
|
|
201
|
+
return new ReadableStream<Uint8Array>({
|
|
202
|
+
async pull(controller) {
|
|
203
|
+
try {
|
|
204
|
+
const { done, value } = await Promise.race([reader.read(), lost]);
|
|
205
|
+
if (done) {
|
|
206
|
+
finish();
|
|
207
|
+
controller.close();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
controller.enqueue(value);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
finish();
|
|
213
|
+
void reader.cancel().catch(() => {});
|
|
214
|
+
controller.error(reason() ?? error);
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
async cancel(why) {
|
|
218
|
+
finish();
|
|
219
|
+
await reader.cancel(why).catch(() => {});
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Liveness through Web Locks.
|
|
3
|
+
*
|
|
4
|
+
* A `MessagePort` never reports that the other side died, but a Web Lock
|
|
5
|
+
* does: a lock held by a page, a worker or a ServiceWorker is released by the
|
|
6
|
+
* browser the moment that context goes away, and a request waiting for it is
|
|
7
|
+
* granted then. So each side that must notice the other's death holds a lock
|
|
8
|
+
* of its own and watches the other's:
|
|
9
|
+
*
|
|
10
|
+
* - a handler holds an owner lock; the ServiceWorker watches it, and fails the
|
|
11
|
+
* handler's requests in flight when it is granted (the tab closed, the
|
|
12
|
+
* worker was terminated);
|
|
13
|
+
* - the ServiceWorker holds a router lock; handlers watch it, and fail their
|
|
14
|
+
* calls through it when it is granted (the browser stopped the worker).
|
|
15
|
+
*
|
|
16
|
+
* Every function here degrades to a no-op where `navigator.locks` is missing.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
interface LockManagerLike {
|
|
20
|
+
query?(): Promise<{ held?: Array<{ name?: string }> }>;
|
|
21
|
+
request(
|
|
22
|
+
name: string,
|
|
23
|
+
options: { signal?: AbortSignal; mode?: "exclusive" | "shared" },
|
|
24
|
+
callback: () => unknown,
|
|
25
|
+
): Promise<unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function lockManager(): LockManagerLike | undefined {
|
|
29
|
+
return (globalThis.navigator as { locks?: LockManagerLike } | undefined)?.locks;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function locksAvailable(): boolean {
|
|
33
|
+
return lockManager() !== undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A fresh lock name: `<prefix>:<uuid>`. */
|
|
37
|
+
export function newLockName(prefix: string): string {
|
|
38
|
+
return `${prefix}:${crypto.randomUUID()}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Takes the lock `name` and holds it until the returned release is called or
|
|
43
|
+
* this context dies. Resolves once the lock is held; with no Web Locks,
|
|
44
|
+
* resolves at once.
|
|
45
|
+
*/
|
|
46
|
+
export async function holdLock(name: string): Promise<() => void> {
|
|
47
|
+
const locks = lockManager();
|
|
48
|
+
if (!locks) return () => {};
|
|
49
|
+
let release: () => void = () => {};
|
|
50
|
+
const released = new Promise<void>((resolve) => {
|
|
51
|
+
release = resolve;
|
|
52
|
+
});
|
|
53
|
+
await new Promise<void>((held) => {
|
|
54
|
+
void locks
|
|
55
|
+
.request(name, {}, () => {
|
|
56
|
+
held();
|
|
57
|
+
return released;
|
|
58
|
+
})
|
|
59
|
+
.catch(() => held());
|
|
60
|
+
});
|
|
61
|
+
return release;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Calls `onLost` once the lock `name` is granted to this context — that is,
|
|
66
|
+
* once whoever holds it is gone — and releases it at once. Returns a function
|
|
67
|
+
* that stops watching. With no Web Locks, never calls `onLost`.
|
|
68
|
+
*/
|
|
69
|
+
export function watchLock(name: string, onLost: () => void): () => void {
|
|
70
|
+
const locks = lockManager();
|
|
71
|
+
if (!locks) return () => {};
|
|
72
|
+
const controller = new AbortController();
|
|
73
|
+
void locks
|
|
74
|
+
.request(name, { signal: controller.signal }, () => {
|
|
75
|
+
if (!controller.signal.aborted) onLost();
|
|
76
|
+
})
|
|
77
|
+
.catch(() => {
|
|
78
|
+
// Aborted by the returned function: not a loss.
|
|
79
|
+
});
|
|
80
|
+
return () => controller.abort();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The names of the locks held right now in this origin, or `undefined` where
|
|
85
|
+
* that cannot be asked. A backup for `watchLock`: a grant can be late, a query
|
|
86
|
+
* cannot.
|
|
87
|
+
*/
|
|
88
|
+
export async function heldLockNames(): Promise<Set<string> | undefined> {
|
|
89
|
+
const locks = lockManager();
|
|
90
|
+
if (!locks?.query) return undefined;
|
|
91
|
+
try {
|
|
92
|
+
const { held = [] } = await locks.query();
|
|
93
|
+
return new Set(held.map((lock) => lock.name ?? ""));
|
|
94
|
+
} catch {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matches replies to requests by id, for the control messages that hand
|
|
3
|
+
* WebSocket ports across: each waiting request has a deadline, and all of a
|
|
4
|
+
* peer's waiting requests can be failed at once when the peer is lost.
|
|
5
|
+
*/
|
|
6
|
+
export class PortRendezvous<T> {
|
|
7
|
+
private readonly _waiting = new Map<
|
|
8
|
+
string,
|
|
9
|
+
{
|
|
10
|
+
resolve: (value: T) => void;
|
|
11
|
+
reject: (error: Error) => void;
|
|
12
|
+
timer: ReturnType<typeof setTimeout>;
|
|
13
|
+
}
|
|
14
|
+
>();
|
|
15
|
+
private _counter = 0;
|
|
16
|
+
|
|
17
|
+
constructor(private readonly _prefix = "r") {}
|
|
18
|
+
|
|
19
|
+
/** A fresh id for a request. */
|
|
20
|
+
newId(): string {
|
|
21
|
+
return `${this._prefix}:${++this._counter}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get size(): number {
|
|
25
|
+
return this._waiting.size;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Waits for the reply to `id`, failing after `timeoutMs`. */
|
|
29
|
+
wait(id: string, timeoutMs: number, describe = "the reply"): Promise<T> {
|
|
30
|
+
return new Promise<T>((resolve, reject) => {
|
|
31
|
+
const timer = setTimeout(() => {
|
|
32
|
+
this._waiting.delete(id);
|
|
33
|
+
reject(new Error(`No answer to ${describe} within ${timeoutMs} ms`));
|
|
34
|
+
}, timeoutMs);
|
|
35
|
+
this._waiting.set(id, { resolve, reject, timer });
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Delivers the reply to `id`. `false` when nobody waits for it (it timed out). */
|
|
40
|
+
settle(id: string, value: T): boolean {
|
|
41
|
+
const waiting = this._waiting.get(id);
|
|
42
|
+
if (!waiting) return false;
|
|
43
|
+
this._waiting.delete(id);
|
|
44
|
+
clearTimeout(waiting.timer);
|
|
45
|
+
waiting.resolve(value);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
fail(id: string, error: Error): void {
|
|
50
|
+
const waiting = this._waiting.get(id);
|
|
51
|
+
if (!waiting) return;
|
|
52
|
+
this._waiting.delete(id);
|
|
53
|
+
clearTimeout(waiting.timer);
|
|
54
|
+
waiting.reject(error);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
failAll(error: Error): void {
|
|
58
|
+
for (const id of [...this._waiting.keys()]) this.fail(id, error);
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/relay/index-sw.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { HttpError } from "@statewalker/webrun-http-streams";
|
|
2
2
|
import { get, set } from "idb-keyval";
|
|
3
|
-
import { callChannel, handleChannelCalls } from "../core/data-calls.js";
|
|
3
|
+
import { callChannel, handleChannelCalls, withTransfer } from "../core/data-calls.js";
|
|
4
4
|
import { newRegistry } from "../core/registry.js";
|
|
5
5
|
import { handleClaimRequests } from "../core/service-worker-control.js";
|
|
6
|
-
import {
|
|
6
|
+
import { HTTP_LINK_PROTOCOL } from "../link/http-link.js";
|
|
7
|
+
import { WS_CONNECT_CALL } from "../sw/protocol.js";
|
|
8
|
+
import type { WebSocketConnection } from "../ws/client.js";
|
|
9
|
+
import { type ConnectReply, sendOverConnection, type WebSocketConnectParams } from "./index.js";
|
|
7
10
|
import { type MountSpec, type MountTable, newMountTable } from "./mount-table.js";
|
|
8
11
|
import { splitServiceUrl } from "./split-service-url.js";
|
|
9
12
|
|
|
@@ -125,8 +128,18 @@ export interface RelayServiceWorkerOptions {
|
|
|
125
128
|
mounts?: Array<{ key: string } & MountSpec>;
|
|
126
129
|
/** Paths the relay never claims. Checked before the table. */
|
|
127
130
|
exclude?: (url: URL) => boolean;
|
|
128
|
-
/**
|
|
129
|
-
|
|
131
|
+
/**
|
|
132
|
+
* Refuse a registration from the wrong client. Default: everyone may.
|
|
133
|
+
*
|
|
134
|
+
* `path` is where the registrant asks to be mounted (`undefined` for the
|
|
135
|
+
* `/~<key>/` addressing), so a host can vet where a key lands, not only who
|
|
136
|
+
* takes it: on an origin whose name is not a secret, a page could otherwise
|
|
137
|
+
* mount an allowed key at `/index.html` and outrank `/` on longest-prefix.
|
|
138
|
+
* Also consulted for `UNREGISTER`, with the path the key is mounted at.
|
|
139
|
+
*/
|
|
140
|
+
canRegister?: (client: Client, key: string, path?: string) => boolean | Promise<boolean>;
|
|
141
|
+
/** Per-stream inactivity timeout of the links to services, in ms. Default none. */
|
|
142
|
+
streamTimeout?: number;
|
|
130
143
|
/** Default `"last-wins"`, the behaviour before this option existed. */
|
|
131
144
|
takeover?: "first-wins" | "last-wins";
|
|
132
145
|
/** Stamp headers on responses the relay makes. Not applied to network fetches. */
|
|
@@ -220,8 +233,12 @@ export function startRelayServiceWorker(
|
|
|
220
233
|
if (!source) return false;
|
|
221
234
|
const { key, path } = data as { key: string; path?: string };
|
|
222
235
|
|
|
223
|
-
if (options.canRegister != null && !(await options.canRegister(source, key))) {
|
|
224
|
-
throw new Error(
|
|
236
|
+
if (options.canRegister != null && !(await options.canRegister(source, key, path))) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
path == null
|
|
239
|
+
? `this client may not register "${key}"`
|
|
240
|
+
: `this client may not register "${key}" at "${path}"`,
|
|
241
|
+
);
|
|
225
242
|
}
|
|
226
243
|
|
|
227
244
|
// `last-wins` -- the untouched default -- must cost exactly what it did
|
|
@@ -242,8 +259,22 @@ export function startRelayServiceWorker(
|
|
|
242
259
|
}),
|
|
243
260
|
);
|
|
244
261
|
register(
|
|
245
|
-
handleChannelCalls(self, "UNREGISTER", async (
|
|
262
|
+
handleChannelCalls(self, "UNREGISTER", async (event, data) => {
|
|
263
|
+
// Only the client that holds a key may drop it -- or anyone the host
|
|
264
|
+
// allows, once the holder is gone. Any page of the relay origin can post
|
|
265
|
+
// here, including foreign content the host serves there.
|
|
266
|
+
const source = event.source as Client | null;
|
|
267
|
+
if (!source) return false;
|
|
246
268
|
const { key } = data as { key: string };
|
|
269
|
+
const current = await clientsRegistry.getMount(key);
|
|
270
|
+
if (current == null) return false;
|
|
271
|
+
const isCurrentLive = (await clientsRegistry.getClient(key)) != null;
|
|
272
|
+
if (isCurrentLive && current.clientId !== source.id) {
|
|
273
|
+
throw new Error(`"${key}" is served by another client`);
|
|
274
|
+
}
|
|
275
|
+
if (options.canRegister != null && !(await options.canRegister(source, key, current.path))) {
|
|
276
|
+
throw new Error(`this client may not unregister "${key}"`);
|
|
277
|
+
}
|
|
247
278
|
removeRegisteredMount(mounts, key, hostKeys);
|
|
248
279
|
return await clientsRegistry.removeClient(key);
|
|
249
280
|
}),
|
|
@@ -253,7 +284,41 @@ export function startRelayServiceWorker(
|
|
|
253
284
|
const { key } = data as { key: string };
|
|
254
285
|
const client = await clientsRegistry.getClient(key);
|
|
255
286
|
if (!client) throw new Error(`Target client was not found. Target key: "${key}".`);
|
|
256
|
-
|
|
287
|
+
const reply = await callChannel<unknown>(client, "CONNECT", data, port);
|
|
288
|
+
// A socket's port comes back in the reply: pass it on.
|
|
289
|
+
const socketPort = (reply as Partial<WebSocketConnection> | null)?.port;
|
|
290
|
+
return socketPort ? withTransfer(reply, [socketPort]) : reply;
|
|
291
|
+
}),
|
|
292
|
+
);
|
|
293
|
+
register(
|
|
294
|
+
handleChannelCalls(self, WS_CONNECT_CALL, async (event, data) => {
|
|
295
|
+
// A page of the relay origin -- content a service serves there -- opens
|
|
296
|
+
// a socket to the service its url routes to, as it would fetch it.
|
|
297
|
+
const source = event.source as Client | null;
|
|
298
|
+
if (!source?.url || new URL(source.url).origin !== self.location.origin) {
|
|
299
|
+
throw new Error("Only this origin's pages may open sockets");
|
|
300
|
+
}
|
|
301
|
+
const { url, protocols = [] } = data as { url: string; protocols?: string[] };
|
|
302
|
+
const httpUrl = new URL(url);
|
|
303
|
+
if (httpUrl.protocol === "ws:") httpUrl.protocol = "http:";
|
|
304
|
+
else if (httpUrl.protocol === "wss:") httpUrl.protocol = "https:";
|
|
305
|
+
await restored.catch(() => {});
|
|
306
|
+
const key = resolveServiceKey(httpUrl, mounts, self.location.origin);
|
|
307
|
+
if (key == null) throw new Error(`No service serves ${url}`);
|
|
308
|
+
const client = await clientsRegistry.getClient(key);
|
|
309
|
+
if (!client) throw new Error(`No service serves ${url}`);
|
|
310
|
+
const channel = new MessageChannel();
|
|
311
|
+
try {
|
|
312
|
+
const connection = await callChannel<WebSocketConnection>(
|
|
313
|
+
client,
|
|
314
|
+
"CONNECT",
|
|
315
|
+
{ type: "ws", key, url: `${httpUrl}`, protocols } satisfies WebSocketConnectParams,
|
|
316
|
+
channel.port2,
|
|
317
|
+
);
|
|
318
|
+
return withTransfer(connection, [connection.port]);
|
|
319
|
+
} finally {
|
|
320
|
+
channel.port1.close();
|
|
321
|
+
}
|
|
257
322
|
}),
|
|
258
323
|
);
|
|
259
324
|
|
|
@@ -264,10 +329,17 @@ export function startRelayServiceWorker(
|
|
|
264
329
|
const channel = new MessageChannel();
|
|
265
330
|
const client = await clientsRegistry.getClient(key);
|
|
266
331
|
if (!client) throw HttpError.errorResourceGone(params);
|
|
267
|
-
const data = { type: "http", key };
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
332
|
+
const data = { type: "http", key, wire: HTTP_LINK_PROTOCOL };
|
|
333
|
+
const reply = await callChannel<boolean | ConnectReply>(
|
|
334
|
+
client,
|
|
335
|
+
"CONNECT",
|
|
336
|
+
data,
|
|
337
|
+
channel.port2,
|
|
338
|
+
);
|
|
339
|
+
if (!reply) throw HttpError.errorForbidden(params);
|
|
340
|
+
const response = await sendOverConnection(channel.port1, reply, request, {
|
|
341
|
+
streamTimeout: options.streamTimeout,
|
|
342
|
+
});
|
|
271
343
|
return options.decorateResponse?.(response, request) ?? response;
|
|
272
344
|
} catch (error) {
|
|
273
345
|
const httpError = HttpError.fromError(error);
|