@volter/twin-world 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/package.json +61 -0
- package/src/browser-proxy-cli.ts +43 -0
- package/src/cli.ts +226 -0
- package/src/configs.ts +24 -0
- package/src/host-cli.ts +86 -0
- package/src/host-worker.ts +22 -0
- package/src/host.ts +142 -0
- package/src/index.ts +42 -0
- package/src/prerequisites.ts +109 -0
- package/src/proxy-daemon.ts +21 -0
- package/src/redirect-proxy.ts +437 -0
- package/src/runtime.ts +1888 -0
- package/src/schema.ts +471 -0
package/src/schema.ts
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
export type WorldServiceType = 'twin' | 'process' | 'external';
|
|
2
|
+
export type WorldMode = 'local' | 'share' | 'sealed';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Process model for the world's twins — a DIAL, not a binary (see src/host.ts):
|
|
6
|
+
* - 'process' (default): one OS process per service — the existing spawn path. Strongest
|
|
7
|
+
* isolation; the oracle, and the only choice for share/sealed/hosted worlds
|
|
8
|
+
* (enforced by `upWorld` — see runtime.ts, not just documented here; TWIN-67).
|
|
9
|
+
* - 'colocated': every service declaring `colocate` runs in ONE `volter-world-host` child
|
|
10
|
+
* sharing a single event loop + heap (host 'shared' mode). Lightest.
|
|
11
|
+
* - 'worker' : same single host child, but one Worker thread per twin — own event loop +
|
|
12
|
+
* heap, independently isolated (a crashed twin stays dead — never respawned),
|
|
13
|
+
* still one OS process to the orchestrator.
|
|
14
|
+
* Services without `colocate` (and all 'external' services) always use the spawn path.
|
|
15
|
+
*/
|
|
16
|
+
export type WorldIsolation = 'process' | 'colocated' | 'worker';
|
|
17
|
+
export type WorldShareProvider = 'cloudflare-quick' | 'command';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* EXTERNAL (self-managed) service type.
|
|
21
|
+
*
|
|
22
|
+
* Most services in a world are owned by us: we spawn `command`, assign a PORT, and inject a
|
|
23
|
+
* `*_URL` (types 'twin' and 'process'). An EXTERNAL service is the opposite: its lifecycle is
|
|
24
|
+
* DELEGATED to an external tool that runs the REAL software (e.g. Supabase's `supabase start`,
|
|
25
|
+
* LocalStack, MinIO, Mailpit). We do NOT assign it a port — the external tool manages its own
|
|
26
|
+
* ports — and its connection URL + keys are DISCOVERED from the tool's own output, then injected
|
|
27
|
+
* into the world env exactly like a twin's `injectEnv`.
|
|
28
|
+
*
|
|
29
|
+
* The runtime is deliberately VENDOR-AGNOSTIC: it never branches on a tool's identity. Everything
|
|
30
|
+
* the runtime needs (how to start, how to probe readiness, how to read connection info, how to
|
|
31
|
+
* stop) is declared in the config below. Supabase is just an EXAMPLE config (see
|
|
32
|
+
* `configs/supabase-world.json`, which mixes a Supabase external service with stripe/github twins);
|
|
33
|
+
* there is zero Supabase-specific code in `runtime.ts`.
|
|
34
|
+
*
|
|
35
|
+
* Lifecycle on world `up`:
|
|
36
|
+
* 1. run `up` (start the external stack),
|
|
37
|
+
* 2. wait for `readyWhen` (Docker-backed externals take seconds to boot),
|
|
38
|
+
* 3. run `status` (or reuse `up` output) to obtain connection info,
|
|
39
|
+
* 4. apply `discover` mappings → inject env vars into the world env (and world.env/instance.json).
|
|
40
|
+
* On world teardown (`down`): run `down` for each external service.
|
|
41
|
+
*
|
|
42
|
+
* Robustness contract: if the external tool is missing on PATH, or `up`/`status` fail, or
|
|
43
|
+
* readiness times out, world `up` fails LOUDLY with the captured output — never a fake success.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/** Source of the text a `discover` mapping reads from: the `status` output (default) or `up` output. */
|
|
47
|
+
export type WorldExternalDiscoverSource = 'status' | 'up';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* One declarative env-var extraction. Exactly one of `jsonPath` or `pattern` must be set.
|
|
51
|
+
* - `jsonPath`: dotted path into the JSON the source command printed (e.g. `API_URL`, `db.url`,
|
|
52
|
+
* `services.0.endpoint`). Numeric segments index into arrays.
|
|
53
|
+
* - `pattern`: a regex applied to the raw source text; capture group `value` (or group 1) is used.
|
|
54
|
+
* The resolved value is injected as the env var named by `as`.
|
|
55
|
+
*/
|
|
56
|
+
export type WorldExternalDiscover = {
|
|
57
|
+
as: string;
|
|
58
|
+
source?: WorldExternalDiscoverSource;
|
|
59
|
+
jsonPath?: string;
|
|
60
|
+
pattern?: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Optional readiness probe, polled after `up` until it passes or `timeoutMs` elapses. Exactly one of:
|
|
65
|
+
* - `command`/`args`: a command that must exit 0,
|
|
66
|
+
* - `httpUrl`: an HTTP(S) URL that must return a 2xx/3xx response,
|
|
67
|
+
* - `stdoutMatch`: a regex that must appear in the `up` command's captured stdout/stderr log.
|
|
68
|
+
*/
|
|
69
|
+
export type WorldExternalReadyWhen = {
|
|
70
|
+
command?: string;
|
|
71
|
+
args?: string[];
|
|
72
|
+
httpUrl?: string;
|
|
73
|
+
stdoutMatch?: string;
|
|
74
|
+
timeoutMs?: number;
|
|
75
|
+
intervalMs?: number;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export type WorldExternalServiceConfig = {
|
|
79
|
+
up: string[];
|
|
80
|
+
status?: string[];
|
|
81
|
+
down: string[];
|
|
82
|
+
discover?: WorldExternalDiscover[];
|
|
83
|
+
readyWhen?: WorldExternalReadyWhen;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export type WorldShareServiceConfig = {
|
|
87
|
+
id: string;
|
|
88
|
+
verifyPath?: string | false;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export type WorldShareConfig = {
|
|
92
|
+
provider?: WorldShareProvider;
|
|
93
|
+
/** Custom tunnel command (provider 'command'). `{url}` in args is replaced with the
|
|
94
|
+
* service's local URL. When set, provider defaults to 'command'. */
|
|
95
|
+
command?: string;
|
|
96
|
+
args?: string[];
|
|
97
|
+
services: WorldShareServiceConfig[];
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export type WorldServiceConfig = {
|
|
101
|
+
id: string;
|
|
102
|
+
type?: WorldServiceType;
|
|
103
|
+
/** Required for 'twin'/'process' services. Omitted for 'external' (the tool's `up`/`down` hold the commands). */
|
|
104
|
+
command?: string;
|
|
105
|
+
args?: string[];
|
|
106
|
+
/** Optional working directory for this process. Relative paths resolve from the world root. */
|
|
107
|
+
cwd?: string;
|
|
108
|
+
port?: number | 'auto';
|
|
109
|
+
/**
|
|
110
|
+
* Required whenever `port` is a literal number (schema-enforced, TWIN-66): stable configs are
|
|
111
|
+
* meant to be instance-agnostic (see WORLD.md's config/instance split), so a numeric port is the
|
|
112
|
+
* declared EXCEPTION, not the norm — it must record WHY the port can't be `'auto'`-allocated
|
|
113
|
+
* (e.g. a consumer needs a pre-known issuer/callback URL baked in elsewhere). Ignored/forbidden
|
|
114
|
+
* when `port` is `'auto'` or unset.
|
|
115
|
+
*/
|
|
116
|
+
portReason?: string;
|
|
117
|
+
env?: Record<string, string>;
|
|
118
|
+
/**
|
|
119
|
+
* Marks this service as world infrastructure rather than an app/vendor process.
|
|
120
|
+
*
|
|
121
|
+
* Control-plane services still receive the world's declared/injected service env, but they do not
|
|
122
|
+
* inherit app-side egress machinery (`NODE_OPTIONS` injector preload or ambient HTTP(S) proxy env).
|
|
123
|
+
* Use this for helper proxies, seeders, local asset servers, or orchestration daemons that must talk
|
|
124
|
+
* to loopback services directly while presenting a disguised world to the app/user.
|
|
125
|
+
*/
|
|
126
|
+
controlPlane?: boolean;
|
|
127
|
+
injectEnv?: string;
|
|
128
|
+
/**
|
|
129
|
+
* Additional env vars exported into the generated world env after this service starts.
|
|
130
|
+
* Templates are resolved against the owned loopback service:
|
|
131
|
+
* - `${url}` / `${httpUrl}` → http://127.0.0.1:<port>
|
|
132
|
+
* - `${host}` → 127.0.0.1
|
|
133
|
+
* - `${port}` → the allocated port
|
|
134
|
+
* This is the process-service equivalent of external.discover for services whose client-facing
|
|
135
|
+
* endpoint is not the default http URL, e.g. a real local LiveKit server needing ws://.
|
|
136
|
+
*/
|
|
137
|
+
injectEnvTemplates?: Record<string, string>;
|
|
138
|
+
rootArg?: string | false;
|
|
139
|
+
portArg?: string | false;
|
|
140
|
+
/**
|
|
141
|
+
* Optional per-CLI redirect: env vars exported into the world env so the vendor's REAL CLI
|
|
142
|
+
* (`aws`, `gh`, `sentry-cli`, …) talks to THIS service instead of the real vendor. The literal
|
|
143
|
+
* `${url}` is substituted with the service's resolved URL — e.g. `{ "AWS_ENDPOINT_URL": "${url}" }`.
|
|
144
|
+
* Honored by `volter-world env/run/activate` (it flows into the world env like `injectEnv`). The
|
|
145
|
+
* pure-env path for CLIs that accept an http endpoint; CLIs needing https use the proxy (Phase 2).
|
|
146
|
+
* See docs/WORLD_ACTIVATE.md.
|
|
147
|
+
*/
|
|
148
|
+
cliRedirect?: Record<string, string>;
|
|
149
|
+
/**
|
|
150
|
+
* Readiness probe for a 'process'/'twin' service. The default (or `'tcp'`) waits only until the
|
|
151
|
+
* port accepts TCP connections — but a process can bind its port BEFORE it is actually serving
|
|
152
|
+
* (Vite, an HTTP app warming up a DB pool). Pass a probe object to make `up` wait until the
|
|
153
|
+
* service truly serves, so `up` returning means "ready" (no post-up re-polling needed):
|
|
154
|
+
* - `{ httpUrl }` — poll until 2xx/3xx. `${url}`/`${host}`/`${port}` resolve to the allocated port.
|
|
155
|
+
* - `{ command, args }` — poll until the command exits 0 (runs with the service env, incl. `PORT`).
|
|
156
|
+
* - `{ stdoutMatch }` — poll until a regex matches the service's stdout/stderr log.
|
|
157
|
+
* 'external' services use `external.readyWhen` instead.
|
|
158
|
+
*/
|
|
159
|
+
ready?: 'tcp' | WorldExternalReadyWhen;
|
|
160
|
+
/**
|
|
161
|
+
* Extra Node preloads for a 'process'/'twin' service, appended as `--require <module>` to the
|
|
162
|
+
* world's NODE_OPTIONS (which already loads `@volter/twin/inject`). Use this to add an app-local
|
|
163
|
+
* preload (e.g. an in-process sandbox) WITHOUT losing the injector. Setting `service.env.NODE_OPTIONS`
|
|
164
|
+
* directly REPLACES the world default (dropping the injector) — only do that if you truly mean to.
|
|
165
|
+
*/
|
|
166
|
+
preload?: string[];
|
|
167
|
+
/** Required when `type === 'external'`: the self-managed lifecycle (up/status/down/discover/readyWhen). */
|
|
168
|
+
external?: WorldExternalServiceConfig;
|
|
169
|
+
/**
|
|
170
|
+
* Names this twin's in-process server factory so it can run CO-LOCATED inside the single
|
|
171
|
+
* `volter-world-host` child when the world's isolation is 'colocated'/'worker'. `module` is
|
|
172
|
+
* anything `import()` resolves — a package name (`@volter/twin-stripe`) or a file path
|
|
173
|
+
* (relative paths resolve from the world root); `export` names a
|
|
174
|
+
* `({port,root,readOnly}) => {port,stop}` factory (every pack ships one, e.g.
|
|
175
|
+
* `createStripeTwinServer`). Ignored under 'process' isolation (the default), where
|
|
176
|
+
* `command` is spawned as usual — declare BOTH to let one config serve every dial setting.
|
|
177
|
+
*/
|
|
178
|
+
colocate?: { module: string; export: string };
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export type WorldConfig = {
|
|
182
|
+
id: string;
|
|
183
|
+
description?: string;
|
|
184
|
+
/** Default process model for this world's twins ('process' when omitted). `upWorld`'s
|
|
185
|
+
* `isolation` option overrides it per boot. */
|
|
186
|
+
isolation?: WorldIsolation;
|
|
187
|
+
env?: Record<string, string>;
|
|
188
|
+
services: WorldServiceConfig[];
|
|
189
|
+
share?: WorldShareConfig;
|
|
190
|
+
actors?: Record<string, unknown>;
|
|
191
|
+
fixtures?: Record<string, unknown>;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
export type WorldServiceInstance = {
|
|
195
|
+
id: string;
|
|
196
|
+
type: WorldServiceType;
|
|
197
|
+
command: string[];
|
|
198
|
+
/** Absent for 'external' services — we do not assign them a port. */
|
|
199
|
+
port?: number;
|
|
200
|
+
/** Absent for 'external' services — their URL is discovered into env vars, not assigned by us. */
|
|
201
|
+
url?: string;
|
|
202
|
+
/** 0 for 'external' services — they have no process we own (the external tool manages it). */
|
|
203
|
+
pid: number;
|
|
204
|
+
log: string;
|
|
205
|
+
env: Record<string, string>;
|
|
206
|
+
/**
|
|
207
|
+
* Set only for 'external' services. Records the `down` command so teardown can stop the
|
|
208
|
+
* self-managed stack, plus the cwd it was started from (the marker that the stack was brought up).
|
|
209
|
+
*/
|
|
210
|
+
external?: {
|
|
211
|
+
down: string[];
|
|
212
|
+
cwd: string;
|
|
213
|
+
/** Env discovered during `up` (the injected vars), passed to the `down` command so teardown can
|
|
214
|
+
* reference connection info it needs (e.g. a stop command that takes the discovered URL). */
|
|
215
|
+
discoveredEnv?: Record<string, string>;
|
|
216
|
+
};
|
|
217
|
+
publicUrl?: string;
|
|
218
|
+
publicUrlEphemeral?: boolean;
|
|
219
|
+
publicReady?: boolean;
|
|
220
|
+
publicVerification?: {
|
|
221
|
+
path: string;
|
|
222
|
+
checkedAt: string;
|
|
223
|
+
hostname: string;
|
|
224
|
+
resolvedIp?: string;
|
|
225
|
+
status?: number;
|
|
226
|
+
body?: string;
|
|
227
|
+
};
|
|
228
|
+
tunnel?: {
|
|
229
|
+
provider: 'cloudflare-quick' | 'command';
|
|
230
|
+
pid: number;
|
|
231
|
+
log: string;
|
|
232
|
+
command: string[];
|
|
233
|
+
startedAt: string;
|
|
234
|
+
};
|
|
235
|
+
/**
|
|
236
|
+
* Set for a 'worker'-isolated co-located twin whose host-managed Worker thread crashed and
|
|
237
|
+
* was given up on (never respawned — still discoverable: the port answers nothing, but the
|
|
238
|
+
* dead twin is no longer silent). Not written directly by the runtime process: the co-located
|
|
239
|
+
* host CHILD records it in a sidecar file (`host-gaveup.json`, same instance dir) since it
|
|
240
|
+
* cannot safely rewrite instance.json out from under the parent; `readWorldInstance` merges
|
|
241
|
+
* the sidecar in here so every reader sees it as if it were in instance.json all along.
|
|
242
|
+
*/
|
|
243
|
+
workerGaveUp?: {
|
|
244
|
+
/** ISO timestamp of the give-up. */
|
|
245
|
+
at: string;
|
|
246
|
+
/** Exits observed for this twin under the no-respawn contract (always 1 — field kept for
|
|
247
|
+
* record compatibility with the sidecar/schema shape). */
|
|
248
|
+
exits: number;
|
|
249
|
+
detail: string;
|
|
250
|
+
};
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
export type WorldInstance = {
|
|
254
|
+
name: string;
|
|
255
|
+
config: string;
|
|
256
|
+
configPath: string;
|
|
257
|
+
root: string;
|
|
258
|
+
createdAt: string;
|
|
259
|
+
mode: WorldMode;
|
|
260
|
+
dirs: {
|
|
261
|
+
instance: string;
|
|
262
|
+
logs: string;
|
|
263
|
+
data: string;
|
|
264
|
+
};
|
|
265
|
+
services: Record<string, WorldServiceInstance>;
|
|
266
|
+
env: Record<string, string>;
|
|
267
|
+
envFile: string;
|
|
268
|
+
pidsFile: string;
|
|
269
|
+
actors?: Record<string, unknown>;
|
|
270
|
+
fixtures?: Record<string, unknown>;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
function assertStringArray(value: unknown, what: string, path: string): asserts value is string[] {
|
|
274
|
+
if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== 'string')) {
|
|
275
|
+
throw new Error(`${what} must be a non-empty string array in ${path}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function assertValidRegex(pattern: string, what: string, path: string): void {
|
|
280
|
+
try {
|
|
281
|
+
new RegExp(pattern);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
throw new Error(`${what} has an invalid regex (${error instanceof Error ? error.message : String(error)}) in ${path}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Shared validator for a readiness probe — used by both `external.readyWhen` and a process/twin
|
|
288
|
+
* service's `ready` object. Exactly one of command/httpUrl/stdoutMatch, correct types, and any
|
|
289
|
+
* stdoutMatch regex must compile (so a typo fails at config time, not mid-boot). */
|
|
290
|
+
function assertReadyWhen(ready: unknown, what: string, path: string): void {
|
|
291
|
+
if (!ready || typeof ready !== 'object' || Array.isArray(ready)) {
|
|
292
|
+
throw new Error(`${what} must be an object in ${path}`);
|
|
293
|
+
}
|
|
294
|
+
const r = ready as WorldExternalReadyWhen;
|
|
295
|
+
const probes = [r.command !== undefined, r.httpUrl !== undefined, r.stdoutMatch !== undefined].filter(Boolean).length;
|
|
296
|
+
if (probes !== 1) {
|
|
297
|
+
throw new Error(`${what} must set exactly one of command/httpUrl/stdoutMatch in ${path}`);
|
|
298
|
+
}
|
|
299
|
+
if (r.command !== undefined && typeof r.command !== 'string') throw new Error(`${what}.command must be a string in ${path}`);
|
|
300
|
+
if (r.httpUrl !== undefined && typeof r.httpUrl !== 'string') throw new Error(`${what}.httpUrl must be a string in ${path}`);
|
|
301
|
+
if (r.stdoutMatch !== undefined) {
|
|
302
|
+
if (typeof r.stdoutMatch !== 'string') throw new Error(`${what}.stdoutMatch must be a string in ${path}`);
|
|
303
|
+
assertValidRegex(r.stdoutMatch, `${what}.stdoutMatch`, path);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function assertExternalServiceConfig(service: Partial<WorldServiceConfig>, path: string): void {
|
|
308
|
+
const id = service.id;
|
|
309
|
+
if (service.command !== undefined) {
|
|
310
|
+
throw new Error(`External service "${id}" must not set "command" (use external.up/down) in ${path}`);
|
|
311
|
+
}
|
|
312
|
+
if (service.port !== undefined) {
|
|
313
|
+
throw new Error(`External service "${id}" must not set "port" — the external tool manages its own ports in ${path}`);
|
|
314
|
+
}
|
|
315
|
+
if (service.portReason !== undefined) {
|
|
316
|
+
throw new Error(`External service "${id}" must not set "portReason" — it never sets a literal "port" in ${path}`);
|
|
317
|
+
}
|
|
318
|
+
const external = service.external;
|
|
319
|
+
if (!external || typeof external !== 'object' || Array.isArray(external)) {
|
|
320
|
+
throw new Error(`External service "${id}" must define an "external" object in ${path}`);
|
|
321
|
+
}
|
|
322
|
+
assertStringArray(external.up, `External service "${id}" external.up`, path);
|
|
323
|
+
assertStringArray(external.down, `External service "${id}" external.down`, path);
|
|
324
|
+
if (external.status !== undefined) assertStringArray(external.status, `External service "${id}" external.status`, path);
|
|
325
|
+
if (external.discover !== undefined) {
|
|
326
|
+
if (!Array.isArray(external.discover)) throw new Error(`External service "${id}" external.discover must be an array in ${path}`);
|
|
327
|
+
for (const mapping of external.discover) {
|
|
328
|
+
if (!mapping || typeof mapping !== 'object' || Array.isArray(mapping)) {
|
|
329
|
+
throw new Error(`External service "${id}" discover entries must be objects in ${path}`);
|
|
330
|
+
}
|
|
331
|
+
if (!mapping.as || typeof mapping.as !== 'string') {
|
|
332
|
+
throw new Error(`External service "${id}" discover entry must define string "as" in ${path}`);
|
|
333
|
+
}
|
|
334
|
+
const hasJson = typeof mapping.jsonPath === 'string';
|
|
335
|
+
const hasPattern = typeof mapping.pattern === 'string';
|
|
336
|
+
if (hasJson === hasPattern) {
|
|
337
|
+
throw new Error(`External service "${id}" discover "${mapping.as}" must set exactly one of jsonPath/pattern in ${path}`);
|
|
338
|
+
}
|
|
339
|
+
if (hasPattern) assertValidRegex(mapping.pattern as string, `External service "${id}" discover "${mapping.as}" pattern`, path);
|
|
340
|
+
if (mapping.source !== undefined && mapping.source !== 'status' && mapping.source !== 'up') {
|
|
341
|
+
throw new Error(`External service "${id}" discover "${mapping.as}" source must be "status" or "up" in ${path}`);
|
|
342
|
+
}
|
|
343
|
+
if (mapping.source === 'status' && external.status === undefined && hasJson) {
|
|
344
|
+
// jsonPath against status requires a status command to produce the JSON.
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (external.status === undefined && external.discover.some((m) => (m.source ?? 'status') === 'status')) {
|
|
348
|
+
throw new Error(`External service "${id}" has discover entries reading "status" but no external.status command in ${path}`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (external.readyWhen !== undefined) {
|
|
352
|
+
assertReadyWhen(external.readyWhen, `External service "${id}" external.readyWhen`, path);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function assertWorldConfig(value: unknown, path: string): WorldConfig {
|
|
357
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
358
|
+
throw new Error(`World config must be an object: ${path}`);
|
|
359
|
+
}
|
|
360
|
+
const config = value as Partial<WorldConfig>;
|
|
361
|
+
if (!config.id || typeof config.id !== 'string') {
|
|
362
|
+
throw new Error(`World config must define string id: ${path}`);
|
|
363
|
+
}
|
|
364
|
+
if (!Array.isArray(config.services)) {
|
|
365
|
+
throw new Error(`World config must define services[]: ${path}`);
|
|
366
|
+
}
|
|
367
|
+
if (config.isolation !== undefined && config.isolation !== 'process' && config.isolation !== 'colocated' && config.isolation !== 'worker') {
|
|
368
|
+
throw new Error(`World config isolation must be "process", "colocated", or "worker" in ${path}`);
|
|
369
|
+
}
|
|
370
|
+
const ids = new Set<string>();
|
|
371
|
+
for (const service of config.services as Partial<WorldServiceConfig>[]) {
|
|
372
|
+
if (!service || typeof service !== 'object') throw new Error(`Invalid service in ${path}`);
|
|
373
|
+
if (!service.id || typeof service.id !== 'string') throw new Error(`Service must define string id in ${path}`);
|
|
374
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/.test(service.id)) throw new Error(`Invalid service id "${service.id}" in ${path}`);
|
|
375
|
+
if (ids.has(service.id)) throw new Error(`Duplicate service id "${service.id}" in ${path}`);
|
|
376
|
+
ids.add(service.id);
|
|
377
|
+
if (service.type !== undefined && service.type !== 'twin' && service.type !== 'process' && service.type !== 'external') {
|
|
378
|
+
throw new Error(`Service "${service.id}" type must be "twin", "process", or "external" in ${path}`);
|
|
379
|
+
}
|
|
380
|
+
if (service.type === 'external') {
|
|
381
|
+
assertExternalServiceConfig(service, path);
|
|
382
|
+
} else {
|
|
383
|
+
if (!service.command || typeof service.command !== 'string') {
|
|
384
|
+
throw new Error(`Service "${service.id}" must define command in ${path}`);
|
|
385
|
+
}
|
|
386
|
+
if (service.ready !== undefined && service.ready !== 'tcp') {
|
|
387
|
+
assertReadyWhen(service.ready, `Service "${service.id}" ready`, path);
|
|
388
|
+
}
|
|
389
|
+
if (service.preload !== undefined) {
|
|
390
|
+
if (!Array.isArray(service.preload) || service.preload.some((p) => typeof p !== 'string')) {
|
|
391
|
+
throw new Error(`Service "${service.id}" preload must be a string array in ${path}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (typeof service.port === 'number') {
|
|
395
|
+
if (!service.portReason || typeof service.portReason !== 'string') {
|
|
396
|
+
throw new Error(`Service "${service.id}" sets a literal "port" — this requires a "portReason" string recording why it can't be 'auto' in ${path}`);
|
|
397
|
+
}
|
|
398
|
+
} else if (service.portReason !== undefined) {
|
|
399
|
+
throw new Error(`Service "${service.id}" sets "portReason" without a literal numeric "port" in ${path}`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (service.colocate !== undefined) {
|
|
403
|
+
if (service.type === 'external') {
|
|
404
|
+
throw new Error(`External service "${service.id}" must not set colocate (its lifecycle is delegated) in ${path}`);
|
|
405
|
+
}
|
|
406
|
+
const colocate = service.colocate as Partial<{ module: string; export: string }>;
|
|
407
|
+
if (!colocate || typeof colocate !== 'object' || Array.isArray(colocate)
|
|
408
|
+
|| !colocate.module || typeof colocate.module !== 'string'
|
|
409
|
+
|| !colocate.export || typeof colocate.export !== 'string') {
|
|
410
|
+
throw new Error(`Service "${service.id}" colocate must be { module, export } (non-empty strings) in ${path}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (service.args && !Array.isArray(service.args)) throw new Error(`Service "${service.id}" args must be an array`);
|
|
414
|
+
if (service.env && (typeof service.env !== 'object' || Array.isArray(service.env))) {
|
|
415
|
+
throw new Error(`Service "${service.id}" env must be an object`);
|
|
416
|
+
}
|
|
417
|
+
if (service.injectEnv !== undefined && typeof service.injectEnv !== 'string') {
|
|
418
|
+
throw new Error(`Service "${service.id}" injectEnv must be a string in ${path}`);
|
|
419
|
+
}
|
|
420
|
+
if (service.injectEnvTemplates !== undefined) {
|
|
421
|
+
if (typeof service.injectEnvTemplates !== 'object' || service.injectEnvTemplates === null || Array.isArray(service.injectEnvTemplates)
|
|
422
|
+
|| Object.values(service.injectEnvTemplates).some((v) => typeof v !== 'string')) {
|
|
423
|
+
throw new Error(`Service "${service.id}" injectEnvTemplates must be a string→string object in ${path}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (service.cliRedirect !== undefined) {
|
|
427
|
+
if (typeof service.cliRedirect !== 'object' || service.cliRedirect === null || Array.isArray(service.cliRedirect)
|
|
428
|
+
|| Object.values(service.cliRedirect).some((v) => typeof v !== 'string')) {
|
|
429
|
+
throw new Error(`Service "${service.id}" cliRedirect must be a string→string object in ${path}`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (config.share !== undefined) {
|
|
434
|
+
if (!config.share || typeof config.share !== 'object' || Array.isArray(config.share)) {
|
|
435
|
+
throw new Error(`World config share must be an object in ${path}`);
|
|
436
|
+
}
|
|
437
|
+
const share = config.share as Partial<WorldShareConfig>;
|
|
438
|
+
if (share.provider !== undefined && share.provider !== 'cloudflare-quick' && share.provider !== 'command') {
|
|
439
|
+
throw new Error(`World config share.provider must be "cloudflare-quick" or "command" in ${path}`);
|
|
440
|
+
}
|
|
441
|
+
if (share.provider === 'command' && (typeof share.command !== 'string' || !share.command)) {
|
|
442
|
+
throw new Error(`World config share.provider "command" requires a non-empty share.command in ${path}`);
|
|
443
|
+
}
|
|
444
|
+
if (share.command !== undefined) {
|
|
445
|
+
if (typeof share.command !== 'string' || !share.command) {
|
|
446
|
+
throw new Error(`World config share.command must be a non-empty string in ${path}`);
|
|
447
|
+
}
|
|
448
|
+
if (share.provider !== undefined && share.provider !== 'command') {
|
|
449
|
+
throw new Error(`World config share.command requires share.provider to be omitted or "command" in ${path}`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (share.args !== undefined) {
|
|
453
|
+
if (share.command === undefined) {
|
|
454
|
+
throw new Error(`World config share.args is only allowed when share.command is set in ${path}`);
|
|
455
|
+
}
|
|
456
|
+
if (!Array.isArray(share.args) || share.args.some((arg) => typeof arg !== 'string')) {
|
|
457
|
+
throw new Error(`World config share.args must be a string array in ${path}`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (!Array.isArray(share.services)) throw new Error(`World config share.services must be an array in ${path}`);
|
|
461
|
+
for (const target of share.services as Partial<WorldShareServiceConfig>[]) {
|
|
462
|
+
if (!target || typeof target !== 'object' || Array.isArray(target)) throw new Error(`Invalid share service in ${path}`);
|
|
463
|
+
if (!target.id || typeof target.id !== 'string') throw new Error(`Share service must define string id in ${path}`);
|
|
464
|
+
if (!ids.has(target.id)) throw new Error(`Share service "${target.id}" does not match a configured service in ${path}`);
|
|
465
|
+
if (target.verifyPath !== undefined && target.verifyPath !== false && typeof target.verifyPath !== 'string') {
|
|
466
|
+
throw new Error(`Share service "${target.id}" verifyPath must be a string or false in ${path}`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return config as WorldConfig;
|
|
471
|
+
}
|