@volter/world-core 3.0.155 → 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 +204 -4
- package/dist/inject.cjs +150 -86
- 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/actions.d.ts +15 -0
- package/dist/src/actions.js +82 -21
- package/dist/src/ancestry.d.ts +5 -4
- package/dist/src/ancestry.js +53 -28
- package/dist/src/blob-store.d.ts +16 -0
- package/dist/src/blob-store.js +76 -1
- package/dist/src/changeset.js +1 -1
- package/dist/src/clickhouse/compression.d.ts +28 -0
- package/dist/src/clickhouse/compression.js +281 -0
- package/dist/src/clickhouse/index.d.ts +1 -0
- package/dist/src/clickhouse/index.js +1 -0
- package/dist/src/derived-core.d.ts +84 -7
- package/dist/src/derived-core.js +336 -61
- package/dist/src/derived-real.js +97 -15
- package/dist/src/derived.d.ts +5 -0
- package/dist/src/derived.js +3 -0
- package/dist/src/executor.d.ts +7 -0
- package/dist/src/executor.js +15 -1
- package/dist/src/fork.js +1 -1
- package/dist/src/git/lfs.d.ts +13 -3
- package/dist/src/git/lfs.js +41 -9
- package/dist/src/grpc-wire.js +2 -1
- package/dist/src/h2.js +63 -10
- package/dist/src/head.d.ts +5 -0
- package/dist/src/head.js +15 -0
- package/dist/src/history.d.ts +1 -1
- package/dist/src/history.js +7 -7
- package/dist/src/index.d.ts +5 -1
- package/dist/src/index.js +6 -1
- 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/log.d.ts +23 -3
- package/dist/src/log.js +160 -37
- 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/managed-original-inputs.d.ts +101 -0
- package/dist/src/managed-original-inputs.js +125 -0
- package/dist/src/mongodb/client.d.ts +8 -0
- package/dist/src/mongodb/client.js +27 -0
- package/dist/src/mongodb/index.d.ts +2 -0
- package/dist/src/mongodb/index.js +5 -0
- package/dist/src/mongodb/stream.d.ts +97 -0
- package/dist/src/mongodb/stream.js +206 -0
- package/dist/src/observe.js +1 -1
- package/dist/src/pack-fetch.d.ts +6 -1
- package/dist/src/pack-fetch.js +69 -14
- package/dist/src/pack-paths.d.ts +2 -1
- package/dist/src/pack-paths.js +6 -1
- package/dist/src/packRegistry.d.ts +10 -14
- package/dist/src/packRegistry.js +2 -13
- package/dist/src/protocol.d.ts +13 -0
- package/dist/src/protocol.js +13 -0
- package/dist/src/redis/engine.d.ts +10 -4
- package/dist/src/redis/engine.js +103 -32
- package/dist/src/redis/member-storage.js +20 -8
- package/dist/src/redis/service-times.d.ts +25 -0
- package/dist/src/redis/service-times.js +87 -0
- package/dist/src/redis/stream.d.ts +2 -1
- package/dist/src/redis/stream.js +109 -20
- package/dist/src/resource-blob.d.ts +4 -0
- package/dist/src/resource-blob.js +8 -1
- package/dist/src/runtime-state.d.ts +11 -0
- package/dist/src/runtime-state.js +13 -0
- package/dist/src/runtime.d.ts +7 -5
- package/dist/src/runtime.js +7 -4
- package/dist/src/serve-http.d.ts +2 -0
- package/dist/src/serve-http.js +73 -6
- package/dist/src/serve.d.ts +10 -0
- package/dist/src/serve.js +43 -3
- package/dist/src/sockets.d.ts +4 -1
- package/dist/src/sockets.js +59 -6
- package/dist/src/storage.d.ts +2 -1
- package/dist/src/storage.js +6 -3
- package/dist/src/vendor-call.js +58 -0
- package/dist/src/world-boot.d.ts +1 -0
- package/dist/src/world-boot.js +6 -0
- package/dist/src/world-clock.js +6 -2
- package/dist/src/world-store.d.ts +122 -7
- package/dist/src/world-store.js +959 -103
- package/dist/src/xml.d.ts +30 -0
- package/dist/src/xml.js +74 -0
- package/generated/pack-facts.json +204 -4
- package/inject.cjs +150 -86
- package/network-policy.cjs +11 -1
- package/network-policy.d.cts +1 -0
- package/pack-facts.d.cts +3 -0
- package/package.json +27 -1
- package/required-credential-transport.cjs +29 -0
- package/required-credential-transport.d.cts +3 -0
- package/src/actions.ts +78 -21
- package/src/ancestry.ts +46 -24
- package/src/blob-store.ts +59 -1
- package/src/changeset.ts +1 -1
- package/src/clickhouse/compression.ts +172 -0
- package/src/clickhouse/index.ts +2 -0
- package/src/derived-core.ts +356 -61
- package/src/derived-real.ts +80 -14
- package/src/derived.ts +9 -1
- package/src/executor.ts +17 -3
- package/src/fork.ts +1 -1
- package/src/git/lfs.ts +40 -9
- package/src/grpc-wire.ts +2 -1
- package/src/h2.ts +46 -5
- package/src/head.ts +15 -0
- package/src/history.ts +7 -7
- package/src/index.ts +7 -1
- package/src/live/ingest.ts +7 -3
- package/src/live/rtmp.ts +7 -5
- package/src/live/segmenter.ts +7 -1
- package/src/log.ts +147 -32
- package/src/machines.ts +5 -1
- package/src/managed-go-programs.ts +146 -0
- package/src/managed-original-inputs.ts +163 -0
- package/src/mongodb/client.ts +27 -0
- package/src/mongodb/index.ts +5 -0
- package/src/mongodb/stream.ts +223 -0
- package/src/observe.ts +1 -1
- package/src/pack-fetch.ts +66 -16
- package/src/pack-paths.ts +5 -1
- package/src/packRegistry.ts +3 -15
- package/src/protocol.ts +13 -0
- package/src/redis/engine.ts +100 -57
- package/src/redis/member-storage.ts +17 -9
- package/src/redis/service-times.ts +81 -0
- package/src/redis/stream.ts +66 -14
- package/src/resource-blob.ts +9 -1
- package/src/runtime-state.ts +14 -0
- package/src/runtime.ts +10 -3
- package/src/serve-http.ts +65 -6
- package/src/serve.ts +45 -3
- package/src/sockets.ts +39 -8
- package/src/storage.ts +5 -3
- package/src/vendor-call.ts +54 -0
- package/src/world-boot.ts +6 -0
- package/src/world-clock.ts +6 -2
- package/src/world-store.ts +768 -91
- package/src/xml.ts +71 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/** Source-bound files at a managed replacement. No vendor interpretation or host paths. */
|
|
2
|
+
import * as nodeCrypto from 'node:crypto';
|
|
3
|
+
const digest = (algorithm: 'sha1' | 'sha256', value: string | Uint8Array): string => nodeCrypto.createHash(algorithm).update(value).digest('hex');
|
|
4
|
+
export interface OriginalInputReference {
|
|
5
|
+
version: 1;
|
|
6
|
+
/** Relative to the needs file, or to the World configuration after init copies it. */
|
|
7
|
+
root: string;
|
|
8
|
+
manifest: "manifest.json";
|
|
9
|
+
sha256: string;
|
|
10
|
+
inventoryDigest: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface OriginalInputCapability {
|
|
14
|
+
version: 1;
|
|
15
|
+
manifestArg: "--original-inputs";
|
|
16
|
+
rootArg: "--original-root";
|
|
17
|
+
digestArg: "--original-inputs-sha256";
|
|
18
|
+
/** Image paths whose complete overlay contents the consumer requires. */
|
|
19
|
+
paths: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface OriginalInputEntry {
|
|
23
|
+
target: string;
|
|
24
|
+
type: "file" | "directory" | "link";
|
|
25
|
+
mode: number;
|
|
26
|
+
bytes?: number;
|
|
27
|
+
sha256?: string;
|
|
28
|
+
object?: string;
|
|
29
|
+
link?: string;
|
|
30
|
+
source: { kind: "image" | "project"; path: string; layer?: string; blob?: string };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ManagedOriginalInputs {
|
|
34
|
+
kind: "volter.managed-original-inputs";
|
|
35
|
+
version: 1;
|
|
36
|
+
service: string;
|
|
37
|
+
family: string;
|
|
38
|
+
/** Canonical selected service declaration; used to refuse a different stack on reuse. */
|
|
39
|
+
declaration: string;
|
|
40
|
+
source: { image: string; manifest: string; config: string; layers: string[]; commit: string; compose: string; composeSha256: string; composeBytes: number; composeObject: string };
|
|
41
|
+
/** Authenticated complete image metadata, before the declared mounts overlay it. */
|
|
42
|
+
imageInventory: Array<{ target: string; type: string; mode: number; bytes: number; layer: string; link?: string }>;
|
|
43
|
+
imageInventoryDigest: string;
|
|
44
|
+
projectInventory: Array<{ path: string; mode: number; blob: string }>;
|
|
45
|
+
projectInventoryDigest: string;
|
|
46
|
+
roots: string[];
|
|
47
|
+
/** Complete contents beneath roots, plus every original project bind mount. */
|
|
48
|
+
inventory: OriginalInputEntry[];
|
|
49
|
+
inventoryDigest: string;
|
|
50
|
+
start: {
|
|
51
|
+
imageConfig: Record<string, unknown>;
|
|
52
|
+
service: Record<string, unknown>;
|
|
53
|
+
imageEntrypoint: string[] | null;
|
|
54
|
+
imageCommand: string[] | null;
|
|
55
|
+
command?: string[];
|
|
56
|
+
entrypoint?: string[];
|
|
57
|
+
environment: Record<string, string>;
|
|
58
|
+
envFiles: Array<{ path: string; bytes: number; sha256: string; object: string }>;
|
|
59
|
+
unset: string[];
|
|
60
|
+
selfHosts: string[];
|
|
61
|
+
mounts: Array<{ kind: string; source?: string; target: string; readOnly: boolean }>;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const originalTarget = (path: string): boolean => path === "/" || (path.startsWith("/") && !path.slice(1).split("/").some(part => !part || part === "." || part === ".." || /[\0\r\n\\]/u.test(part)));
|
|
66
|
+
export const originalRelative = (path: string): boolean => !!path && !path.split("/").some(part => !part || part === "." || part === ".." || /[\0\r\n\\:]/u.test(part));
|
|
67
|
+
|
|
68
|
+
const hex = (value: unknown): value is string => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
69
|
+
const oci = (value: unknown): value is string => typeof value === 'string' && /^sha256:[a-f0-9]{64}$/.test(value);
|
|
70
|
+
const bytesOf = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0;
|
|
71
|
+
const strings = (value: unknown): value is string[] => Array.isArray(value) && value.every(entry => typeof entry === 'string' && !/[\0\r\n]/.test(entry));
|
|
72
|
+
const bound = 128 * 1024 * 1024;
|
|
73
|
+
|
|
74
|
+
export function referenceOf(value: unknown): OriginalInputReference {
|
|
75
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Original inputs need a versioned reference');
|
|
76
|
+
const ref = value as OriginalInputReference;
|
|
77
|
+
if (ref.version !== 1 || ref.manifest !== 'manifest.json' || typeof ref.root !== 'string' || !originalRelative(ref.root) || !hex(ref.sha256) || !hex(ref.inventoryDigest)
|
|
78
|
+
|| Object.keys(ref).some(key => !['version', 'root', 'manifest', 'sha256', 'inventoryDigest'].includes(key))) throw new Error('Invalid original-input reference');
|
|
79
|
+
return { ...ref };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Hash and validate neutral carrier data. The caller supplies I/O; no pack/vendor behavior or import effects. */
|
|
83
|
+
export function readSnapshot(bytes: Uint8Array, expected: string, readObject: (path: string, bytes: number) => Uint8Array): { manifest: ManagedOriginalInputs; objects: ReadonlyMap<string, Uint8Array> } {
|
|
84
|
+
if (!(bytes instanceof Uint8Array) || bytes.length > 64 * 1024 * 1024 || !hex(expected) || digest('sha256', bytes) !== expected) throw new Error('Original-input manifest identity differs');
|
|
85
|
+
const manifest = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) as ManagedOriginalInputs;
|
|
86
|
+
const source = manifest.source, start = manifest.start;
|
|
87
|
+
if (manifest.kind !== 'volter.managed-original-inputs' || manifest.version !== 1 || typeof manifest.service !== 'string' || !manifest.service || typeof manifest.family !== 'string' || !/^[a-z][a-z0-9-]*$/.test(manifest.family)
|
|
88
|
+
|| typeof manifest.declaration !== 'string' || !source || !/^[a-f0-9]{40}$/.test(source.commit) || !/^[A-Za-z0-9./:_-]+@sha256:[a-f0-9]{64}$/.test(source.image)
|
|
89
|
+
|| !oci(source.manifest) || !oci(source.config) || !Array.isArray(source.layers) || !source.layers.every(oci) || !originalRelative(source.compose)
|
|
90
|
+
|| !Array.isArray(manifest.imageInventory) || !Array.isArray(manifest.projectInventory) || !Array.isArray(manifest.inventory) || !strings(manifest.roots) || !manifest.roots.length || !manifest.roots.every(originalTarget) || new Set(manifest.roots).size !== manifest.roots.length
|
|
91
|
+
|| !hex(manifest.imageInventoryDigest) || digest('sha256', JSON.stringify(manifest.imageInventory)) !== manifest.imageInventoryDigest
|
|
92
|
+
|| !hex(manifest.inventoryDigest) || digest('sha256', JSON.stringify(manifest.inventory)) !== manifest.inventoryDigest
|
|
93
|
+
|| !hex(manifest.projectInventoryDigest) || digest('sha256', JSON.stringify(manifest.projectInventory)) !== manifest.projectInventoryDigest
|
|
94
|
+
|| !start || !start.imageConfig || typeof start.imageConfig !== 'object' || Array.isArray(start.imageConfig) || !start.service || typeof start.service !== 'object' || Array.isArray(start.service)
|
|
95
|
+
|| !strings(start.selfHosts) || !start.selfHosts.length || !strings(start.unset) || !Array.isArray(start.envFiles) || !Array.isArray(start.mounts)
|
|
96
|
+
|| !start.environment || typeof start.environment !== 'object' || Array.isArray(start.environment) || Object.entries(start.environment).some(([key, value]) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || typeof value !== 'string')
|
|
97
|
+
|| [start.imageEntrypoint, start.imageCommand].some(value => value !== null && !strings(value)) || [start.command, start.entrypoint].some(value => value !== undefined && !strings(value))) throw new Error('Invalid original-input manifest');
|
|
98
|
+
if (JSON.stringify(JSON.parse(manifest.declaration)) !== JSON.stringify(start.service) || start.service.name !== manifest.service
|
|
99
|
+
|| JSON.stringify(start.command) !== JSON.stringify(start.service.command) || JSON.stringify(start.entrypoint) !== JSON.stringify(start.service.entrypoint)
|
|
100
|
+
|| JSON.stringify(start.imageEntrypoint) !== JSON.stringify(start.imageConfig.Entrypoint ?? null) || JSON.stringify(start.imageCommand) !== JSON.stringify(start.imageConfig.Cmd ?? null)
|
|
101
|
+
|| !Array.isArray(start.service.volumes)
|
|
102
|
+
|| JSON.stringify(start.mounts.map(mount => [mount.kind, mount.source ?? null, mount.target, mount.readOnly])) !== JSON.stringify((start.service.volumes as ManagedOriginalInputs['start']['mounts']).map(mount => [mount.kind, mount.source ?? null, mount.target, mount.readOnly]))) throw new Error('Original-input start declaration differs');
|
|
103
|
+
const project = new Map<string, ManagedOriginalInputs['projectInventory'][number]>();
|
|
104
|
+
for (const entry of manifest.projectInventory) {
|
|
105
|
+
if (!entry || !originalRelative(entry.path) || project.has(entry.path) || ![0o100644, 0o100755, 0o120000, 0o160000].includes(entry.mode) || !/^[a-f0-9]{40}$/.test(entry.blob)) throw new Error('Invalid original project inventory');
|
|
106
|
+
project.set(entry.path, entry);
|
|
107
|
+
}
|
|
108
|
+
const image = new Map<string, ManagedOriginalInputs['imageInventory'][number]>();
|
|
109
|
+
for (const entry of manifest.imageInventory) {
|
|
110
|
+
if (!entry || !originalTarget(entry.target) || image.has(entry.target) || !['file', 'directory', 'link', 'other'].includes(entry.type) || !Number.isInteger(entry.mode) || entry.mode < 0 || entry.mode > 0o7777 || !bytesOf(entry.bytes) || !source.layers.includes(entry.layer)
|
|
111
|
+
|| (entry.type === 'link' && (typeof entry.link !== 'string' || /[\0\r\n\\]/.test(entry.link)))) throw new Error('Invalid original image inventory');
|
|
112
|
+
image.set(entry.target, entry);
|
|
113
|
+
}
|
|
114
|
+
const targets = new Set<string>(), selected = new Map<string, OriginalInputEntry>(), expectedObjects = new Map<string, number>();
|
|
115
|
+
const hold = (entry: { sha256?: string; bytes?: number; object?: string }): void => {
|
|
116
|
+
if (!hex(entry.sha256) || !bytesOf(entry.bytes) || entry.object !== `objects/${entry.sha256}` || (expectedObjects.has(entry.sha256) && expectedObjects.get(entry.sha256) !== entry.bytes)) throw new Error('Invalid original-input payload identity');
|
|
117
|
+
expectedObjects.set(entry.sha256, entry.bytes);
|
|
118
|
+
};
|
|
119
|
+
for (const entry of manifest.inventory) {
|
|
120
|
+
if (!entry || !originalTarget(entry.target) || targets.has(entry.target) || !['file', 'directory', 'link'].includes(entry.type) || !Number.isInteger(entry.mode) || entry.mode < 0 || entry.mode > 0o7777 || !entry.source || !['image', 'project'].includes(entry.source.kind)) throw new Error('Invalid original-input inventory');
|
|
121
|
+
targets.add(entry.target);
|
|
122
|
+
selected.set(entry.target, entry);
|
|
123
|
+
if (entry.source.kind === 'image') {
|
|
124
|
+
const original = image.get(entry.source.path);
|
|
125
|
+
if (!original || original.layer !== entry.source.layer || original.type !== entry.type || original.mode !== entry.mode || (entry.type === 'file' && original.bytes !== entry.bytes) || (entry.type === 'link' && original.link !== entry.link)) throw new Error('Original-input image provenance differs');
|
|
126
|
+
} else {
|
|
127
|
+
const original = project.get(entry.source.path);
|
|
128
|
+
if (!originalRelative(entry.source.path) || (entry.type === 'file' && (!original || original.blob !== entry.source.blob || (original.mode & 0o777) !== entry.mode))) throw new Error('Original-input project provenance differs');
|
|
129
|
+
}
|
|
130
|
+
if (entry.type === 'file') hold(entry);
|
|
131
|
+
else if (entry.object !== undefined || entry.bytes !== undefined || entry.sha256 !== undefined || (entry.type === 'link' && (typeof entry.link !== 'string' || /[\0\r\n\\]/.test(entry.link)))) throw new Error('Invalid original-input non-file');
|
|
132
|
+
}
|
|
133
|
+
for (const mount of start.mounts) if (!mount || !['bind', 'named', 'anonymous'].includes(mount.kind) || !originalTarget(mount.target) || typeof mount.readOnly !== 'boolean' || (mount.source !== undefined && typeof mount.source !== 'string')) throw new Error('Invalid original-input mount declaration');
|
|
134
|
+
const roots = new Set(manifest.roots), mounted = new Set(start.mounts.filter(mount => mount.kind === 'bind').map(mount => mount.target));
|
|
135
|
+
const under = (path: string, roots: ReadonlySet<string>): boolean => {
|
|
136
|
+
for (let at = path;; at = at.slice(0, at.lastIndexOf('/')) || '/') {
|
|
137
|
+
if (roots.has(at)) return true;
|
|
138
|
+
if (at === '/') return false;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
for (const entry of image.values()) if (under(entry.target, roots) && !under(entry.target, mounted)) {
|
|
142
|
+
const captured = selected.get(entry.target);
|
|
143
|
+
if (!captured || captured.source.kind !== 'image' || captured.source.path !== entry.target) throw new Error('Original image input was omitted from its declared root');
|
|
144
|
+
}
|
|
145
|
+
hold({ sha256: source.composeSha256, bytes: source.composeBytes, object: source.composeObject });
|
|
146
|
+
for (const file of start.envFiles) {
|
|
147
|
+
if (!file || !originalRelative(file.path)) throw new Error('Invalid original-input env_file');
|
|
148
|
+
hold(file);
|
|
149
|
+
}
|
|
150
|
+
if ([...expectedObjects.values()].reduce((sum, bytes) => sum + bytes, 0) > bound) throw new Error('Original-input snapshot exceeds its byte bound');
|
|
151
|
+
const objects = new Map<string, Uint8Array>();
|
|
152
|
+
for (const [sha256, size] of expectedObjects) {
|
|
153
|
+
const data = readObject(`objects/${sha256}`, size);
|
|
154
|
+
if (!(data instanceof Uint8Array) || data.length !== size || digest('sha256', data) !== sha256) throw new Error('Original-input payload bytes differ');
|
|
155
|
+
objects.set(sha256, data.slice());
|
|
156
|
+
}
|
|
157
|
+
for (const entry of manifest.inventory) if (entry.type === 'file' && entry.source.kind === 'project') {
|
|
158
|
+
const data = objects.get(entry.sha256!)!, header = new TextEncoder().encode(`blob ${data.length}\0`), blob = new Uint8Array(header.length + data.length);
|
|
159
|
+
blob.set(header); blob.set(data, header.length);
|
|
160
|
+
if (digest('sha1', blob) !== entry.source.blob) throw new Error('Original-input project blob identity differs');
|
|
161
|
+
}
|
|
162
|
+
return { manifest, objects };
|
|
163
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// A MongoDB client over any byte exchange: the other side of ./stream.ts, so a journey speaks the wire as an
|
|
2
|
+
// application's driver does. It sends one command document as an OP_MSG and returns the document the server
|
|
3
|
+
// answered; it reads what the protocol answers and decides nothing.
|
|
4
|
+
// source: archive:https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz#sha256=a12bd599cb4f39cdf222b32380ea3efd1f60c6d88615e0700632a303a9006b02!/package/src/cmap/commands.ts "moreToCome is an OP_MSG only concept"
|
|
5
|
+
import { MongoOp, decodeMsg, encodeMsg, type MongoCodec, type MongoDocument } from './stream.ts';
|
|
6
|
+
|
|
7
|
+
/** Sends bytes and returns every byte the server answered to them. */
|
|
8
|
+
export type MongoExchange = (bytes: Uint8Array) => Promise<Uint8Array>;
|
|
9
|
+
|
|
10
|
+
/** A client on one connection: `command` sends a document to `database` and resolves with the reply document,
|
|
11
|
+
* whatever its `ok`. A reply that is not one whole OP_MSG answering this request is an error. */
|
|
12
|
+
export function mongoClient(exchange: MongoExchange, codec: MongoCodec): { command(database: string, command: MongoDocument): Promise<MongoDocument> } {
|
|
13
|
+
let nextRequest = 0;
|
|
14
|
+
return {
|
|
15
|
+
async command(database, command) {
|
|
16
|
+
const requestId = ++nextRequest;
|
|
17
|
+
const answered = await exchange(encodeMsg(codec, { ...command, $db: database }, requestId, 0));
|
|
18
|
+
if (answered.length < 16) throw new Error('the server answered no message');
|
|
19
|
+
const view = new DataView(answered.buffer, answered.byteOffset, answered.byteLength);
|
|
20
|
+
const length = view.getInt32(0, true), responseTo = view.getInt32(8, true), opcode = view.getInt32(12, true);
|
|
21
|
+
if (length !== answered.length) throw new Error(`the server answered ${answered.length} bytes, and its message says ${length}`);
|
|
22
|
+
if (opcode !== MongoOp.MSG) throw new Error(`the server answered opcode ${opcode}, not OP_MSG`);
|
|
23
|
+
if (responseTo !== requestId) throw new Error(`the server answered request ${responseTo}, not ${requestId}`);
|
|
24
|
+
return decodeMsg(codec, answered).document;
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// THE KERNEL'S MONGODB WIRE (`@volter/world-core/mongodb`): MongoDB's message framing as a TwinStream, and its client.
|
|
2
|
+
// A pack that serves MongoDB passes what a command means (its engine) and the document codec; the kernel names no vendor.
|
|
3
|
+
// docs/contributing/architecture.md, "Other wires", the MongoDB wire.
|
|
4
|
+
export * from './stream.ts';
|
|
5
|
+
export * from './client.ts';
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// THE KERNEL'S MONGODB WIRE (`@volter/world-core/mongodb`): MongoDB's client/server message framing as a TwinStream,
|
|
2
|
+
// so an unmodified MongoDB driver connects to a twin as to a server. The kernel holds the framing, which every
|
|
3
|
+
// MongoDB-speaking vendor shares; the pack holds what a command means (it answers each command document from its own
|
|
4
|
+
// engine over the tree) and the document codec (BSON, a library the pack installs). The messages served are what the
|
|
5
|
+
// vendor's own driver sends, by citation (docs/contributing/architecture.md, "Other wires", the MongoDB wire): OP_MSG
|
|
6
|
+
// with a body section and document-sequence sections, and the legacy OP_QUERY a driver's first hello may use, answered
|
|
7
|
+
// with OP_REPLY. Any other message ends the connection.
|
|
8
|
+
// source: archive:https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz#sha256=a12bd599cb4f39cdf222b32380ea3efd1f60c6d88615e0700632a303a9006b02!/package/src/cmap/commands.ts "moreToCome is an OP_MSG only concept"
|
|
9
|
+
import type { TwinStream, TwinStreamSink } from '../twin-fetch.ts';
|
|
10
|
+
|
|
11
|
+
/** A command or reply document as the pack's codec gives it: the pack decides how BSON's types appear in it. */
|
|
12
|
+
export type MongoDocument = Record<string, unknown>;
|
|
13
|
+
/** BSON, as the pack supplies it (the `bson` library, installed by the pack's package.json). The kernel frames
|
|
14
|
+
* documents and never reads inside one but for a command's `$db`. */
|
|
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
|
+
};
|
|
26
|
+
|
|
27
|
+
/** What the sender's message said beside its command: whether it will take more than one reply to it (OP_MSG's exhaustAllowed). */
|
|
28
|
+
export type MongoAsked = { exhaustAllowed: boolean };
|
|
29
|
+
const TIMED = Symbol('mongodb timed reply');
|
|
30
|
+
/**
|
|
31
|
+
* An answer the wire sends LATER, or goes on sending: the vendor's protocol does this in real time (an awaitable
|
|
32
|
+
* `hello` is held for the sender's maxAwaitTimeMS, and one sent with exhaustAllowed is answered again every such
|
|
33
|
+
* interval, each reply marked moreToCome). The pack says WHAT and WHEN as data and holds no clock: the same request
|
|
34
|
+
* gets the same answer every time. The wire does the holding and the repeating on the wall clock, and the delay is
|
|
35
|
+
* never read back into state (architecture, "The MongoDB wire").
|
|
36
|
+
*/
|
|
37
|
+
export type MongoTimedReply = { readonly [TIMED]: true; reply: MongoDocument; afterMs: number; everyMs?: number };
|
|
38
|
+
/**
|
|
39
|
+
* `reply` after `afterMs` (0: at once); with `everyMs`, again each `everyMs` until the peer closes, where the sender
|
|
40
|
+
* allowed more than one reply. The two differ in the vendor's own answers: a hello naming the server's current
|
|
41
|
+
* topology is first answered after the wait, one naming another process at once, and both then repeat at the wait.
|
|
42
|
+
*/
|
|
43
|
+
export const timedReply = (reply: MongoDocument, afterMs: number, everyMs?: number): MongoTimedReply => ({ [TIMED]: true, reply, afterMs, ...(everyMs !== undefined ? { everyMs } : {}) });
|
|
44
|
+
const isTimed = (answer: MongoDocument | MongoTimedReply): answer is MongoTimedReply => (answer as MongoTimedReply)[TIMED] === true;
|
|
45
|
+
|
|
46
|
+
export type MongoStreamOptions<Session> = {
|
|
47
|
+
codec: MongoCodec;
|
|
48
|
+
/** A connection opened: what the pack keeps for it (its sessions, an open transaction); never the tree. */
|
|
49
|
+
open(peer: string): Session;
|
|
50
|
+
/** Answer ONE command document sent to `database`. A document sequence (an insert's `documents`, an update's
|
|
51
|
+
* `updates`) arrives as that field holding the array. The answer is the reply document, `ok: 1` or the vendor's
|
|
52
|
+
* error document; a command the pack does not serve is the pack's to answer as the vendor answers an unknown one. */
|
|
53
|
+
command(session: Session, database: string, command: MongoDocument, asked: MongoAsked): Promise<MongoDocument | MongoTimedReply>;
|
|
54
|
+
/** The connection went away: abort what it left open. */
|
|
55
|
+
close?(session: Session): void;
|
|
56
|
+
/** Told why a connection is ended by this stream and not by its peer: the pack threw answering a command, or a
|
|
57
|
+
* message was one this wire does not serve. Ending the connection is all the wire can say to the client, so the
|
|
58
|
+
* reason is said here; unset, it is written to the process's error output. A stream that ends connections in
|
|
59
|
+
* silence leaves its operator with a client that hangs and nothing to read. */
|
|
60
|
+
ended?(reason: string, peer: string): void;
|
|
61
|
+
/** The largest message accepted; the vendor's server names 48,000,000 in its hello (`maxMessageSizeBytes`). */
|
|
62
|
+
maxMessageSizeBytes?: number;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const MongoOp = { REPLY: 1, QUERY: 2004, MSG: 2013 } as const;
|
|
66
|
+
/** OP_MSG's flag bits. */
|
|
67
|
+
export const MongoMsgFlag = { CHECKSUM_PRESENT: 1 << 0, MORE_TO_COME: 1 << 1, EXHAUST_ALLOWED: 1 << 16 } as const;
|
|
68
|
+
const HEADER = 16;
|
|
69
|
+
const dec = new TextDecoder();
|
|
70
|
+
const enc = new TextEncoder();
|
|
71
|
+
|
|
72
|
+
const view = (bytes: Uint8Array): DataView => new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
73
|
+
function concat(parts: Uint8Array[]): Uint8Array {
|
|
74
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); let at = 0;
|
|
75
|
+
for (const p of parts) { out.set(p, at); at += p.length; }
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
function header(length: number, requestId: number, responseTo: number, opcode: number): Uint8Array {
|
|
79
|
+
const out = new Uint8Array(HEADER), v = view(out);
|
|
80
|
+
v.setInt32(0, length, true); v.setInt32(4, requestId, true); v.setInt32(8, responseTo, true); v.setInt32(12, opcode, true);
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** An OP_MSG message carrying one document as its body section. */
|
|
85
|
+
export function encodeMsg(codec: MongoCodec, document: MongoDocument, requestId: number, responseTo: number, flags = 0): Uint8Array {
|
|
86
|
+
const body = codec.serialize(document), bits = new Uint8Array(5);
|
|
87
|
+
view(bits).setUint32(0, flags, true);
|
|
88
|
+
return concat([header(HEADER + 4 + 1 + body.length, requestId, responseTo, MongoOp.MSG), bits, body]);
|
|
89
|
+
}
|
|
90
|
+
/** The command of an OP_MSG message: its body section, with each document sequence as the field it names. */
|
|
91
|
+
export function decodeMsg(codec: MongoCodec, message: Uint8Array): { flags: number; document: MongoDocument } {
|
|
92
|
+
const v = view(message), flags = v.getUint32(HEADER, true), end = message.length - (flags & MongoMsgFlag.CHECKSUM_PRESENT ? 4 : 0);
|
|
93
|
+
let document: MongoDocument | undefined; const sequences: Record<string, MongoDocument[]> = {};
|
|
94
|
+
for (let at = HEADER + 4; at < end;) {
|
|
95
|
+
const kind = message[at]!; at += 1;
|
|
96
|
+
if (kind === 0) {
|
|
97
|
+
const size = v.getInt32(at, true);
|
|
98
|
+
if (size < 5 || at + size > end) throw new Error('OP_MSG body section runs past the message');
|
|
99
|
+
document = codec.deserialize(message.subarray(at, at + size)); at += size;
|
|
100
|
+
} else if (kind === 1) {
|
|
101
|
+
const size = v.getInt32(at, true), stop = at + size;
|
|
102
|
+
if (size < 4 || stop > end) throw new Error('OP_MSG document sequence runs past the message');
|
|
103
|
+
const nameEnd = message.indexOf(0, at + 4);
|
|
104
|
+
if (nameEnd < 0 || nameEnd >= stop) throw new Error('OP_MSG document sequence names no field');
|
|
105
|
+
const name = dec.decode(message.subarray(at + 4, nameEnd)), documents: MongoDocument[] = [];
|
|
106
|
+
for (let cursor = nameEnd + 1; cursor < stop;) {
|
|
107
|
+
const length = v.getInt32(cursor, true);
|
|
108
|
+
if (length < 5 || cursor + length > stop) throw new Error('OP_MSG document sequence holds a document that runs past it');
|
|
109
|
+
documents.push(codec.deserialize(message.subarray(cursor, cursor + length))); cursor += length;
|
|
110
|
+
}
|
|
111
|
+
sequences[name] = documents; at = stop;
|
|
112
|
+
} else throw new Error(`OP_MSG section kind ${kind} is unknown`);
|
|
113
|
+
}
|
|
114
|
+
if (!document) throw new Error('OP_MSG carries no body section');
|
|
115
|
+
return { flags, document: { ...document, ...sequences } };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** MongoDB's wire, served: each command document answered by the pack, in arrival order, on one connection. */
|
|
119
|
+
export function createMongoStream<Session>(options: MongoStreamOptions<Session>): TwinStream {
|
|
120
|
+
const limit = options.maxMessageSizeBytes ?? 48_000_000;
|
|
121
|
+
let nextResponse = 0;
|
|
122
|
+
return (sink: TwinStreamSink, peer: string) => {
|
|
123
|
+
const session = options.open(peer);
|
|
124
|
+
let buffer: Uint8Array = new Uint8Array(0);
|
|
125
|
+
let closed = false;
|
|
126
|
+
let chain: Promise<void> = Promise.resolve();
|
|
127
|
+
// A reply being held or repeated (a timed reply): its timer, and the messages that arrived meanwhile. A
|
|
128
|
+
// connection serves one command at a time, so nothing else is answered until a held reply is sent, and nothing
|
|
129
|
+
// at all while a repeating one runs (recording:hello-awaitable-exhaust-then-command.json: a ping sent into the
|
|
130
|
+
// stream has no reply). The timer is not work: `settled` does not wait for it, it does not keep the process
|
|
131
|
+
// alive (unref), and a close from either side clears it at once.
|
|
132
|
+
let held: ReturnType<typeof setTimeout> | undefined;
|
|
133
|
+
const parked: Uint8Array[] = [];
|
|
134
|
+
const release = (): void => { if (held !== undefined) { clearTimeout(held); held = undefined; } parked.length = 0; };
|
|
135
|
+
const hold = (ms: number, then: () => void): void => {
|
|
136
|
+
held = setTimeout(() => { held = undefined; if (!closed) then(); }, Math.max(0, ms));
|
|
137
|
+
(held as { unref?: () => void }).unref?.();
|
|
138
|
+
};
|
|
139
|
+
const end = (reason: string): void => {
|
|
140
|
+
if (closed) return;
|
|
141
|
+
closed = true; release();
|
|
142
|
+
(options.ended ?? ((why, from) => console.error(`mongodb wire: ended the connection from ${from}: ${why}`)))(reason, peer);
|
|
143
|
+
options.close?.(session); sink.end();
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const answer = async (message: Uint8Array): Promise<void> => {
|
|
147
|
+
const v = view(message), requestId = v.getInt32(4, true), opcode = v.getInt32(12, true);
|
|
148
|
+
if (opcode === MongoOp.MSG) {
|
|
149
|
+
const { flags, document } = decodeMsg(options.codec, message);
|
|
150
|
+
const database = typeof document.$db === 'string' ? document.$db : 'admin';
|
|
151
|
+
const exhaustAllowed = (flags & MongoMsgFlag.EXHAUST_ALLOWED) !== 0;
|
|
152
|
+
const answered = await options.command(session, database, document, { exhaustAllowed });
|
|
153
|
+
// the sender waits for no reply to a message it marked moreToCome
|
|
154
|
+
if (flags & MongoMsgFlag.MORE_TO_COME) return;
|
|
155
|
+
if (!isTimed(answered)) { sink.write(encodeMsg(options.codec, answered, ++nextResponse, requestId)); return; }
|
|
156
|
+
if (answered.everyMs === undefined || !exhaustAllowed) {
|
|
157
|
+
// held once: one reply after the wait, with no flag; then what arrived meanwhile is answered in order
|
|
158
|
+
// source: recording:hello-awaitable-no-exhaust.json "received"
|
|
159
|
+
hold(answered.afterMs, () => {
|
|
160
|
+
sink.write(encodeMsg(options.codec, answered.reply, ++nextResponse, requestId));
|
|
161
|
+
for (const message of parked.splice(0)) take(message);
|
|
162
|
+
});
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
// repeated: each reply marked moreToCome; the first answers the request, each later one answers this
|
|
166
|
+
// wire's own previous reply, until the peer closes
|
|
167
|
+
// source: recording:hello-awaitable-exhaust.json "received"
|
|
168
|
+
let answers = requestId;
|
|
169
|
+
const again = (): void => {
|
|
170
|
+
const id = ++nextResponse;
|
|
171
|
+
sink.write(encodeMsg(options.codec, answered.reply, id, answers, MongoMsgFlag.MORE_TO_COME));
|
|
172
|
+
answers = id;
|
|
173
|
+
hold(answered.everyMs!, again);
|
|
174
|
+
};
|
|
175
|
+
// source: recording:hello-stale-process-exhaust.json "received" (the first at once, the rest at the wait)
|
|
176
|
+
hold(answered.afterMs, again);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (opcode === MongoOp.QUERY) {
|
|
180
|
+
// the legacy form a driver's first hello may take: a query on `<database>.$cmd` whose document is the command
|
|
181
|
+
const nameEnd = message.indexOf(0, HEADER + 4);
|
|
182
|
+
if (nameEnd < 0) throw new Error('OP_QUERY names no collection');
|
|
183
|
+
const namespace = dec.decode(message.subarray(HEADER + 4, nameEnd));
|
|
184
|
+
if (!namespace.endsWith('.$cmd')) throw new Error(`OP_QUERY on ${namespace}: only a command on <database>.$cmd is served`);
|
|
185
|
+
const command = options.codec.deserialize(message.subarray(nameEnd + 1 + 8));
|
|
186
|
+
// the legacy form has no flags of OP_MSG's: nothing is held or repeated for it
|
|
187
|
+
const legacy = await options.command(session, namespace.slice(0, -'.$cmd'.length), command, { exhaustAllowed: false });
|
|
188
|
+
const reply = options.codec.serialize(isTimed(legacy) ? legacy.reply : legacy);
|
|
189
|
+
// OP_REPLY: responseFlags (AwaitCapable), cursorID 0, startingFrom 0, numberReturned 1, then the document
|
|
190
|
+
const fixed = new Uint8Array(20), f = view(fixed);
|
|
191
|
+
f.setInt32(0, 8, true); f.setInt32(16, 1, true);
|
|
192
|
+
sink.write(concat([header(HEADER + 20 + reply.length, ++nextResponse, requestId, MongoOp.REPLY), fixed, reply]));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
throw new Error(`opcode ${opcode} is not served`);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// One message, in arrival order: parked while a reply is held, answered otherwise.
|
|
199
|
+
const take = (message: Uint8Array): void => {
|
|
200
|
+
chain = chain.then(() => (closed ? undefined : held !== undefined ? void parked.push(message) : answer(message))).catch((error) => end(error instanceof Error ? error.message : String(error)));
|
|
201
|
+
};
|
|
202
|
+
return {
|
|
203
|
+
data(chunk: Uint8Array): void {
|
|
204
|
+
if (closed) return;
|
|
205
|
+
buffer = buffer.length ? concat([buffer, chunk]) : chunk;
|
|
206
|
+
while (buffer.length >= 4) {
|
|
207
|
+
const length = view(buffer).getInt32(0, true);
|
|
208
|
+
// where the citation stops: what the vendor's server does with a malformed or oversized message is not
|
|
209
|
+
// read; this ends the connection, which no driver's well-formed message reaches
|
|
210
|
+
if (length < HEADER || length > limit) { end(`a message of ${length} bytes (accepted: ${HEADER} to ${limit})`); return; }
|
|
211
|
+
if (buffer.length < length) break;
|
|
212
|
+
const message = buffer.subarray(0, length); buffer = buffer.subarray(length);
|
|
213
|
+
take(message.slice());
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
settled: () => chain,
|
|
217
|
+
close: () => { if (!closed) { closed = true; release(); options.close?.(session); } },
|
|
218
|
+
};
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The bytes of a C string, for a client's OP_QUERY. */
|
|
223
|
+
export const cstring = (text: string): Uint8Array => concat([enc.encode(text), new Uint8Array(1)]);
|
package/src/observe.ts
CHANGED
package/src/pack-fetch.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parseExactJson } from './exact-json.ts';
|
|
2
|
+
import { redisServiceTimes } from './redis/service-times.ts';
|
|
2
3
|
import { readHostPort } from './host-port.ts';
|
|
3
4
|
// A Protocol 3 pack's whole HTTP surface, served by the kernel from the pack's declarations (docs/contributing/
|
|
4
5
|
// architecture.md, "The pack contract: the kernel's root"): the discovery door (`GET /twin`, the manifest's
|
|
@@ -11,7 +12,7 @@ import { ORIGINAL_PATH_HEADER } from './sigv4.ts';
|
|
|
11
12
|
import { placedCookies, TWIN_PREFIX_HEADER } from './twin-fetch.ts';
|
|
12
13
|
import { PREFIX_PARAMS_HEADER } from './derived.ts';
|
|
13
14
|
import { withHeaders } from './with-headers.ts';
|
|
14
|
-
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';
|
|
15
16
|
import { compileSurface, createDerivedFetch, matchOperation, pathParams, type DerivedFetch, type DerivedOperation, type DerivedOwner, type DerivedSurface, type Workspace, type BoardFrame } from './derived.ts';
|
|
16
17
|
import { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER, runAsVendorMove } from './request-scope.ts';
|
|
17
18
|
import { parseScenarioDocument, ScenarioEngine, ScenarioError, statefulTwinManifest, twinManifest, type PackScenarioAdapter } from './scenario.ts';
|
|
@@ -26,7 +27,7 @@ import { hostRules } from '../host-routing.cjs';
|
|
|
26
27
|
|
|
27
28
|
/** A descriptor's host rules, as data. */
|
|
28
29
|
type PackDescriptorHosts = ReadonlyArray<HostRule> | undefined;
|
|
29
|
-
import { socketUpgrade, type SocketEngine } from './sockets.ts';
|
|
30
|
+
import { expireSocketWaits, socketUpgrade, type SocketEngine } from './sockets.ts';
|
|
30
31
|
import { liveIngest } from './live/ingest.ts';
|
|
31
32
|
import type { LiveEngine } from './live/types.ts';
|
|
32
33
|
import { readResourceBlob, removeResourceBlob, writeResourceBlob } from './resource-blob.ts';
|
|
@@ -143,6 +144,29 @@ function hostMatcher(pattern: string): (host: string) => Match | undefined {
|
|
|
143
144
|
/** The host a request is for: the vendor host the World's injector names, else its own. */
|
|
144
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 ?? '';
|
|
145
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
|
+
|
|
146
170
|
/** The answer for a path no operation, door or screen serves, and for an operation nothing models: the manifest's
|
|
147
171
|
* `gap`, its `{method}` and `{path}` filled, else the manifest's not-found refusal. */
|
|
148
172
|
function gapAnswer(m: DerivedManifest, request: Request, otherMethod = false): Response {
|
|
@@ -194,6 +218,16 @@ async function encoded(m: DerivedManifest, request: Request, res: Response): Pro
|
|
|
194
218
|
/** A door's path as it is served and listed: under `/_twin/`, whether the manifest writes the prefix or not. */
|
|
195
219
|
const doorPath = (path: string): string => (path.startsWith('/_twin/') ? path : `/_twin${path.startsWith('/') ? '' : '/'}${path}`);
|
|
196
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
|
+
|
|
197
231
|
export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}): DerivedFetch {
|
|
198
232
|
const m = parts.manifest;
|
|
199
233
|
const scope: CoreScope = {
|
|
@@ -310,8 +344,8 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
|
|
|
310
344
|
} catch { return false; }
|
|
311
345
|
};
|
|
312
346
|
// a context the kernel opens for what no surface operation names: a door, a screen, the clock, the board
|
|
313
|
-
const open = (request: Request, id: string, method: string, path: string, params: Record<string, string
|
|
314
|
-
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);
|
|
315
349
|
const advanceClock = async (request: Request): Promise<void> => {
|
|
316
350
|
if (parts.clock) await runAsVendorMove(async () => parts.clock!(await open(new Request(request.url, m.tenant ? { headers: request.headers } : {}), 'clock', 'POST', '/_twin/clock', {})));
|
|
317
351
|
};
|
|
@@ -408,7 +442,7 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
|
|
|
408
442
|
const asked = options.readOnly && !isReadOnlyRequest(there) ? withHeaders(there, new Headers([...there.headers, [READ_ONLY_REQUEST_HEADER, '1']])) : there;
|
|
409
443
|
if (method === 'POST' && ((options.readOnly ?? false) || isReadOnlyRequest(asked))) return refusedReadOnly();
|
|
410
444
|
if (parts.clock) await runAsVendorMove(async () => parts.clock!(await open(new Request(asked.url, m.tenant ? { headers: asked.headers } : {}), 'clock', 'POST', '/_twin/clock', {})));
|
|
411
|
-
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));
|
|
412
446
|
};
|
|
413
447
|
/** A request as the kernel reads it: the headers only the kernel names taken off, its body decoded (undefined when it
|
|
414
448
|
* does not decode as its Content-Encoding says). */
|
|
@@ -430,7 +464,9 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
|
|
|
430
464
|
const readable = await prepared(arrived);
|
|
431
465
|
return readable instanceof Response ? readable : readable ? atContentHost(readable) : undecodable(arrived);
|
|
432
466
|
};
|
|
433
|
-
const fetch = async (
|
|
467
|
+
const fetch = async (received: Request): Promise<Response> => {
|
|
468
|
+
const arrived = m.mergeSlashes ? slashesMerged(received) : received;
|
|
469
|
+
redisServiceTimes.lap('1 request made by the stream');
|
|
434
470
|
// the discovery door is the pack's own, whichever lane would take its path
|
|
435
471
|
const asked = new URL(arrived.url).pathname.replace(/\/+$/, '') || '/';
|
|
436
472
|
const discovery = arrived.method === 'GET' && m.discovery !== undefined && (asked === '/twin' || asked === '/twin/scenario');
|
|
@@ -460,6 +496,7 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
|
|
|
460
496
|
const url = new URL(request.url);
|
|
461
497
|
const path = url.pathname.replace(/\/+$/, '') || '/';
|
|
462
498
|
const readOnly = (options.readOnly ?? false) || isReadOnlyRequest(request);
|
|
499
|
+
redisServiceTimes.lap('2 content and lane routes, decoding, routing');
|
|
463
500
|
if (request.method === 'GET' && path === '/twin' && m.discovery) {
|
|
464
501
|
const d = m.discovery;
|
|
465
502
|
const discoveryDoors = [...(m.doors ?? []), ...(m.machines ? [{ id: 'machine-pool', method: 'POST', path: '/_twin/machine-pool', note: 'Enroll the World execution pool: { kind: light | docker | none | fake, images?: { image: { context, command?, runtime? } } }.' }] : [])];
|
|
@@ -496,6 +533,8 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
|
|
|
496
533
|
// the vendor's own moves, not the caller's: its context carries none of the request's headers (a Stripe-Account
|
|
497
534
|
// header would make a payout time makes the caller's), but a tenant's, when the vendor keeps tenants apart
|
|
498
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);
|
|
499
538
|
if (path.startsWith('/_twin/')) {
|
|
500
539
|
// the World's machine pool, enrolled by the kernel for a vendor that runs images (machines.ts)
|
|
501
540
|
if (m.machines && path === '/_twin/machine-pool' && request.method === 'POST') {
|
|
@@ -544,8 +583,10 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
|
|
|
544
583
|
?? (apiPath ? undefined : hits.find((x) => x.onHost) ?? (addressed ? undefined : hits.find((x) => !rooted(x))
|
|
545
584
|
?? (placed ? hits.find((x) => x.s.decl.kind === 'workspace') : undefined)));
|
|
546
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}` } });
|
|
547
588
|
if (readOnly && request.method !== 'GET' && request.method !== 'HEAD') return refusedReadOnly();
|
|
548
|
-
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));
|
|
549
590
|
}
|
|
550
591
|
for (const route of m.machineRoutes ?? []) {
|
|
551
592
|
const host = request.headers.get('x-volter-twin-original-host') ?? new URL(request.url).hostname;
|
|
@@ -562,12 +603,19 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
|
|
|
562
603
|
if (!parts.around) return encoded(m, request, await decided(request));
|
|
563
604
|
// The front reads a context before forwarding (possibly with new headers). Materialize a transport body before
|
|
564
605
|
// those independent reads, rather than chaining tees of the incoming stream through the front and the scenario.
|
|
606
|
+
redisServiceTimes.lap('3 doors, screens and machine routes passed');
|
|
565
607
|
const bytes = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
|
|
566
608
|
const buffered = new Request(request.url, { method: request.method, headers: request.headers, signal: request.signal, ...(bytes ? { body: bytes } : {}) });
|
|
567
609
|
const matched = matchOperation(routes, buffered.method, url.pathname, url.searchParams, buffered.headers);
|
|
568
610
|
const operation = matched?.operation ?? ({ id: 'unmatched', method: buffered.method, path: url.pathname, class: buffered.method === 'GET' ? 'retrieve' : 'action' } as DerivedOperation);
|
|
611
|
+
redisServiceTimes.lap('4 body buffered, operation matched');
|
|
569
612
|
const ctx = await contextFor(m, { request: buffered.clone(), operation, params: matched?.params ?? pathParams(routes, url.pathname) }, scope);
|
|
570
|
-
|
|
613
|
+
redisServiceTimes.lap('5 the around context opened');
|
|
614
|
+
const answered = await parts.around(ctx, (rewritten) => decided(rewritten ?? buffered));
|
|
615
|
+
redisServiceTimes.lap('6d the handler, and the around after it');
|
|
616
|
+
const sent = await encoded(m, buffered, answered);
|
|
617
|
+
redisServiceTimes.lap('7 answer encoded');
|
|
618
|
+
return sent;
|
|
571
619
|
};
|
|
572
620
|
// the request's id is the correlation of every entry it appends (runtime contract D3), minted here, always: it is the
|
|
573
621
|
// group a perform sends as one call (the real-system adapters, "Perform"). Under `auto` the group is performed once
|
|
@@ -612,7 +660,7 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
|
|
|
612
660
|
// request that opened it (operation `socket:<id>`)
|
|
613
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}`);
|
|
614
662
|
const ownUpgrade = m.sockets?.length ? socketUpgrade(m.service, options.root, m.sockets, parts.sockets!,
|
|
615
|
-
(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,
|
|
616
664
|
// a turn a session speaks (a voice agent's reply) is the World's scenario's, as a turn over HTTP is
|
|
617
665
|
async (asked) => engineOf()?.serve(asked)) : undefined;
|
|
618
666
|
const upgrade = laneUpgrades(parts.lanes ?? {}, ownUpgrade);
|
|
@@ -743,8 +791,16 @@ export function createVendorFetch(manifest: VendorManifest, lanes: Record<string
|
|
|
743
791
|
const ingest = Object.values(lanes).find((l) => l.fetch.ingest)?.fetch.ingest;
|
|
744
792
|
const lane = laneRouter(manifest.vendor, manifest.lanes, lanes);
|
|
745
793
|
return Object.assign(async (request: Request): Promise<Response> => {
|
|
746
|
-
for (const part of Object.values(lanes)) await (part.fetch as typeof part.fetch & { advanceClock?: (request: Request) => Promise<void> }).advanceClock?.(request);
|
|
747
794
|
const path = new URL(request.url).pathname.replace(/\/+$/, '') || '/';
|
|
795
|
+
// the discovery door is answered before any lane's clock moves, as a pack's own is (createPackFetch): reading what
|
|
796
|
+
// the twin is moves no vendor clock
|
|
797
|
+
if (request.method === 'GET' && path === '/twin' && manifest.discovery) {
|
|
798
|
+
// the vendor's screens are its lanes' (a dashboard drawn by one lane is the vendor's)
|
|
799
|
+
const screens = lanesWorkspaces(lanes);
|
|
800
|
+
const laneDiscovery = await laneDoors(lanes, request, '/twin');
|
|
801
|
+
return Response.json({ ...statefulTwinManifest({ vendor: manifest.vendor, ...manifest.discovery }), ...(screens.length ? { screens } : {}), ...(Object.keys(laneDiscovery).length ? { lanes: laneDiscovery } : {}) });
|
|
802
|
+
}
|
|
803
|
+
for (const part of Object.values(lanes)) await (part.fetch as typeof part.fetch & { advanceClock?: (request: Request) => Promise<void> }).advanceClock?.(request);
|
|
748
804
|
// the vendor's board is the frames of the lanes that lay one out, each drawn when any lane's screen takes its url
|
|
749
805
|
if (request.method === 'GET' && path === '/twin/board') {
|
|
750
806
|
const frames: BoardFrame[] = [];
|
|
@@ -758,12 +814,6 @@ export function createVendorFetch(manifest: VendorManifest, lanes: Record<string
|
|
|
758
814
|
const draws = (url: string): boolean => Object.values(lanes).some((l) => l.fetch.draws?.(url) ?? false);
|
|
759
815
|
return Response.json({ frames: frames.map((f) => ({ ...f, drawn: draws(f.url) })) });
|
|
760
816
|
}
|
|
761
|
-
if (request.method === 'GET' && path === '/twin' && manifest.discovery) {
|
|
762
|
-
// the vendor's screens are its lanes' (a dashboard drawn by one lane is the vendor's)
|
|
763
|
-
const screens = lanesWorkspaces(lanes);
|
|
764
|
-
const laneDiscovery = await laneDoors(lanes, request, '/twin');
|
|
765
|
-
return Response.json({ ...statefulTwinManifest({ vendor: manifest.vendor, ...manifest.discovery }), ...(screens.length ? { screens } : {}), ...(Object.keys(laneDiscovery).length ? { lanes: laneDiscovery } : {}) });
|
|
766
|
-
}
|
|
767
817
|
if (request.method === 'GET' && path === '/twin/scenario') {
|
|
768
818
|
const laneScenarios = await laneDoors(lanes, request, '/twin/scenario');
|
|
769
819
|
if (Object.keys(laneScenarios).length) return Response.json({ vendor: manifest.vendor, lanes: laneScenarios });
|