@volter/world-runtime 2.0.54 → 3.0.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/dist/known-external-services.json +5 -4
- package/dist/src/actions-runner-cli.d.ts +2 -0
- package/dist/src/actions-runner-cli.js +25 -0
- package/dist/src/actions-runner.d.ts +18 -0
- package/dist/src/actions-runner.js +281 -0
- package/dist/src/ancestry.js +1 -1
- package/dist/src/app-url.js +1 -1
- package/dist/src/backings.d.ts +29 -0
- package/dist/src/backings.js +45 -0
- package/dist/src/branch.js +3 -2
- package/dist/src/browser-proxy-cli.js +1 -1
- package/dist/src/catalog-index.d.ts +38 -0
- package/dist/src/catalog-index.js +37 -0
- package/dist/src/catalog.d.ts +16 -3
- package/dist/src/catalog.js +69 -26
- package/dist/src/changeset.d.ts +1 -1
- package/dist/src/changeset.js +9 -11
- package/dist/src/cli-args.d.ts +4 -0
- package/dist/src/cli-args.js +18 -0
- package/dist/src/cli-commands.d.ts +1 -0
- package/dist/src/cli-commands.js +1216 -0
- package/dist/src/cli.js +8 -1300
- package/dist/src/configs.js +1 -1
- package/dist/src/console-apart.d.ts +2 -2
- package/dist/src/console-apart.js +5 -5
- package/dist/src/consumers.js +1 -1
- package/dist/src/covers.d.ts +1 -1
- package/dist/src/covers.js +94 -30
- package/dist/src/env-output.d.ts +1 -1
- package/dist/src/env-output.js +2 -2
- package/dist/src/fixture-env.js +18 -26
- package/dist/src/host-cli.js +4 -2
- package/dist/src/host-fault-fixture.js +6 -0
- package/dist/src/host-ready.d.ts +8 -2
- package/dist/src/host-ready.js +16 -4
- package/dist/src/host-worker.js +6 -1
- package/dist/src/host.d.ts +2 -0
- package/dist/src/host.js +9 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +2 -2
- package/dist/src/init.d.ts +33 -23
- package/dist/src/init.js +228 -96
- package/dist/src/lifecycle-record.js +2 -2
- package/dist/src/local-branches.js +1 -1
- package/dist/src/origin.d.ts +1 -1
- package/dist/src/origin.js +12 -10
- package/dist/src/pack-facts.d.ts +30 -6
- package/dist/src/pack-facts.js +13 -3
- package/dist/src/pglite-backing.js +2 -1
- package/dist/src/placeholder.d.ts +1 -1
- package/dist/src/placeholder.js +12 -4
- package/dist/src/project-inspect.d.ts +1 -1
- package/dist/src/project-inspect.js +2 -2
- package/dist/src/redirect-proxy.d.ts +5 -0
- package/dist/src/redirect-proxy.js +8 -1
- package/dist/src/redis-backing.js +4 -2
- package/dist/src/reflect.js +1 -1
- package/dist/src/root.d.ts +11 -33
- package/dist/src/root.js +28 -59
- package/dist/src/runtime-test-support.js +1 -1
- package/dist/src/runtime.d.ts +7 -1
- package/dist/src/runtime.js +317 -53
- package/dist/src/schema.d.ts +27 -5
- package/dist/src/schema.js +13 -12
- package/dist/src/served-world.d.ts +11 -35
- package/dist/src/served-world.js +54 -141
- package/dist/src/tail.js +1 -1
- package/dist/src/up-cli.d.ts +3 -0
- package/dist/src/up-cli.js +55 -0
- package/dist/src/websocket-relay.d.ts +1 -1
- package/dist/src/world-origins.d.ts +1 -1
- package/dist/src/world-origins.js +1 -1
- package/dist/src/world-view.d.ts +1 -1
- package/dist/src/world-view.js +2 -2
- package/known-external-services.json +5 -4
- package/package.json +11 -11
- package/src/actions-runner-cli.ts +24 -0
- package/src/actions-runner.ts +244 -0
- package/src/ancestry.ts +1 -1
- package/src/app-url.ts +1 -1
- package/src/backings.ts +55 -0
- package/src/branch.ts +3 -2
- package/src/browser-proxy-cli.ts +1 -1
- package/src/catalog-index.ts +44 -0
- package/src/catalog.ts +67 -24
- package/src/changeset.ts +10 -13
- package/src/cli-args.ts +17 -0
- package/src/cli-commands.ts +1070 -0
- package/src/cli.ts +9 -1139
- package/src/configs.ts +1 -1
- package/src/console-apart.ts +5 -5
- package/src/consumers.ts +1 -1
- package/src/covers.ts +83 -30
- package/src/env-output.ts +2 -2
- package/src/fixture-env.ts +18 -26
- package/src/host-cli.ts +4 -2
- package/src/host-ready.ts +13 -4
- package/src/host-worker.ts +6 -2
- package/src/host.ts +11 -2
- package/src/index.ts +2 -2
- package/src/init.ts +222 -101
- package/src/lifecycle-record.ts +2 -2
- package/src/local-branches.ts +1 -1
- package/src/origin.ts +12 -10
- package/src/pack-facts.ts +22 -6
- package/src/pglite-backing.ts +2 -1
- package/src/placeholder.ts +13 -4
- package/src/project-inspect.ts +2 -2
- package/src/redirect-proxy.ts +9 -1
- package/src/redis-backing.ts +4 -2
- package/src/reflect.ts +1 -1
- package/src/root.ts +33 -65
- package/src/runtime-test-support.ts +1 -1
- package/src/runtime.ts +261 -55
- package/src/schema.ts +26 -13
- package/src/served-world.ts +50 -130
- package/src/tail.ts +1 -1
- package/src/up-cli.ts +55 -0
- package/src/websocket-relay.ts +1 -1
- package/src/world-origins.ts +1 -1
- package/src/world-view.ts +3 -3
package/src/schema.ts
CHANGED
|
@@ -39,6 +39,13 @@ export type WorldManifestV2 = {
|
|
|
39
39
|
|
|
40
40
|
/** The env value `init` writes for a name whose fake must be minted per instance (a service-account JSON with a real throwaway key). */
|
|
41
41
|
export const MINT_ENV = '$mint';
|
|
42
|
+
/** An env value `$issue:<service id>`: the credential that service's twin issues through its descriptor's
|
|
43
|
+
* `credentialDoor`, asked for at boot once the twin is up (never committed, like `$mint`). */
|
|
44
|
+
export const ISSUE_ENV_PREFIX = '$issue:';
|
|
45
|
+
/** The env value `init` writes for the application's own origin (its base URL, its auth URL): resolved when a command
|
|
46
|
+
* attaches, from the application URL the booter recorded (`volter-world app-url <world> --set`), so the app names
|
|
47
|
+
* the address it is reached at, which only its booter knows (a browser tab's preview origin, a local port). */
|
|
48
|
+
export const APP_URL_ENV = '$app-url';
|
|
42
49
|
export type WorldShareProvider = 'cloudflare-quick' | 'command';
|
|
43
50
|
|
|
44
51
|
/**
|
|
@@ -80,6 +87,9 @@ export type WorldExternalDiscoverSource = 'status' | 'up';
|
|
|
80
87
|
*/
|
|
81
88
|
export type WorldExternalDiscover = {
|
|
82
89
|
as: string;
|
|
90
|
+
/** The wire the discovered connection speaks (`postgres`, `mysql`, `mongodb`, `redis`), so a coverage report binds it
|
|
91
|
+
* to the application's driver instead of calling it unknown. */
|
|
92
|
+
protocol?: string;
|
|
83
93
|
source?: WorldExternalDiscoverSource;
|
|
84
94
|
jsonPath?: string;
|
|
85
95
|
pattern?: string;
|
|
@@ -142,6 +152,10 @@ export type WorldServiceConfig = {
|
|
|
142
152
|
'//'?: string;
|
|
143
153
|
id: string;
|
|
144
154
|
type?: WorldServiceType;
|
|
155
|
+
/** The credential this twin issues the World's applications at boot (its pack's `credentialDoor`, copied by `init`):
|
|
156
|
+
* `up` POSTs `body` to `path` on the running twin and sets each name of `fill` whose value is `$issue:<this id>` to
|
|
157
|
+
* the answer's field `fill` names for it. */
|
|
158
|
+
issues?: { path: string; body: Record<string, unknown>; fill: Record<string, string> };
|
|
145
159
|
/**
|
|
146
160
|
* The twin PACKAGE this service boots from (`@volter/twin-<vendor>`), resolved from the world
|
|
147
161
|
* root at boot by node's own `node_modules` walk (docs/concepts/worlds.md#the-config-and-the-running-world).
|
|
@@ -174,9 +188,6 @@ export type WorldServiceConfig = {
|
|
|
174
188
|
* to loopback services directly while presenting a disguised world to the app/user.
|
|
175
189
|
*/
|
|
176
190
|
controlPlane?: boolean;
|
|
177
|
-
/** The account a vendor's own screens (its mirror) open signed in as: its handle, username or email, which the pack
|
|
178
|
-
* resolves against the accounts this World holds (`<vendor>MirrorSignIn`). Nothing secret is written here. */
|
|
179
|
-
signIn?: { as: string };
|
|
180
191
|
injectEnv?: string;
|
|
181
192
|
/**
|
|
182
193
|
* Additional env vars exported into the generated world env after this service starts.
|
|
@@ -297,6 +308,8 @@ export type WorldServiceInstance = {
|
|
|
297
308
|
port?: number;
|
|
298
309
|
/** Absent for 'external' services — their URL is discovered into env vars, not assigned by us. */
|
|
299
310
|
url?: string;
|
|
311
|
+
/** The twin's side ports (TwinPack.sidePorts: the SMTP twin's mailbox), by name: `volter-world url <world> <id>.<name>`. */
|
|
312
|
+
sides?: Record<string, { port: number; url: string }>;
|
|
300
313
|
/** 0 for 'external' services — they have no process we own (the external tool manages it). */
|
|
301
314
|
pid: number;
|
|
302
315
|
log: string;
|
|
@@ -404,6 +417,10 @@ export type WorldInstance = {
|
|
|
404
417
|
/** The ORIGIN this world clones from and fetches (docs/concepts/the-model.md): a hosted twin's
|
|
405
418
|
* namespace — its canonical history. Absent = the placeholder is the only origin. */
|
|
406
419
|
origin?: { url: string; namespace: string; clonedAt?: string; fetchedAt?: string };
|
|
420
|
+
/** The facts files of the packs this World resolved from installed packages (each released pack carries its own,
|
|
421
|
+
* `generated/pack-facts.json`): the kernel's pack-facts loader overlays them on the facts it was built with
|
|
422
|
+
* (docs/contributing/architecture.md, "The catalog: where twins come from"). */
|
|
423
|
+
packFacts?: string[];
|
|
407
424
|
};
|
|
408
425
|
|
|
409
426
|
function assertStringArray(value: unknown, what: string, path: string): asserts value is string[] {
|
|
@@ -471,10 +488,13 @@ function assertExternalServiceConfig(service: Partial<WorldServiceConfig>, path:
|
|
|
471
488
|
if (!mapping || typeof mapping !== 'object' || Array.isArray(mapping)) {
|
|
472
489
|
throw new Error(`External service "${id}" discover entries must be objects in ${path}`);
|
|
473
490
|
}
|
|
474
|
-
assertKeys(mapping as unknown as Record<string, unknown>, ['as', 'source', 'jsonPath', 'pattern'], `External service "${id}" discover entry`, path);
|
|
491
|
+
assertKeys(mapping as unknown as Record<string, unknown>, ['as', 'protocol', 'source', 'jsonPath', 'pattern'], `External service "${id}" discover entry`, path);
|
|
475
492
|
if (!mapping.as || typeof mapping.as !== 'string') {
|
|
476
493
|
throw new Error(`External service "${id}" discover entry must define string "as" in ${path}`);
|
|
477
494
|
}
|
|
495
|
+
if (mapping.protocol !== undefined && !['postgres', 'mysql', 'mongodb', 'redis', 'http'].includes(mapping.protocol)) {
|
|
496
|
+
throw new Error(`External service "${id}" discover "${mapping.as}" protocol must be postgres, mysql, mongodb, redis or http in ${path}`);
|
|
497
|
+
}
|
|
478
498
|
const hasJson = typeof mapping.jsonPath === 'string';
|
|
479
499
|
const hasPattern = typeof mapping.pattern === 'string';
|
|
480
500
|
if (hasJson === hasPattern) {
|
|
@@ -716,7 +736,7 @@ export function assertWorldSelection(value: unknown, path: string): asserts valu
|
|
|
716
736
|
|
|
717
737
|
function serviceV2ToLegacy(value: unknown, path: string): WorldServiceConfig {
|
|
718
738
|
if (!record(value)) throw new Error(`World service must be an object in ${path}`);
|
|
719
|
-
assertKeys(value, ['id', 'type', 'description', 'source', 'execution', 'endpoint', 'bindings', 'root'
|
|
739
|
+
assertKeys(value, ['id', 'type', 'description', 'source', 'execution', 'endpoint', 'bindings', 'root'], `Service ${String(value.id ?? '')}`, path, true);
|
|
720
740
|
const source = value.source === undefined ? {} : value.source;
|
|
721
741
|
const execution = value.execution === undefined ? {} : value.execution;
|
|
722
742
|
const endpoint = value.endpoint === undefined ? {} : value.endpoint;
|
|
@@ -733,11 +753,6 @@ function serviceV2ToLegacy(value: unknown, path: string): WorldServiceConfig {
|
|
|
733
753
|
assertKeys(environment, ['values'], 'service.execution.environment', path);
|
|
734
754
|
if (typeof value.id !== 'string' || value.id === '') throw new Error(`World service must define string id in ${path}`);
|
|
735
755
|
if (value.description !== undefined && typeof value.description !== 'string') throw new Error(`Service "${value.id}" description must be a string in ${path}`);
|
|
736
|
-
if (value.signIn !== undefined) {
|
|
737
|
-
if (!record(value.signIn)) throw new Error(`Service "${value.id}" signIn must be an object in ${path}`);
|
|
738
|
-
assertKeys(value.signIn, ['as'], `Service "${value.id}" signIn`, path);
|
|
739
|
-
if (typeof value.signIn.as !== 'string' || value.signIn.as.trim() === '') throw new Error(`Service "${value.id}" signIn.as must name an account (a handle, username or email) in ${path}`);
|
|
740
|
-
}
|
|
741
756
|
if (value.description !== undefined && value['//'] !== undefined) throw new Error(`Service "${value.id}" must not set both description and // in ${path}`);
|
|
742
757
|
if (execution.cwd !== undefined && typeof execution.cwd !== 'string') throw new Error(`Service "${value.id}" execution.cwd must be a string in ${path}`);
|
|
743
758
|
if (execution.controlPlane !== undefined && typeof execution.controlPlane !== 'boolean') throw new Error(`Service "${value.id}" execution.controlPlane must be a boolean in ${path}`);
|
|
@@ -754,7 +769,7 @@ function serviceV2ToLegacy(value: unknown, path: string): WorldServiceConfig {
|
|
|
754
769
|
if (!Array.isArray(bindings.discover)) throw new Error(`Service "${value.id}" bindings.discover must be an array in ${path}`);
|
|
755
770
|
for (const mapping of bindings.discover) {
|
|
756
771
|
if (!record(mapping)) throw new Error(`Service "${value.id}" bindings.discover entries must be objects in ${path}`);
|
|
757
|
-
assertKeys(mapping, ['as', 'source', 'jsonPath', 'pattern'], `Service "${value.id}" bindings.discover entry`, path);
|
|
772
|
+
assertKeys(mapping, ['as', 'protocol', 'source', 'jsonPath', 'pattern'], `Service "${value.id}" bindings.discover entry`, path);
|
|
758
773
|
}
|
|
759
774
|
}
|
|
760
775
|
const type = value.type;
|
|
@@ -787,7 +802,6 @@ function serviceV2ToLegacy(value: unknown, path: string): WorldServiceConfig {
|
|
|
787
802
|
...(bindings.injectEnvTemplates === undefined ? {} : { injectEnvTemplates: bindings.injectEnvTemplates as Record<string, string> }),
|
|
788
803
|
...(bindings.cliRedirect === undefined ? {} : { cliRedirect: bindings.cliRedirect as Record<string, string> }),
|
|
789
804
|
...(value.root === undefined ? {} : { root: value.root as WorldServiceConfig['root'] }),
|
|
790
|
-
...(value.signIn === undefined ? {} : { signIn: { as: (value.signIn as { as: string }).as } }),
|
|
791
805
|
...(external === undefined ? {} : { external }),
|
|
792
806
|
};
|
|
793
807
|
if (type !== 'external' && bindings.discover !== undefined) throw new Error(`Service "${legacy.id}" bindings.discover is only valid for external services in ${path}`);
|
|
@@ -908,7 +922,6 @@ function serviceToV2(service: WorldServiceConfig): Record<string, unknown> {
|
|
|
908
922
|
...(Object.keys(endpoint).length ? { endpoint } : {}),
|
|
909
923
|
...(Object.keys(bindings).length ? { bindings } : {}),
|
|
910
924
|
...(service.root === undefined ? {} : { root: service.root }),
|
|
911
|
-
...(service.signIn === undefined ? {} : { signIn: service.signIn }),
|
|
912
925
|
};
|
|
913
926
|
}
|
|
914
927
|
|
package/src/served-world.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getActiveWorldStore,
|
|
1
|
+
import { getActiveWorldStore, withHistoryLocks, captureHistory, historyAtInstant, historyDigest, historyEntries, historyLength, readHistoryView, worldPaths, type HistoryReference } from '@volter/world-core/runtime';
|
|
2
2
|
// A SERVED WORLD (docs/reference/http-api.md#a-served-world): `volter world serve` puts a world at
|
|
3
3
|
// `http://<host>:<port>/<org>/<world>/`. Each twin's vendor API is under its vendor id — the
|
|
4
4
|
// addressed grammar RH2 depends on — and the world's own doors are under `/-/<org>/<world>/`:
|
|
@@ -19,27 +19,28 @@ import { getActiveWorldStore, withAncestryLock, captureHistory, historyAtInstant
|
|
|
19
19
|
// GET/PUT clock, POST clock/advance the frozen instant every twin stamps from, forward only
|
|
20
20
|
// GET/POST branches, DELETE branches/<org>/<world> the host's branches of this World ("as of" views)
|
|
21
21
|
// GET history?at=<instant> each twin's history cut at an instant (where such a branch starts)
|
|
22
|
-
// GET /<org>/<world>/<vendor
|
|
22
|
+
// GET /<org>/<world>/<vendor>/<path> the vendor's own screens, the pack's workspaces (its `GET /twin` lists them)
|
|
23
23
|
// Tokens ride `x-volter-token`: the token opens everything, the read token only reads — on the vendor
|
|
24
24
|
// wire, a twin that enforces it (`requestScopes: ['read']`) is handed the read token's other requests
|
|
25
25
|
// marked read-only and refuses the writes itself. `serve` mints both, prints them once and writes them
|
|
26
26
|
// to `.volter/token` and `.volter/token.read` (0600).
|
|
27
27
|
// One writer per world by construction: the doors run in this process, serialized per twin.
|
|
28
28
|
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync , rmSync} from 'node:fs';
|
|
29
|
-
import { CALLER_HEADER, newTraceparent, parseTraceparent, traceparentForDelivery, readTwinRequestJournalFrom, type TwinRequestJournalCursor, assertSafeChangesetName, blobDigest, destinationOf,
|
|
29
|
+
import { CALLER_HEADER, callerForHeader, newTraceparent, parseTraceparent, traceparentForDelivery, readTwinRequestJournalFrom, type TwinRequestJournalCursor, assertSafeChangesetName, blobDigest, destinationOf, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, openSealedAt, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, validateRemoteOrigin, type SealedCredential, type TwinStreamConnection, type TwinStreamSink } from '@volter/world-core/runtime';
|
|
30
30
|
import { APP_READ_ENDPOINT_ENV } from './init.ts';
|
|
31
|
+
import { packFacts } from './pack-facts.ts';
|
|
31
32
|
import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass, type TrustedIssuer, type WorldCredentials } from '@volter/world-access';
|
|
32
33
|
import { createHash } from 'node:crypto';
|
|
33
34
|
import { dirname, join, resolve } from 'node:path';
|
|
34
35
|
import {
|
|
35
36
|
appendActionIfAbsent, branchEntries, branchLogPath, branchMetaPath, changesetContentHash, changesetReadiness, confirmAction, parentLogPath, readTree, stateDirName, wholeLog, worldNow,
|
|
36
37
|
parentEntries, readBranchMeta, rebaseBranch, type Changeset, type Entry, type Receipt, type RootConfig,
|
|
37
|
-
} from '@volter/world-core';
|
|
38
|
+
} from '@volter/world-core/runtime';
|
|
38
39
|
import { approveWorldChangeset, createWorldChangeset, diffWorld, findWorldChangeset, listWorldChangesets, pushWorldChangeset, verifyWorldChangeset, worldChangesetsDir } from './changeset.ts';
|
|
39
40
|
import { fetchFromOrigin } from './origin.ts';
|
|
40
41
|
import { CONSOLE_BASE, consoleRedirect, serveConsoleApart, type ConsoleMount } from './console-apart.ts';
|
|
41
42
|
import { loadWorldConfig, worldChangesetRules, writeWorldConfig } from './configs.ts';
|
|
42
|
-
import { adaptersFor, credentialPath,
|
|
43
|
+
import { adaptersFor, credentialPath, credentialPayloadFrom, deployTwin, deployWorld, loadWorldChecks, twinSigningSecret, materializeRoots, refreshPosture, refreshTwin, rootForControlRoot, rewrapTwinCredentials, sealTwinCredential, sealedCredentialInfo, bindLegacyCredentials, setTwinRoot, spanMs } from './root.ts';
|
|
43
44
|
import { clockFile, downWorld, listWorlds, statusWorld, upWorld } from './runtime.ts';
|
|
44
45
|
import { assertChangesetRules, type WorldChangesetRules, type WorldInstance } from './schema.ts';
|
|
45
46
|
|
|
@@ -143,10 +144,6 @@ export async function mountWorld(name: string, opts: { /** the platforms whose p
|
|
|
143
144
|
const declared = loadWorldConfig(configRef, worldRoot).config.services.find((s) => s.id === vendor)?.colocate?.scenarioPath;
|
|
144
145
|
return declared ? resolve(worldRoot, declared) : null;
|
|
145
146
|
},
|
|
146
|
-
// a local mirror is the pack's own, built by its builder (at serve under Bun, prebuilt under Node)
|
|
147
|
-
mirror: (vendor, file) => localMirrorFile(worldRoot, vendor, file),
|
|
148
|
-
hasMirror: async (vendor) => (await localMirror(worldRoot, vendor)) !== null,
|
|
149
|
-
mirrorSignIn: async (vendor, as, ctx) => { const mirror = await localMirror(worldRoot, vendor); try { return (await mirror?.signIn?.(as, ctx)) ?? null; } catch { return null; } },
|
|
150
147
|
clockPath: () => clockFile(worldRoot, name),
|
|
151
148
|
...(opts.branches?.(served) ? { branches: opts.branches(served)! } : {}),
|
|
152
149
|
...(opts.browserOrigin ? { browserOrigin: () => opts.browserOrigin!(served), originOf: (other: string) => opts.browserOrigin!(other) } : {}),
|
|
@@ -157,7 +154,9 @@ export async function mountWorld(name: string, opts: { /** the platforms whose p
|
|
|
157
154
|
twinFetch: (vendor) => {
|
|
158
155
|
const target = instance?.services[vendor]?.url;
|
|
159
156
|
if (!target) return null;
|
|
160
|
-
|
|
157
|
+
// the body as bytes, none when empty: an empty body streamed is sent chunked with no last chunk (Bun 1.2), and the
|
|
158
|
+
// twin's server reads the next request on the connection as its continuation (HPE_INVALID_CHUNK_SIZE)
|
|
159
|
+
return async (request) => { const at = new URL(request.url); const body = request.body ? new Uint8Array(await request.arrayBuffer()) : undefined; return fetch(`${target.replace(/\/+$/, '')}${at.pathname}${at.search}`, { method: request.method, headers: request.headers, ...(body?.byteLength ? { body } : {}), redirect: 'manual' } as RequestInit); };
|
|
161
160
|
},
|
|
162
161
|
};
|
|
163
162
|
const world = new WorldDoors(name, worldRoot, configRef, served, host, held('token') ?? mintToken('tok_'), held('token.read') ?? mintToken('tok_r_'));
|
|
@@ -222,28 +221,6 @@ export async function mountWorld(name: string, opts: { /** the platforms whose p
|
|
|
222
221
|
return mounted;
|
|
223
222
|
}
|
|
224
223
|
|
|
225
|
-
/** A local World's mirrors: each pack's builders, found once per process, and each client built once. */
|
|
226
|
-
const localMirrors = new Map<string, Promise<PackMirror | null>>();
|
|
227
|
-
const builtMirrorFiles = new Map<string, Promise<string | null>>();
|
|
228
|
-
function localMirror(worldRoot: string, vendor: string): Promise<PackMirror | null> {
|
|
229
|
-
const key = `${worldRoot}\0${vendor}`;
|
|
230
|
-
let held = localMirrors.get(key);
|
|
231
|
-
if (!held) { held = packMirror(vendor, worldRoot).catch(() => null); localMirrors.set(key, held); }
|
|
232
|
-
return held;
|
|
233
|
-
}
|
|
234
|
-
async function localMirrorFile(worldRoot: string, vendor: string, file: string): Promise<Response | null> {
|
|
235
|
-
const mirror = await localMirror(worldRoot, vendor);
|
|
236
|
-
if (!mirror) return null;
|
|
237
|
-
if (file === 'index.html') return new Response(mirror.html(), { headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
238
|
-
const build = file === 'assets/app.js' ? mirror.client : file === 'assets/styles.css' ? mirror.styles : undefined;
|
|
239
|
-
if (!build) return null;
|
|
240
|
-
const key = `${worldRoot}\0${vendor}\0${file}`;
|
|
241
|
-
let held = builtMirrorFiles.get(key);
|
|
242
|
-
// a failed build is not kept: the next request builds again
|
|
243
|
-
if (!held) { held = build().catch(() => { builtMirrorFiles.delete(key); return null; }); builtMirrorFiles.set(key, held); }
|
|
244
|
-
const body = await held;
|
|
245
|
-
return body === null ? null : new Response(body, { headers: { 'content-type': file.endsWith('.css') ? 'text/css; charset=utf-8' : 'text/javascript; charset=utf-8' } });
|
|
246
|
-
}
|
|
247
224
|
|
|
248
225
|
/** Boot the world (state kept) and serve it on its own port. Announces and returns after boot; `stop` downs the world. */
|
|
249
226
|
export async function serveWorld(name: string, opts: { root?: string; port?: number; host?: string; consolePort?: number; announce?: (info: { name: string; base: string; token: string; readToken: string; console: string | null }) => void } = {}): Promise<ServedWorld> {
|
|
@@ -306,7 +283,7 @@ export type DoorHost = {
|
|
|
306
283
|
reload: () => WorldLayout;
|
|
307
284
|
/** The twin's wire for a request whose path is the twin's own; null when the world has no such twin. */
|
|
308
285
|
twinFetch: (vendor: string) => ((request: Request) => Promise<Response>) | null;
|
|
309
|
-
/** The world's byte-stream doors (a TCP protocol a twin serves: smtp's
|
|
286
|
+
/** The world's byte-stream doors (a TCP protocol a twin serves: smtp's),
|
|
310
287
|
* by the id a local World gives that listener's service, when this host carries bytes over a
|
|
311
288
|
* WebSocket (a hosted World takes no inbound TCP). */
|
|
312
289
|
streams?: () => Record<string, { protocol: string }>;
|
|
@@ -314,16 +291,6 @@ export type DoorHost = {
|
|
|
314
291
|
/** Where this host reads a twin's scenario (the handlers document a generative twin serves from,
|
|
315
292
|
* re-read on every request), or null when the twin takes none here. */
|
|
316
293
|
scenarioPath?: (vendor: string) => string | null;
|
|
317
|
-
/** A twin's mirror as its pack builds it: `index.html` (the shell) or `assets/<file>`, or null when
|
|
318
|
-
* the twin has none here. Every host answers the same mount (`/<served>/<vendor>/mirror/`); only
|
|
319
|
-
* where the files come from differs (built at serve under Bun, prebuilt under Node, static assets
|
|
320
|
-
* on Cloudflare). */
|
|
321
|
-
mirror?: (vendor: string, file: string) => Promise<Response | null>;
|
|
322
|
-
/** Whether the twin has a mirror on this host, without building it. */
|
|
323
|
-
hasMirror?: (vendor: string) => boolean | Promise<boolean>;
|
|
324
|
-
/** The account and token a twin's screens open signed in as, resolved by its pack from the twin's state at `root`
|
|
325
|
-
* for the account the World's config names (`signIn.as`); null when the pack cannot, or has no such account. */
|
|
326
|
-
mirrorSignIn?: (vendor: string, as: string, ctx: { root: string; twin: (path: string, init?: RequestInit) => Promise<Response> }) => Promise<{ account: string; token: string } | null>;
|
|
327
294
|
/** Where this World's clock lives in its store (the file every twin reads `worldNow()` from). */
|
|
328
295
|
clockPath?: () => string;
|
|
329
296
|
/** The origin a browser reaches this World at (`http://<world>--<org>.localhost:<port>`), when the
|
|
@@ -375,7 +342,6 @@ export function worldOriginLabel(served: string): string | null {
|
|
|
375
342
|
/** The header the doors set on a read-scope request to a twin: the twin refuses the caller's writes
|
|
376
343
|
* and still makes the vendor's own moves. It only restricts, so a twin trusts it without a token. */
|
|
377
344
|
export const READ_ONLY_HEADER = 'x-volter-read-only';
|
|
378
|
-
const MIRROR_ASSET = /^assets\/[A-Za-z0-9._-]+$/;
|
|
379
345
|
const SPAN = /^(\d+(?:\.\d+)?)(s|m|h|d)$/;
|
|
380
346
|
const SPAN_MS = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 } as const;
|
|
381
347
|
/** A W3C traceparent's trace id, or null when the value is not one. */
|
|
@@ -400,7 +366,7 @@ export class WorldDoors {
|
|
|
400
366
|
constructor(readonly name: string, readonly worldRoot: string, readonly configRef: string, readonly served: string, private readonly host: DoorHost, token: string, readToken: string) { this.token = token; this.readToken = readToken; }
|
|
401
367
|
/** New tokens; a request presenting the old ones is refused from the next call on. */
|
|
402
368
|
// new tokens end everything the old ones reached: every session and every named key
|
|
403
|
-
retoken(token: string, readToken: string): void { this.token = token; this.readToken = readToken; this.endSessions(); this.keepKeys([]);
|
|
369
|
+
retoken(token: string, readToken: string): void { this.token = token; this.readToken = readToken; this.endSessions(); this.keepKeys([]); }
|
|
404
370
|
|
|
405
371
|
/** BROWSER SESSIONS: the session cookie carries an opaque id, never a token, so a leaked cookie is one session and
|
|
406
372
|
* not the World's write token; rotating the tokens ends every session. Kept in the World's store, so a restart
|
|
@@ -578,7 +544,7 @@ export class WorldDoors {
|
|
|
578
544
|
private scope(request: Request): 'write' | 'read' | null { return this.credential(request).scope; }
|
|
579
545
|
/** The request's scope, whether it came by a token the caller presented or by a browser's session
|
|
580
546
|
* (the cookie `POST …/session` set), and the token that opened it. Every form presented is a
|
|
581
|
-
* candidate: a
|
|
547
|
+
* candidate: a screen's page sends the VENDOR's bearer (the token its sign-in holds) beside the World's
|
|
582
548
|
* session cookie, and the cookie is what opens the World. With a browser origin of its own
|
|
583
549
|
* (`browserOrigin`), a World honours its session only on that origin: a session cookie on any other
|
|
584
550
|
* (a host's shared origin, path-addressed) opens nothing. */
|
|
@@ -724,14 +690,21 @@ export class WorldDoors {
|
|
|
724
690
|
// its env is what the client reads, with ${host}/${port} the bridge's listener
|
|
725
691
|
const ws = url.origin.replace(/^http/, 'ws');
|
|
726
692
|
const streams = Object.fromEntries(Object.entries(this.host.streams?.() ?? {}).map(([id, { protocol }]) => [id, { protocol, url: `${ws}/-/${this.served}/streams/${id}`, env: streamEnv(id, protocol) }]));
|
|
727
|
-
//
|
|
728
|
-
|
|
693
|
+
// each twin's endpoint variables as its descriptor declares them (`endpointEnv`, or the app-read table's), at its
|
|
694
|
+
// place here: the name, and each template on its URL (a template on a listener's host and port is a stream's)
|
|
695
|
+
const endpoints = Object.fromEntries(Object.keys(vendors).flatMap((v) => {
|
|
696
|
+
const declared = packFacts()[v]?.endpointEnv;
|
|
697
|
+
const name = declared?.name ?? APP_READ_ENDPOINT_ENV[v]?.injectEnv;
|
|
698
|
+
const templates = declared?.templates ?? APP_READ_ENDPOINT_ENV[v]?.injectEnvTemplates ?? {};
|
|
699
|
+
return [
|
|
700
|
+
...(name ? [[name, vendors[v]!]] : []),
|
|
701
|
+
...Object.entries(templates).filter(([, t]) => !/\$\{(?:host|port)\}/.test(t)).map(([n, t]) => [n, t.replaceAll('${url}', vendors[v]!)]),
|
|
702
|
+
];
|
|
703
|
+
}));
|
|
729
704
|
return Response.json({ name: this.served, vendors, ca: null, proxy: null, env: { ...endpoints, VOLTER_TWINS_KEY: presented }, ...(Object.keys(streams).length ? { streams } : {}) });
|
|
730
705
|
}
|
|
731
706
|
if (url.pathname.startsWith(prefix)) {
|
|
732
707
|
const rest = url.pathname.slice(prefix.length);
|
|
733
|
-
const mirror = /^([a-z0-9_-]+)\/mirror(?:\/(.*))?$/.exec(rest);
|
|
734
|
-
if (mirror) return await this.mirror(request, url, mirror[1]!, mirror[2] ?? '');
|
|
735
708
|
return await this.wire(request, url, rest);
|
|
736
709
|
}
|
|
737
710
|
return Response.json({ error: `not this world: serving /${this.served}/` }, { status: 404 });
|
|
@@ -740,64 +713,21 @@ export class WorldDoors {
|
|
|
740
713
|
}
|
|
741
714
|
}
|
|
742
715
|
|
|
743
|
-
/**
|
|
744
|
-
*
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
const
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
if (rest === '' && !atSite && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '')) return new Response(null, { status: 302, headers: { location: `/${this.served}/${vendor}/${url.search}` } });
|
|
759
|
-
// the shell's <base> is the twin's place under this World (its reads go to the World's wire), its assets stay under
|
|
760
|
-
// mirror/, and its `#/…` links keep working under that base (world-core mirror-shell.ts)
|
|
761
|
-
let html = mirrorShellUnder(await file.text(), `/${this.served}/${vendor}/`);
|
|
762
|
-
// SIGNED IN AS the config says (`signIn.as`): the screens are handed the account and a credential the World minted
|
|
763
|
-
// for it, before they start. Only to a request that may write this World (a read link signs no one in, and minting
|
|
764
|
-
// is a write), on the vendor's site (never the keyless mount), at the World's own origin: a shared origin's other
|
|
765
|
-
// pages, and a rebound name reaching this port, are not this World's pages. Checked here, whoever called.
|
|
766
|
-
const own = this.host.browserOrigin?.() ?? null;
|
|
767
|
-
const signs = atSite && own !== null && url.host === new URL(own).host && this.credential(request).scope === 'write';
|
|
768
|
-
const as = signs ? loadWorldConfig(this.configRef, this.worldRoot).config.services.find((s) => s.id === vendor)?.signIn?.as : undefined;
|
|
769
|
-
const given = as ? await this.signedIn(vendor, as, url.origin) : null;
|
|
770
|
-
if (given) {
|
|
771
|
-
// data, never markup: every character that could end the script or the line is escaped, and the replacement is a
|
|
772
|
-
// function, so no `$&` or `$'` in a value is expanded into the page
|
|
773
|
-
const data = JSON.stringify({ account: given.account, token: given.token }).replace(/[<>&\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
774
|
-
html = html.replace(/<script/i, (tag) => `<script>window.__VOLTER_SIGN_IN__=${data};</script>${tag}`);
|
|
775
|
-
}
|
|
776
|
-
// where the console lives on the World's own origin, only that origin frames the World's pages (no clickjacking);
|
|
777
|
-
// a page that carries a credential is never stored, and differs by who asked
|
|
778
|
-
return new Response(html, { status: 200, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': given ? 'no-store, private' : 'no-cache', vary: given ? 'Sec-Fetch-Dest, Cookie, Authorization' : 'Sec-Fetch-Dest', ...(own ? { 'content-security-policy': "frame-ancestors 'self'" } : {}) } });
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
/** A vendor's screens' sign-in (`signIn.as`), once per account every twelve hours while this World is served (rotating the
|
|
782
|
-
* tokens forgets them): the pack finds the account and mints its credential through the twin's own doors; a failure
|
|
783
|
-
* is not kept, so the next page asks again. An account or credential that is not plain printable text is refused. */
|
|
784
|
-
private readonly signIns = new Map<string, Promise<{ account: string; token: string } | null>>();
|
|
785
|
-
private readonly signInAt = new Map<string, number>();
|
|
786
|
-
private signedIn(vendor: string, as: string, origin: string): Promise<{ account: string; token: string } | null> {
|
|
787
|
-
const key = `${vendor}\0${as}`;
|
|
788
|
-
if (Date.now() - (this.signInAt.get(key) ?? 0) > 43_200_000) this.signIns.delete(key);
|
|
789
|
-
let held = this.signIns.get(key);
|
|
790
|
-
if (!held) {
|
|
791
|
-
this.signInAt.set(key, Date.now());
|
|
792
|
-
const twin = this.host.twinFetch(vendor);
|
|
793
|
-
held = !twin || !this.host.mirrorSignIn ? Promise.resolve(null) : this.host.mirrorSignIn(vendor, as, {
|
|
794
|
-
root: this.controlRoot(vendor),
|
|
795
|
-
twin: (path, init) => twin(new Request(`${origin}${path.startsWith('/') ? path : `/${path}`}`, init)),
|
|
796
|
-
}).then((given) => (given && /^[\x21-\x7e]{1,512}$/.test(given.token) && /^[^\x00-\x1f\x7f]{1,256}$/.test(given.account) ? given : null)).catch(() => null);
|
|
797
|
-
this.signIns.set(key, held);
|
|
798
|
-
void held.then((given) => { if (!given) this.signIns.delete(key); });
|
|
799
|
-
}
|
|
800
|
-
return held;
|
|
716
|
+
/** A twin's workspaces as its discovery door lists them (`GET /twin`'s `screens`: each built workspace screen's id and
|
|
717
|
+
* path), which a person is shown at `/<served>/<vendor><path>`. Asked again after a minute, as `enforcesRead` is. */
|
|
718
|
+
private readonly workspaces = new Map<string, { screens: Array<{ id: string; path: string }>; at: number }>();
|
|
719
|
+
private async screensOf(vendor: string): Promise<Array<{ id: string; path: string }>> {
|
|
720
|
+
const known = this.workspaces.get(vendor);
|
|
721
|
+
if (known !== undefined && Date.now() - known.at < 60_000) return known.screens;
|
|
722
|
+
let screens: Array<{ id: string; path: string }> = [];
|
|
723
|
+
const twin = this.host.twinFetch(vendor);
|
|
724
|
+
try {
|
|
725
|
+
const answer = twin ? await twin(new Request('http://world.local/twin', { headers: { [READ_ONLY_HEADER]: '1' } })) : null;
|
|
726
|
+
const listed = answer?.ok ? ((await answer.json()) as { screens?: unknown }).screens : undefined;
|
|
727
|
+
if (Array.isArray(listed)) screens = listed.filter((x): x is { id: string; path: string } => typeof x?.id === 'string' && typeof x?.path === 'string').map(({ id, path }) => ({ id, path }));
|
|
728
|
+
} catch { screens = []; }
|
|
729
|
+
this.workspaces.set(vendor, { screens, at: Date.now() });
|
|
730
|
+
return screens;
|
|
801
731
|
}
|
|
802
732
|
|
|
803
733
|
/** Whether a twin enforces a read-scope request itself (its manifest names `requestScopes: ['read']`):
|
|
@@ -848,7 +778,7 @@ export class WorldDoors {
|
|
|
848
778
|
// the request's trace: the caller's trace-id goes on (or a new one), under a parent-id the World mints, which the
|
|
849
779
|
// request's log entries carry, so each entry is joined to its caller by an id no caller chose
|
|
850
780
|
headers.delete(CALLER_HEADER);
|
|
851
|
-
const who = this.callerOf(request); if (who) headers.set(CALLER_HEADER, who);
|
|
781
|
+
const who = this.callerOf(request); if (who) headers.set(CALLER_HEADER, callerForHeader(who));
|
|
852
782
|
headers.set('traceparent', traceparentForDelivery(headers.get('traceparent')) ?? newTraceparent());
|
|
853
783
|
// at a twin that enforces the read scope, every request of the read token is marked read-only (a GET
|
|
854
784
|
// that would write for its caller is refused too); at any other, its GET passes as it always has
|
|
@@ -863,19 +793,6 @@ export class WorldDoors {
|
|
|
863
793
|
const answer = await twin(new Request(`${url.origin}/${tail.join('/')}${url.search}`, { method: request.method, headers, ...(body === undefined ? {} : { body }), redirect: 'manual' }));
|
|
864
794
|
// an upgrade the twin answered (a tunnel's control or visitor socket) goes back as it is
|
|
865
795
|
if (answer.status === 101) return answer;
|
|
866
|
-
// THE VENDOR'S SITE: a page the twin does not serve as a page itself (it serves an OAuth screen, a redirect) is the
|
|
867
|
-
// vendor's own screens, the mirror, answered at that very address: the twin's place is the vendor's site, and a
|
|
868
|
-
// mirror's routes are real URLs, reloadable and linkable, as a dev server falls back to its app for unknown pages
|
|
869
|
-
const servedAsPage = (answer.status >= 300 && answer.status < 400) || (answer.status < 300 && (answer.headers.get('content-type') ?? '').includes('text/html'));
|
|
870
|
-
// a credential reached it: the manifest's keyless GET never becomes a page (and never signs anyone in). A resource
|
|
871
|
-
// the twin answers (a JSON link, a stored file, a PDF: 2xx) opened in a tab is that resource, as the vendor's own
|
|
872
|
-
// address would be; an address it refuses or has nothing at (a browser brings no vendor token), and the site's
|
|
873
|
-
// home, are the screens
|
|
874
|
-
const unanswered = answer.status >= 400 || tail.join('/') === '';
|
|
875
|
-
if (page && scope !== null && !servedAsPage && unanswered && (await this.host.hasMirror?.(vendor!))) {
|
|
876
|
-
await answer.body?.cancel().catch(() => undefined);
|
|
877
|
-
return await this.mirror(request, url, vendor!, '', true);
|
|
878
|
-
}
|
|
879
796
|
// staleness is a fact on the wire: a twin with a root says when the vendor was last observed
|
|
880
797
|
const out = new Headers(answer.headers);
|
|
881
798
|
const observedAt = this.observedAt(vendor!);
|
|
@@ -887,7 +804,8 @@ export class WorldDoors {
|
|
|
887
804
|
// an origin of its own with no scripts, so it can neither read the session's World nor make it a key
|
|
888
805
|
const type = (answer.headers.get('content-type') ?? '').toLowerCase();
|
|
889
806
|
if (page && answer.status < 300 && /^(image\/svg\+xml|application\/xhtml\+xml|text\/xml|application\/xml)\b/.test(type)) out.set('content-security-policy', 'sandbox');
|
|
890
|
-
// a page and an API call to one address
|
|
807
|
+
// a page and an API call to one address may be answered differently (a stored file opened as a page is sandboxed
|
|
808
|
+
// above): caches keep them apart
|
|
891
809
|
out.append('vary', 'Sec-Fetch-Dest');
|
|
892
810
|
const setCookies = answer.headers.getSetCookie();
|
|
893
811
|
if (setCookies.some((c) => /^\s*(?:__Host-)?volter_world=/i.test(c))) {
|
|
@@ -1031,7 +949,7 @@ export class WorldDoors {
|
|
|
1031
949
|
if (scope === null) return Response.json({ error: 'token required' }, { status: 401 });
|
|
1032
950
|
const parts = path.split('/').filter(Boolean);
|
|
1033
951
|
const [kind, name, verb] = parts;
|
|
1034
|
-
// a browser session is opened by either token: the read token's is how a view-only link opens a
|
|
952
|
+
// a browser session is opened by either token: the read token's is how a view-only link opens a vendor's screens
|
|
1035
953
|
// the session gate comes first: a page that holds only the cookie (a sibling World's, the same site) can neither
|
|
1036
954
|
// re-issue nor end the session through this door
|
|
1037
955
|
const foreign = this.crossOriginSession(request, via);
|
|
@@ -1051,7 +969,8 @@ export class WorldDoors {
|
|
|
1051
969
|
const instant = url.searchParams.get('at') ?? '';
|
|
1052
970
|
if (Number.isNaN(Date.parse(instant))) return Response.json({ error: 'history?at=<ISO-8601 instant>' }, { status: 400 });
|
|
1053
971
|
const views: Record<string, HistoryReference> = {};
|
|
1054
|
-
|
|
972
|
+
const vendors = Object.keys(this.up().services);
|
|
973
|
+
withHistoryLocks(vendors.map((vendor) => worldPaths(this.stateOf(vendor), this.controlRoot(vendor)).dir), () => { for (const vendor of vendors) views[vendor] = historyAtInstant(this.stateOf(vendor), instant, this.controlRoot(vendor)); });
|
|
1055
974
|
return Response.json({ world: this.served, at: new Date(Date.parse(instant)).toISOString(), views });
|
|
1056
975
|
}
|
|
1057
976
|
if (kind === 'map' && !name && request.method === 'GET') return Response.json(await this.map());
|
|
@@ -1358,7 +1277,7 @@ export class WorldDoors {
|
|
|
1358
1277
|
const marker = getActiveWorldStore().read(join(this.controlRoot(name), stateDirName(), 'world', name, 'refresh.json'));
|
|
1359
1278
|
let refresh: unknown = null; try { if (marker !== null) refresh = JSON.parse(marker); } catch { refresh = null; }
|
|
1360
1279
|
const last = [...parentEntries(state, this.controlRoot(name))].reverse().find((e) => e.landsId && e.receipt);
|
|
1361
|
-
return Response.json({ twin: name, state, protocol: this.up().services[name]?.protocol ?? null,
|
|
1280
|
+
return Response.json({ twin: name, state, protocol: this.up().services[name]?.protocol ?? null, screens: await this.screensOf(name), root: root ?? null, credential: sealedCredentialInfo(this.worldRoot, name), refresh, position: wholeLog(state, this.controlRoot(name)).length, lastReceipt: last?.receipt ?? null });
|
|
1362
1281
|
}
|
|
1363
1282
|
if (verb === 'refresh' && request.method === 'POST') {
|
|
1364
1283
|
const root = rootForControlRoot(this.controlRoot(name), name);
|
|
@@ -1370,7 +1289,7 @@ export class WorldDoors {
|
|
|
1370
1289
|
}
|
|
1371
1290
|
|
|
1372
1291
|
/** A browser session for this World: the presented token as an HttpOnly cookie scoped to the World's
|
|
1373
|
-
* paths, so a page served under it (a
|
|
1292
|
+
* paths, so a page served under it (a vendor's screen) reads the wire as the token's holder, in the
|
|
1374
1293
|
* token's scope. */
|
|
1375
1294
|
/** THE LOCAL SESSION: a World served on this machine's loopback never asks the person at it for a token. Any
|
|
1376
1295
|
* website can send requests to 127.0.0.1, so what stands in for the token is proof the request is this World's own
|
|
@@ -1536,7 +1455,7 @@ export class WorldDoors {
|
|
|
1536
1455
|
return newest;
|
|
1537
1456
|
}
|
|
1538
1457
|
|
|
1539
|
-
/** THE MAP: every twin and what it holds, by resource type, and
|
|
1458
|
+
/** THE MAP: every twin and what it holds, by resource type, and the workspaces it shows. */
|
|
1540
1459
|
private async map(): Promise<unknown> {
|
|
1541
1460
|
const twins: unknown[] = [];
|
|
1542
1461
|
for (const vendor of Object.keys(this.up().services)) {
|
|
@@ -1547,7 +1466,7 @@ export class WorldDoors {
|
|
|
1547
1466
|
for (const r of readTree(state, controlRoot)) if (r.deleted !== true && !r.type.startsWith('_')) counts.set(r.type, (counts.get(r.type) ?? 0) + 1);
|
|
1548
1467
|
twins.push({
|
|
1549
1468
|
twin: vendor, position: wholeLog(state, controlRoot).length,
|
|
1550
|
-
|
|
1469
|
+
screens: await this.screensOf(vendor),
|
|
1551
1470
|
root: rootForControlRoot(controlRoot, vendor) ?? null,
|
|
1552
1471
|
resources: [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([type, count]) => ({ type, count })),
|
|
1553
1472
|
});
|
|
@@ -1676,7 +1595,8 @@ export async function landChangeset(name: string, worldRoot: string, instance: W
|
|
|
1676
1595
|
byTwin.set(item.service, [...(byTwin.get(item.service) ?? []), item.action as Entry]);
|
|
1677
1596
|
}
|
|
1678
1597
|
const landedByTwin = new Map<string, string[]>();
|
|
1679
|
-
|
|
1598
|
+
const pushed = [...new Set([...Object.keys(expected).filter((twin) => instance.services[twin]), ...byTwin.keys()])];
|
|
1599
|
+
withHistoryLocks(pushed.map((twin) => worldPaths(stateOf(twin), join(instance.dirs.data, twin)).dir), () => {
|
|
1680
1600
|
for (const [twin, at] of Object.entries(expected)) {
|
|
1681
1601
|
if (!instance.services[twin]) throw new Error(`Unknown expected twin ${twin}`);
|
|
1682
1602
|
const head = captureHistory(stateOf(twin), join(instance.dirs.data, twin));
|
package/src/tail.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// notice, never an error.
|
|
9
9
|
import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs';
|
|
10
10
|
import { basename, dirname, join } from 'node:path';
|
|
11
|
-
import { stateDirName } from '@volter/world-core';
|
|
11
|
+
import { stateDirName } from '@volter/world-core/runtime';
|
|
12
12
|
import { statusWorld } from './runtime.ts';
|
|
13
13
|
|
|
14
14
|
/** The slice of a ledger row `tail` renders. Rows are vendor-recorded TwinActions
|
package/src/up-cli.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// `volter-world up`, loaded on its own (./cli.ts): it hands the boot to its owner process (./up-task.ts) before
|
|
2
|
+
// any other command's modules load, so a World's start never waits on them. Where each module is compiled as
|
|
3
|
+
// the process starts (a browser tab's engine compiles every module it loads), those modules were most of the
|
|
4
|
+
// CLI's time before the owner process began.
|
|
5
|
+
import { optionValue, ownerOption } from './cli-args.ts';
|
|
6
|
+
import type { upWorld } from './runtime.ts';
|
|
7
|
+
import { superviseWorldUp } from './up-task.ts';
|
|
8
|
+
|
|
9
|
+
export async function upCommand(subject: string | undefined, rest: string[], root: string): Promise<void> {
|
|
10
|
+
if (rest.some(arg => arg === '--env-file' || arg.startsWith('--env-file='))) throw new Error('--env-file is not an input for this command. Use --env-out=<new-output-path>; never pass credentials or custody as an output.');
|
|
11
|
+
if (!subject) throw new Error('volter-world up: missing config');
|
|
12
|
+
const envFile = optionValue(rest, '--env-out');
|
|
13
|
+
if (!envFile) throw new Error('volter-world up: missing required --env-out=<path>');
|
|
14
|
+
const name = optionValue(rest, '--name') || undefined;
|
|
15
|
+
const mode = optionValue(rest, '--mode', 'local') as 'local' | 'share' | 'sealed';
|
|
16
|
+
const isolationArg = optionValue(rest, '--isolation');
|
|
17
|
+
if (isolationArg && isolationArg !== 'process' && isolationArg !== 'colocated' && isolationArg !== 'worker') {
|
|
18
|
+
throw new Error(`volter-world up: invalid --isolation "${isolationArg}" (want process|colocated|worker)`);
|
|
19
|
+
}
|
|
20
|
+
const isolation = (isolationArg || undefined) as 'process' | 'colocated' | 'worker' | undefined;
|
|
21
|
+
// The boot runs in its own owner process (./up-task.ts): a closed terminal does not leave it half-done.
|
|
22
|
+
// --keep-state: boot over the state the World's directory already holds (a World carried somewhere else, e.g. a
|
|
23
|
+
// browser tab's image, comes up over its data instead of starting empty, as a fresh `up` does)
|
|
24
|
+
const keepState = rest.includes('--keep-state');
|
|
25
|
+
const instance = await superviseWorldUp(subject, { name, root, mode, envFile, forceEnvOutput: rest.includes('--force'), owner: ownerOption(rest), ...(isolation ? { isolation } : {}), ...(keepState ? { keepState: true } : {}) });
|
|
26
|
+
printReady(instance);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function printReady(instance: Awaited<ReturnType<typeof upWorld>>): void {
|
|
30
|
+
process.stdout.write(`World ${instance.name} is ready (${instance.config})
|
|
31
|
+
|
|
32
|
+
Services:
|
|
33
|
+
${Object.values(instance.services).map((service) => ` ${service.id}: ${service.url ?? '(no listener — a World output)'}`).join('\n')}
|
|
34
|
+
|
|
35
|
+
This instance started CLEAN (a world's state lives only while it runs; the world dir is the
|
|
36
|
+
reproducible story) — set the clock and run your seed now, after every up.
|
|
37
|
+
|
|
38
|
+
The two moves: if the twin stores it, create it through the vendor's own API (seed as the
|
|
39
|
+
person, move the clock first for history). Everything else — judgment, lookups, faults — is
|
|
40
|
+
a handler in the world dir's handlers/<vendor>.json. EVERY twin explains itself at
|
|
41
|
+
GET <url>/twin; a scripted twin's GET <url>/twin/scenario lists handlers + misses.
|
|
42
|
+
|
|
43
|
+
Env:
|
|
44
|
+
${instance.envFile}
|
|
45
|
+
|
|
46
|
+
Instance:
|
|
47
|
+
${instance.dirs.instance}/instance.json
|
|
48
|
+
|
|
49
|
+
Stop:
|
|
50
|
+
volter-world down ${instance.name}
|
|
51
|
+
|
|
52
|
+
For a bounded job, use volter-world run ... -- <command> to tear down automatically on exit.
|
|
53
|
+
Persistent up does not expire or infer that your sessions are idle.
|
|
54
|
+
`);
|
|
55
|
+
}
|
package/src/websocket-relay.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ServeHttpOptions } from '@volter/world-core';
|
|
1
|
+
import type { ServeHttpOptions } from '@volter/world-core/runtime';
|
|
2
2
|
|
|
3
3
|
/** A message relay for the World's TLS fronts. Transport state is connection-local. */
|
|
4
4
|
export function websocketRelay(targetFor: (request: Request) => string | undefined): NonNullable<ServeHttpOptions['upgrade']> {
|
package/src/world-origins.ts
CHANGED
|
@@ -31,7 +31,7 @@ export function worldDoorUnderConsole(path: string, served: (name: string) => bo
|
|
|
31
31
|
return Boolean(m && served(`${m[1]}/${m[2]}`));
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
/** Whether a path on a World's origin belongs to that World (its wire, its doors, its
|
|
34
|
+
/** Whether a path on a World's origin belongs to that World (its wire, its doors, its vendors' screens) or to
|
|
35
35
|
* what every origin carries (`/-/ping`, the console): a World's origin serves no other World. */
|
|
36
36
|
export function pathOfWorld(path: string, served: string, consoleBase: string): boolean {
|
|
37
37
|
return path === '/-/ping' || path === consoleBase || path.startsWith(`${consoleBase}/`) || path.startsWith(`/${served}/`) || path.startsWith(`/-/${served}/`) || path === `/${served}` || path === `/-/${served}`;
|
package/src/world-view.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
// THE LOCAL VIEW: one World served for a person to step into — its doors, its
|
|
1
|
+
// THE LOCAL VIEW: one World served for a person to step into — its doors, its vendors' screens, its branches
|
|
2
2
|
// ("as of" views) and the console, on one origin (docs/contributing/architecture.md, "Viewing a
|
|
3
3
|
// World"). `volter world view` is this; the runtime's `up` gains no step. A host that serves many
|
|
4
4
|
// Worlds (world-host) answers the same doors; this serves one, and the branches made of it.
|
|
5
5
|
import { existsSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { loadWorldConfig } from './configs.ts';
|
|
7
7
|
import { join, resolve } from 'node:path';
|
|
8
|
-
import { serveHttp, stateDirName } from '@volter/world-core';
|
|
8
|
+
import { serveHttp, stateDirName } from '@volter/world-core/runtime';
|
|
9
9
|
import { LocalBranches } from './local-branches.ts';
|
|
10
10
|
import { mountWorld, type MountedWorld } from './served-world.ts';
|
|
11
11
|
import { localWorldOrigin, pathOfWorld, worldDoorUnderConsole, worldOfHost } from './world-origins.ts';
|
|
@@ -28,7 +28,7 @@ function branchRoots(dir: string): string[] {
|
|
|
28
28
|
return out.sort();
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export async function serveWorldView(name: string, opts: { /** the person signed in on this machine (`volter login`): this World's own page acts as them */ localPerson?: string; root?: string; port?: number; host?: string; console?: ViewConsole; announce?: (line: string) => void; /** each World a browser steps into at an origin of its own (on by default on loopback) */ origins?: boolean; /** the World's own origin as a browser reaches it through a proxy on this machine (
|
|
31
|
+
export async function serveWorldView(name: string, opts: { /** the person signed in on this machine (`volter login`): this World's own page acts as them */ localPerson?: string; root?: string; port?: number; host?: string; console?: ViewConsole; announce?: (line: string) => void; /** each World a browser steps into at an origin of its own (on by default on loopback) */ origins?: boolean; /** the World's own origin as a browser reaches it through a proxy on this machine (an app that serves it as one of its own pages):
|
|
32
32
|
* a request the proxy forwards from that host (X-Forwarded-Host) is answered as at that origin, its local session included */ origin?: string } = {}): Promise<WorldView> {
|
|
33
33
|
const root = resolve(opts.root ?? process.cwd());
|
|
34
34
|
const host = opts.host ?? '127.0.0.1';
|