@volter/world-core 3.0.156 → 3.0.157
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/attach.cjs +2 -0
- package/capability-decision.cjs +6 -4
- package/dist/attach.cjs +2 -0
- package/dist/capability-decision.cjs +6 -4
- package/dist/generated/pack-facts.json +77 -8
- package/dist/network-policy.cjs +11 -1
- package/dist/network-policy.d.cts +1 -0
- package/dist/pack-facts.d.cts +3 -0
- package/dist/required-credential-transport.cjs +29 -0
- package/dist/required-credential-transport.d.cts +3 -0
- package/dist/src/blob-store.d.ts +16 -0
- package/dist/src/blob-store.js +76 -1
- package/dist/src/derived-core.d.ts +72 -6
- package/dist/src/derived-core.js +192 -51
- package/dist/src/derived-real.js +97 -15
- package/dist/src/derived.d.ts +5 -0
- package/dist/src/executor.d.ts +7 -0
- package/dist/src/executor.js +15 -1
- package/dist/src/git/lfs.d.ts +13 -3
- package/dist/src/git/lfs.js +41 -9
- package/dist/src/head.d.ts +2 -0
- package/dist/src/head.js +6 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/live/ingest.js +9 -13
- package/dist/src/live/rtmp.js +7 -5
- package/dist/src/live/segmenter.js +7 -1
- package/dist/src/machines.js +5 -1
- package/dist/src/managed-go-programs.d.ts +55 -0
- package/dist/src/managed-go-programs.js +133 -0
- package/dist/src/mongodb/stream.d.ts +23 -0
- package/dist/src/pack-fetch.d.ts +6 -1
- package/dist/src/pack-fetch.js +49 -6
- package/dist/src/pack-paths.d.ts +2 -1
- package/dist/src/pack-paths.js +6 -1
- package/dist/src/resource-blob.d.ts +4 -0
- package/dist/src/resource-blob.js +8 -1
- package/dist/src/runtime.d.ts +3 -3
- package/dist/src/runtime.js +2 -2
- package/dist/src/serve-http.js +53 -3
- package/dist/src/sockets.d.ts +4 -1
- package/dist/src/sockets.js +59 -6
- package/dist/src/vendor-call.js +58 -0
- package/dist/src/world-store.d.ts +15 -1
- package/dist/src/world-store.js +78 -2
- package/generated/pack-facts.json +77 -8
- package/network-policy.cjs +11 -1
- package/network-policy.d.cts +1 -0
- package/pack-facts.d.cts +3 -0
- package/package.json +8 -1
- package/required-credential-transport.cjs +29 -0
- package/required-credential-transport.d.cts +3 -0
- package/src/blob-store.ts +59 -1
- package/src/derived-core.ts +215 -51
- package/src/derived-real.ts +80 -14
- package/src/derived.ts +6 -1
- package/src/executor.ts +17 -3
- package/src/git/lfs.ts +40 -9
- package/src/head.ts +6 -0
- package/src/index.ts +1 -0
- package/src/live/ingest.ts +7 -3
- package/src/live/rtmp.ts +7 -5
- package/src/live/segmenter.ts +7 -1
- package/src/machines.ts +5 -1
- package/src/managed-go-programs.ts +146 -0
- package/src/mongodb/stream.ts +10 -0
- package/src/pack-fetch.ts +46 -8
- package/src/pack-paths.ts +5 -1
- package/src/resource-blob.ts +9 -1
- package/src/runtime.ts +4 -2
- package/src/serve-http.ts +43 -3
- package/src/sockets.ts +39 -8
- package/src/vendor-call.ts +54 -0
- package/src/world-store.ts +66 -2
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/** Tagged managed Go operation metadata. This structural reader grants no
|
|
2
|
+
* source/image correspondence, filesystem custody, catalog admission or exec. */
|
|
3
|
+
import { posix } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export interface NativeGoKitReference {
|
|
6
|
+
package: { name: string; version: string };
|
|
7
|
+
target: 'wasm32-wali-go-single-thread';
|
|
8
|
+
releaseSha256: string;
|
|
9
|
+
producerSha256: string;
|
|
10
|
+
sourceSha256: string;
|
|
11
|
+
selection: { path: string; sha256: string };
|
|
12
|
+
recipe: { path: string; sha256: string };
|
|
13
|
+
installedRoot: string;
|
|
14
|
+
}
|
|
15
|
+
export interface NativeGoOperation {
|
|
16
|
+
sourceService: string;
|
|
17
|
+
role: 'server' | 'prerequisite' | 'health';
|
|
18
|
+
kit: string;
|
|
19
|
+
program: { name: string; version: string; manifestId: string; manifestSha256: string; path: string };
|
|
20
|
+
/** Resolved arguments require separate comparison with the full source. */
|
|
21
|
+
args: string[];
|
|
22
|
+
/** Names of existing World values, never caller secrets in this record. */
|
|
23
|
+
environmentBindings: Record<string, string>;
|
|
24
|
+
requires: { service: string; condition: 'service_started' | 'service_healthy' | 'service_completed_successfully' }[];
|
|
25
|
+
}
|
|
26
|
+
export interface NativeGoReference {
|
|
27
|
+
version: 2;
|
|
28
|
+
implementation: string;
|
|
29
|
+
release: string;
|
|
30
|
+
directory: string;
|
|
31
|
+
snapshotSha256: string;
|
|
32
|
+
declarationSha256: string;
|
|
33
|
+
image: string;
|
|
34
|
+
kits: Record<string, NativeGoKitReference>;
|
|
35
|
+
server: NativeGoOperation;
|
|
36
|
+
prerequisites: NativeGoOperation[];
|
|
37
|
+
health: NativeGoOperation;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const fail = (field: string): never => { throw new Error('Native Go operation metadata is invalid: ' + field); };
|
|
41
|
+
function object(value: unknown, fields: readonly string[], label: string): Record<string, unknown> {
|
|
42
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return fail(label);
|
|
43
|
+
const row = value as Record<string, unknown>;
|
|
44
|
+
if (Object.keys(row).length !== fields.length || fields.some(key => !Object.hasOwn(row, key))) return fail(label);
|
|
45
|
+
return row;
|
|
46
|
+
}
|
|
47
|
+
function text(value: unknown, label: string): string {
|
|
48
|
+
if (typeof value !== 'string' || !value || value.includes('\0')) return fail(label);
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
function digest(value: unknown, label: string): string {
|
|
52
|
+
const out = text(value, label);
|
|
53
|
+
if (!/^[a-f0-9]{64}$/u.test(out)) return fail(label);
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
function relativePath(value: unknown, label: string): string {
|
|
57
|
+
const out = text(value, label);
|
|
58
|
+
if (out.startsWith('/') || out.includes('\\') || posix.normalize(out) !== out
|
|
59
|
+
|| out.split('/').some(part => !part || part === '.' || part === '..')) return fail(label);
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
function installedPath(value: unknown, label: string): string {
|
|
63
|
+
const out = text(value, label);
|
|
64
|
+
if (!out.startsWith('/opt/programs/') || out.includes('\\') || posix.normalize(out) !== out
|
|
65
|
+
|| out.split('/').slice(1).some(part => !part || part === '.' || part === '..')) return fail(label);
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
function pin(value: unknown, label: string): { path: string; sha256: string } {
|
|
69
|
+
const row = object(value, ['path', 'sha256'], label);
|
|
70
|
+
return { path: relativePath(row.path, label + '.path'), sha256: digest(row.sha256, label + '.sha256') };
|
|
71
|
+
}
|
|
72
|
+
function kit(value: unknown, label: string): NativeGoKitReference {
|
|
73
|
+
const row = object(value, ['package', 'target', 'releaseSha256', 'producerSha256', 'sourceSha256', 'selection', 'recipe', 'installedRoot'], label);
|
|
74
|
+
const pack = object(row.package, ['name', 'version'], label + '.package');
|
|
75
|
+
if (row.target !== 'wasm32-wali-go-single-thread') return fail(label + '.target');
|
|
76
|
+
return { package: { name: text(pack.name, label + '.package.name'), version: text(pack.version, label + '.package.version') },
|
|
77
|
+
target: row.target, releaseSha256: digest(row.releaseSha256, label + '.releaseSha256'),
|
|
78
|
+
producerSha256: digest(row.producerSha256, label + '.producerSha256'), sourceSha256: digest(row.sourceSha256, label + '.sourceSha256'),
|
|
79
|
+
selection: pin(row.selection, label + '.selection'), recipe: pin(row.recipe, label + '.recipe'),
|
|
80
|
+
installedRoot: installedPath(row.installedRoot, label + '.installedRoot') };
|
|
81
|
+
}
|
|
82
|
+
function operation(value: unknown, role: NativeGoOperation['role'], kits: Record<string, NativeGoKitReference>, label: string): NativeGoOperation {
|
|
83
|
+
const row = object(value, ['sourceService', 'role', 'kit', 'program', 'args', 'environmentBindings', 'requires'], label);
|
|
84
|
+
const selected = text(row.kit, label + '.kit');
|
|
85
|
+
if (row.role !== role || !Object.hasOwn(kits, selected)) return fail(label + '.role/kit');
|
|
86
|
+
const provider = kits[selected]!;
|
|
87
|
+
const command = object(row.program, ['name', 'version', 'manifestId', 'manifestSha256', 'path'], label + '.program');
|
|
88
|
+
const path = installedPath(command.path, label + '.program.path'), version = text(command.version, label + '.program.version');
|
|
89
|
+
if (version !== provider.package.version || !path.startsWith(provider.installedRoot + '/')) return fail(label + '.program placement/version');
|
|
90
|
+
if (!Array.isArray(row.args)) return fail(label + '.args');
|
|
91
|
+
const args: string[] = [];
|
|
92
|
+
for (const arg of row.args) {
|
|
93
|
+
if (typeof arg !== 'string' || arg.includes('\0')) return fail(label + '.args.' + args.length);
|
|
94
|
+
args.push(arg);
|
|
95
|
+
}
|
|
96
|
+
if (!row.environmentBindings || typeof row.environmentBindings !== 'object' || Array.isArray(row.environmentBindings)) return fail(label + '.environmentBindings');
|
|
97
|
+
const bindings: Record<string, string> = Object.create(null);
|
|
98
|
+
for (const [name, value] of Object.entries(row.environmentBindings)) {
|
|
99
|
+
const source = text(value, label + '.environmentBindings.' + name);
|
|
100
|
+
if (![name, source].every(item => /^[A-Za-z_][A-Za-z0-9_]*$/u.test(item))) return fail(label + '.environmentBindings name');
|
|
101
|
+
bindings[name] = source;
|
|
102
|
+
}
|
|
103
|
+
if (!Array.isArray(row.requires)) return fail(label + '.requires');
|
|
104
|
+
const dependencies: NativeGoOperation['requires'] = [];
|
|
105
|
+
for (const value of row.requires) {
|
|
106
|
+
const edge = object(value, ['service', 'condition'], label + '.requires');
|
|
107
|
+
if (edge.condition !== 'service_started' && edge.condition !== 'service_healthy' && edge.condition !== 'service_completed_successfully')
|
|
108
|
+
return fail(label + '.requires.condition');
|
|
109
|
+
const service = text(edge.service, label + '.requires.service');
|
|
110
|
+
if (dependencies.some(prior => prior.service === service)) return fail(label + '.requires duplicate service');
|
|
111
|
+
dependencies.push({ service, condition: edge.condition });
|
|
112
|
+
}
|
|
113
|
+
return { sourceService: text(row.sourceService, label + '.sourceService'), role, kit: selected,
|
|
114
|
+
program: { name: text(command.name, label + '.program.name'), version, manifestId: text(command.manifestId, label + '.program.manifestId'),
|
|
115
|
+
manifestSha256: digest(command.manifestSha256, label + '.program.manifestSha256'), path },
|
|
116
|
+
args, environmentBindings: bindings, requires: dependencies };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** All fields are declared data. The later owned consumer must authenticate
|
|
120
|
+
* the complete source, kit, original image and admitted installed manifests. */
|
|
121
|
+
export function nativeGoReference(value: unknown): NativeGoReference {
|
|
122
|
+
const row = object(value, ['version', 'implementation', 'release', 'directory', 'snapshotSha256', 'declarationSha256', 'image', 'kits', 'server', 'prerequisites', 'health'], 'reference');
|
|
123
|
+
if (row.version !== 2) return fail('version');
|
|
124
|
+
const image = text(row.image, 'image');
|
|
125
|
+
if (!/^[^\s@]+@sha256:[a-f0-9]{64}$/u.test(image)) return fail('image');
|
|
126
|
+
if (!row.kits || typeof row.kits !== 'object' || Array.isArray(row.kits) || !Object.keys(row.kits).length) return fail('kits');
|
|
127
|
+
const kits: Record<string, NativeGoKitReference> = Object.create(null);
|
|
128
|
+
for (const [name, value] of Object.entries(row.kits)) kits[text(name, 'kit name')] = kit(value, 'kits.' + name);
|
|
129
|
+
const server = operation(row.server, 'server', kits, 'server'), health = operation(row.health, 'health', kits, 'health');
|
|
130
|
+
const implementation = text(row.implementation, 'implementation'), release = text(row.release, 'release');
|
|
131
|
+
if (kits[server.kit]!.package.name !== implementation || kits[server.kit]!.package.version !== release
|
|
132
|
+
|| health.sourceService !== server.sourceService) return fail('server/health implementation');
|
|
133
|
+
if (!Array.isArray(row.prerequisites)) return fail('prerequisites');
|
|
134
|
+
const prerequisites: NativeGoOperation[] = [];
|
|
135
|
+
for (const value of row.prerequisites)
|
|
136
|
+
prerequisites.push(operation(value, 'prerequisite', kits, 'prerequisites.' + prerequisites.length));
|
|
137
|
+
if (new Set(prerequisites.map(item => item.sourceService)).size !== prerequisites.length
|
|
138
|
+
|| prerequisites.some(item => item.sourceService === server.sourceService)) return fail('prerequisite service');
|
|
139
|
+
const usedKits = new Set([server.kit, health.kit, ...prerequisites.map(item => item.kit)]);
|
|
140
|
+
if (Object.keys(kits).some(name => !usedKits.has(name))) return fail('unconsumed kit');
|
|
141
|
+
for (const item of prerequisites) if (!server.requires.some(edge => edge.service === item.sourceService && edge.condition === 'service_completed_successfully'))
|
|
142
|
+
return fail('server prerequisite completion edge');
|
|
143
|
+
return { version: 2, implementation, release, directory: relativePath(row.directory, 'directory'),
|
|
144
|
+
snapshotSha256: digest(row.snapshotSha256, 'snapshotSha256'), declarationSha256: digest(row.declarationSha256, 'declarationSha256'),
|
|
145
|
+
image, kits, server, prerequisites, health };
|
|
146
|
+
}
|
package/src/mongodb/stream.ts
CHANGED
|
@@ -13,6 +13,16 @@ export type MongoDocument = Record<string, unknown>;
|
|
|
13
13
|
/** BSON, as the pack supplies it (the `bson` library, installed by the pack's package.json). The kernel frames
|
|
14
14
|
* documents and never reads inside one but for a command's `$db`. */
|
|
15
15
|
export type MongoCodec = { serialize(document: MongoDocument): Uint8Array; deserialize(bytes: Uint8Array): MongoDocument };
|
|
16
|
+
/** What a pack's stream reads of the `bson` library its generated fetch's `loadBson` resolves from the pack: the codec,
|
|
17
|
+
* extended JSON, and the number kinds a reply names. The kernel declares the shape and imports nothing of it. */
|
|
18
|
+
export type BsonLibrary = {
|
|
19
|
+
serialize(document: MongoDocument): Uint8Array;
|
|
20
|
+
deserialize(bytes: Uint8Array, options?: { promoteValues?: boolean; promoteLongs?: boolean; bsonRegExp?: boolean }): MongoDocument;
|
|
21
|
+
EJSON: { serialize(value: unknown, options?: { relaxed?: boolean }): MongoDocument; deserialize(value: unknown, options?: { relaxed?: boolean }): MongoDocument };
|
|
22
|
+
Double: new (value: number) => unknown;
|
|
23
|
+
Int32: new (value: number) => unknown;
|
|
24
|
+
Long: { fromNumber(value: number): unknown };
|
|
25
|
+
};
|
|
16
26
|
|
|
17
27
|
/** What the sender's message said beside its command: whether it will take more than one reply to it (OP_MSG's exhaustAllowed). */
|
|
18
28
|
export type MongoAsked = { exhaustAllowed: boolean };
|
package/src/pack-fetch.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { ORIGINAL_PATH_HEADER } from './sigv4.ts';
|
|
|
12
12
|
import { placedCookies, TWIN_PREFIX_HEADER } from './twin-fetch.ts';
|
|
13
13
|
import { PREFIX_PARAMS_HEADER } from './derived.ts';
|
|
14
14
|
import { withHeaders } from './with-headers.ts';
|
|
15
|
-
import { authRefusal, bindSemantics, contextFor, LANE_HEADER, SCENARIO_DECISION_HEADER, scenarioDecisionHeader, storedType, coreFor, crossCutting, derivedRequestScopes, vendorError, type CoreScope, type DerivedManifest, type ErrorSpec, type Handler, type HandlerContext, type LanesDecl, type VendorManifest } from './derived-core.ts';
|
|
15
|
+
import { authRefusal, bindSemantics, contextFor, LANE_HEADER, SCENARIO_DECISION_HEADER, scenarioDecisionHeader, storedType, coreFor, crossCutting, derivedRequestScopes, vendorError, type CoreScope, type DerivedManifest, type ErrorSpec, type Handler, type HandlerContext, type LanesDecl, type ScreenDecl, type VendorManifest } from './derived-core.ts';
|
|
16
16
|
import { compileSurface, createDerivedFetch, matchOperation, pathParams, type DerivedFetch, type DerivedOperation, type DerivedOwner, type DerivedSurface, type Workspace, type BoardFrame } from './derived.ts';
|
|
17
17
|
import { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER, runAsVendorMove } from './request-scope.ts';
|
|
18
18
|
import { parseScenarioDocument, ScenarioEngine, ScenarioError, statefulTwinManifest, twinManifest, type PackScenarioAdapter } from './scenario.ts';
|
|
@@ -27,7 +27,7 @@ import { hostRules } from '../host-routing.cjs';
|
|
|
27
27
|
|
|
28
28
|
/** A descriptor's host rules, as data. */
|
|
29
29
|
type PackDescriptorHosts = ReadonlyArray<HostRule> | undefined;
|
|
30
|
-
import { socketUpgrade, type SocketEngine } from './sockets.ts';
|
|
30
|
+
import { expireSocketWaits, socketUpgrade, type SocketEngine } from './sockets.ts';
|
|
31
31
|
import { liveIngest } from './live/ingest.ts';
|
|
32
32
|
import type { LiveEngine } from './live/types.ts';
|
|
33
33
|
import { readResourceBlob, removeResourceBlob, writeResourceBlob } from './resource-blob.ts';
|
|
@@ -144,6 +144,29 @@ function hostMatcher(pattern: string): (host: string) => Match | undefined {
|
|
|
144
144
|
/** The host a request is for: the vendor host the World's injector names, else its own. */
|
|
145
145
|
const hostOf = (request: Request): string => readHostPort(request.headers.get('x-volter-twin-original-host') ?? request.headers.get('host') ?? new URL(request.url).host)?.host ?? '';
|
|
146
146
|
|
|
147
|
+
/** Where a workspace asked `?show=<type>:<id>` opens the record (architecture, "Workspace"): the built workspace page
|
|
148
|
+
* that shows that type, its parameter the id; else the one that lists the type; else the workspace's home (its root
|
|
149
|
+
* page). Every other parameter a page's path names is the asking page's, and a page that cannot be filled is passed
|
|
150
|
+
* over. Undefined when nothing is asked; the path (with no `show`) otherwise. */
|
|
151
|
+
export function shownRecord(url: URL, workspaces: ReadonlyArray<ScreenDecl>, params: Record<string, string>): string | undefined {
|
|
152
|
+
const asked = /^([^:]+):(.+)$/.exec(url.searchParams.get('show') ?? '');
|
|
153
|
+
if (!asked) return undefined;
|
|
154
|
+
const [, type, id] = asked;
|
|
155
|
+
const fill = (path: string, extra: Record<string, string>): string | undefined => {
|
|
156
|
+
let unfilled = false;
|
|
157
|
+
const filled = path.replace(/\{([^}]+)\}|<([^>]+)>/g, (_, a?: string, b?: string) => {
|
|
158
|
+
const v = { ...params, ...extra }[(a ?? b)!];
|
|
159
|
+
if (v === undefined || v === '') { unfilled = true; return ''; }
|
|
160
|
+
return encodeURIComponent(v);
|
|
161
|
+
});
|
|
162
|
+
return unfilled ? undefined : filled;
|
|
163
|
+
};
|
|
164
|
+
const shows = workspaces.filter((s) => s.shows?.type === type).map((s) => fill(s.path, { [s.shows!.param]: id! })).find((p) => p !== undefined);
|
|
165
|
+
const lists = workspaces.filter((s) => s.lists?.includes(type!)).map((s) => fill(s.path, {})).find((p) => p !== undefined);
|
|
166
|
+
const home = workspaces.find((s) => s.path.replace(/\/+$/, '') === '') ? '/' : undefined;
|
|
167
|
+
return shows ?? lists ?? home ?? url.pathname;
|
|
168
|
+
}
|
|
169
|
+
|
|
147
170
|
/** The answer for a path no operation, door or screen serves, and for an operation nothing models: the manifest's
|
|
148
171
|
* `gap`, its `{method}` and `{path}` filled, else the manifest's not-found refusal. */
|
|
149
172
|
function gapAnswer(m: DerivedManifest, request: Request, otherMethod = false): Response {
|
|
@@ -195,6 +218,16 @@ async function encoded(m: DerivedManifest, request: Request, res: Response): Pro
|
|
|
195
218
|
/** A door's path as it is served and listed: under `/_twin/`, whether the manifest writes the prefix or not. */
|
|
196
219
|
const doorPath = (path: string): string => (path.startsWith('/_twin/') ? path : `/_twin${path.startsWith('/') ? '' : '/'}${path}`);
|
|
197
220
|
|
|
221
|
+
/** A request with its path's repeated slashes merged, as a front that merges them routes it (the manifest's
|
|
222
|
+
* `mergeSlashes`); one with none is itself. */
|
|
223
|
+
function slashesMerged(request: Request): Request {
|
|
224
|
+
const url = new URL(request.url);
|
|
225
|
+
const merged = url.pathname.replace(/\/{2,}/g, '/');
|
|
226
|
+
if (merged === url.pathname) return request;
|
|
227
|
+
url.pathname = merged;
|
|
228
|
+
return new Request(url, request);
|
|
229
|
+
}
|
|
230
|
+
|
|
198
231
|
export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}): DerivedFetch {
|
|
199
232
|
const m = parts.manifest;
|
|
200
233
|
const scope: CoreScope = {
|
|
@@ -311,8 +344,8 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
|
|
|
311
344
|
} catch { return false; }
|
|
312
345
|
};
|
|
313
346
|
// a context the kernel opens for what no surface operation names: a door, a screen, the clock, the board
|
|
314
|
-
const open = (request: Request, id: string, method: string, path: string, params: Record<string, string
|
|
315
|
-
contextFor(m, { request, operation: { id, method, path, class: method === 'GET' ? 'retrieve' : 'action' } as DerivedOperation, params }, scope);
|
|
347
|
+
const open = (request: Request, id: string, method: string, path: string, params: Record<string, string>, streamed?: true): Promise<HandlerContext> =>
|
|
348
|
+
contextFor(m, { request, operation: { id, method, path, class: method === 'GET' ? 'retrieve' : 'action' } as DerivedOperation, params, ...(streamed ? { streamed } : {}) }, scope);
|
|
316
349
|
const advanceClock = async (request: Request): Promise<void> => {
|
|
317
350
|
if (parts.clock) await runAsVendorMove(async () => parts.clock!(await open(new Request(request.url, m.tenant ? { headers: request.headers } : {}), 'clock', 'POST', '/_twin/clock', {})));
|
|
318
351
|
};
|
|
@@ -409,7 +442,7 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
|
|
|
409
442
|
const asked = options.readOnly && !isReadOnlyRequest(there) ? withHeaders(there, new Headers([...there.headers, [READ_ONLY_REQUEST_HEADER, '1']])) : there;
|
|
410
443
|
if (method === 'POST' && ((options.readOnly ?? false) || isReadOnlyRequest(asked))) return refusedReadOnly();
|
|
411
444
|
if (parts.clock) await runAsVendorMove(async () => parts.clock!(await open(new Request(asked.url, m.tenant ? { headers: asked.headers } : {}), 'clock', 'POST', '/_twin/clock', {})));
|
|
412
|
-
return hit.s.answer(await open(asked, hit.s.decl.id, method, hit.s.decl.path, { ...(hit.s.host(host)?.params ?? {}), ...hit.hit!.params }));
|
|
445
|
+
return hit.s.answer(await open(asked, hit.s.decl.id, method, hit.s.decl.path, { ...(hit.s.host(host)?.params ?? {}), ...hit.hit!.params }, true));
|
|
413
446
|
};
|
|
414
447
|
/** A request as the kernel reads it: the headers only the kernel names taken off, its body decoded (undefined when it
|
|
415
448
|
* does not decode as its Content-Encoding says). */
|
|
@@ -431,7 +464,8 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
|
|
|
431
464
|
const readable = await prepared(arrived);
|
|
432
465
|
return readable instanceof Response ? readable : readable ? atContentHost(readable) : undecodable(arrived);
|
|
433
466
|
};
|
|
434
|
-
const fetch = async (
|
|
467
|
+
const fetch = async (received: Request): Promise<Response> => {
|
|
468
|
+
const arrived = m.mergeSlashes ? slashesMerged(received) : received;
|
|
435
469
|
redisServiceTimes.lap('1 request made by the stream');
|
|
436
470
|
// the discovery door is the pack's own, whichever lane would take its path
|
|
437
471
|
const asked = new URL(arrived.url).pathname.replace(/\/+$/, '') || '/';
|
|
@@ -499,6 +533,8 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
|
|
|
499
533
|
// the vendor's own moves, not the caller's: its context carries none of the request's headers (a Stripe-Account
|
|
500
534
|
// header would make a payout time makes the caller's), but a tenant's, when the vendor keeps tenants apart
|
|
501
535
|
await advanceClock(request);
|
|
536
|
+
// a reply a session awaits that the World's clock has run out (sockets.ts)
|
|
537
|
+
if (m.sockets?.length) expireSocketWaits(m.service, options.root);
|
|
502
538
|
if (path.startsWith('/_twin/')) {
|
|
503
539
|
// the World's machine pool, enrolled by the kernel for a vendor that runs images (machines.ts)
|
|
504
540
|
if (m.machines && path === '/_twin/machine-pool' && request.method === 'POST') {
|
|
@@ -547,8 +583,10 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
|
|
|
547
583
|
?? (apiPath ? undefined : hits.find((x) => x.onHost) ?? (addressed ? undefined : hits.find((x) => !rooted(x))
|
|
548
584
|
?? (placed ? hits.find((x) => x.s.decl.kind === 'workspace') : undefined)));
|
|
549
585
|
if (byPath) {
|
|
586
|
+
const asked = byPath.s.decl.kind === 'workspace' && request.method === 'GET' ? shownRecord(url, screens.filter((x) => x.decl.kind === 'workspace').map((x) => x.decl), { ...(byPath.onHost?.params ?? {}), ...byPath.hit!.params }) : undefined;
|
|
587
|
+
if (asked !== undefined && asked !== path) return new Response(null, { status: 302, headers: { location: `${(request.headers.get(TWIN_PREFIX_HEADER) ?? '').replace(/\/+$/, '')}${asked}` } });
|
|
550
588
|
if (readOnly && request.method !== 'GET' && request.method !== 'HEAD') return refusedReadOnly();
|
|
551
|
-
return byPath.s.answer(await open(request, byPath.s.decl.id, request.method, byPath.s.decl.path, { ...(byPath.onHost?.params ?? {}), ...byPath.hit!.params }));
|
|
589
|
+
return byPath.s.answer(await open(request, byPath.s.decl.id, request.method, byPath.s.decl.path, { ...(byPath.onHost?.params ?? {}), ...byPath.hit!.params }, true));
|
|
552
590
|
}
|
|
553
591
|
for (const route of m.machineRoutes ?? []) {
|
|
554
592
|
const host = request.headers.get('x-volter-twin-original-host') ?? new URL(request.url).hostname;
|
|
@@ -622,7 +660,7 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
|
|
|
622
660
|
// request that opened it (operation `socket:<id>`)
|
|
623
661
|
for (const d of m.sockets ?? []) if (!parts.sockets?.[d.id]) throw new Error(`${m.vendor}: the manifest declares the socket ${d.id}, and semantics/sockets.ts exports no ${d.id}`);
|
|
624
662
|
const ownUpgrade = m.sockets?.length ? socketUpgrade(m.service, options.root, m.sockets, parts.sockets!,
|
|
625
|
-
(request, d) => open(request, `socket:${d.id}`, 'GET', d.path,
|
|
663
|
+
(request, d, params) => open(request, `socket:${d.id}`, 'GET', d.path, params), hostOf, (request) => new URL(routed(request).url).pathname,
|
|
626
664
|
// a turn a session speaks (a voice agent's reply) is the World's scenario's, as a turn over HTTP is
|
|
627
665
|
async (asked) => engineOf()?.serve(asked)) : undefined;
|
|
628
666
|
const upgrade = laneUpgrades(parts.lanes ?? {}, ownUpgrade);
|
package/src/pack-paths.ts
CHANGED
|
@@ -102,13 +102,17 @@ export function installedPackageDir(from: string, name: string): string | undefi
|
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
/** A pack repository itself, or
|
|
105
|
+
/** A pack repository itself, or a platform checkout's P3 repository: the one `VOLTER_PACKS` names (a worktree's own
|
|
106
|
+
* pinned packs, as the repository's scripts read it), else the one beside it. Applications do not acquire a
|
|
106
107
|
* sibling checkout merely because one happens to be beside them. */
|
|
107
108
|
export function packRepository(from: string): string | undefined {
|
|
108
109
|
let dir = resolve(from);
|
|
109
110
|
for (;;) {
|
|
110
111
|
if (packageName(dir) === 'twin-packs-p3') return dir;
|
|
111
112
|
if (packageName(join(dir, 'packages', 'world-core')) === '@volter/world-core') {
|
|
113
|
+
const env = globalThis.process?.env?.VOLTER_PACKS;
|
|
114
|
+
const named = env ? resolve(env) : undefined;
|
|
115
|
+
if (named && packageName(named) === 'twin-packs-p3') return named;
|
|
112
116
|
const sibling = join(dirname(dir), 'twin-packs-p3');
|
|
113
117
|
if (packageName(sibling) === 'twin-packs-p3') return sibling;
|
|
114
118
|
}
|
package/src/resource-blob.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isAbsolute, join, relative } from 'node:path';
|
|
2
|
-
import { getActiveBlobStore, readBlobRange } from './blob-store.ts';
|
|
2
|
+
import { type BlobWritten, getActiveBlobStore, readBlobRange, streamChunks, writeBlobStream } from './blob-store.ts';
|
|
3
3
|
import { readBranchMeta } from './log.ts';
|
|
4
4
|
import { worldPaths } from './storage.ts';
|
|
5
5
|
|
|
@@ -19,6 +19,14 @@ export async function writeResourceBlob(service: string, key: string, bytes: Uin
|
|
|
19
19
|
await getActiveBlobStore().put(resourceBlobFile(worldPaths(service, root).resources, key), bytes);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** Write a resource payload on this branch from a body as it arrives (a model's weights, an upload's bytes), kept only
|
|
23
|
+
* when `accept` holds for its size and SHA-256 (the blob seam's `writeBlobStream`). True when kept. */
|
|
24
|
+
export async function writeResourceBlobStream(service: string, key: string, body: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>, accept: (written: BlobWritten) => boolean, root?: string): Promise<boolean> {
|
|
25
|
+
assertResourceKey(key);
|
|
26
|
+
const chunks = body instanceof ReadableStream ? streamChunks(body) : body;
|
|
27
|
+
return writeBlobStream(resourceBlobFile(worldPaths(service, root).resources, key), chunks, accept);
|
|
28
|
+
}
|
|
29
|
+
|
|
22
30
|
/** Remove this branch's copy of a resource payload; an ancestor's stays its own (the row that named it is what a
|
|
23
31
|
* deletion ends). */
|
|
24
32
|
export async function removeResourceBlob(service: string, key: string, root?: string): Promise<void> {
|
package/src/runtime.ts
CHANGED
|
@@ -157,8 +157,10 @@ export {
|
|
|
157
157
|
setActiveBlobStore,
|
|
158
158
|
withBlobStore,
|
|
159
159
|
readBlobRange,
|
|
160
|
+
streamChunks,
|
|
161
|
+
writeBlobStream,
|
|
160
162
|
} from './blob-store.ts';
|
|
161
|
-
export type { BlobStore } from './blob-store.ts';
|
|
163
|
+
export type { BlobStore, BlobWritten } from './blob-store.ts';
|
|
162
164
|
export {
|
|
163
165
|
applyTwinWrite,
|
|
164
166
|
applyTwinWrites,
|
|
@@ -338,7 +340,7 @@ export { corsHeaders, withCors, type CorsDecl } from './cors.ts';
|
|
|
338
340
|
export { setInProcessTwins, vendorFetch, VendorUnreachableError, type InProcessTwin } from './vendor-call.ts';
|
|
339
341
|
export { closeManagedDatabases, databaseSession, EngineDatabaseError, EngineUnavailableError, managedDatabase, quoteIdent, useDatabaseDriver, type DatabaseDriver, type DatabaseSession } from './managed-database.ts';
|
|
340
342
|
export type { Batch as EngineBatch, ManagedDatabase, Statement as EngineStatement, StatementResult as EngineStatementResult } from './managed-database.ts';
|
|
341
|
-
export { readResourceBlob, readResourceBlobRange, removeResourceBlob, resourceBlobFile, resourceBlobKey, resourceBlobSize, resourceChain, writeResourceBlob } from './resource-blob.ts';
|
|
343
|
+
export { readResourceBlob, readResourceBlobRange, removeResourceBlob, resourceBlobFile, resourceBlobKey, resourceBlobSize, resourceChain, writeResourceBlob, writeResourceBlobStream } from './resource-blob.ts';
|
|
342
344
|
|
|
343
345
|
export { assertStateRemovable, checkParent, stateGeneration, withAncestryLock, withHistoryLock, withHistoryLocks, withStateLock, withStateRemoval } from './ancestry.ts';
|
|
344
346
|
|
package/src/serve-http.ts
CHANGED
|
@@ -105,6 +105,28 @@ export async function serveHttp(options: ServeHttpOptions): Promise<HttpServer>
|
|
|
105
105
|
// the global `Bun.serve` — the request journal's wrap of it (serve.ts) applies to packs and to
|
|
106
106
|
// this seam alike, so the seam never decorates twice on Bun
|
|
107
107
|
const upgrade = options.upgrade;
|
|
108
|
+
// the sockets open now, by the seam's own count: Bun keeps a socket its server closed (a refused key's 1008, from
|
|
109
|
+
// `open` or later) in `pendingWebSockets` after its close handler ran, and `stop()`'s promise then never settles
|
|
110
|
+
// (measured on Bun 1.3.11, macOS arm64, alone: a Bun.serve whose open() calls ws.close(1008) leaves
|
|
111
|
+
// pendingWebSockets at 1 with its close handler called once, and an awaited stop(true) had not settled after 3 s;
|
|
112
|
+
// unawaited, the listener closed and the process used 4 ms of CPU over the next 2 s; a client-initiated close
|
|
113
|
+
// stops in 0 ms). Conform's life hung on it after OpenAI's keyless Realtime step.
|
|
114
|
+
// and the requests whose handlers have not answered: a stop that drops connections (closeActive) aborts each one's
|
|
115
|
+
// signal, and Bun's stop still waits for the handler to settle, which a handler that never answers never does
|
|
116
|
+
// (measured on Bun 1.3.11, macOS arm64, alone: a Bun.serve whose fetch never resolves, its request's signal aborted
|
|
117
|
+
// by stop(true), pendingRequests 1, the stop unsettled after 3 s). Conform's client check hung on a pack served with
|
|
118
|
+
// a scenario that answered nothing. Node's path below reports such a handler as cleanup and resolves; so does this one.
|
|
119
|
+
const live = new Set<unknown>();
|
|
120
|
+
const inflight = new Set<Request>();
|
|
121
|
+
const served = options.fetch;
|
|
122
|
+
const tracked = (request: Request, ...rest: unknown[]): Response | Promise<Response> => {
|
|
123
|
+
inflight.add(request);
|
|
124
|
+
let answer: Response | Promise<Response>;
|
|
125
|
+
try { answer = (served as (r: Request, ...x: unknown[]) => Response | Promise<Response>)(request, ...rest); } catch (error) { inflight.delete(request); throw error; }
|
|
126
|
+
if (!(answer instanceof Promise)) { inflight.delete(request); return answer; }
|
|
127
|
+
return answer.finally(() => { inflight.delete(request); });
|
|
128
|
+
};
|
|
129
|
+
options = { ...options, fetch: tracked };
|
|
108
130
|
const server = bun.serve({ ...options, hostname, port: options.port ?? 0,
|
|
109
131
|
...(upgrade ? {
|
|
110
132
|
fetch: (request: Request, server: { upgrade: (request: Request, options: { data: { request: Request } }) => boolean }) => {
|
|
@@ -114,14 +136,32 @@ export async function serveHttp(options: ServeHttpOptions): Promise<HttpServer>
|
|
|
114
136
|
return options.fetch(request);
|
|
115
137
|
},
|
|
116
138
|
websocket: {
|
|
117
|
-
open: (peer: WebSocketPeer & { data: { request: Request } }) => upgrade.open(peer, peer.data.request),
|
|
139
|
+
open: (peer: WebSocketPeer & { data: { request: Request } }) => { live.add(peer); return upgrade.open(peer, peer.data.request); },
|
|
118
140
|
message: (peer: WebSocketPeer, data: string | Uint8Array) => upgrade.message(peer, data),
|
|
119
|
-
close: upgrade.close,
|
|
141
|
+
close: (peer: WebSocketPeer, ...rest: unknown[]) => { live.delete(peer); return (upgrade.close as (p: WebSocketPeer, ...r: unknown[]) => unknown)(peer, ...rest); },
|
|
120
142
|
},
|
|
121
143
|
} : {}),
|
|
122
144
|
});
|
|
123
145
|
const port = server.port ?? 0;
|
|
124
|
-
return keepHandler({ hostname, port, url: server.url instanceof URL ? server.url : new URL(`http://${urlHost}:${port}/`), stop: async (closeActive = true) => {
|
|
146
|
+
return keepHandler({ hostname, port, url: server.url instanceof URL ? server.url : new URL(`http://${urlHost}:${port}/`), stop: async (closeActive = true) => {
|
|
147
|
+
const stopping = Promise.resolve(server.stop(closeActive));
|
|
148
|
+
// stop closes the sockets still open (closeActive), each a close Bun may then keep counting, and drops the
|
|
149
|
+
// connections of handlers still running: once every socket the seam opened has closed and every handler left is
|
|
150
|
+
// one whose request was dropped, Bun's stop would never settle on them (above), and the seam has stopped
|
|
151
|
+
let settled = false;
|
|
152
|
+
stopping.then(() => { settled = true; }, () => { settled = true; });
|
|
153
|
+
while (!settled) {
|
|
154
|
+
const orphaned = [...inflight].every((request) => request.signal.aborted);
|
|
155
|
+
const counted = ((server as { pendingWebSockets?: number }).pendingWebSockets ?? 0) > 0;
|
|
156
|
+
if (live.size === 0 && orphaned && (counted || (closeActive && inflight.size > 0))) {
|
|
157
|
+
stopping.catch(() => {});
|
|
158
|
+
if (inflight.size) console.error(`serveHttp: forced stop left pending handler cleanup: ${inflight.size} request(s)`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
162
|
+
}
|
|
163
|
+
await stopping;
|
|
164
|
+
} }, options.fetch);
|
|
125
165
|
}
|
|
126
166
|
return keepHandler(await serveOnNode({ ...options, hostname, urlHost, fetch: decorate ? decorate(options) : options.fetch }), options.fetch);
|
|
127
167
|
}
|
package/src/sockets.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// upgrade through the serve seam, and offers every write to every open session of the vendor, after the write's
|
|
7
7
|
// webhooks. A session's own counters (a sequence number, a heartbeat) are the connection's, never the World's state.
|
|
8
8
|
// Nothing here runs at module scope beyond the empty registry.
|
|
9
|
+
import { worldNow } from './world-clock.ts';
|
|
9
10
|
import type { EventWrite } from './events.ts';
|
|
10
11
|
import type { ScenarioEngine } from './scenario.ts';
|
|
11
12
|
import type { WebSocketPeer, WebSocketUpgrade } from './serve-http.ts';
|
|
@@ -48,7 +49,7 @@ export type SocketEngine<C> = {
|
|
|
48
49
|
close?(session: SocketSession, ctx: C): void | Promise<void>;
|
|
49
50
|
};
|
|
50
51
|
|
|
51
|
-
type Open = { session: SocketSession; engine: SocketEngine<unknown>; context: () => Promise<unknown>; queue: Promise<void>; pending: Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout
|
|
52
|
+
type Open = { session: SocketSession; engine: SocketEngine<unknown>; context: () => Promise<unknown>; queue: Promise<void>; pending: Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout>; due: number }> };
|
|
52
53
|
const sessions = new Map<string, Set<Open>>();
|
|
53
54
|
/** A World's next session number, per World (its service and root): a session's id is the World's, never the
|
|
54
55
|
* process's, so a life replayed on a fresh World numbers its sessions as it did the first time. */
|
|
@@ -60,6 +61,15 @@ export function openSessions(service: string, root: string | undefined, socket:
|
|
|
60
61
|
return [...(sessions.get(keyOf(service, root)) ?? [])].filter((o) => o.session.socket === socket).map((o) => o.session);
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
/** What a session awaits that the World's clock has run out (a life's `wait` past a relay's timeout), rejected as the
|
|
65
|
+
* machine's clock would reject it; called as the vendor serves a request ("A request answered by a session"). */
|
|
66
|
+
export function expireSocketWaits(service: string, root: string | undefined): void {
|
|
67
|
+
const open = sessions.get(keyOf(service, root));
|
|
68
|
+
if (!open?.size) return;
|
|
69
|
+
const now = Date.parse(worldNow());
|
|
70
|
+
for (const o of open) for (const [k, p] of o.pending) if (p.due <= now) { clearTimeout(p.timer); o.pending.delete(k); p.reject(new Error('no reply in time')); }
|
|
71
|
+
}
|
|
72
|
+
|
|
63
73
|
/** Offer a write to every open session of the vendor, each in its turn after what it is already doing. Queued, never
|
|
64
74
|
* awaited: the write may be one a session's own frame made, which that session finishes before it is told. */
|
|
65
75
|
export function socketWrite(service: string, root: string | undefined, write: EventWrite): void {
|
|
@@ -72,21 +82,42 @@ export function socketWrite(service: string, root: string | undefined, write: Ev
|
|
|
72
82
|
}
|
|
73
83
|
}
|
|
74
84
|
|
|
85
|
+
/** A declared socket path's parameters for a request's path, or undefined when it does not match: a `{name}` segment
|
|
86
|
+
* matches one segment, as an operation's path parameter does (architecture, sockets). */
|
|
87
|
+
function socketParams(declared: string, path: string): Record<string, string> | undefined {
|
|
88
|
+
const names: string[] = [];
|
|
89
|
+
const pattern = (declared.replace(/\/+$/, '') || '/').split(/(\{[^}/]+\})/).map((part) => {
|
|
90
|
+
const name = /^\{([^}/]+)\}$/.exec(part)?.[1];
|
|
91
|
+
if (name === undefined) return part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
92
|
+
names.push(name);
|
|
93
|
+
return '([^/]+)';
|
|
94
|
+
}).join('');
|
|
95
|
+
const hit = new RegExp(`^${pattern}$`).exec(path);
|
|
96
|
+
if (!hit) return undefined;
|
|
97
|
+
try { return Object.fromEntries(names.map((name, i) => [name, decodeURIComponent(hit[i + 1]!)])); } catch { return undefined; }
|
|
98
|
+
}
|
|
99
|
+
|
|
75
100
|
/** The upgrade the serve seam takes: a request to a declared socket's path (and host) opens a session of its engine. */
|
|
76
101
|
export function socketUpgrade<C>(
|
|
77
102
|
service: string,
|
|
78
103
|
root: string | undefined,
|
|
79
104
|
decls: ReadonlyArray<SocketDecl>,
|
|
80
105
|
engines: Record<string, SocketEngine<C>>,
|
|
81
|
-
context: (request: Request, decl: SocketDecl) => Promise<C>,
|
|
106
|
+
context: (request: Request, decl: SocketDecl, params: Record<string, string>) => Promise<C>,
|
|
82
107
|
hostOf: (request: Request) => string,
|
|
83
108
|
pathOf: (request: Request) => string = (request) => new URL(request.url).pathname,
|
|
84
109
|
decide: (asked: unknown) => Promise<ScenarioServed | undefined> = async () => undefined,
|
|
85
110
|
): WebSocketUpgrade {
|
|
86
|
-
const
|
|
111
|
+
const matchFor = (request: Request): { decl: SocketDecl; params: Record<string, string> } | undefined => {
|
|
87
112
|
const path = pathOf(request).replace(/\/+$/, '') || '/';
|
|
88
|
-
|
|
89
|
-
|
|
113
|
+
for (const d of decls) {
|
|
114
|
+
if ((d.host && d.host.toLowerCase() !== hostOf(request)) || engines[d.id] === undefined) continue;
|
|
115
|
+
const params = socketParams(d.path, path);
|
|
116
|
+
if (params) return { decl: d, params };
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
};
|
|
120
|
+
const declFor = (request: Request): SocketDecl | undefined => matchFor(request)?.decl;
|
|
90
121
|
const byPeer = new WeakMap<WebSocketPeer, Open>();
|
|
91
122
|
const key = keyOf(service, root);
|
|
92
123
|
return {
|
|
@@ -99,7 +130,7 @@ export function socketUpgrade<C>(
|
|
|
99
130
|
return declared ? offered.find((p) => declared.includes(p)) : offered[0];
|
|
100
131
|
},
|
|
101
132
|
open: (peer, request) => {
|
|
102
|
-
const decl =
|
|
133
|
+
const { decl, params } = matchFor(request)!;
|
|
103
134
|
const engine = engines[decl.id]! as SocketEngine<unknown>;
|
|
104
135
|
const pending: Open['pending'] = new Map();
|
|
105
136
|
const session: SocketSession = {
|
|
@@ -108,7 +139,7 @@ export function socketUpgrade<C>(
|
|
|
108
139
|
close: (code, reason) => { peer.close(code, reason); },
|
|
109
140
|
expect: (key, ms) => new Promise((resolve, reject) => {
|
|
110
141
|
const timer = setTimeout(() => { pending.delete(key); reject(new Error('no reply in time')); }, ms);
|
|
111
|
-
pending.set(key, { resolve, reject, timer });
|
|
142
|
+
pending.set(key, { resolve, reject, timer, due: Date.parse(worldNow()) + ms });
|
|
112
143
|
}),
|
|
113
144
|
fulfil: (key, value) => {
|
|
114
145
|
const p = pending.get(key);
|
|
@@ -118,7 +149,7 @@ export function socketUpgrade<C>(
|
|
|
118
149
|
},
|
|
119
150
|
decide,
|
|
120
151
|
};
|
|
121
|
-
const o: Open = { session, engine, context: () => context(request.clone(), decl), queue: Promise.resolve(), pending };
|
|
152
|
+
const o: Open = { session, engine, context: () => context(request.clone(), decl, params), queue: Promise.resolve(), pending };
|
|
122
153
|
byPeer.set(peer, o);
|
|
123
154
|
(sessions.get(key) ?? sessions.set(key, new Set()).get(key)!).add(o);
|
|
124
155
|
if (engine.open) o.queue = o.queue.then(async () => engine.open!(session, await o.context())).catch((error) => session.close(1011, String((error as Error).message ?? error).slice(0, 120)));
|
package/src/vendor-call.ts
CHANGED
|
@@ -41,6 +41,50 @@ async function worldTwins(): Promise<Record<string, string>> {
|
|
|
41
41
|
return { ...twinOrigins(env), ...twinOrigins(instanceEnv) };
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** The hosts each claiming twin last listed at its `hostsClaimed` door, by the door's address, and when; read again
|
|
45
|
+
* after CLAIMS_TTL_MS or for a host it lacks, as the injector polls the doors every five seconds (inject.cjs, "HOSTS A TWIN CLAIMS WHILE IT
|
|
46
|
+
* RUNS"). */
|
|
47
|
+
const claimsRead = new Map<string, { hosts: Set<string>; at: number }>();
|
|
48
|
+
const CLAIMS_TTL_MS = 5_000;
|
|
49
|
+
|
|
50
|
+
/** The hosts a twin claims at its door: in process (a journey walk's companion) or at its address, within the injector's
|
|
51
|
+
* first-read deadline (inject.cjs CLAIMS_WAIT_MS, 1500 ms, measured beside it); a door that does not answer claims
|
|
52
|
+
* nothing. */
|
|
53
|
+
async function claimedHosts(at: string, ask: () => Promise<Response>, host: string): Promise<Set<string>> {
|
|
54
|
+
const held = claimsRead.get(at);
|
|
55
|
+
// a host the held list lacks is asked for again once, as the injector refreshes before it refuses: a domain claimed a
|
|
56
|
+
// moment ago is found
|
|
57
|
+
if (held && Date.now() - held.at < CLAIMS_TTL_MS && held.hosts.has(host)) return held.hosts;
|
|
58
|
+
let hosts = new Set<string>();
|
|
59
|
+
try {
|
|
60
|
+
const r = await ask();
|
|
61
|
+
const body = r.ok ? await r.json() as { hosts?: unknown[] } : null;
|
|
62
|
+
hosts = new Set((body?.hosts ?? []).map((h) => String(h).toLowerCase()));
|
|
63
|
+
} catch { /* an unanswered door claims nothing */ }
|
|
64
|
+
claimsRead.set(at, { hosts, at: Date.now() });
|
|
65
|
+
return hosts;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The twin that claims a host at its `hostsClaimed` door (an R2 bucket's custom domain, a Worker's), as the injector
|
|
69
|
+
* routes it: a twin this process answers first, the World's twins after. */
|
|
70
|
+
async function claimingTwin(target: URL, twins: Record<string, string>): Promise<{ vendor: string; local: boolean } | undefined> {
|
|
71
|
+
const { packFacts } = await import('../pack-facts.cjs') as { packFacts: () => { packs: Record<string, { hostsClaimed?: { door?: string } }> } };
|
|
72
|
+
const host = target.hostname.toLowerCase();
|
|
73
|
+
const key = process.env.VOLTER_TWINS_KEY;
|
|
74
|
+
const doors = Object.entries(packFacts().packs).flatMap(([vendor, facts]) => (facts.hostsClaimed?.door ? [[vendor, facts.hostsClaimed.door] as const] : []));
|
|
75
|
+
for (const [vendor, door] of doors) {
|
|
76
|
+
const twin = inProcessTwins[vendor];
|
|
77
|
+
if (twin && (await claimedHosts(`in-process:${vendor}${door}`, async () => twin(new Request(`http://${vendor}.twin${door}`)), host)).has(host)) return { vendor, local: true };
|
|
78
|
+
}
|
|
79
|
+
for (const [vendor, door] of doors) {
|
|
80
|
+
const origin = twins[vendor];
|
|
81
|
+
if (origin === undefined) continue;
|
|
82
|
+
const at = `${origin.replace(/\/$/, '')}${door}`;
|
|
83
|
+
if ((await claimedHosts(at, () => fetch(at, { headers: key ? { 'x-twins-key': key } : {}, signal: AbortSignal.timeout(1500) }), host)).has(host)) return { vendor, local: false };
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
44
88
|
/** One URL answered only by a World-held application or vendor twin: the fetch's application stand-in first (its answer
|
|
45
89
|
* is the application's; `'unreachable'` leaves no application to reach, the World's twins still reachable). */
|
|
46
90
|
async function worldResponse(target: URL, init: RequestInit, standIn?: ApplicationStandIn): Promise<Response> {
|
|
@@ -67,8 +111,18 @@ async function worldResponse(target: URL, init: RequestInit, standIn?: Applicati
|
|
|
67
111
|
if (key && !headers.has('x-twins-key')) headers.set('x-twins-key', key);
|
|
68
112
|
return fetch(`${twins[vendor]!.replace(/\/$/, '')}${target.pathname}${target.search}`, { ...init, headers, redirect: 'manual' });
|
|
69
113
|
}
|
|
114
|
+
// the application's own hosts before any twin's claimed ones: a call to the application asks no door
|
|
70
115
|
const { appDestination, appFetch } = await import('../app-route.cjs') as { appDestination: (url: URL) => unknown; appFetch: (url: URL, init: RequestInit) => Promise<Response> };
|
|
71
116
|
if (scripted !== 'unreachable' && appDestination(target)) return appFetch(target, { ...init, redirect: 'manual' });
|
|
117
|
+
const claimer = await claimingTwin(target, twins);
|
|
118
|
+
if (claimer !== undefined) {
|
|
119
|
+
const headers = new Headers(init.headers);
|
|
120
|
+
headers.set('x-volter-twin-original-host', target.host);
|
|
121
|
+
if (claimer.local) return inProcessTwins[claimer.vendor]!(new Request(target, { ...init, headers, redirect: 'manual' }));
|
|
122
|
+
const key = process.env.VOLTER_TWINS_KEY;
|
|
123
|
+
if (key && !headers.has('x-twins-key')) headers.set('x-twins-key', key);
|
|
124
|
+
return fetch(`${twins[claimer.vendor]!.replace(/\/$/, '')}${target.pathname}${target.search}`, { ...init, headers, redirect: 'manual' });
|
|
125
|
+
}
|
|
72
126
|
throw new VendorUnreachableError(`${target.host}${target.pathname}: this World holds no application or vendor twin at this URL`);
|
|
73
127
|
}
|
|
74
128
|
|