@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 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
@@ -0,0 +1,3 @@
1
+ export { createEventScope } from "./scope.mjs";
2
+ export { createEventRunner } from "./runner.mjs";
3
+ export { createHttpHandler, createStreamingHttpHandler } from "./http.mjs";
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
+ }