@volter/world-core 3.0.38 → 3.0.39
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/app-route.cjs +26 -25
- package/app-route.d.cts +1 -0
- package/dist/app-route.cjs +26 -25
- package/dist/app-route.d.cts +1 -0
- package/dist/generated/pack-facts.json +409 -352
- package/dist/inject.cjs +474 -95
- package/dist/network-policy.cjs +108 -13
- package/dist/network-policy.d.cts +8 -3
- package/dist/src/actions.js +2 -1
- package/dist/src/anthropic-wire.d.ts +2 -0
- package/dist/src/anthropic-wire.js +5 -0
- package/dist/src/attestation.d.ts +6 -0
- package/dist/src/attestation.js +27 -0
- package/dist/src/bytes.d.ts +3 -0
- package/dist/src/bytes.js +17 -0
- package/dist/src/changeset.js +2 -1
- package/dist/src/clickhouse/sql.js +73 -14
- package/dist/src/derived-core.d.ts +210 -32
- package/dist/src/derived-core.js +510 -244
- package/dist/src/derived-real.d.ts +1 -1
- package/dist/src/derived-real.js +704 -93
- package/dist/src/derived.d.ts +18 -2
- package/dist/src/derived.js +21 -2
- package/dist/src/events.d.ts +28 -45
- package/dist/src/events.js +40 -58
- package/dist/src/exact-json.d.ts +10 -0
- package/dist/src/exact-json.js +98 -0
- package/dist/src/executor.d.ts +17 -0
- package/dist/src/executor.js +91 -30
- package/dist/src/file-response.js +1 -1
- package/dist/src/form.d.ts +8 -0
- package/dist/src/form.js +76 -0
- package/dist/src/git/format.d.ts +16 -0
- package/dist/src/git/format.js +110 -0
- package/dist/src/git/index.d.ts +1 -0
- package/dist/src/git/index.js +1 -0
- package/dist/src/git/lfs.d.ts +1 -1
- package/dist/src/git/lfs.js +4 -3
- package/dist/src/graphql-wire.d.ts +6 -0
- package/dist/src/graphql-wire.js +6 -3
- package/dist/src/grpc-wire.js +2 -1
- package/dist/src/head.d.ts +1 -0
- package/dist/src/head.js +15 -4
- package/dist/src/held-reads.d.ts +6 -0
- package/dist/src/held-reads.js +60 -0
- package/dist/src/history.js +5 -4
- package/dist/src/host-port.d.ts +5 -0
- package/dist/src/host-port.js +14 -0
- package/dist/src/index.d.ts +11 -3
- package/dist/src/index.js +9 -4
- package/dist/src/log.js +12 -7
- package/dist/src/machines.d.ts +81 -5
- package/dist/src/machines.js +125 -9
- package/dist/src/managed-database.d.ts +15 -11
- package/dist/src/managed-database.js +38 -37
- package/dist/src/multipart.d.ts +21 -3
- package/dist/src/multipart.js +112 -38
- package/dist/src/observe.d.ts +3 -0
- package/dist/src/observe.js +6 -2
- package/dist/src/openai-wire.d.ts +16 -0
- package/dist/src/openai-wire.js +43 -12
- package/dist/src/pack-fetch.d.ts +4 -6
- package/dist/src/pack-fetch.js +189 -57
- package/dist/src/packRegistry.d.ts +7 -1
- package/dist/src/packRegistry.js +20 -3
- package/dist/src/private-transport.d.ts +1 -0
- package/dist/src/private-transport.js +11 -0
- package/dist/src/protobuf.js +10 -4
- package/dist/src/rateBudget.d.ts +8 -1
- package/dist/src/rateBudget.js +1 -0
- package/dist/src/redis/engine.d.ts +14 -5
- package/dist/src/redis/engine.js +271 -72
- package/dist/src/redis/frames.d.ts +17 -0
- package/dist/src/redis/frames.js +123 -0
- package/dist/src/redis/index.d.ts +6 -0
- package/dist/src/redis/index.js +6 -0
- package/dist/src/redis/json.d.ts +2 -0
- package/dist/src/redis/json.js +14 -0
- package/dist/src/redis/lua-libs.d.ts +4 -0
- package/dist/src/redis/lua-libs.js +173 -0
- package/dist/src/redis/lua.d.ts +1 -0
- package/dist/src/redis/lua.js +36 -0
- package/dist/src/redis/member-storage.d.ts +5 -0
- package/dist/src/redis/member-storage.js +75 -0
- package/dist/src/redis/resp2.d.ts +4 -0
- package/dist/src/redis/resp2.js +29 -0
- package/dist/src/redis/stream.d.ts +14 -0
- package/dist/src/redis/stream.js +335 -0
- package/dist/src/remote-execute.d.ts +2 -2
- package/dist/src/request-body.d.ts +11 -0
- package/dist/src/request-body.js +11 -0
- package/dist/src/runtime.d.ts +6 -5
- package/dist/src/runtime.js +5 -5
- package/dist/src/s3/wire.d.ts +4 -4
- package/dist/src/s3/wire.js +5 -3
- package/dist/src/scenario.d.ts +22 -0
- package/dist/src/scenario.js +35 -31
- package/dist/src/serve-http.d.ts +2 -1
- package/dist/src/serve-http.js +10 -1
- package/dist/src/serve.js +7 -4
- package/dist/src/signing.d.ts +23 -5
- package/dist/src/signing.js +74 -11
- package/dist/src/sigv4.d.ts +3 -1
- package/dist/src/sigv4.js +4 -2
- package/dist/src/smtp.js +2 -1
- package/dist/src/sockets.js +8 -3
- package/dist/src/state-system.d.ts +3 -0
- package/dist/src/storage.js +5 -2
- package/dist/src/twin-fetch.d.ts +3 -1
- package/dist/src/twin-fetch.js +3 -2
- package/dist/src/vendor-call.d.ts +10 -3
- package/dist/src/vendor-call.js +58 -14
- package/dist/test-fixtures/attestation.SOURCE.md +1 -0
- package/dist/test-fixtures/attestation.json +50 -0
- package/generated/pack-facts.json +409 -352
- package/inject.cjs +474 -95
- package/network-policy.cjs +108 -13
- package/network-policy.d.cts +8 -3
- package/package.json +11 -4
- package/src/actions.ts +2 -1
- package/src/anthropic-wire.ts +6 -0
- package/src/attestation.ts +23 -0
- package/src/bytes.ts +21 -0
- package/src/changeset.ts +2 -1
- package/src/clickhouse/sql.ts +50 -16
- package/src/derived-core.ts +573 -249
- package/src/derived-real.ts +552 -42
- package/src/derived.ts +23 -4
- package/src/events.ts +54 -85
- package/src/exact-json.ts +71 -0
- package/src/executor.ts +86 -27
- package/src/file-response.ts +1 -1
- package/src/form.ts +66 -0
- package/src/git/format.ts +89 -0
- package/src/git/index.ts +1 -0
- package/src/git/lfs.ts +5 -4
- package/src/graphql-wire.ts +8 -3
- package/src/grpc-wire.ts +2 -1
- package/src/head.ts +15 -5
- package/src/held-reads.ts +54 -0
- package/src/history.ts +5 -4
- package/src/host-port.ts +9 -0
- package/src/index.ts +10 -4
- package/src/log.ts +11 -7
- package/src/machines.ts +135 -13
- package/src/managed-database.ts +48 -40
- package/src/multipart.ts +110 -35
- package/src/observe.ts +6 -3
- package/src/openai-wire.ts +45 -14
- package/src/pack-fetch.ts +173 -50
- package/src/packRegistry.ts +27 -4
- package/src/private-transport.ts +8 -0
- package/src/protobuf.ts +8 -4
- package/src/rateBudget.ts +6 -1
- package/src/redis/engine.ts +168 -63
- package/src/redis/frames.ts +53 -0
- package/src/redis/index.ts +6 -0
- package/src/redis/json.ts +13 -0
- package/src/redis/lua-libs.ts +62 -0
- package/src/redis/lua.ts +20 -0
- package/src/redis/member-storage.ts +58 -0
- package/src/redis/resp2.ts +25 -0
- package/src/redis/stream.ts +102 -0
- package/src/remote-execute.ts +2 -2
- package/src/request-body.ts +12 -0
- package/src/runtime.ts +6 -5
- package/src/s3/wire.ts +8 -5
- package/src/scenario.ts +34 -13
- package/src/serve-http.ts +11 -2
- package/src/serve.ts +4 -3
- package/src/signing.ts +71 -17
- package/src/sigv4.ts +5 -3
- package/src/smtp.ts +3 -2
- package/src/sockets.ts +8 -3
- package/src/state-system.ts +3 -0
- package/src/storage.ts +5 -2
- package/src/twin-fetch.ts +4 -3
- package/src/vendor-call.ts +59 -12
- package/test-fixtures/attestation.json +50 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Redis's Lua JSON/MessagePack runtime libraries. Values only; no state or vendor names.
|
|
2
|
+
// https://redis.io/docs/latest/develop/programmability/lua-api/ (cjson, cmsgpack, bit)
|
|
3
|
+
import { LuaTable, LuaError, LUA_NULL, luaPattern, numberToLuaString, luaTypeName, type LuaValue, type LuaHost } from './lua.ts';
|
|
4
|
+
const bytes = (s: string) => Uint8Array.from(s, (c) => c.charCodeAt(0));
|
|
5
|
+
const text = (b: Uint8Array) => Array.from(b, (v) => String.fromCharCode(v)).join('');
|
|
6
|
+
const objectTable = (v: any): LuaTable => { const t = new LuaTable(); for (const [k, x] of Object.entries(v)) t.set(k, from(x)); return t; };
|
|
7
|
+
const from = (v: any): LuaValue => v === null ? LUA_NULL : Array.isArray(v) ? LuaTable.fromArray(v.map(from)) : typeof v === 'object' ? objectTable(v) : v;
|
|
8
|
+
const to = (v: LuaValue): any => v === LUA_NULL || v === null ? null : v instanceof LuaTable ? v.length > 0 ? v.toArray().map(to) : Object.fromEntries([...v.map.entries()].map(([k, x]) => [String(k), to(x)])) : v;
|
|
9
|
+
export function unpackMessage(s: string): LuaValue {
|
|
10
|
+
const b = bytes(s), d = new DataView(b.buffer); let i = 0;
|
|
11
|
+
const n = (width: number, signed = false): number => { let v: number; if (i + width > b.length) throw new LuaError('Bad data format in input.'); if (width === 1) v = signed ? d.getInt8(i) : d.getUint8(i); else if (width === 2) v = signed ? d.getInt16(i) : d.getUint16(i); else if (width === 4) v = signed ? d.getInt32(i) : d.getUint32(i); else v = Number(signed ? d.getBigInt64(i) : d.getBigUint64(i)); i += width; return v; };
|
|
12
|
+
const string = (len: number) => { const out = text(b.subarray(i, i + len)); i += len; if (i > b.length) throw new LuaError('Bad data format in input.'); return out; };
|
|
13
|
+
const array = (len: number) => LuaTable.fromArray(Array.from({ length: len }, read));
|
|
14
|
+
const map = (len: number) => { const t = new LuaTable(); for (let j = 0; j < len; j++) t.set(read(), read()); return t; };
|
|
15
|
+
function read(): LuaValue {
|
|
16
|
+
const c = n(1); if (c < 128) return c; if (c >= 224) return c - 256;
|
|
17
|
+
if ((c & 0xe0) === 0xa0) return string(c & 31); if ((c & 0xf0) === 0x90) return array(c & 15); if ((c & 0xf0) === 0x80) return map(c & 15);
|
|
18
|
+
switch (c) {
|
|
19
|
+
case 0xc0: return null; case 0xc2: return false; case 0xc3: return true;
|
|
20
|
+
case 0xc4: case 0xd9: return string(n(1)); case 0xc5: case 0xda: return string(n(2)); case 0xc6: case 0xdb: return string(n(4));
|
|
21
|
+
case 0xcc: return n(1); case 0xcd: return n(2); case 0xce: return n(4); case 0xcf: return n(8);
|
|
22
|
+
case 0xd0: return n(1, true); case 0xd1: return n(2, true); case 0xd2: return n(4, true); case 0xd3: return n(8, true);
|
|
23
|
+
case 0xca: { const v = d.getFloat32(i); i += 4; return v; } case 0xcb: { const v = d.getFloat64(i); i += 8; return v; }
|
|
24
|
+
case 0xdc: return array(n(2)); case 0xdd: return array(n(4)); case 0xde: return map(n(2)); case 0xdf: return map(n(4));
|
|
25
|
+
default: throw new LuaError('Bad data format in input.');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const value = read(); if (i !== b.length) throw new LuaError('Extra bytes in input.'); return value;
|
|
29
|
+
}
|
|
30
|
+
export function packMessage(value: LuaValue): string {
|
|
31
|
+
const out: number[] = [];
|
|
32
|
+
const len = (tag: number, size: number, n: number) => { out.push(tag); for (let j = size - 1; j >= 0; j--) out.push(Math.floor(n / 2 ** (j * 8)) & 255); };
|
|
33
|
+
const put = (v: LuaValue): void => {
|
|
34
|
+
if (v === null || v === LUA_NULL) { out.push(0xc0); return; } if (typeof v === 'boolean') { out.push(v ? 0xc3 : 0xc2); return; }
|
|
35
|
+
if (typeof v === 'number') { if (Number.isInteger(v) && v >= 0 && v < 128 || v >= -32 && v < 0 && Number.isInteger(v)) out.push(v & 255); else { const b = new Uint8Array(8); new DataView(b.buffer).setFloat64(0, v); out.push(0xcb, ...b); } return; }
|
|
36
|
+
if (typeof v === 'string') { if (v.length < 32) out.push(0xa0 | v.length); else len(v.length < 256 ? 0xd9 : 0xda, v.length < 256 ? 1 : 2, v.length); out.push(...bytes(v)); return; }
|
|
37
|
+
if (!(v instanceof LuaTable)) throw new LuaError('Unsupported type');
|
|
38
|
+
if (v.length > 0) { const a = v.toArray(); if (a.length < 16) out.push(0x90 | a.length); else len(0xdc, 2, a.length); a.forEach(put); }
|
|
39
|
+
else { if (v.map.size < 16) out.push(0x80 | v.map.size); else len(0xde, 2, v.map.size); for (const [k, x] of v.map) { put(k as LuaValue); put(x); } }
|
|
40
|
+
};
|
|
41
|
+
put(value); return text(Uint8Array.from(out));
|
|
42
|
+
}
|
|
43
|
+
export const standardLuaLibraries: NonNullable<LuaHost['extend']> = ({ get, set, commandApi }) => {
|
|
44
|
+
// Lua 5.1 string.match is a selected script runtime capability, not a base-library change.
|
|
45
|
+
// source: https://www.lua.org/manual/5.1/manual.html "Returns the captures from the pattern"
|
|
46
|
+
const string = get('string') as LuaTable;
|
|
47
|
+
string.set('match', (a) => {
|
|
48
|
+
const str = (i: number): string => { const v = a[i] ?? null; if (typeof v === 'string') return v; if (typeof v === 'number') return numberToLuaString(v); throw new LuaError(`bad argument #${i + 1} to 'string.match' (string expected, got ${luaTypeName(v)})`); };
|
|
49
|
+
const value = str(0), pattern = str(1);
|
|
50
|
+
const start = a[2] === undefined ? 0 : Math.max(0, Number(a[2]) - 1);
|
|
51
|
+
const match = new RegExp(luaPattern(pattern)).exec(value.slice(start));
|
|
52
|
+
return match ? match.length > 1 ? match.slice(1) : [match[0]] : [null];
|
|
53
|
+
});
|
|
54
|
+
set('redis', commandApi); set('server', commandApi);
|
|
55
|
+
const json = new LuaTable(); json.set('null', LUA_NULL);
|
|
56
|
+
json.set('encode', (a) => [text(new TextEncoder().encode(JSON.stringify(to(a[0] ?? null))))]);
|
|
57
|
+
json.set('decode', (a) => { try { return [from(JSON.parse(new TextDecoder().decode(bytes(String(a[0])))))]; } catch { throw new LuaError('Expected value but found invalid token'); } }); set('cjson', json);
|
|
58
|
+
const msg = new LuaTable(); msg.set('unpack', (a) => [unpackMessage(String(a[0]))]); msg.set('pack', (a) => [packMessage(a[0] ?? null)]); set('cmsgpack', msg);
|
|
59
|
+
const bit = new LuaTable(); const ns = (a: LuaValue[]) => a.map(Number);
|
|
60
|
+
bit.set('band', (a) => [ns(a).reduce((x, y) => x & y, -1)]); bit.set('bor', (a) => [ns(a).reduce((x, y) => x | y, 0)]); bit.set('bxor', (a) => [ns(a).reduce((x, y) => x ^ y, 0)]);
|
|
61
|
+
bit.set('bnot', (a) => [~Number(a[0])]); bit.set('lshift', (a) => [Number(a[0]) << Number(a[1])]); bit.set('rshift', (a) => [Number(a[0]) >>> Number(a[1])]); bit.set('arshift', (a) => [Number(a[0]) >> Number(a[1])]); set('bit', bit);
|
|
62
|
+
};
|
package/src/redis/lua.ts
CHANGED
|
@@ -559,6 +559,26 @@ class Scope {
|
|
|
559
559
|
const BREAK = Symbol('lua-break');
|
|
560
560
|
type Signal = typeof BREAK | { ret: LuaValue[] } | null;
|
|
561
561
|
|
|
562
|
+
// Lua pattern subset used by command-language scripts; unsupported balanced/frontier patterns refuse explicitly.
|
|
563
|
+
export function luaPattern(pattern: string): string {
|
|
564
|
+
const classes: Record<string, string> = { a: 'A-Za-z', d: '0-9', l: 'a-z', u: 'A-Z', w: 'A-Za-z0-9', s: '\\s', x: 'A-Fa-f0-9', p: '!-/:-@[-`{-~', c: '\\x00-\\x1f\\x7f', z: '\\x00' };
|
|
565
|
+
let out = '', inClass = false;
|
|
566
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
567
|
+
const c = pattern[i]!;
|
|
568
|
+
if (c === '%') {
|
|
569
|
+
const next = pattern[++i]!; if (next === 'b' || next === 'f') throw new LuaError('unsupported Lua balanced/frontier pattern');
|
|
570
|
+
if (classes[next.toLowerCase()]) { const content = classes[next.toLowerCase()]!; out += inClass ? content : `[${next === next.toUpperCase() ? '^' : ''}${content}]`; }
|
|
571
|
+
else if (/\d/.test(next)) out += `\\${next}`;
|
|
572
|
+
else out += next.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
|
|
573
|
+
} else if (c === '[') { inClass = true; out += c; }
|
|
574
|
+
else if (c === ']') { inClass = false; out += c; }
|
|
575
|
+
else if (!inClass && c === '-') out += '*?';
|
|
576
|
+
else if ('\\{}|'.includes(c)) out += `\\${c}`;
|
|
577
|
+
else out += c;
|
|
578
|
+
}
|
|
579
|
+
return out;
|
|
580
|
+
}
|
|
581
|
+
|
|
562
582
|
export function luaTruthy(v: LuaValue): boolean {
|
|
563
583
|
return v !== null && v !== false;
|
|
564
584
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Redis's collection layout over the World tree. First need: the managed Redis queue's growing events stream.
|
|
2
|
+
// Key metadata and each hash field/set member/sorted-set member/stream entry are separate subjects.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { applyTwinWrite } from '../serve.ts';
|
|
5
|
+
import { projectResources } from '../actions.ts';
|
|
6
|
+
import { treeStamp, treeChangesSince } from '../log.ts';
|
|
7
|
+
import { getActiveWorldStore } from '../world-store.ts';
|
|
8
|
+
import type { RedisContext, KeyRow, KeyspaceImage, KeySpace } from './engine.ts';
|
|
9
|
+
|
|
10
|
+
const scope = (c: RedisContext) => c.database === undefined ? '' : `db:${c.database}:`;
|
|
11
|
+
const collections = new Set(['hash', 'set', 'zset', 'stream', 'list']);
|
|
12
|
+
const held = new WeakMap<object, Map<string, { stamp: string; subjects: Map<string, any>; image: KeyspaceImage }>>();
|
|
13
|
+
const members = (r: KeyRow | null): Map<string, unknown> => !r || !collections.has(r.kind) ? new Map() : new Map((r.v as any[]).map((x, i) => r.kind === 'list' ? [String(i), x] : r.kind === 'set' ? [x, x] : [x[0], x[1]]));
|
|
14
|
+
const memberId = (c: RedisContext, name: string, field: string) => `${scope(c)}member:${createHash('sha256').update(JSON.stringify([name, field])).digest('hex')}`;
|
|
15
|
+
|
|
16
|
+
export const memberStorage = {
|
|
17
|
+
image(ctx: RedisContext): KeyspaceImage {
|
|
18
|
+
const cache = held.get(getActiveWorldStore()) ?? new Map(); held.set(getActiveWorldStore(), cache);
|
|
19
|
+
const key = `${ctx.service}\0${ctx.root ?? ''}\0${scope(ctx)}`;
|
|
20
|
+
let h = cache.get(key);
|
|
21
|
+
const stamp = treeStamp(ctx.service, ctx.root);
|
|
22
|
+
if (h?.stamp === stamp) return h.image;
|
|
23
|
+
const delta = h && treeChangesSince(ctx.service, ctx.root, h.stamp);
|
|
24
|
+
if (!h || !delta || delta.removed.length) h = { stamp, subjects: new Map(), image: { rows: new Map(), scripts: new Map(), revs: new Map() } };
|
|
25
|
+
for (const r of delta && !delta.removed.length ? delta.changed : projectResources(ctx.service, ctx.root)) h.subjects.set(r.id, r);
|
|
26
|
+
// Reconstruct only keys affected by the tree delta, not every member on every read.
|
|
27
|
+
const affected = new Set<string>();
|
|
28
|
+
const changed = delta && !delta.removed.length ? delta.changed : [...h.subjects.values()];
|
|
29
|
+
for (const r of changed) {
|
|
30
|
+
if (!r.id.startsWith(scope(ctx))) continue;
|
|
31
|
+
if (r.type === 'key') affected.add(r.name);
|
|
32
|
+
if (r.type === 'member') affected.add(r.key);
|
|
33
|
+
if (r.type === 'script') { if (r.gone) h.image.scripts.delete(r.sha); else h.image.scripts.set(r.sha, r.body); }
|
|
34
|
+
}
|
|
35
|
+
for (const name of affected) {
|
|
36
|
+
const meta = h.subjects.get(`${scope(ctx)}key:${name}`);
|
|
37
|
+
if (!meta) continue;
|
|
38
|
+
h.image.revs.set(name, meta._rev ?? 0);
|
|
39
|
+
if (meta.gone) { h.image.rows.delete(name); continue; }
|
|
40
|
+
const v = collections.has(meta.kind) ? [...h.subjects.values()].filter((r) => r.type === 'member' && r.id.startsWith(scope(ctx)) && r.key === name && r.gone !== true).sort((a, b) => meta.kind === 'list' ? Number(a.field) - Number(b.field) : 0).map((r) => meta.kind === 'list' ? r.value : meta.kind === 'set' ? r.field : [r.field, r.value]) : meta.v;
|
|
41
|
+
h.image.rows.set(name, { name, kind: meta.kind, v, pexpireAt: meta.pexpire_at == null ? null : String(meta.pexpire_at), lastId: meta.last_id, _rev: meta._rev });
|
|
42
|
+
}
|
|
43
|
+
h.stamp = stamp; cache.set(key, h); return h.image;
|
|
44
|
+
},
|
|
45
|
+
async flush(space: KeySpace): Promise<void> {
|
|
46
|
+
const ctx = space.ctx;
|
|
47
|
+
const write = async (type: string, id: string, operation: string, fields: any) => applyTwinWrite(ctx.service, { operation, subjectType: type, subjectId: id, fields, occurredAt: ctx.occurredAt, actor: { kind: 'agent' } }, ctx.root);
|
|
48
|
+
for (const { name, row, operation } of space.pendingWrites()) {
|
|
49
|
+
const before = members(space.image.rows.get(name) ?? null), after = members(row);
|
|
50
|
+
for (const field of new Set([...before.keys(), ...after.keys()])) {
|
|
51
|
+
if (before.has(field) && after.has(field) && JSON.stringify(before.get(field)) === JSON.stringify(after.get(field))) continue;
|
|
52
|
+
await write('member', memberId(ctx, name, field), operation, { key: name, field, value: after.get(field) ?? null, gone: !after.has(field) });
|
|
53
|
+
}
|
|
54
|
+
await write('key', `${scope(ctx)}key:${name}`, operation, { name, kind: row?.kind ?? 'string', v: row && !collections.has(row.kind) ? row.v : null, pexpire_at: row?.pexpireAt ?? null, last_id: row?.lastId ?? '', gone: row === null, _rev: space.nextRev(name) });
|
|
55
|
+
}
|
|
56
|
+
for (const { sha, body, operation } of space.pendingScriptWrites()) await write('script', `${scope(ctx)}script:${sha}`, operation, { sha, body: body ?? '', gone: body === null });
|
|
57
|
+
},
|
|
58
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// A reply as RESP2 writes it (https://redis.io/docs/latest/develop/reference/protocol-spec/): a status as a simple string,
|
|
2
|
+
// an error as a simple error, an integer as an integer, a string as a bulk string (its length in bytes), a null as the
|
|
3
|
+
// null bulk string, an array as an array of its elements' replies. RESP2 has no float or double: a number that is not an
|
|
4
|
+
// integer is its bulk string, as Redis sends INCRBYFLOAT's. "Simple strings never contain carriage return ( \r ) or line
|
|
5
|
+
// feed ( \n ) characters" (the page), and an error is a simple string's form: Redis writes a status's text with each CR
|
|
6
|
+
// and LF mapped to a space (script_lua.c, `sdsmapchars(ok,"\r\n"," ",2)` before `addReplyStatusLength`), and an
|
|
7
|
+
// error's with the CRs and LFs at its ends trimmed and each one left inside mapped to a space (networking.c,
|
|
8
|
+
// `addReplyErrorFormatInternal`: `sdstrim(s, "\r\n")`, then `sdsmapchars(s, "\r\n", " ", 2)`), so a message holding
|
|
9
|
+
// one never ends the reply early. Source: https://raw.githubusercontent.com/redis/redis/7.4.0/src/networking.c and
|
|
10
|
+
// https://raw.githubusercontent.com/redis/redis/7.4.0/src/script_lua.c.
|
|
11
|
+
// Shared by every wire that answers a Redis reply as RESP2 bytes.
|
|
12
|
+
import { RedisInteger, RedisStatus, type RedisValue } from './engine.ts';
|
|
13
|
+
|
|
14
|
+
const spaced = (text: string): string => text.replace(/[\r\n]/g, ' ');
|
|
15
|
+
const trimmed = (text: string): string => text.replace(/^[\r\n]+|[\r\n]+$/g, '');
|
|
16
|
+
|
|
17
|
+
export function resp2(value: RedisValue | { error: string }): string {
|
|
18
|
+
if (value instanceof RedisInteger) return `:${value.integer}\r\n`;
|
|
19
|
+
if (value === null) return '$-1\r\n';
|
|
20
|
+
if (value instanceof RedisStatus) return `+${spaced(value.value)}\r\n`;
|
|
21
|
+
if (Array.isArray(value)) return `*${value.length}\r\n${value.map((v) => resp2(v)).join('')}`;
|
|
22
|
+
if (typeof value === 'number') return Number.isInteger(value) ? `:${value}\r\n` : resp2(String(value));
|
|
23
|
+
if (typeof value === 'string') return `$${new TextEncoder().encode(value).length}\r\n${value}\r\n`;
|
|
24
|
+
return `-${spaced(trimmed(value.error))}\r\n`;
|
|
25
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Connection-local Redis request/reply machinery. The pack supplies its dialect and executes through its own context.
|
|
2
|
+
// https://redis.io/docs/latest/develop/reference/protocol-spec/; commands AUTH, CLIENT, MULTI, EXEC, BZPOPMIN.
|
|
3
|
+
import type { TwinStream, TwinStreamSink } from '../twin-fetch.ts';
|
|
4
|
+
import { RedisStatus, commandInteger, commandShapeError, globMatch, type RedisDialect, type RunItem } from './engine.ts';
|
|
5
|
+
import { binaryBytes, binaryText, inlineCommand, readResp, respReply } from './frames.ts';
|
|
6
|
+
export type RedisStreamOptions = {
|
|
7
|
+
dialect: RedisDialect; version: string; password?: string; databases: number;
|
|
8
|
+
run(commands: string[][], database: string): Promise<{ items: RunItem[]; wrote: boolean; instant?: number }>;
|
|
9
|
+
};
|
|
10
|
+
export function createRedisStream(options: RedisStreamOptions): TwinStream {
|
|
11
|
+
type Session = { id: number; name: string; lib: Record<string, string>; database: string; authenticated: boolean; queue: string[][] | null; dirty: boolean; subscriptions: Set<string>; patterns: Set<string>; sink: TwinStreamSink; closed: boolean; pump?: () => Promise<void>; blocked?: { command: string[]; deadline: number; timer?: ReturnType<typeof setTimeout> } };
|
|
12
|
+
const sessions = new Set<Session>(); let nextId = 0, commandCount = 0; let serial: Promise<any> = Promise.resolve();
|
|
13
|
+
const ordered = <T>(f: () => Promise<T>): Promise<T> => { const p = serial.then(f); serial = p.catch(() => {}); return p; };
|
|
14
|
+
const error = (message: string) => ({ error: message }); const ok = new RedisStatus('OK');
|
|
15
|
+
const answer = (s: Session, v: any) => { if (!s.closed) s.sink.write(binaryBytes(respReply(v))); };
|
|
16
|
+
const close = (s: Session) => { s.closed = true; if (s.blocked?.timer) clearTimeout(s.blocked.timer); sessions.delete(s); };
|
|
17
|
+
const unblock = (s: Session, v: any) => { if (s.blocked?.timer) clearTimeout(s.blocked.timer); s.blocked = undefined; answer(s, v); queueMicrotask(() => { void s.pump?.(); }); };
|
|
18
|
+
const blockRun = async (s: Session, argv: string[]): Promise<any> => {
|
|
19
|
+
if (argv[0]!.toUpperCase() === 'BZPOPMIN') {
|
|
20
|
+
for (const key of argv.slice(1, -1)) { const res = (await options.run([['ZPOPMIN', key]], s.database)).items[0]!; if ('error' in res) return res; if (Array.isArray(res.result) && res.result.length) return [key, ...res.result]; } return null;
|
|
21
|
+
}
|
|
22
|
+
throw new Error('the blocking command has no executor');
|
|
23
|
+
};
|
|
24
|
+
const wake = async () => { for (const s of sessions) if (s.blocked) { const v = await blockRun(s, s.blocked.command); if (v !== null) unblock(s, v); } };
|
|
25
|
+
const command = async (s: Session, argv: string[], executing = false): Promise<void> => {
|
|
26
|
+
if (!argv.length || s.closed) return;
|
|
27
|
+
commandCount++;
|
|
28
|
+
const cmd = argv[0]!.toUpperCase(), a = argv.slice(1);
|
|
29
|
+
const shape = commandShapeError(argv, options.dialect); if (shape) { if (s.queue) s.dirty = true; answer(s, error(shape)); return; }
|
|
30
|
+
if (!executing && !s.authenticated && !['AUTH', 'QUIT'].includes(cmd)) { answer(s, error('NOAUTH Authentication required.')); return; }
|
|
31
|
+
if (!executing && s.subscriptions.size + s.patterns.size > 0 && !['SUBSCRIBE', 'PSUBSCRIBE', 'UNSUBSCRIBE', 'PUNSUBSCRIBE', 'PING', 'QUIT'].includes(cmd)) { answer(s, error(`ERR Can't execute '${cmd.toLowerCase()}': only (P|S)SUBSCRIBE / (P|S)UNSUBSCRIBE / PING / QUIT / RESET are allowed in this context`)); return; }
|
|
32
|
+
if (s.queue && !['EXEC', 'MULTI', 'DISCARD', 'QUIT'].includes(cmd)) { s.queue.push(argv); answer(s, new RedisStatus('QUEUED')); return; }
|
|
33
|
+
switch (cmd) {
|
|
34
|
+
// source: https://raw.githubusercontent.com/redis/redis/c9d29f6a918c335bc1778d9f68e521c1bbb36a0f/src/acl.c "Only two or three argument forms are allowed."
|
|
35
|
+
case 'AUTH': {
|
|
36
|
+
if (a.length > 2) { answer(s, error("ERR syntax error")); return; }
|
|
37
|
+
if (!options.password && a.length === 1) { answer(s, error('ERR AUTH <password> called without any password configured for the default user. Are you sure your configuration is correct?')); return; }
|
|
38
|
+
if (a.length === 2 && a[0] !== 'default' || options.password && a.at(-1) !== options.password) { answer(s, error('WRONGPASS invalid username-password pair or user is disabled.')); return; }
|
|
39
|
+
s.authenticated = true; answer(s, ok); return;
|
|
40
|
+
}
|
|
41
|
+
case 'SELECT': { let db: number; try { db = commandInteger(a[0]!); } catch { answer(s, error('ERR value is not an integer or out of range')); return; } if (db < -2147483648 || db > 2147483647) { answer(s, error('ERR value is out of range, value must between -2147483648 and 2147483647')); return; } if (db < 0 || db >= options.databases) { answer(s, error('ERR DB index is out of range')); return; } s.database = String(db); answer(s, ok); return; }
|
|
42
|
+
case 'CLIENT': {
|
|
43
|
+
const sub = a[0]!.toUpperCase();
|
|
44
|
+
if (sub === 'SETNAME') { if (/[^\x21-\x7e]/.test(a[1] ?? '')) { answer(s, error('ERR Client names cannot contain spaces, newlines or special characters.')); return; } s.name = a[1] ?? ''; answer(s, ok); }
|
|
45
|
+
else if (sub === 'SETINFO') { if (!['LIB-NAME', 'LIB-VER'].includes(a[1]!.toUpperCase())) { answer(s, error(`ERR Unrecognized option '${a[1]}'`)); return; } if (/[^\x21-\x7e]/.test(a[2]!)) { answer(s, error(`ERR ${a[1]} cannot contain spaces, newlines or special characters.`)); return; } s.lib[a[1]!.toUpperCase()] = a[2]!; answer(s, ok); }
|
|
46
|
+
else if (sub === 'LIST') {
|
|
47
|
+
let clients = [...sessions];
|
|
48
|
+
// source: https://raw.githubusercontent.com/redis/redis/c9d29f6a918c335bc1778d9f68e521c1bbb36a0f/src/networking.c "Invalid client ID"
|
|
49
|
+
if (a[1]?.toUpperCase() === 'ID' && a.length > 2) { let ids: number[]; try { ids = a.slice(2).map((raw) => commandInteger(raw, 'ERR Invalid client ID')); } catch (e) { answer(s, error((e as Error).message)); return; } clients = ids.flatMap((id) => [...sessions].filter((x) => x.id === id)); }
|
|
50
|
+
else if (a[1]?.toUpperCase() === 'TYPE' && a.length === 3) { const type = a[2]!.toLowerCase(); if (!['normal', 'pubsub', 'master', 'replica'].includes(type)) { answer(s, error(`ERR Unknown client type '${a[2]}'`)); return; } clients = clients.filter((x) => type === (x.subscriptions.size + x.patterns.size ? 'pubsub' : 'normal')); }
|
|
51
|
+
else if (a.length !== 1) { answer(s, error('ERR syntax error')); return; }
|
|
52
|
+
answer(s, clients.map((x) => `id=${x.id} addr=127.0.0.1:0 name=${x.name} db=${x.database} flags=${x.subscriptions.size + x.patterns.size ? 'P' : 'N'} lib-name=${x.lib['LIB-NAME'] ?? ''} lib-ver=${x.lib['LIB-VER'] ?? ''}\n`).join(''));
|
|
53
|
+
}
|
|
54
|
+
else answer(s, error(options.dialect.unknownCommand(argv))); return;
|
|
55
|
+
}
|
|
56
|
+
// source: https://raw.githubusercontent.com/redis/redis/c9d29f6a918c335bc1778d9f68e521c1bbb36a0f/src/server.c "It works in a different way if the client is in"
|
|
57
|
+
case 'PING': if (a.length > 1) { answer(s, error("ERR wrong number of arguments for 'ping' command")); return; } answer(s, s.subscriptions.size + s.patterns.size ? ['pong', a[0] ?? ''] : a.length ? a[0] : new RedisStatus('PONG')); return;
|
|
58
|
+
case 'INFO': {
|
|
59
|
+
const blocks: Record<string, string> = { server: `# Server\r\nredis_version:${options.version}\r\nredis_mode:standalone\r\n`, clients: `# Clients\r\nconnected_clients:${sessions.size}\r\nblocked_clients:${[...sessions].filter((x) => x.blocked).length}\r\n`, memory: `# Memory\r\nmaxmemory:0\r\nmaxmemory_policy:noeviction\r\n`, stats: `# Stats\r\ntotal_connections_received:${nextId}\r\ntotal_commands_processed:${commandCount}\r\n`, persistence: '# Persistence\r\nloading:0\r\n' };
|
|
60
|
+
answer(s, a.length && !['ALL', 'DEFAULT'].includes(a[0]!.toUpperCase()) ? a.map((x) => blocks[x.toLowerCase()] ?? '').join('') : Object.values(blocks).join('\r\n')); return;
|
|
61
|
+
}
|
|
62
|
+
case 'MULTI': if (s.queue) answer(s, error('ERR MULTI calls can not be nested')); else { s.queue = []; s.dirty = false; answer(s, ok); } return;
|
|
63
|
+
case 'DISCARD': if (!s.queue) answer(s, error('ERR DISCARD without MULTI')); else { s.queue = null; s.dirty = false; answer(s, ok); } return;
|
|
64
|
+
case 'EXEC': { if (!s.queue) { answer(s, error('ERR EXEC without MULTI')); return; } const q = s.queue; s.queue = null; if (s.dirty) { s.dirty = false; answer(s, error('EXECABORT Transaction discarded because of previous errors.')); return; } // source: https://raw.githubusercontent.com/redis/redis/c9d29f6a918c335bc1778d9f68e521c1bbb36a0f/src/multi.c "Exec all the queued commands"
|
|
65
|
+
// Redis writes the queue length before dispatch; pub/sub may write multiple acknowledgement frames.
|
|
66
|
+
s.sink.write(binaryBytes(`*${q.length}\r\n`)); for (const queued of q) await command(s, queued, true); await wake(); return; }
|
|
67
|
+
case 'QUIT': answer(s, ok); close(s); s.sink.end(); return;
|
|
68
|
+
case 'SUBSCRIBE': case 'PSUBSCRIBE': case 'UNSUBSCRIBE': case 'PUNSUBSCRIBE': {
|
|
69
|
+
const target = cmd.includes('PSUB') || cmd.includes('PUNSUB') ? s.patterns : s.subscriptions; const removing = cmd.includes('UNSUB'); const names = a.length ? a : [...target];
|
|
70
|
+
for (const name of names.length ? names : [null]) { if (name !== null) { if (removing) target.delete(name); else target.add(name); } answer(s, [cmd.toLowerCase(), name, s.subscriptions.size + s.patterns.size]); } return;
|
|
71
|
+
}
|
|
72
|
+
case 'PUBLISH': { let count = 0; for (const target of sessions) { if (target.subscriptions.has(a[0]!)) { answer(target, ['message', a[0]!, a[1]!]); count++; } for (const pattern of target.patterns) if (globMatch(pattern, a[0]!)) { answer(target, ['pmessage', pattern, a[0]!, a[1]!]); count++; } } answer(s, count); return; }
|
|
73
|
+
case 'BZPOPMIN': {
|
|
74
|
+
// source: https://raw.githubusercontent.com/redis/redis/c9d29f6a918c335bc1778d9f68e521c1bbb36a0f/src/timeout.c "timeout is negative"
|
|
75
|
+
const raw = a.at(-1)!;
|
|
76
|
+
const infinite = /^[+-]?inf(?:inity)?$/i.test(raw);
|
|
77
|
+
if (raw !== raw.trim() || raw === '' || !infinite && !/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(raw)) { answer(s, error('ERR timeout is not a float or out of range')); return; }
|
|
78
|
+
const millis = (infinite ? raw.startsWith('-') ? -Infinity : Infinity : Number(raw)) * 1000;
|
|
79
|
+
if (millis > 9223372036854775807) { answer(s, error('ERR timeout is out of range')); return; }
|
|
80
|
+
const timeout = Math.ceil(millis);
|
|
81
|
+
if (!Number.isFinite(timeout) || timeout < 0) { answer(s, error('ERR timeout is negative')); return; }
|
|
82
|
+
const v = await blockRun(s, argv); if (v !== null) { answer(s, v); return; }
|
|
83
|
+
// source: https://raw.githubusercontent.com/redis/redis/c9d29f6a918c335bc1778d9f68e521c1bbb36a0f/src/multi.c "we do not want to allow blocking commands inside multi"
|
|
84
|
+
if (executing) { answer(s, null); return; }
|
|
85
|
+
const initial = await options.run([], s.database); s.blocked = { command: argv, deadline: timeout === 0 ? Infinity : (initial.instant ?? 0) + timeout };
|
|
86
|
+
const poll = () => { if (!s.blocked || s.closed) return; s.blocked.timer = setTimeout(() => { void ordered(async () => { if (!s.blocked) return; const now = await options.run([], s.database); if ((now.instant ?? 0) >= s.blocked.deadline) unblock(s, null); else poll(); }); }, 50); }; poll(); return;
|
|
87
|
+
}
|
|
88
|
+
default: { const res = await options.run([argv], s.database); const item = res.items[0]!; answer(s, 'error' in item ? item : item.result); if (res.wrote && !executing) await wake(); }
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
return (sink) => {
|
|
92
|
+
const s: Session = { id: ++nextId, name: '', lib: {}, database: '0', authenticated: !options.password, queue: null, dirty: false, subscriptions: new Set(), patterns: new Set(), sink, closed: false }; sessions.add(s);
|
|
93
|
+
let buffer = '', chain: Promise<any> = Promise.resolve(); const pending: string[][] = [];
|
|
94
|
+
const pump = async () => { while (pending.length && !s.blocked && !s.closed) await ordered(() => command(s, pending.shift()!)); };
|
|
95
|
+
s.pump = pump;
|
|
96
|
+
return {
|
|
97
|
+
data(chunk) { if (s.closed) return; buffer += binaryText(chunk); try { while (buffer.length) { let argv: string[]; let end: number; if (buffer[0] === '*') { const f = readResp(buffer); if (!f) break; if (!Array.isArray(f.value) || f.value.some((x: unknown) => typeof x !== 'string')) throw new Error('Protocol error: expected bulk string'); argv = f.value; end = f.end; } else { const at = buffer.indexOf('\r\n'); if (at < 0) break; argv = inlineCommand(buffer.slice(0, at)); end = at + 2; } buffer = buffer.slice(end); pending.push(argv); } chain = chain.then(pump); } catch (e) { answer(s, error(`ERR ${(e as Error).message}`)); close(s); sink.end(); } },
|
|
98
|
+
settled: () => chain,
|
|
99
|
+
close: () => close(s),
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
}
|
package/src/remote-execute.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// packs can adapt their vendor-specific executors to it; BUILDING one (origin +
|
|
4
4
|
// sealed-credential egress) is the twins service's job, never a pack's.
|
|
5
5
|
export type RemoteExecuteRequest = {
|
|
6
|
+
/** The lane of a vendor of lanes that sends the call: the descriptor's `lanes` strategy picks its own by it. */
|
|
7
|
+
lane?: string;
|
|
6
8
|
method: string;
|
|
7
9
|
path: string;
|
|
8
10
|
headers?: Record<string, string>;
|
|
@@ -20,8 +22,6 @@ export type RemoteExecuteRequest = {
|
|
|
20
22
|
* that its upload calls carry instead of the API token), on the root's own origin: it is sent as the request's
|
|
21
23
|
* Authorization and the sealed credential does not go with it. */
|
|
22
24
|
issued?: string;
|
|
23
|
-
/** the lane of a vendor of lanes the request is (its strategy, when its lanes authenticate apart) */
|
|
24
|
-
lane?: string;
|
|
25
25
|
};
|
|
26
26
|
export type RemoteExecuteResponse = {
|
|
27
27
|
status: number;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Compressed-request output only: Node's plain requests remain uncapped; Bun retains its own plain-body limit.
|
|
2
|
+
* The default is Bun's existing largest plain body (128 MiB), so both runtimes decode the same.
|
|
3
|
+
* Source: bun-types/serve.d.ts, ServeOptions.maxRequestBodySize (@default 1024 * 1024 * 128),
|
|
4
|
+
* https://github.com/oven-sh/bun/blob/main/packages/bun-types/serve.d.ts .
|
|
5
|
+
* A pack's descriptor.maxRequestBodySize overrides decoded output as it does Bun's plain-body limit. */
|
|
6
|
+
export const DEFAULT_MAX_DECODED_REQUEST_BODY_SIZE = 128 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
export class RequestBodyTooLargeError extends Error {
|
|
9
|
+
constructor() { super('Payload Too Large'); this.name = 'RequestBodyTooLargeError'; }
|
|
10
|
+
}
|
|
11
|
+
export const oversizedBody = (): Response => new Response('Payload Too Large', { status: 413 });
|
|
12
|
+
export const decodedBodyLimit = (declared?: number): number => declared ?? DEFAULT_MAX_DECODED_REQUEST_BODY_SIZE;
|
package/src/runtime.ts
CHANGED
|
@@ -20,7 +20,7 @@ export { currentTraceparent, deliveryTraceHeaders, newTraceparent, parseTracepar
|
|
|
20
20
|
export type { Traceparent } from './trace-context.ts';
|
|
21
21
|
export type { DerivedCall, DerivedCoreOutcome, DerivedFetch, DerivedFetchOptions, DerivedHandler, DerivedOperation, DerivedOwner, DerivedSurface, BoardFrame } from './derived.ts';
|
|
22
22
|
export { authRefusal, bindSemantics, coreFor, crossCutting, flagOf, derivedRequestScopes, observeTransitions, resourcesOfType as twinResourcesOfType, semanticsContext, stateOf, transitionFor, parseBracketForm, readParams, render as renderDerived, serveCore, sse, vendorError } from './derived-core.ts';
|
|
23
|
-
export { deliverEvents, fillEnvelope, signEvent, signEventAll, verifyEvent } from './events.ts';
|
|
23
|
+
export { deliverEvents, fillEnvelope, setDeliveryObserver, signEvent, signEventAll, verifyEvent } from './events.ts';
|
|
24
24
|
export { equalSecrets, hmac, jwks, jwtDecode, jwtSign, jwtVerify, keyId, lettersFrom, md5, sha256, SIGNING_ALGORITHMS, uuidFrom } from './signing.ts';
|
|
25
25
|
export type { HandlerCrypto, Jwk, Jwks, JwtVerdict, SigningKey } from './signing.ts';
|
|
26
26
|
export type { EventDelivery, EventRender, EventScheme, EventsDecl, EventTransport, EventWrite } from './events.ts';
|
|
@@ -323,17 +323,17 @@ export { readTreeMap, readParentTreeMap, treeStamp, treeChangesSince, originEntr
|
|
|
323
323
|
|
|
324
324
|
export { referenceField, registerReferences, packReferences, resolveReferences, type ReferenceDeclaration } from './references.ts';
|
|
325
325
|
// the HTTP server seam: the one place the serve path meets the runtime (Bun or Node)
|
|
326
|
-
export { serveHttp, serveStream, nodeBuiltin, WORLD_BOOT_PATH, type HttpServer, type ServeHttpOptions, type HttpHandler } from './serve-http.ts';
|
|
326
|
+
export { serveHttp, serveStream, connectWebSocket, nodeBuiltin, WORLD_BOOT_PATH, type HttpServer, type ServeHttpOptions, type HttpHandler } from './serve-http.ts';
|
|
327
327
|
// two runtime-neutral helpers for a serve path: a file as a Response, a page's client bundle
|
|
328
328
|
export { fileResponse, contentTypeOf } from './file-response.ts';
|
|
329
329
|
export { bundleClient, filePathOf } from './client-bundle.ts';
|
|
330
330
|
// the brand's tokens and faces for a Volter page (the console, the site, the UI kit), fetched at build
|
|
331
331
|
export { brandTokensResponse } from './brand-tokens.ts';
|
|
332
332
|
|
|
333
|
-
export { multipartBoundary, multipartParts, type MultipartPart } from './multipart.ts';
|
|
333
|
+
export { multipartBoundary, multipartParts, multipartEnvelope, inferMultipartBoundary, multipartDelimiterPattern, type MultipartPart, type MultipartFramedPart, type MultipartEnvelope } from './multipart.ts';
|
|
334
334
|
export { corsHeaders, withCors, type CorsDecl } from './cors.ts';
|
|
335
|
-
export { vendorFetch, VendorUnreachableError } from './vendor-call.ts';
|
|
336
|
-
export { closeManagedDatabases, EngineDatabaseError, EngineUnavailableError, managedDatabase, quoteIdent, useDatabaseDriver,
|
|
335
|
+
export { setInProcessTwins, vendorFetch, VendorUnreachableError, type InProcessTwin } from './vendor-call.ts';
|
|
336
|
+
export { closeManagedDatabases, databaseSession, EngineDatabaseError, EngineUnavailableError, managedDatabase, quoteIdent, useDatabaseDriver, type DatabaseDriver, type DatabaseSession } from './managed-database.ts';
|
|
337
337
|
export type { Batch as EngineBatch, ManagedDatabase, Statement as EngineStatement, StatementResult as EngineStatementResult } from './managed-database.ts';
|
|
338
338
|
export { readResourceBlob, readResourceBlobRange, removeResourceBlob, resourceBlobSize, resourceChain, writeResourceBlob } from './resource-blob.ts';
|
|
339
339
|
|
|
@@ -343,6 +343,7 @@ export { captureHistory, captureParentHistory, historyChanges, historyAtInstant,
|
|
|
343
343
|
export type { HistoryView, HistoryLayout, HistoryReference, HistoryOrigin } from './history.ts';
|
|
344
344
|
export { foldHistory } from './log.ts';
|
|
345
345
|
export { volterHome } from './volter-home.ts';
|
|
346
|
+
export type { MachinePool, MachineSpec, MachineProcess, MachineStatus, MachineFiles, MachineFileStat, GuestImage, LightPoolOptions } from './machines.ts';
|
|
346
347
|
|
|
347
348
|
|
|
348
349
|
export { boardFrameKey } from './board-keys.ts';
|
package/src/s3/wire.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { exactInteger } from '../exact-json.ts';
|
|
1
2
|
// S3'S WIRE — the XML bodies S3 answers and reads, a bucket's CORS and lifecycle documents, a delete batch, an object's
|
|
2
3
|
// ETag, and a request's body kept as its bytes (docs/contributing/architecture.md, "Other wires": S3's wire is a kernel
|
|
3
4
|
// library, as Redis's command core is). Every vendor that speaks S3's API speaks this wire: AWS S3 itself, Cloudflare
|
|
@@ -6,6 +7,8 @@
|
|
|
6
7
|
// ported from the aws pack's S3 code; a pack may import neither (B4). Importing this module does nothing.
|
|
7
8
|
import { createHash } from 'node:crypto';
|
|
8
9
|
|
|
10
|
+
const wireInteger = (text: string): number | string => /^[+-]?\d+$/.test(text) ? exactInteger(text) : Number(text);
|
|
11
|
+
|
|
9
12
|
// ── XML ──────────────────────────────────────────────────────────────────────────────────────
|
|
10
13
|
|
|
11
14
|
const DECL = '<?xml version="1.0" encoding="UTF-8"?>';
|
|
@@ -36,14 +39,14 @@ export const oneOf = (xml: string, tag: string): string | undefined => allOf(xml
|
|
|
36
39
|
|
|
37
40
|
// ── CORS (https://developers.cloudflare.com/r2/buckets/cors/) ──────────────────────────────────
|
|
38
41
|
|
|
39
|
-
export type CorsRule = { id?: string; allowedOrigins: string[]; allowedMethods: string[]; allowedHeaders: string[]; exposeHeaders: string[]; maxAgeSeconds?: number };
|
|
42
|
+
export type CorsRule = { id?: string; allowedOrigins: string[]; allowedMethods: string[]; allowedHeaders: string[]; exposeHeaders: string[]; maxAgeSeconds?: number | string };
|
|
40
43
|
|
|
41
44
|
export function parseCors(xml: string): CorsRule[] {
|
|
42
45
|
return [...xml.matchAll(/<CORSRule>([\s\S]*?)<\/CORSRule>/g)].map((m) => {
|
|
43
46
|
const b = m[1]!;
|
|
44
47
|
const id = oneOf(b, 'ID');
|
|
45
48
|
const age = oneOf(b, 'MaxAgeSeconds');
|
|
46
|
-
return { ...(id ? { id } : {}), allowedOrigins: allOf(b, 'AllowedOrigin'), allowedMethods: allOf(b, 'AllowedMethod'), allowedHeaders: allOf(b, 'AllowedHeader'), exposeHeaders: allOf(b, 'ExposeHeader'), ...(age ? { maxAgeSeconds:
|
|
49
|
+
return { ...(id ? { id } : {}), allowedOrigins: allOf(b, 'AllowedOrigin'), allowedMethods: allOf(b, 'AllowedMethod'), allowedHeaders: allOf(b, 'AllowedHeader'), exposeHeaders: allOf(b, 'ExposeHeader'), ...(age ? { maxAgeSeconds: wireInteger(age) } : {}) };
|
|
47
50
|
});
|
|
48
51
|
}
|
|
49
52
|
export function corsXml(rules: CorsRule[]): string {
|
|
@@ -85,7 +88,7 @@ export const requestedHeaders = (request: Request): string[] => (request.headers
|
|
|
85
88
|
* sends a transition rule with neither, for the whole bucket); it is answered as it was sent. A
|
|
86
89
|
* transition moves an object to Infrequent Access (Object lifecycles: "transition objects to Infrequent Access storage
|
|
87
90
|
* after 30 days", its example `Transitions: [{ Days: 30, StorageClass: "STANDARD_IA" }]`). */
|
|
88
|
-
export type LifecycleRule = { id: string; enabled: boolean; prefix: string; prefixOnRule?: boolean; noPrefix?: boolean; expireDays?: number; expireDate?: string; abortMultipartDays?: number; transitions?: Array<{ days?: number; date?: string; storageClass: string }> };
|
|
91
|
+
export type LifecycleRule = { id: string; enabled: boolean; prefix: string; prefixOnRule?: boolean; noPrefix?: boolean; expireDays?: number | string; expireDate?: string; abortMultipartDays?: number | string; transitions?: Array<{ days?: number | string; date?: string; storageClass: string }> };
|
|
89
92
|
|
|
90
93
|
export function parseLifecycle(xml: string): LifecycleRule[] {
|
|
91
94
|
return [...xml.matchAll(/<Rule>([\s\S]*?)<\/Rule>/g)].map((m) => {
|
|
@@ -98,11 +101,11 @@ export function parseLifecycle(xml: string): LifecycleRule[] {
|
|
|
98
101
|
const transitions = [...b.matchAll(/<Transition>([\s\S]*?)<\/Transition>/g)].map((x) => {
|
|
99
102
|
const d = oneOf(x[1]!, 'Days');
|
|
100
103
|
const on = oneOf(x[1]!, 'Date');
|
|
101
|
-
return { ...(d !== undefined ? { days:
|
|
104
|
+
return { ...(d !== undefined ? { days: wireInteger(d) } : {}), ...(on !== undefined ? { date: on } : {}), storageClass: oneOf(x[1]!, 'StorageClass') ?? '' };
|
|
102
105
|
});
|
|
103
106
|
return {
|
|
104
107
|
id: oneOf(b, 'ID') ?? '', enabled: (oneOf(b, 'Status') ?? 'Enabled') === 'Enabled', prefix: oneOf(b, 'Prefix') ?? '', ...(/<Filter>/.test(b) ? {} : /<Prefix>/.test(b) ? { prefixOnRule: true } : { noPrefix: true }),
|
|
105
|
-
...(days ? { expireDays:
|
|
108
|
+
...(days ? { expireDays: wireInteger(days) } : {}), ...(date ? { expireDate: date } : {}), ...(after ? { abortMultipartDays: wireInteger(after) } : {}), ...(transitions.length ? { transitions } : {}),
|
|
106
109
|
};
|
|
107
110
|
});
|
|
108
111
|
}
|
package/src/scenario.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { exactInteger, compareExactNumbers, isExactIntegerValue } from './exact-json.ts';
|
|
1
2
|
// THE scenario engine — System 2 of the twin programming model (one grammar, per-pack
|
|
2
3
|
// vocabulary). See company-repo BRIEFS/TWIN-PROGRAMMING-MODEL.md (LOCKED, 2026-08-27).
|
|
3
4
|
//
|
|
@@ -59,6 +60,8 @@ export type PackScenarioAdapter<Req> = {
|
|
|
59
60
|
scopeKey?: (req: Req) => string;
|
|
60
61
|
/** The VENDOR'S error envelope for a `status` fault — a 429 from this vendor looks like this
|
|
61
62
|
* vendor's 429. Omitted → the kernel's envelope `{ error: { message, type: "twin_fault" } }`. */
|
|
63
|
+
/** Opt-in scope check for post-admission model generation failures; absent rejects them. */
|
|
64
|
+
validateGenerationFault?: (fault: ScenarioGenerationFault, handler: ScenarioHandler) => string | null;
|
|
62
65
|
renderFault?: (fault: ScenarioStatusFault, req: Req) => { body: unknown; headers?: Record<string, string> };
|
|
63
66
|
};
|
|
64
67
|
|
|
@@ -70,13 +73,16 @@ export type PackScenarioAdapter<Req> = {
|
|
|
70
73
|
* status — answer this HTTP status instead of serving: a 4xx/5xx the vendor's own envelope
|
|
71
74
|
* (the pack's adapter renders it; a kernel envelope when it declares none), with
|
|
72
75
|
* `Retry-After` when `retryAfterSeconds` is set;
|
|
76
|
+
* generation — opt-in post-admission compute failure, carried to the handler for its SDK error wire;
|
|
73
77
|
* drop — never answer: the request hangs until the client's own timeout ends it (what an
|
|
74
78
|
* outage looks like from the client; a TCP reset is not something a fetch can do),
|
|
75
79
|
* released after `holdMs` (default 300 000) so a socket is not held forever. */
|
|
76
80
|
export type ScenarioFault =
|
|
77
81
|
| { kind: "slow"; ms: number }
|
|
78
|
-
| { kind: "status"; status: number; retryAfterSeconds?: number; message?: string }
|
|
82
|
+
| { kind: "status"; status: number; retryAfterSeconds?: number; message?: string; errorType?: string; metadata?: Record<string, unknown> }
|
|
83
|
+
| { kind: "generation"; status: number; message: string; errorType: string; metadata?: Record<string, unknown>; partialText?: string; event?: string }
|
|
79
84
|
| { kind: "drop"; holdMs?: number };
|
|
85
|
+
export type ScenarioGenerationFault = Extract<ScenarioFault, { kind: "generation" }>;
|
|
80
86
|
export type ScenarioStatusFault = Extract<ScenarioFault, { kind: "status" }>;
|
|
81
87
|
/** What a `status` fault serves: the pack's rendered envelope, or the kernel's. */
|
|
82
88
|
export type ScenarioFaultResult = { status: number; headers: Record<string, string>; body: unknown };
|
|
@@ -203,15 +209,24 @@ function parseHandler<Req>(raw: unknown, index: number, adapter: PackScenarioAda
|
|
|
203
209
|
if (onRefusal) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: ${onRefusal}`);
|
|
204
210
|
if (h.fault !== undefined) {
|
|
205
211
|
const f = h.fault as Record<string, unknown>;
|
|
206
|
-
if (typeof f !== "object" || f === null || Array.isArray(f)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault is an object { kind: "slow" | "status" | "drop", ... }`);
|
|
207
|
-
const kinds = ["slow", "status", "drop"];
|
|
212
|
+
if (typeof f !== "object" || f === null || Array.isArray(f)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault is an object { kind: "slow" | "status" | "drop" | "generation", ... }`);
|
|
213
|
+
const kinds = ["slow", "status", "drop", "generation"];
|
|
208
214
|
if (!kinds.includes(f.kind as string)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.kind is one of ${kinds.join(", ")}, got ${JSON.stringify(f.kind)}`);
|
|
209
|
-
const keysFor: Record<string, string[]> = { slow: ["kind", "ms"], status: ["kind", "status", "retryAfterSeconds", "message"], drop: ["kind", "holdMs"] };
|
|
215
|
+
const keysFor: Record<string, string[]> = { slow: ["kind", "ms"], status: ["kind", "status", "retryAfterSeconds", "message", "metadata", "errorType"], generation: ["kind", "status", "message", "errorType", "metadata", "partialText", "event"], drop: ["kind", "holdMs"] };
|
|
210
216
|
for (const key of Object.keys(f)) if (!keysFor[f.kind as string]!.includes(key)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.${key} is not a key of a ${String(f.kind)} fault (valid: ${keysFor[f.kind as string]!.join(", ")})`);
|
|
211
217
|
if (f.kind === "slow" && !(Number.isInteger(f.ms) && (f.ms as number) >= 0 && (f.ms as number) <= 600_000)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.ms is an integer 0..600000 (milliseconds)`);
|
|
212
|
-
if (f.kind === "status" && !(Number.isInteger(f.status) && (f.status as number) >= 400 && (f.status as number) <= 599)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.status is an integer 400..599 — a fault is a refusal or a failure, never a success`);
|
|
218
|
+
if ((f.kind === "status" || f.kind === "generation") && !(Number.isInteger(f.status) && (f.status as number) >= 400 && (f.status as number) <= 599)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.status is an integer 400..599 — a fault is a refusal or a failure, never a success`);
|
|
213
219
|
if (f.kind === "status" && f.retryAfterSeconds !== undefined && !(Number.isInteger(f.retryAfterSeconds) && (f.retryAfterSeconds as number) >= 0)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.retryAfterSeconds is a non-negative integer`);
|
|
214
220
|
if (f.kind === "status" && f.message !== undefined && typeof f.message !== "string") throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.message is a string`);
|
|
221
|
+
if ((f.kind === "status" || f.kind === "generation") && f.metadata !== undefined && (typeof f.metadata !== "object" || f.metadata === null || Array.isArray(f.metadata))) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.metadata is an object`);
|
|
222
|
+
if (f.kind === "status" && f.errorType !== undefined && typeof f.errorType !== "string") throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.errorType is a string`);
|
|
223
|
+
if (f.kind === "generation") {
|
|
224
|
+
if (typeof f.message !== "string" || typeof f.errorType !== "string" || !f.errorType) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: generation fault requires message and nonempty errorType`);
|
|
225
|
+
for (const key of ["partialText", "event"]) if (f[key] !== undefined && typeof f[key] !== "string") throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.${key} is a string`);
|
|
226
|
+
if (!adapter.validateGenerationFault) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: generation faults require a compute-only adapter`);
|
|
227
|
+
const refusal = adapter.validateGenerationFault(f as ScenarioGenerationFault, h as unknown as ScenarioHandler);
|
|
228
|
+
if (refusal) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: ${refusal}`);
|
|
229
|
+
}
|
|
215
230
|
if (f.kind === "drop" && f.holdMs !== undefined && !(Number.isInteger(f.holdMs) && (f.holdMs as number) >= 0)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.holdMs is a non-negative integer`);
|
|
216
231
|
if (f.kind !== "slow" && "respond" in h) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: a ${String(f.kind)} fault serves no content — drop \`respond\` (only a slow fault may carry one)`);
|
|
217
232
|
}
|
|
@@ -257,9 +272,10 @@ function adapterKind<Req>(adapter: PackScenarioAdapter<Req>, kind: string): { va
|
|
|
257
272
|
function applyTransform(value: string | number, transform: string | undefined, vendor: string, where: string): string | number {
|
|
258
273
|
if (!transform) return value;
|
|
259
274
|
const [op, rawN] = transform.slice(1).split(":") as [string, string];
|
|
260
|
-
if (typeof value !== "number") throw new ScenarioError(`${vendor} scenario: ${where} applies |${op}:${rawN} to a non-numeric extractor value ${JSON.stringify(value)}`);
|
|
261
|
-
const n = Number(rawN);
|
|
262
|
-
|
|
275
|
+
if (typeof value !== "number" && !(typeof value === "string" && isExactIntegerValue(value))) throw new ScenarioError(`${vendor} scenario: ${where} applies |${op}:${rawN} to a non-numeric extractor value ${JSON.stringify(value)}`);
|
|
276
|
+
const n = /^-?\d+$/.test(rawN) ? exactInteger(rawN) : Number(rawN);
|
|
277
|
+
const cmp = compareExactNumbers(value, n);
|
|
278
|
+
return op === "min" ? (cmp <= 0 ? value : n) : (cmp >= 0 ? value : n);
|
|
263
279
|
}
|
|
264
280
|
|
|
265
281
|
type ScopeState = { calls: number; phase: string | undefined; onceFired: Set<string> };
|
|
@@ -382,7 +398,7 @@ export class ScenarioEngine<Req> {
|
|
|
382
398
|
if (!m) throw new ScenarioError(`${this.adapter.vendor} scenario: placeholder "{{${name}}}": pattern did not match the request (the handler demanded a value the request never stated)`);
|
|
383
399
|
const captured = m[1] ?? m[0]!;
|
|
384
400
|
if (tp.as === "number") {
|
|
385
|
-
const n = Number(captured);
|
|
401
|
+
const n = /^-?\d+$/.test(captured) ? exactInteger(captured) : Number(captured);
|
|
386
402
|
if (Number.isNaN(n)) throw new ScenarioError(`${this.adapter.vendor} scenario: placeholder "{{${name}}}": captured "${captured}" is not a number`);
|
|
387
403
|
value = n;
|
|
388
404
|
} else value = captured;
|
|
@@ -434,6 +450,7 @@ function waitForScenarioFault(ms: number, signal?: AbortSignal): Promise<void> {
|
|
|
434
450
|
|
|
435
451
|
export async function scenarioFaultResult(fault: ScenarioFault, render?: () => { body: unknown; headers?: Record<string, string> } | undefined, signal?: AbortSignal): Promise<ScenarioFaultResult | null> {
|
|
436
452
|
throwIfScenarioAborted(signal);
|
|
453
|
+
if (fault.kind === "generation") return null; // compute handler renders the admitted generation failure
|
|
437
454
|
if (fault.kind === "slow") { await waitForScenarioFault(fault.ms, signal); throwIfScenarioAborted(signal); return null; }
|
|
438
455
|
if (fault.kind === "drop") {
|
|
439
456
|
await waitForScenarioFault(fault.holdMs ?? 300_000, signal);
|
|
@@ -447,19 +464,23 @@ export async function scenarioFaultResult(fault: ScenarioFault, render?: () => {
|
|
|
447
464
|
return { status: fault.status, headers, body: rendered?.body ?? { error: { message: fault.message ?? `twin fault: ${fault.status}`, type: "twin_fault" } } };
|
|
448
465
|
}
|
|
449
466
|
|
|
450
|
-
export function statefulTwinManifest(input: { vendor: string; twinOf: string; stores: string; identity?: string; notes?: string }): Record<string, unknown> {
|
|
467
|
+
export function statefulTwinManifest(input: { vendor: string; twinOf: string; stores: string; identity?: string; behavior?: string; notes?: string; doors?: ReadonlyArray<{ method: string; path: string; note?: string }> }): Record<string, unknown> {
|
|
468
|
+
// the World's doors the pack declares (what the vendor's own pages do that its API does not: issuing an account's key)
|
|
469
|
+
const doors = input.doors ?? [];
|
|
451
470
|
return {
|
|
452
471
|
twin: true,
|
|
453
472
|
vendor: input.vendor,
|
|
454
473
|
twinOf: input.twinOf,
|
|
455
474
|
program: {
|
|
456
|
-
state:
|
|
475
|
+
state: doors.length
|
|
476
|
+
? `Stateful: it stores ${input.stores}. Create state through the vendor's OWN API with the real SDK or plain fetch pointed here; what the vendor does on its own pages (not its API) is a door of the World, listed under doors. There is no fixture language.`
|
|
477
|
+
: `Stateful: it stores ${input.stores}. Create state through the vendor's OWN API with the real SDK or plain fetch pointed here — there is no fixture language and no write door besides the vendor's.`,
|
|
457
478
|
identity: input.identity ?? 'Authenticate as the vendor does; the twin accepts any non-sentinel credential.',
|
|
458
479
|
time: 'Writes are stamped from the WORLD CLOCK (volter-world clock <world> set/advance) — deterministic history is clock-set, seed, clock-advance.',
|
|
459
|
-
behavior: 'This twin is state, not scripting — answers are functions of what you seeded. Judgment/fault scripting lives on the scripted vendor twins (their GET /twin explains).',
|
|
480
|
+
behavior: input.behavior ?? 'This twin is state, not scripting — answers are functions of what you seeded. Judgment/fault scripting lives on the scripted vendor twins (their GET /twin explains).',
|
|
460
481
|
...(input.notes ? { notes: input.notes } : {}),
|
|
461
482
|
},
|
|
462
|
-
doors: { manifest: 'GET /twin' },
|
|
483
|
+
doors: { manifest: 'GET /twin', ...Object.fromEntries(doors.map((d) => [`${d.method} ${d.path}`, d.note ?? 'a door of the World'])) },
|
|
463
484
|
};
|
|
464
485
|
}
|
|
465
486
|
|
package/src/serve-http.ts
CHANGED
|
@@ -10,6 +10,13 @@
|
|
|
10
10
|
|
|
11
11
|
import type { TwinStream } from './twin-fetch.ts';
|
|
12
12
|
|
|
13
|
+
/** One socket library at the host seam, shared by inbound upgrades and authenticated outbound relays. */
|
|
14
|
+
const socketLibrary = () => import('ws');
|
|
15
|
+
export async function connectWebSocket(url: string, headers: Record<string, string>, protocols: string[] = []): Promise<WebSocket> {
|
|
16
|
+
const { WebSocket: Client } = await socketLibrary();
|
|
17
|
+
return new Client(url, protocols, { headers }) as unknown as WebSocket;
|
|
18
|
+
}
|
|
19
|
+
|
|
13
20
|
export type HttpHandler = (request: Request) => Response | Promise<Response>;
|
|
14
21
|
export type WebSocketPeer = { send: (data: string | Uint8Array) => unknown; close: (code?: number, reason?: string) => unknown };
|
|
15
22
|
export type WebSocketUpgrade = {
|
|
@@ -33,7 +40,7 @@ export type ServeHttpOptions = {
|
|
|
33
40
|
fetch: HttpHandler;
|
|
34
41
|
/** seconds a connection may idle (Bun honors it; Node's keep-alive timeout is set from it) */
|
|
35
42
|
idleTimeout?: number;
|
|
36
|
-
/**
|
|
43
|
+
/** Explicit transport limit in bytes; absent, Bun retains its 128 MiB default. */
|
|
37
44
|
maxRequestBodySize?: number;
|
|
38
45
|
/** what a thrown handler answers with; absent, a 500 carrying the error (as Bun.serve) */
|
|
39
46
|
error?: (error: Error) => Response | Promise<Response>;
|
|
@@ -62,6 +69,8 @@ export async function serveHttp(options: ServeHttpOptions): Promise<HttpServer>
|
|
|
62
69
|
// twin from a leftover World's that holds the same port. Outside a World nothing changes.
|
|
63
70
|
// a pack's fetch carries the upgrade its sockets take (pack-fetch.ts), read before anything wraps the fetch: served on
|
|
64
71
|
// its own or in a World, its sockets answer
|
|
72
|
+
const declaredCap = (options.fetch as { maxRequestBodySize?: number }).maxRequestBodySize;
|
|
73
|
+
if (options.maxRequestBodySize === undefined && declaredCap !== undefined) options = { ...options, maxRequestBodySize: declaredCap };
|
|
65
74
|
const carried = options.upgrade ?? (options.fetch as { upgrade?: WebSocketUpgrade }).upgrade;
|
|
66
75
|
if (carried) options = { ...options, upgrade: carried };
|
|
67
76
|
const worldBoot = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.VOLTER_WORLD_BOOT_ID;
|
|
@@ -301,7 +310,7 @@ async function serveOnNode(options: ServeHttpOptions & { hostname: string; urlHo
|
|
|
301
310
|
});
|
|
302
311
|
let socketServer: import('ws').WebSocketServer | undefined;
|
|
303
312
|
if (options.upgrade) {
|
|
304
|
-
const { WebSocketServer } = await
|
|
313
|
+
const { WebSocketServer } = await socketLibrary();
|
|
305
314
|
const upgrade = options.upgrade;
|
|
306
315
|
const requestOf = (req: import('node:http').IncomingMessage): Request => new Request(`${protocol}://${req.headers.host ?? 'localhost'}${req.url ?? '/'}`, { headers: Object.fromEntries(Object.entries(req.headers).filter((entry): entry is [string, string] => typeof entry[1] === 'string')) });
|
|
307
316
|
// the subprotocol the socket selects, answered as Bun's branch answers it
|