@lenso/workers-runtime 0.1.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 +76 -0
- package/build.mjs +119 -0
- package/clock.mjs +31 -0
- package/http.mjs +366 -0
- package/index.mjs +3 -0
- package/package.json +36 -0
- package/runner.mjs +283 -0
- package/scope.mjs +170 -0
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Lenso Workers Runtime
|
|
2
|
+
|
|
3
|
+
This package owns event resources, generation admission and reset, and the
|
|
4
|
+
JavaScript timer domain used by the `lenso-workers-driver` Rust crate. It does
|
|
5
|
+
not resolve Plugins, grant network authority, authenticate, or authorize requests.
|
|
6
|
+
The new package is under qualification; no registry release is claimed.
|
|
7
|
+
|
|
8
|
+
Use `lenso-workers-build --manifest Cargo.toml --package my-host --out-dir pkg`
|
|
9
|
+
to build a consumer Host with its locked dependency graph. It requires Rust
|
|
10
|
+
1.94.0, `wasm32-unknown-unknown`, and wasm-bindgen CLI 0.2.127. `CARGO` and
|
|
11
|
+
`WASM_BINDGEN` may select executable paths. No sibling repository layout is
|
|
12
|
+
required. The generated module imports `@lenso/workers-runtime/clock`; the Host
|
|
13
|
+
must give its runner `clearTimers` from that same module. Use one runner per
|
|
14
|
+
generated module/timer domain. Multiple independent Wasm modules require separate
|
|
15
|
+
timer domains and are not supported by the shared default clock export.
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
import { createEventScope, createEventRunner } from '@lenso/workers-runtime';
|
|
19
|
+
import { clearTimers } from '@lenso/workers-runtime/clock';
|
|
20
|
+
const runner = createEventRunner({
|
|
21
|
+
instantiate: () => initSync({ module }), resetState: __wbg_reset_state, clearTimers,
|
|
22
|
+
});
|
|
23
|
+
// Inside this request's owner context, never at module scope:
|
|
24
|
+
const scope = createEventScope(scope => ({
|
|
25
|
+
batch: input => scope.run(() => database.batch(prepare(input)), JSON.stringify),
|
|
26
|
+
}));
|
|
27
|
+
const result = await runner.run(() => invoke(input, scope), { scope, signal: request.signal });
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
An adapter uses `scope.operation(() => ({ promise, abort }))` for abortable work,
|
|
31
|
+
`scope.run(start, project)` for unabortable work, and `scope.trackNative(promise)`
|
|
32
|
+
for native reads/cancellation spawned by that operation. Native adapters must
|
|
33
|
+
bound this work; `trackNative` is not an application admission API. Projection
|
|
34
|
+
functions must be JavaScript-only and must not capture Wasm callbacks. The scope
|
|
35
|
+
admits at most 128 simultaneous operations by default, rejects new operations on
|
|
36
|
+
close, fences stale promise delivery synchronously, and drains native cleanup
|
|
37
|
+
including work registered by a late completion. Cleanup has one total 250 ms
|
|
38
|
+
budget by default. An uncertain result remains uncertain on repeated settlement;
|
|
39
|
+
no retry, rollback, or exactly-once mutation guarantee is implied.
|
|
40
|
+
|
|
41
|
+
`runner.run` accepts a serialized JSON terminal receipt. `runner.open` accepts
|
|
42
|
+
an operation resolving `{ value, closed }`: `value` contains response/session
|
|
43
|
+
metadata, while `closed` resolves to `{ shutdown: 'clean' }` only after the body
|
|
44
|
+
and App have shut down. It returns `{ value, generation, invoke, cancel, closed }`.
|
|
45
|
+
The Host must observe `closed`; failure after headers is a failed stream, not a
|
|
46
|
+
replacement HTTP success. Route every subsequent Wasm entry through
|
|
47
|
+
`session.invoke(() => ...)`. Do not call old Wasm destructors after abandonment.
|
|
48
|
+
`cancel()` signals the event scope; it does not fabricate a terminal receipt.
|
|
49
|
+
A cancellation must settle within `cancellationLimitMs` (default 1 s), after
|
|
50
|
+
which the generation is abandoned.
|
|
51
|
+
|
|
52
|
+
Headers keep the original event startup deadline (default 1 s). After opening,
|
|
53
|
+
the session has `sessionLimitMs` (default 5 min). Generation retirement waits for
|
|
54
|
+
all admitted sessions. A trap or deadline abandons the entire generation,
|
|
55
|
+
synchronously fences all admitted scopes, and rejects their owners; native
|
|
56
|
+
cleanup runs only in each owner's continuation. These are shared-instance failure
|
|
57
|
+
semantics, not independent per-session isolation. WebSocket hibernation is not
|
|
58
|
+
provided by this event runtime.
|
|
59
|
+
|
|
60
|
+
The buffered HTTP compatibility bridge is exported from `./http`. New Host
|
|
61
|
+
integrations should create one immutable `createEventScope` for all D1, Fetch and
|
|
62
|
+
cancellation bindings. `createCancellationScope` preserves legacy mutable Host
|
|
63
|
+
composition only; it is not the preferred API.
|
|
64
|
+
|
|
65
|
+
Run `npm test` for focused resource, HTTP, and session boundary checks. Actual
|
|
66
|
+
Workers deployment and each Plugin's own conformance remain separate evidence.
|
|
67
|
+
|
|
68
|
+
`createStreamingHttpHandler` consumes an `openHttp` adapter returning
|
|
69
|
+
`{ value: { status, headers, read }, closed }`. Each `read()` yields one
|
|
70
|
+
`Uint8Array` or `null`; the adapter must finish App shutdown for `closed` before
|
|
71
|
+
clean EOF. Reads begin only on consumer demand, copy the current Wasm memory
|
|
72
|
+
view, and enforce per-chunk and total byte limits. Disconnect cancels the lease;
|
|
73
|
+
a failed generation errors an outstanding body read. The real Rust/Wasm duplex fixture is qualified on the deployed Workers target;
|
|
74
|
+
receipts are in `experiments/workers-g2/evidence/duplex.json`. Supply Web's
|
|
75
|
+
`createWebSocketTransport()` through `upgradeWebSocket` for authorized status-101
|
|
76
|
+
responses. Web owns that transport and its Capability, not Runtime.
|
package/build.mjs
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { mkdirSync } from "node:fs";
|
|
6
|
+
|
|
7
|
+
// Paths come from the consumer, never a sibling repository's checkout layout.
|
|
8
|
+
export function build({
|
|
9
|
+
manifest,
|
|
10
|
+
packageName,
|
|
11
|
+
outDir,
|
|
12
|
+
cargo = process.env.CARGO || "cargo",
|
|
13
|
+
cargoConfig,
|
|
14
|
+
bindgen = process.env.WASM_BINDGEN || "wasm-bindgen",
|
|
15
|
+
features = [],
|
|
16
|
+
}) {
|
|
17
|
+
if (!manifest || !packageName || !outDir)
|
|
18
|
+
throw new Error("manifest, package, and out-dir are required");
|
|
19
|
+
const version = spawnSync(bindgen, ["--version"], { encoding: "utf8" });
|
|
20
|
+
if (
|
|
21
|
+
version.status !== 0 ||
|
|
22
|
+
version.stdout.trim() !== "wasm-bindgen 0.2.127"
|
|
23
|
+
) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
"Install wasm-bindgen-cli 0.2.127, matching the Workers Driver lock",
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
const command = [
|
|
29
|
+
"+1.94.0",
|
|
30
|
+
"rustc",
|
|
31
|
+
"--locked",
|
|
32
|
+
...(cargoConfig ? ["--config", resolve(cargoConfig)] : []),
|
|
33
|
+
"--manifest-path",
|
|
34
|
+
resolve(manifest),
|
|
35
|
+
"--target",
|
|
36
|
+
"wasm32-unknown-unknown",
|
|
37
|
+
"-p",
|
|
38
|
+
packageName,
|
|
39
|
+
"--release",
|
|
40
|
+
"--no-default-features",
|
|
41
|
+
"--message-format=json",
|
|
42
|
+
...(features.length ? ["--features", features.join(",")] : []),
|
|
43
|
+
"--",
|
|
44
|
+
"-C",
|
|
45
|
+
"link-arg=--export=__wasm_call_ctors",
|
|
46
|
+
];
|
|
47
|
+
const result = spawnSync(cargo, command, {
|
|
48
|
+
encoding: "utf8",
|
|
49
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
50
|
+
stdio: ["inherit", "pipe", "inherit"],
|
|
51
|
+
});
|
|
52
|
+
const messages = (result.stdout || "")
|
|
53
|
+
.split("\n")
|
|
54
|
+
.filter(Boolean)
|
|
55
|
+
.map((line) => JSON.parse(line));
|
|
56
|
+
for (const message of messages) {
|
|
57
|
+
if (message.reason === "compiler-message")
|
|
58
|
+
process.stderr.write(message.message.rendered || message.message.message);
|
|
59
|
+
}
|
|
60
|
+
if (result.status !== 0)
|
|
61
|
+
throw result.error || new Error(`Cargo failed (${result.status})`);
|
|
62
|
+
const artifacts = messages
|
|
63
|
+
.filter(
|
|
64
|
+
(message) =>
|
|
65
|
+
message.reason === "compiler-artifact" &&
|
|
66
|
+
message.target.name === packageName.replaceAll("-", "_"),
|
|
67
|
+
)
|
|
68
|
+
.flatMap((message) => message.filenames)
|
|
69
|
+
.filter((name) => name.endsWith(".wasm"));
|
|
70
|
+
if (artifacts.length !== 1)
|
|
71
|
+
throw new Error("Expected exactly one Wasm host artifact");
|
|
72
|
+
mkdirSync(resolve(outDir), { recursive: true });
|
|
73
|
+
const generated = spawnSync(
|
|
74
|
+
bindgen,
|
|
75
|
+
[
|
|
76
|
+
artifacts[0],
|
|
77
|
+
"--target",
|
|
78
|
+
"web",
|
|
79
|
+
"--experimental-reset-state-function",
|
|
80
|
+
"--out-dir",
|
|
81
|
+
resolve(outDir),
|
|
82
|
+
],
|
|
83
|
+
{ stdio: "inherit" },
|
|
84
|
+
);
|
|
85
|
+
if (generated.status !== 0)
|
|
86
|
+
throw generated.error || new Error("wasm-bindgen failed");
|
|
87
|
+
return artifacts[0];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (
|
|
91
|
+
process.argv[1] &&
|
|
92
|
+
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
|
93
|
+
) {
|
|
94
|
+
try {
|
|
95
|
+
const options = {},
|
|
96
|
+
names = {
|
|
97
|
+
"--manifest": "manifest",
|
|
98
|
+
"--package": "packageName",
|
|
99
|
+
"--out-dir": "outDir",
|
|
100
|
+
"--features": "features",
|
|
101
|
+
"--config": "cargoConfig",
|
|
102
|
+
};
|
|
103
|
+
for (let index = 2; index < process.argv.length; index += 2) {
|
|
104
|
+
const key = names[process.argv[index]],
|
|
105
|
+
value = process.argv[index + 1];
|
|
106
|
+
if (!key || !value || value.startsWith("--"))
|
|
107
|
+
throw new Error(
|
|
108
|
+
"Usage: lenso-workers-build --manifest Cargo.toml --package host --out-dir pkg [--features a,b]",
|
|
109
|
+
);
|
|
110
|
+
if (key in options)
|
|
111
|
+
throw new Error(`Duplicate option ${process.argv[index]}`);
|
|
112
|
+
options[key] = key === "features" ? value.split(",") : value;
|
|
113
|
+
}
|
|
114
|
+
build(options);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
console.error(error.message);
|
|
117
|
+
process.exitCode = 1;
|
|
118
|
+
}
|
|
119
|
+
}
|
package/clock.mjs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// One generated wasm-bindgen module owns this timer domain. Its runner must use
|
|
2
|
+
// this same clearTimers export before resetting that module's state.
|
|
3
|
+
const timers = new Map();
|
|
4
|
+
let nextId = 0;
|
|
5
|
+
export function clock(operation, callback, value) {
|
|
6
|
+
if (operation === 0) return performance.now();
|
|
7
|
+
if (operation === 1) {
|
|
8
|
+
do {
|
|
9
|
+
nextId = nextId === 0x7fffffff ? 1 : nextId + 1;
|
|
10
|
+
} while (timers.has(nextId));
|
|
11
|
+
const id = nextId;
|
|
12
|
+
timers.set(
|
|
13
|
+
id,
|
|
14
|
+
setTimeout(() => {
|
|
15
|
+
timers.delete(id);
|
|
16
|
+
callback();
|
|
17
|
+
}, value),
|
|
18
|
+
);
|
|
19
|
+
return id;
|
|
20
|
+
}
|
|
21
|
+
if (operation === 2) {
|
|
22
|
+
clearTimeout(timers.get(value));
|
|
23
|
+
timers.delete(value);
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
throw new Error("Unknown clock operation");
|
|
27
|
+
}
|
|
28
|
+
export function clearTimers() {
|
|
29
|
+
for (const timer of timers.values()) clearTimeout(timer);
|
|
30
|
+
timers.clear();
|
|
31
|
+
}
|
package/http.mjs
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
// Event-owned transport adaptation; routing and authorization stay in Web Ingress.
|
|
2
|
+
import { createEventScope } from "./scope.mjs";
|
|
3
|
+
// Compatibility name; all cancellation and native I/O now share one scope.
|
|
4
|
+
export function createCancellationScope(extra = {}) {
|
|
5
|
+
// Legacy Hosts used mutable finalizer composition. New Hosts use createEventScope.
|
|
6
|
+
return { ...createEventScope(extra) };
|
|
7
|
+
}
|
|
8
|
+
export function attachCancellation(scope, callback) {
|
|
9
|
+
scope.attach(callback);
|
|
10
|
+
}
|
|
11
|
+
export function detachCancellation(scope) {
|
|
12
|
+
scope.detach();
|
|
13
|
+
}
|
|
14
|
+
export function cancellation(scope, callback) {
|
|
15
|
+
if (callback === null) scope.detach();
|
|
16
|
+
else scope.attach(callback);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function transportFailure(status, error) {
|
|
20
|
+
return Response.json(
|
|
21
|
+
{ error },
|
|
22
|
+
{
|
|
23
|
+
status,
|
|
24
|
+
headers: {
|
|
25
|
+
"cache-control": "no-store",
|
|
26
|
+
"x-content-type-options": "nosniff",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function responseBytes(result, limit) {
|
|
33
|
+
const fail = (message) => {
|
|
34
|
+
const error = new Error(message);
|
|
35
|
+
error.status = 502;
|
|
36
|
+
throw error;
|
|
37
|
+
};
|
|
38
|
+
if (Object.hasOwn(result, "body_base64")) {
|
|
39
|
+
if (Object.hasOwn(result, "body") || typeof result.body_base64 !== "string")
|
|
40
|
+
fail("invalid_response_body");
|
|
41
|
+
const encoded = result.body_base64;
|
|
42
|
+
// Bound allocation before decoding. Require canonical padded standard Base64.
|
|
43
|
+
if (encoded.length > 4 * Math.ceil(limit / 3))
|
|
44
|
+
fail("response_body_too_large");
|
|
45
|
+
const padding = encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0;
|
|
46
|
+
if (
|
|
47
|
+
encoded.length % 4 !== 0 ||
|
|
48
|
+
/[^A-Za-z0-9+/]/.test(encoded.slice(0, encoded.length - padding))
|
|
49
|
+
) {
|
|
50
|
+
fail("invalid_response_body");
|
|
51
|
+
}
|
|
52
|
+
if ((encoded.length / 4) * 3 - padding > limit)
|
|
53
|
+
fail("response_body_too_large");
|
|
54
|
+
const decoded = atob(encoded);
|
|
55
|
+
if (btoa(decoded) !== encoded) fail("invalid_response_body");
|
|
56
|
+
const bytes = new Uint8Array(decoded.length);
|
|
57
|
+
for (let index = 0; index < decoded.length; index++)
|
|
58
|
+
bytes[index] = decoded.charCodeAt(index);
|
|
59
|
+
return bytes;
|
|
60
|
+
}
|
|
61
|
+
if (!Array.isArray(result.body)) fail("invalid_response_body");
|
|
62
|
+
if (result.body.length > limit) fail("response_body_too_large");
|
|
63
|
+
if (
|
|
64
|
+
result.body.some(
|
|
65
|
+
(byte) => !Number.isInteger(byte) || byte < 0 || byte > 255,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
fail("invalid_response_body");
|
|
69
|
+
return Uint8Array.from(result.body);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function readBody(request, limit, timeoutMs) {
|
|
73
|
+
const length = request.headers.get("content-length");
|
|
74
|
+
if (length !== null && /^\d+$/.test(length) && Number(length) > limit) {
|
|
75
|
+
const error = new Error("payload_too_large");
|
|
76
|
+
error.status = 413;
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
if (request.signal.aborted)
|
|
80
|
+
throw new DOMException("Request aborted", "AbortError");
|
|
81
|
+
if (!request.body) return new Uint8Array();
|
|
82
|
+
const reader = request.body.getReader();
|
|
83
|
+
let timer;
|
|
84
|
+
let abort;
|
|
85
|
+
const interrupted = new Promise((_, reject) => {
|
|
86
|
+
abort = () => reject(new DOMException("Request aborted", "AbortError"));
|
|
87
|
+
request.signal.addEventListener("abort", abort, { once: true });
|
|
88
|
+
timer = setTimeout(() => {
|
|
89
|
+
const error = new Error("request_body_timeout");
|
|
90
|
+
error.status = 408;
|
|
91
|
+
reject(error);
|
|
92
|
+
}, timeoutMs);
|
|
93
|
+
});
|
|
94
|
+
const chunks = [];
|
|
95
|
+
let lengthRead = 0;
|
|
96
|
+
let complete = false;
|
|
97
|
+
try {
|
|
98
|
+
while (true) {
|
|
99
|
+
const { value, done } = await Promise.race([reader.read(), interrupted]);
|
|
100
|
+
if (done) {
|
|
101
|
+
complete = true;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
if (value.byteLength > limit - lengthRead) {
|
|
105
|
+
const error = new Error("payload_too_large");
|
|
106
|
+
error.status = 413;
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
lengthRead += value.byteLength;
|
|
110
|
+
chunks.push(value);
|
|
111
|
+
}
|
|
112
|
+
const bytes = new Uint8Array(lengthRead);
|
|
113
|
+
let offset = 0;
|
|
114
|
+
for (const chunk of chunks) {
|
|
115
|
+
bytes.set(chunk, offset);
|
|
116
|
+
offset += chunk.byteLength;
|
|
117
|
+
}
|
|
118
|
+
return bytes;
|
|
119
|
+
} finally {
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
request.signal.removeEventListener("abort", abort);
|
|
122
|
+
if (!complete) await reader.cancel().catch(() => {});
|
|
123
|
+
reader.releaseLock();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function createHttpHandler({
|
|
128
|
+
run,
|
|
129
|
+
handleHttp,
|
|
130
|
+
maxRequestBodyBytes = 1048576,
|
|
131
|
+
maxResponseBodyBytes = 1048576,
|
|
132
|
+
maxRequestHeadBytes = 16384,
|
|
133
|
+
bodyReadTimeoutMs = 30000,
|
|
134
|
+
createScope = () => createCancellationScope(),
|
|
135
|
+
onReceipt = () => {},
|
|
136
|
+
}) {
|
|
137
|
+
return async function handleRequest(request) {
|
|
138
|
+
let scope;
|
|
139
|
+
try {
|
|
140
|
+
scope = createScope(request);
|
|
141
|
+
// URL.search loses a trailing empty '?'. Slice the original platform URL.
|
|
142
|
+
const url = new URL(request.url);
|
|
143
|
+
const uri = request.url.slice(url.origin.length).split("#", 1)[0] || "/";
|
|
144
|
+
const headers = [...request.headers];
|
|
145
|
+
const encoder = new TextEncoder();
|
|
146
|
+
let headBytes = encoder.encode(request.method + " " + uri).byteLength;
|
|
147
|
+
for (const [name, value] of headers)
|
|
148
|
+
headBytes += encoder.encode(name + ": " + value).byteLength + 2;
|
|
149
|
+
if (headBytes > maxRequestHeadBytes)
|
|
150
|
+
return transportFailure(431, "request_header_fields_too_large");
|
|
151
|
+
const body = await readBody(
|
|
152
|
+
request,
|
|
153
|
+
maxRequestBodyBytes,
|
|
154
|
+
bodyReadTimeoutMs,
|
|
155
|
+
);
|
|
156
|
+
const input = JSON.stringify({
|
|
157
|
+
method: request.method,
|
|
158
|
+
uri,
|
|
159
|
+
headers,
|
|
160
|
+
body: [...body],
|
|
161
|
+
});
|
|
162
|
+
const result = await run(() => handleHttp(input, scope), {
|
|
163
|
+
scope,
|
|
164
|
+
signal: request.signal,
|
|
165
|
+
});
|
|
166
|
+
if (result.shutdown !== "clean")
|
|
167
|
+
throw new Error("HTTP App shutdown was not clean");
|
|
168
|
+
const responseBody = responseBytes(result, maxResponseBodyBytes);
|
|
169
|
+
const responseHeaders = new Headers();
|
|
170
|
+
for (const [name, value] of result.headers)
|
|
171
|
+
responseHeaders.append(name, value);
|
|
172
|
+
const bodyForbidden =
|
|
173
|
+
request.method === "HEAD" ||
|
|
174
|
+
[101, 204, 205, 304].includes(result.status);
|
|
175
|
+
const response = new Response(bodyForbidden ? null : responseBody, {
|
|
176
|
+
status: result.status,
|
|
177
|
+
headers: responseHeaders,
|
|
178
|
+
});
|
|
179
|
+
onReceipt(result, response);
|
|
180
|
+
return response;
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (error.name === "AbortError")
|
|
183
|
+
return transportFailure(503, "request_cancelled");
|
|
184
|
+
return transportFailure(
|
|
185
|
+
error.status ?? 503,
|
|
186
|
+
error.status ? error.message : "host_unavailable",
|
|
187
|
+
);
|
|
188
|
+
} finally {
|
|
189
|
+
// Early head/length rejection must release an unread incoming body too.
|
|
190
|
+
if (request.body && !request.bodyUsed && !request.body.locked) {
|
|
191
|
+
await request.body.cancel().catch(() => {});
|
|
192
|
+
}
|
|
193
|
+
// Runner also finalizes admitted events; this covers body-read/admission errors.
|
|
194
|
+
try {
|
|
195
|
+
scope?.abort();
|
|
196
|
+
const settled = await scope?.settled?.();
|
|
197
|
+
// A bounded storage adapter can report uncertainty without retrying writes.
|
|
198
|
+
if (settled === false)
|
|
199
|
+
return transportFailure(503, "storage_cleanup_unconfirmed");
|
|
200
|
+
} catch {
|
|
201
|
+
return transportFailure(503, "storage_cleanup_unconfirmed");
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Pull-based response transport. openHttp returns { value: { status, headers,
|
|
209
|
+
* read }, closed }; read resolves one Uint8Array or null. closed resolves only
|
|
210
|
+
* after the Plugin stream terminal and App shutdown. No Wasm callback survives
|
|
211
|
+
* its runner lease. The Host owns request parsing; Ingress still owns routing.
|
|
212
|
+
*/
|
|
213
|
+
export function createStreamingHttpHandler({
|
|
214
|
+
open,
|
|
215
|
+
openHttp,
|
|
216
|
+
maxRequestBodyBytes = 1048576,
|
|
217
|
+
maxRequestHeadBytes = 16384,
|
|
218
|
+
maxResponseChunkBytes = 65536,
|
|
219
|
+
maxResponseBodyBytes = 64 * 1024 * 1024,
|
|
220
|
+
bodyReadTimeoutMs = 30000,
|
|
221
|
+
createScope = () => createEventScope(),
|
|
222
|
+
upgradeWebSocket,
|
|
223
|
+
}) {
|
|
224
|
+
for (const limit of [
|
|
225
|
+
maxRequestBodyBytes,
|
|
226
|
+
maxRequestHeadBytes,
|
|
227
|
+
maxResponseChunkBytes,
|
|
228
|
+
maxResponseBodyBytes,
|
|
229
|
+
bodyReadTimeoutMs,
|
|
230
|
+
]) {
|
|
231
|
+
if (!Number.isSafeInteger(limit) || limit < 1)
|
|
232
|
+
throw new TypeError("invalid transport limit");
|
|
233
|
+
}
|
|
234
|
+
return async (request) => {
|
|
235
|
+
let scope,
|
|
236
|
+
session,
|
|
237
|
+
transferred = false;
|
|
238
|
+
try {
|
|
239
|
+
scope = createScope(request);
|
|
240
|
+
const url = new URL(request.url);
|
|
241
|
+
const uri = request.url.slice(url.origin.length).split("#", 1)[0] || "/";
|
|
242
|
+
const headers = [...request.headers],
|
|
243
|
+
encoder = new TextEncoder();
|
|
244
|
+
const headBytes =
|
|
245
|
+
encoder.encode(request.method + " " + uri).byteLength +
|
|
246
|
+
headers.reduce(
|
|
247
|
+
(bytes, [name, value]) =>
|
|
248
|
+
bytes + encoder.encode(name + ": " + value).byteLength + 2,
|
|
249
|
+
0,
|
|
250
|
+
);
|
|
251
|
+
if (headBytes > maxRequestHeadBytes)
|
|
252
|
+
return transportFailure(431, "request_header_fields_too_large");
|
|
253
|
+
const body = await readBody(
|
|
254
|
+
request,
|
|
255
|
+
maxRequestBodyBytes,
|
|
256
|
+
bodyReadTimeoutMs,
|
|
257
|
+
);
|
|
258
|
+
session = await open(
|
|
259
|
+
() =>
|
|
260
|
+
openHttp(
|
|
261
|
+
JSON.stringify({
|
|
262
|
+
method: request.method,
|
|
263
|
+
uri,
|
|
264
|
+
headers,
|
|
265
|
+
body: [...body],
|
|
266
|
+
}),
|
|
267
|
+
scope,
|
|
268
|
+
),
|
|
269
|
+
{ scope, signal: request.signal },
|
|
270
|
+
);
|
|
271
|
+
const head = session.value;
|
|
272
|
+
if (head?.status === 101 && upgradeWebSocket) {
|
|
273
|
+
const response = upgradeWebSocket(request, session, scope);
|
|
274
|
+
transferred = true;
|
|
275
|
+
return response;
|
|
276
|
+
}
|
|
277
|
+
if (
|
|
278
|
+
!Number.isInteger(head?.status) ||
|
|
279
|
+
head.status < 200 ||
|
|
280
|
+
head.status > 599 ||
|
|
281
|
+
typeof head.read !== "function"
|
|
282
|
+
)
|
|
283
|
+
throw new Error("invalid_stream_response");
|
|
284
|
+
const responseHeaders = new Headers();
|
|
285
|
+
for (const [name, value] of head.headers)
|
|
286
|
+
responseHeaders.append(name, value);
|
|
287
|
+
const forbidden =
|
|
288
|
+
request.method === "HEAD" || [204, 205, 304].includes(head.status);
|
|
289
|
+
let received = 0,
|
|
290
|
+
ended = false;
|
|
291
|
+
const stream = forbidden
|
|
292
|
+
? null
|
|
293
|
+
: new ReadableStream(
|
|
294
|
+
{
|
|
295
|
+
start(controller) {
|
|
296
|
+
session.closed.catch(() => {
|
|
297
|
+
if (!ended) {
|
|
298
|
+
ended = true;
|
|
299
|
+
controller.error(new Error("response_stream_failed"));
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
},
|
|
303
|
+
async pull(controller) {
|
|
304
|
+
if (ended) return;
|
|
305
|
+
try {
|
|
306
|
+
const chunk = await session.invoke(() => head.read());
|
|
307
|
+
if (chunk === null) {
|
|
308
|
+
await session.closed;
|
|
309
|
+
ended = true;
|
|
310
|
+
controller.close();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (
|
|
314
|
+
!(chunk instanceof Uint8Array) ||
|
|
315
|
+
chunk.byteLength > maxResponseChunkBytes ||
|
|
316
|
+
chunk.byteLength > maxResponseBodyBytes - received
|
|
317
|
+
)
|
|
318
|
+
throw new Error("invalid_stream_chunk");
|
|
319
|
+
// A copy releases the Wasm memory view before a later receive grows memory.
|
|
320
|
+
received += chunk.byteLength;
|
|
321
|
+
controller.enqueue(chunk.slice());
|
|
322
|
+
} catch {
|
|
323
|
+
ended = true;
|
|
324
|
+
session.cancel();
|
|
325
|
+
controller.error(new Error("response_stream_failed"));
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
async cancel() {
|
|
329
|
+
ended = true;
|
|
330
|
+
session.cancel();
|
|
331
|
+
await session.closed.catch(() => {});
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
{ highWaterMark: 0 },
|
|
335
|
+
);
|
|
336
|
+
const response = new Response(stream, {
|
|
337
|
+
status: head.status,
|
|
338
|
+
headers: responseHeaders,
|
|
339
|
+
});
|
|
340
|
+
if (forbidden) {
|
|
341
|
+
session.cancel();
|
|
342
|
+
await session.closed;
|
|
343
|
+
}
|
|
344
|
+
transferred = true;
|
|
345
|
+
return response;
|
|
346
|
+
} catch (error) {
|
|
347
|
+
session?.cancel();
|
|
348
|
+
return transportFailure(
|
|
349
|
+
error.status ?? 503,
|
|
350
|
+
error.status ? error.message : "host_unavailable",
|
|
351
|
+
);
|
|
352
|
+
} finally {
|
|
353
|
+
if (request.body && !request.bodyUsed && !request.body.locked)
|
|
354
|
+
await request.body.cancel().catch(() => {});
|
|
355
|
+
if (!transferred) {
|
|
356
|
+
try {
|
|
357
|
+
scope?.abort();
|
|
358
|
+
if ((await scope?.settled?.()) === false)
|
|
359
|
+
return transportFailure(503, "storage_cleanup_unconfirmed");
|
|
360
|
+
} catch {
|
|
361
|
+
return transportFailure(503, "storage_cleanup_unconfirmed");
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
}
|
package/index.mjs
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lenso/workers-runtime",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"description": "Event-owned resource and generation boundaries for Lenso Workers hosts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.mjs",
|
|
9
|
+
"./http": "./http.mjs",
|
|
10
|
+
"./runner": "./runner.mjs",
|
|
11
|
+
"./clock": "./clock.mjs",
|
|
12
|
+
"./build": "./build.mjs"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"*.mjs",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "node --test test/*.test.mjs"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=22"
|
|
23
|
+
},
|
|
24
|
+
"bin": {
|
|
25
|
+
"lenso-workers-build": "./build.mjs"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/LioRael/lenso-runtime-rust.git",
|
|
30
|
+
"directory": "packages/workers-runtime"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public",
|
|
34
|
+
"provenance": true
|
|
35
|
+
}
|
|
36
|
+
}
|
package/runner.mjs
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// Product-neutral extraction of the qualified G1 generation boundary.
|
|
2
|
+
// Every run owns its continuation, cancellation scope and finalizer.
|
|
3
|
+
export function createEventRunner({
|
|
4
|
+
instantiate,
|
|
5
|
+
resetState,
|
|
6
|
+
clearTimers,
|
|
7
|
+
eventLimitMs = 1000,
|
|
8
|
+
maxConcurrent = 32,
|
|
9
|
+
retirementAdmissionLimit = 64,
|
|
10
|
+
sessionLimitMs = 300_000,
|
|
11
|
+
cancellationLimitMs = 1000,
|
|
12
|
+
}) {
|
|
13
|
+
for (const [name, value] of Object.entries({
|
|
14
|
+
eventLimitMs,
|
|
15
|
+
sessionLimitMs,
|
|
16
|
+
cancellationLimitMs,
|
|
17
|
+
})) {
|
|
18
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > 0x7fffffff) {
|
|
19
|
+
throw new RangeError(`${name} must be a positive 32-bit timer duration`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (
|
|
23
|
+
!Number.isInteger(maxConcurrent) ||
|
|
24
|
+
maxConcurrent < 1 ||
|
|
25
|
+
maxConcurrent > 32
|
|
26
|
+
) {
|
|
27
|
+
throw new RangeError("maxConcurrent must be an integer from 1 to 32");
|
|
28
|
+
}
|
|
29
|
+
if (
|
|
30
|
+
!Number.isInteger(retirementAdmissionLimit) ||
|
|
31
|
+
retirementAdmissionLimit < 1 ||
|
|
32
|
+
retirementAdmissionLimit > 96
|
|
33
|
+
) {
|
|
34
|
+
throw new RangeError(
|
|
35
|
+
"retirementAdmissionLimit must be an integer from 1 to 96",
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
let exports = instantiate();
|
|
39
|
+
exports.__wasm_call_ctors();
|
|
40
|
+
let generation = 1;
|
|
41
|
+
let unavailable = false;
|
|
42
|
+
const pending = new Set();
|
|
43
|
+
|
|
44
|
+
// Bound retained allocation even if a dependency retains request App state.
|
|
45
|
+
// Retire at the next idle boundary after the configured admission count; 96 is the hard ceiling.
|
|
46
|
+
let admittedCount = 0;
|
|
47
|
+
let queued = 0;
|
|
48
|
+
function resetInstance() {
|
|
49
|
+
unavailable = true;
|
|
50
|
+
clearTimers();
|
|
51
|
+
const previousMemory = exports.memory;
|
|
52
|
+
resetState();
|
|
53
|
+
exports = instantiate();
|
|
54
|
+
if (exports.memory === previousMemory)
|
|
55
|
+
throw new Error("Wasm memory was reused");
|
|
56
|
+
exports.__wasm_call_ctors();
|
|
57
|
+
admittedCount = 0;
|
|
58
|
+
unavailable = false;
|
|
59
|
+
}
|
|
60
|
+
function rotate() {
|
|
61
|
+
generation++;
|
|
62
|
+
resetInstance();
|
|
63
|
+
}
|
|
64
|
+
// Failure is distinct from clean retirement: reject every admitted event, then
|
|
65
|
+
// rebuild. Owner-context finalizers dispose I/O after rejection.
|
|
66
|
+
function abandon(cause) {
|
|
67
|
+
const abandoned = generation;
|
|
68
|
+
const failure = new Error(
|
|
69
|
+
`Wasm generation ${abandoned} abandoned: ${cause}`,
|
|
70
|
+
);
|
|
71
|
+
failure.code = "instance_abandoned";
|
|
72
|
+
failure.generation = abandoned;
|
|
73
|
+
unavailable = true;
|
|
74
|
+
for (const event of pending) {
|
|
75
|
+
clearTimeout(event.timer);
|
|
76
|
+
event.invalidate();
|
|
77
|
+
event.reject(failure);
|
|
78
|
+
}
|
|
79
|
+
pending.clear();
|
|
80
|
+
try {
|
|
81
|
+
rotate();
|
|
82
|
+
} catch {
|
|
83
|
+
/* Admission remains closed on reset failure. */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function execute(operation, { scope, signal } = {}, opened) {
|
|
88
|
+
if (signal?.aborted)
|
|
89
|
+
return Promise.reject(new DOMException("Request aborted", "AbortError"));
|
|
90
|
+
if (unavailable)
|
|
91
|
+
return Promise.reject(new Error("Wasm instance unavailable"));
|
|
92
|
+
if (admittedCount >= 96) {
|
|
93
|
+
if (!pending.size) {
|
|
94
|
+
try {
|
|
95
|
+
rotate();
|
|
96
|
+
} catch (error) {
|
|
97
|
+
return Promise.reject(error);
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
if (queued >= 32)
|
|
101
|
+
return Promise.reject(new Error("Rotation queue capacity exceeded"));
|
|
102
|
+
queued++;
|
|
103
|
+
// Each waiter owns its timer. A shared cross-request Promise can be
|
|
104
|
+
// canceled by workerd when its creating request has no local I/O left.
|
|
105
|
+
return (async () => {
|
|
106
|
+
try {
|
|
107
|
+
const deadline = Date.now() + eventLimitMs;
|
|
108
|
+
while (admittedCount >= 96 && pending.size) {
|
|
109
|
+
if (signal?.aborted)
|
|
110
|
+
throw new DOMException("Request aborted", "AbortError");
|
|
111
|
+
if (unavailable) throw new Error("Wasm instance unavailable");
|
|
112
|
+
if (Date.now() >= deadline)
|
|
113
|
+
throw new Error("Rotation admission deadline exceeded");
|
|
114
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
115
|
+
}
|
|
116
|
+
return await execute(operation, { scope, signal }, opened);
|
|
117
|
+
} finally {
|
|
118
|
+
queued--;
|
|
119
|
+
}
|
|
120
|
+
})();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (pending.size >= maxConcurrent)
|
|
124
|
+
return Promise.reject(new Error("Event capacity exceeded"));
|
|
125
|
+
const admitted = generation;
|
|
126
|
+
admittedCount++;
|
|
127
|
+
let event;
|
|
128
|
+
const result = new Promise((resolve, reject) => {
|
|
129
|
+
const onAbort = () => {
|
|
130
|
+
scope?.abort();
|
|
131
|
+
if (event && !event.cancelling) {
|
|
132
|
+
event.cancelling = true;
|
|
133
|
+
clearTimeout(event.timer);
|
|
134
|
+
event.timer = setTimeout(
|
|
135
|
+
() => abandon("cancellation deadline exceeded"),
|
|
136
|
+
cancellationLimitMs,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
event = {
|
|
141
|
+
reject,
|
|
142
|
+
invalidate() {
|
|
143
|
+
scope?.invalidate?.();
|
|
144
|
+
},
|
|
145
|
+
dispose() {
|
|
146
|
+
scope?.abort();
|
|
147
|
+
signal?.removeEventListener("abort", onAbort);
|
|
148
|
+
},
|
|
149
|
+
timer: setTimeout(
|
|
150
|
+
() => abandon("event deadline exceeded"),
|
|
151
|
+
eventLimitMs,
|
|
152
|
+
),
|
|
153
|
+
};
|
|
154
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
155
|
+
if (signal?.aborted) onAbort();
|
|
156
|
+
pending.add(event);
|
|
157
|
+
let pendingOperation;
|
|
158
|
+
try {
|
|
159
|
+
pendingOperation = operation();
|
|
160
|
+
} catch (error) {
|
|
161
|
+
abandon(String(error));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
Promise.resolve(pendingOperation).then(
|
|
165
|
+
(value) => {
|
|
166
|
+
if (!pending.has(event) || admitted !== generation) return;
|
|
167
|
+
if (opened) {
|
|
168
|
+
// Headers may leave the Host now; the generation remains admitted until
|
|
169
|
+
// the session's clean terminal receipt or a bounded failure.
|
|
170
|
+
if (
|
|
171
|
+
!value ||
|
|
172
|
+
!value.closed ||
|
|
173
|
+
typeof value.closed.then !== "function"
|
|
174
|
+
) {
|
|
175
|
+
reject(
|
|
176
|
+
new TypeError("open operation must return { value, closed }"),
|
|
177
|
+
);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
clearTimeout(event.timer);
|
|
181
|
+
event.timer = setTimeout(
|
|
182
|
+
() =>
|
|
183
|
+
abandon(
|
|
184
|
+
event.cancelling
|
|
185
|
+
? "cancellation deadline exceeded"
|
|
186
|
+
: "session deadline exceeded",
|
|
187
|
+
),
|
|
188
|
+
event.cancelling ? cancellationLimitMs : sessionLimitMs,
|
|
189
|
+
);
|
|
190
|
+
opened({
|
|
191
|
+
value: value.value,
|
|
192
|
+
generation: admitted,
|
|
193
|
+
invoke(call) {
|
|
194
|
+
if (
|
|
195
|
+
!pending.has(event) ||
|
|
196
|
+
admitted !== generation ||
|
|
197
|
+
scope?.closed
|
|
198
|
+
) {
|
|
199
|
+
throw new Error("session_closed");
|
|
200
|
+
}
|
|
201
|
+
return call();
|
|
202
|
+
},
|
|
203
|
+
cancel() {
|
|
204
|
+
if (pending.has(event) && admitted === generation) onAbort();
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
Promise.resolve(value.closed).then(
|
|
208
|
+
(receipt) => {
|
|
209
|
+
if (!pending.has(event) || admitted !== generation) return;
|
|
210
|
+
clearTimeout(event.timer);
|
|
211
|
+
if (receipt?.shutdown !== "clean")
|
|
212
|
+
abandon("session_shutdown_unconfirmed");
|
|
213
|
+
else resolve(receipt);
|
|
214
|
+
},
|
|
215
|
+
(error) => {
|
|
216
|
+
if (admitted === generation) abandon(String(error));
|
|
217
|
+
},
|
|
218
|
+
);
|
|
219
|
+
} else {
|
|
220
|
+
clearTimeout(event.timer);
|
|
221
|
+
try {
|
|
222
|
+
resolve({
|
|
223
|
+
...JSON.parse(value),
|
|
224
|
+
generation: admitted,
|
|
225
|
+
wasm_memory_bytes: exports.memory.buffer.byteLength,
|
|
226
|
+
});
|
|
227
|
+
} catch (error) {
|
|
228
|
+
reject(error);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
(error) => {
|
|
233
|
+
if (admitted === generation) abandon(String(error));
|
|
234
|
+
},
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
// Register cleanup in the owning fetch context. Another event may reject this
|
|
238
|
+
// promise, but it must never directly abort this request's native I/O objects.
|
|
239
|
+
return result.finally(async () => {
|
|
240
|
+
try {
|
|
241
|
+
clearTimeout(event?.timer);
|
|
242
|
+
event?.dispose();
|
|
243
|
+
if ((await scope?.settled?.()) === false)
|
|
244
|
+
throw new Error("Storage completion unavailable");
|
|
245
|
+
} catch {
|
|
246
|
+
const error = new Error("storage_cleanup_unconfirmed");
|
|
247
|
+
error.status = 503;
|
|
248
|
+
throw error;
|
|
249
|
+
} finally {
|
|
250
|
+
// Even a normal result can leave unconfirmed native work. Fence every
|
|
251
|
+
// continuation before this event leaves the generation's pending set.
|
|
252
|
+
event?.invalidate();
|
|
253
|
+
pending.delete(event);
|
|
254
|
+
if (
|
|
255
|
+
!unavailable &&
|
|
256
|
+
!pending.size &&
|
|
257
|
+
admittedCount >= retirementAdmissionLimit
|
|
258
|
+
)
|
|
259
|
+
rotate();
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
function run(operation, options) {
|
|
264
|
+
return execute(operation, options);
|
|
265
|
+
}
|
|
266
|
+
function open(operation, options) {
|
|
267
|
+
let publish, fail;
|
|
268
|
+
const ready = new Promise((resolve, reject) => {
|
|
269
|
+
publish = resolve;
|
|
270
|
+
fail = reject;
|
|
271
|
+
});
|
|
272
|
+
let session;
|
|
273
|
+
const closed = execute(operation, options, (value) => {
|
|
274
|
+
session = value;
|
|
275
|
+
publish({ ...session, closed });
|
|
276
|
+
});
|
|
277
|
+
// Always own rejection even if a transport fails before consuming headers.
|
|
278
|
+
// The returned closed Promise remains rejected for the owner to observe.
|
|
279
|
+
closed.catch(fail);
|
|
280
|
+
return ready;
|
|
281
|
+
}
|
|
282
|
+
return { run, open, generation: () => generation };
|
|
283
|
+
}
|
package/scope.mjs
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One request's native resources and detachable Wasm continuations.
|
|
3
|
+
* Invalidation is synchronous JS-only fencing; abort/settled run in the owner.
|
|
4
|
+
* Native adapters may track cleanup spawned by a late completion, but cannot
|
|
5
|
+
* admit another application operation after this scope closes.
|
|
6
|
+
*/
|
|
7
|
+
export function createEventScope(
|
|
8
|
+
bindings = {},
|
|
9
|
+
{ cleanupTimeoutMs = 250, maxOperations = 128 } = {},
|
|
10
|
+
) {
|
|
11
|
+
if (!Number.isFinite(cleanupTimeoutMs) || cleanupTimeoutMs <= 0) {
|
|
12
|
+
throw new RangeError("cleanupTimeoutMs must be positive and finite");
|
|
13
|
+
}
|
|
14
|
+
if (!Number.isSafeInteger(maxOperations) || maxOperations < 1) {
|
|
15
|
+
throw new RangeError("maxOperations must be a positive integer");
|
|
16
|
+
}
|
|
17
|
+
let closed = false,
|
|
18
|
+
invalidated = false,
|
|
19
|
+
callback,
|
|
20
|
+
cancelled = false;
|
|
21
|
+
const pending = new Set(),
|
|
22
|
+
gates = new Set(),
|
|
23
|
+
aborters = new Set();
|
|
24
|
+
let settlement;
|
|
25
|
+
const closedError = () => new Error("event_scope_closed");
|
|
26
|
+
|
|
27
|
+
function trackNative(value) {
|
|
28
|
+
const promise = Promise.resolve(value);
|
|
29
|
+
pending.add(promise);
|
|
30
|
+
// Both handlers own rejection immediately and never call Wasm.
|
|
31
|
+
promise.then(
|
|
32
|
+
() => pending.delete(promise),
|
|
33
|
+
() => pending.delete(promise),
|
|
34
|
+
);
|
|
35
|
+
return promise;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function operation(start, project = (value) => value) {
|
|
39
|
+
if (closed) return { promise: Promise.reject(closedError()), abort() {} };
|
|
40
|
+
if (aborters.size >= maxOperations) {
|
|
41
|
+
return {
|
|
42
|
+
promise: Promise.reject(new Error("event_operation_capacity")),
|
|
43
|
+
abort() {},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
let resource;
|
|
47
|
+
try {
|
|
48
|
+
resource = start();
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return { promise: Promise.reject(error), abort() {} };
|
|
51
|
+
}
|
|
52
|
+
if (!resource || !("promise" in resource))
|
|
53
|
+
throw new TypeError("operation requires a promise");
|
|
54
|
+
let aborted = false;
|
|
55
|
+
const abort = () => {
|
|
56
|
+
if (aborted) return;
|
|
57
|
+
aborted = true;
|
|
58
|
+
// Cancellation errors must be observed by bounded cleanup too.
|
|
59
|
+
try {
|
|
60
|
+
const result = resource.abort?.();
|
|
61
|
+
if (result !== undefined) trackNative(result);
|
|
62
|
+
} catch {
|
|
63
|
+
cleanupFailed = true;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
aborters.add(abort);
|
|
67
|
+
const gate = {};
|
|
68
|
+
const promise = new Promise((resolve, reject) => {
|
|
69
|
+
gate.resolve = resolve;
|
|
70
|
+
gate.reject = reject;
|
|
71
|
+
});
|
|
72
|
+
gates.add(gate);
|
|
73
|
+
const finish = (error, value) => {
|
|
74
|
+
aborters.delete(abort);
|
|
75
|
+
gates.delete(gate);
|
|
76
|
+
const { resolve, reject } = gate;
|
|
77
|
+
gate.resolve = gate.reject = undefined;
|
|
78
|
+
if (invalidated) return;
|
|
79
|
+
if (error) reject?.(value);
|
|
80
|
+
else {
|
|
81
|
+
try {
|
|
82
|
+
resolve?.(project(value));
|
|
83
|
+
} catch (failure) {
|
|
84
|
+
reject?.(failure);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
trackNative(resource.promise).then(
|
|
89
|
+
(value) => finish(false, value),
|
|
90
|
+
(error) => finish(true, error),
|
|
91
|
+
);
|
|
92
|
+
return { promise, abort };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let cleanupFailed = false;
|
|
96
|
+
const scope = {
|
|
97
|
+
get closed() {
|
|
98
|
+
return closed;
|
|
99
|
+
},
|
|
100
|
+
get invalidated() {
|
|
101
|
+
return invalidated;
|
|
102
|
+
},
|
|
103
|
+
trackNative,
|
|
104
|
+
operation,
|
|
105
|
+
run(start, project) {
|
|
106
|
+
return operation(() => ({ promise: start() }), project).promise;
|
|
107
|
+
},
|
|
108
|
+
attach(next) {
|
|
109
|
+
if (invalidated) return;
|
|
110
|
+
callback = next;
|
|
111
|
+
if (cancelled) callback?.();
|
|
112
|
+
},
|
|
113
|
+
detach() {
|
|
114
|
+
callback = undefined;
|
|
115
|
+
},
|
|
116
|
+
invalidate() {
|
|
117
|
+
closed = invalidated = true;
|
|
118
|
+
callback = undefined;
|
|
119
|
+
for (const gate of gates) gate.resolve = gate.reject = undefined;
|
|
120
|
+
gates.clear();
|
|
121
|
+
},
|
|
122
|
+
abort() {
|
|
123
|
+
closed = cancelled = true;
|
|
124
|
+
const cancel = callback;
|
|
125
|
+
callback = undefined;
|
|
126
|
+
try {
|
|
127
|
+
cancel?.();
|
|
128
|
+
} catch {
|
|
129
|
+
cleanupFailed = true;
|
|
130
|
+
}
|
|
131
|
+
for (const abort of [...aborters]) abort();
|
|
132
|
+
},
|
|
133
|
+
settled() {
|
|
134
|
+
if (settlement) return settlement;
|
|
135
|
+
// No admission once cleanup begins, even on an early HTTP rejection.
|
|
136
|
+
closed = true;
|
|
137
|
+
settlement = (async () => {
|
|
138
|
+
let timer;
|
|
139
|
+
const timeout = new Promise((resolve) => {
|
|
140
|
+
timer = setTimeout(() => resolve(false), cleanupTimeoutMs);
|
|
141
|
+
});
|
|
142
|
+
const drain = async () => {
|
|
143
|
+
// A completed read can start reader cancellation. A single snapshot
|
|
144
|
+
// is insufficient: drain every newly registered native operation.
|
|
145
|
+
while (pending.size) await Promise.allSettled([...pending]);
|
|
146
|
+
return !cleanupFailed;
|
|
147
|
+
};
|
|
148
|
+
try {
|
|
149
|
+
const clean = await Promise.race([drain(), timeout]);
|
|
150
|
+
if (!clean) scope.invalidate();
|
|
151
|
+
return clean;
|
|
152
|
+
} finally {
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
}
|
|
155
|
+
})();
|
|
156
|
+
return settlement;
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
const values = typeof bindings === "function" ? bindings(scope) : bindings;
|
|
160
|
+
if (!values || typeof values !== "object")
|
|
161
|
+
throw new TypeError("event bindings must be an object");
|
|
162
|
+
for (const name of Object.keys(values)) {
|
|
163
|
+
if (name in scope || name === "bindings")
|
|
164
|
+
throw new TypeError(`reserved event binding: ${name}`);
|
|
165
|
+
}
|
|
166
|
+
scope.bindings = Object.freeze({ ...values });
|
|
167
|
+
// The direct projection preserves the existing wasm-bindgen Host ABI.
|
|
168
|
+
Object.assign(scope, scope.bindings);
|
|
169
|
+
return Object.freeze(scope);
|
|
170
|
+
}
|