@volter/world-runtime 3.0.6 → 3.0.8

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.
@@ -46,7 +46,7 @@ export { landReceipts, mountWorld, READ_ONLY_HEADER, worldOriginLabel, readServe
46
46
  export type { BranchDoors, BranchRow, DoorHost, MountedWorld, ServeRecord, WorldLayout } from './served-world.js';
47
47
  export { LocalBranches } from './local-branches.js';
48
48
  export { serveWorldView, VIEW_CONSOLE_BASE } from './world-view.js';
49
- export { LOCAL_ORIGIN_BASE, localWorldOrigin, pathOfWorld, worldDoorUnderConsole, worldOfHost } from './world-origins.js';
49
+ export { LOCAL_ORIGIN_BASE, localWorldOrigin, pathOfWorld, siteOfHost, worldDoorUnderConsole, worldOfHost } from './world-origins.js';
50
50
  export type { ViewConsole, WorldView } from './world-view.js';
51
51
  export type { LocalBranchesOptions } from './local-branches.js';
52
52
  export type { ServedWorld } from './served-world.js';
package/dist/src/index.js CHANGED
@@ -33,7 +33,7 @@ export { deployWorld, refreshTwin, adaptersFor, credentialPath, rewrapTwinCreden
33
33
  export { landReceipts, mountWorld, READ_ONLY_HEADER, worldOriginLabel, readServeRecord, refreshSchedule, servedName, serveWorld, sessionsFile, keysFile, TOKEN_HEADER, WorldDoors } from "./served-world.js";
34
34
  export { LocalBranches } from "./local-branches.js";
35
35
  export { serveWorldView, VIEW_CONSOLE_BASE } from "./world-view.js";
36
- export { LOCAL_ORIGIN_BASE, localWorldOrigin, pathOfWorld, worldDoorUnderConsole, worldOfHost } from "./world-origins.js";
36
+ export { LOCAL_ORIGIN_BASE, localWorldOrigin, pathOfWorld, siteOfHost, worldDoorUnderConsole, worldOfHost } from "./world-origins.js";
37
37
  export { CONSOLE_BASE, consoleRedirect, serveConsoleApart, serveConsoleFor } from "./console-apart.js";
38
38
  export { assertWorldStateRemovable } from "./ancestry.js";
39
39
  export { withoutVaultSignIn } from "./runtime.js";
@@ -42,6 +42,11 @@ export type PackFacts = {
42
42
  body: Record<string, unknown>;
43
43
  fill: Record<string, string>;
44
44
  };
45
+ /** The door listing the hostnames the twin answers while it runs (TwinPack.hostsClaimed). */
46
+ hostsClaimed?: {
47
+ door: string;
48
+ note?: string;
49
+ };
45
50
  credentialIssuer?: string;
46
51
  /** The pack's colocatable `create<Name>TwinServer` factory export, derived (or descriptor-
47
52
  * declared under ambiguity) by scripts/pack-facts.ts. Consumed by `init` to emit
@@ -54,7 +54,8 @@ export type MountedWorld = {
54
54
  token: string;
55
55
  readToken: string;
56
56
  };
57
- handle: (request: Request) => Promise<Response>;
57
+ handle: (request: Request) => Promise<Response>; /** a hostname this World's twins answer, as a browser reads it (siteOfHost) */
58
+ site: (request: Request, host: string) => Promise<Response>;
58
59
  boot: (url: string) => Promise<void>;
59
60
  stop: () => Promise<void>;
60
61
  };
@@ -291,6 +292,15 @@ export declare class WorldDoors {
291
292
  private refresh;
292
293
  private controlRoot;
293
294
  private stateOf;
295
+ /** The hostnames each of this World's twins answers while it runs (its hostsClaimed door). */
296
+ claimedHosts(origin: string): Promise<Array<{
297
+ vendor: string;
298
+ hosts: string[];
299
+ }>>;
300
+ /** A hostname one of this World's twins answers (its hostsClaimed door lists it), read as a browser reads it there:
301
+ * the request goes to that twin with the hostname as its original host, as the injector routes it inside the World.
302
+ * Reads only: the site is the twin's state as the World holds it, never a way to write it. */
303
+ site(request: Request, host: string): Promise<Response>;
294
304
  handle(request: Request): Promise<Response>;
295
305
  /** A twin's workspaces as its discovery door lists them (`GET /twin`'s `screens`: each built workspace screen's id and
296
306
  * path), which a person is shown at `/<served>/<vendor><path>`. Asked again after a minute, as `enforcesRead` is. */
@@ -26,7 +26,7 @@ import { getActiveWorldStore, withHistoryLocks, captureHistory, historyAtInstant
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, readFileSync, writeFileSync, rmSync } from 'node:fs';
29
- import { CALLER_HEADER, callerForHeader, newTraceparent, parseTraceparent, traceparentForDelivery, readTwinRequestJournalFrom, assertSafeChangesetName, blobDigest, destinationOf, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, openSealedAt, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, validateRemoteOrigin } from '@volter/world-core/runtime';
29
+ import { CALLER_HEADER, callerForHeader, newTraceparent, parseTraceparent, traceparentForDelivery, readTwinRequestJournalFrom, assertSafeChangesetName, blobDigest, destinationOf, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, openSealedAt, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, validateRemoteOrigin } from '@volter/world-core/runtime';
30
30
  import { APP_READ_ENDPOINT_ENV } from "./init.js";
31
31
  import { packFacts } from "./pack-facts.js";
32
32
  import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass } from '@volter/world-access';
@@ -158,6 +158,7 @@ export async function mountWorld(name, opts = {}) {
158
158
  twins: () => world.twins(),
159
159
  rotate: () => { world.retoken(mintToken('tok_'), mintToken('tok_r_')); keep(); return { token: world.token, readToken: world.readToken }; },
160
160
  handle: (request) => world.handle(request),
161
+ site: (request, host) => world.site(request, host),
161
162
  async boot(url) {
162
163
  keep();
163
164
  const port = Number(new URL(url).port) || 0;
@@ -746,6 +747,56 @@ export class WorldDoors {
746
747
  stateOf(vendor) {
747
748
  return stateServiceOf(this.controlRoot(vendor), vendor);
748
749
  }
750
+ /** The hostnames each of this World's twins answers while it runs (its hostsClaimed door). */
751
+ async claimedHosts(origin) {
752
+ const out = [];
753
+ for (const vendor of Object.keys(this.up().services)) {
754
+ const door = packFacts()[vendor]?.hostsClaimed?.door;
755
+ const twin = door ? this.host.twinFetch(vendor) : undefined;
756
+ if (!door || !twin)
757
+ continue;
758
+ const listed = await twin(new Request(`${origin}${door}`, { headers: { [READ_ONLY_HEADER]: '1' } })).then((r) => (r.ok ? r.json() : null)).catch(() => null);
759
+ const hosts = Array.isArray(listed?.hosts) ? listed.hosts.map((h) => String(h).toLowerCase()) : [];
760
+ if (hosts.length)
761
+ out.push({ vendor, hosts });
762
+ }
763
+ return out;
764
+ }
765
+ /** A hostname one of this World's twins answers (its hostsClaimed door lists it), read as a browser reads it there:
766
+ * the request goes to that twin with the hostname as its original host, as the injector routes it inside the World.
767
+ * Reads only: the site is the twin's state as the World holds it, never a way to write it. */
768
+ async site(request, host) {
769
+ if (request.method !== 'GET' && request.method !== 'HEAD')
770
+ return Response.json({ error: 'a site in a World is read: GET and HEAD' }, { status: 405 });
771
+ const url = new URL(request.url);
772
+ for (const { vendor, hosts } of await this.claimedHosts(url.origin)) {
773
+ const twin = this.host.twinFetch(vendor);
774
+ if (!twin || !hosts.includes(host))
775
+ continue;
776
+ const headers = new Headers();
777
+ for (const name of ['accept', 'accept-language', 'if-none-match', 'range', 'user-agent']) {
778
+ const v = request.headers.get(name);
779
+ if (v !== null)
780
+ headers.set(name, v);
781
+ }
782
+ headers.set('x-volter-twin-original-host', host);
783
+ headers.set(READ_ONLY_HEADER, '1');
784
+ const answer = await twin(new Request(`${url.origin}${url.pathname}${url.search}`, { method: request.method, headers, redirect: 'manual' }));
785
+ // a redirect the site answers names its own hostname: in the World it stays under the World's origin
786
+ const out = new Headers(answer.headers);
787
+ const location = out.get('location');
788
+ if (location) {
789
+ try {
790
+ const to = new URL(location, `https://${host}`);
791
+ if (to.hostname === host)
792
+ out.set('location', `${to.pathname}${to.search}${to.hash}`);
793
+ }
794
+ catch { /* left as answered */ }
795
+ }
796
+ return new Response(request.method === 'HEAD' ? null : answer.body, { status: answer.status, statusText: answer.statusText, headers: out });
797
+ }
798
+ return Response.json({ error: `no twin of ${this.served} answers ${host}` }, { status: 404 });
799
+ }
749
800
  async handle(request) {
750
801
  try {
751
802
  if (!this.host.ready() && !new URL(request.url).pathname.startsWith('/-/ping'))
@@ -890,6 +941,14 @@ export class WorldDoors {
890
941
  headers.set(TWIN_PREFIX_HEADER, `/${this.served}/${vendor}`);
891
942
  headers.set('x-forwarded-host', url.host);
892
943
  headers.set('x-forwarded-proto', url.protocol.replace(':', ''));
944
+ // where this World shows the hostnames its twins answer, for a page that links one (twinSiteUrl): under its own
945
+ // origin on this machine (siteOfHost); a World without one shows none
946
+ const own = this.host.browserOrigin?.() ?? null;
947
+ const sites = own ? new URL(own) : null;
948
+ if (sites && sites.hostname.endsWith('.localhost'))
949
+ headers.set(TWIN_SITES_HEADER, `${sites.protocol}//{host}.${sites.host}`);
950
+ else
951
+ headers.delete(TWIN_SITES_HEADER);
893
952
  const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
894
953
  const answer = await twin(new Request(`${url.origin}/${tail.join('/')}${url.search}`, { method: request.method, headers, ...(body === undefined ? {} : { body }), redirect: 'manual' }));
895
954
  // an upgrade the twin answered (a tunnel's control or visitor socket) goes back as it is
@@ -934,7 +993,7 @@ export class WorldDoors {
934
993
  // what leaves for the vendor before the seal is applied: no World token in any header it can arrive
935
994
  // under (an SDK's own `Authorization: Bearer` included), no cookie, no routing headers of this World
936
995
  const caller = new Headers(request.headers);
937
- for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, CALLER_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie'])
996
+ for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, CALLER_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie'])
938
997
  caller.delete(h);
939
998
  // a secret this World minted (`tok_…`, live, revoked or expired) or one that opens it never leaves
940
999
  const authorization = caller.get('authorization');
@@ -1126,7 +1185,9 @@ export class WorldDoors {
1126
1185
  return this.timeline(url);
1127
1186
  if (kind === 'diff' && !name && request.method === 'GET') {
1128
1187
  const delta = diffWorld(this.name, { root: this.worldRoot });
1129
- return Response.json({ world: this.served, base: { id: delta.base.id, at: delta.base.createdAt ?? null }, changes: delta.actions.map((a) => ({ twin: a.service, entry: a.action })) });
1188
+ // what another World pushed here is in its changeset already: never this World's uncut changes
1189
+ const received = new Set(listWorldChangesets({ root: this.worldRoot, world: this.name }).filter((l) => l.changeset.pushedBy).flatMap((l) => l.changeset.actions.map((a) => a.action.id)));
1190
+ return Response.json({ world: this.served, base: { id: delta.base.id, at: delta.base.createdAt ?? null }, changes: delta.actions.filter((a) => !received.has(a.action.id)).map((a) => ({ twin: a.service, entry: a.action })) });
1130
1191
  }
1131
1192
  if (kind === 'log' && name && request.method === 'GET') {
1132
1193
  const state = this.stateOf(name);
@@ -11,3 +11,10 @@ export declare function worldDoorUnderConsole(path: string, served: (name: strin
11
11
  /** Whether a path on a World's origin belongs to that World (its wire, its doors, its vendors' screens) or to
12
12
  * what every origin carries (`/-/ping`, the console): a World's origin serves no other World. */
13
13
  export declare function pathOfWorld(path: string, served: string, consoleBase: string): boolean;
14
+ /** A site a World's twins answer, reached under the World's origin: `<hostname>.<world>--<org>.localhost` names the
15
+ * hostname `<hostname>` as that World's twins answer it (a Workers Custom Domain, an R2 public domain). The World and
16
+ * the hostname, or undefined for any other host. Each site is an origin of its own, apart from the World's session. */
17
+ export declare function siteOfHost(hostname: string, served: Iterable<string>, base?: string): {
18
+ world: string;
19
+ site: string;
20
+ } | undefined;
@@ -35,3 +35,21 @@ export function worldDoorUnderConsole(path, served, consoleBase) {
35
35
  export function pathOfWorld(path, served, consoleBase) {
36
36
  return path === '/-/ping' || path === consoleBase || path.startsWith(`${consoleBase}/`) || path.startsWith(`/${served}/`) || path.startsWith(`/-/${served}/`) || path === `/${served}` || path === `/-/${served}`;
37
37
  }
38
+ /** A site a World's twins answer, reached under the World's origin: `<hostname>.<world>--<org>.localhost` names the
39
+ * hostname `<hostname>` as that World's twins answer it (a Workers Custom Domain, an R2 public domain). The World and
40
+ * the hostname, or undefined for any other host. Each site is an origin of its own, apart from the World's session. */
41
+ export function siteOfHost(hostname, served, base = LOCAL_ORIGIN_BASE) {
42
+ const suffix = `.${base}`;
43
+ if (!hostname.endsWith(suffix))
44
+ return undefined;
45
+ const rest = hostname.slice(0, -suffix.length);
46
+ for (const name of served) {
47
+ const label = worldOriginLabel(name);
48
+ if (!label || !rest.endsWith(`.${label}`))
49
+ continue;
50
+ const site = rest.slice(0, -(label.length + 1));
51
+ if (site.includes('.'))
52
+ return { world: name, site };
53
+ }
54
+ return undefined;
55
+ }
@@ -8,7 +8,7 @@ import { join, resolve } from 'node:path';
8
8
  import { serveHttp, stateDirName } from '@volter/world-core/runtime';
9
9
  import { LocalBranches } from "./local-branches.js";
10
10
  import { mountWorld } from "./served-world.js";
11
- import { localWorldOrigin, pathOfWorld, worldDoorUnderConsole, worldOfHost } from "./world-origins.js";
11
+ import { localWorldOrigin, pathOfWorld, siteOfHost, worldDoorUnderConsole, worldOfHost } from "./world-origins.js";
12
12
  export const VIEW_CONSOLE_BASE = '/-/console';
13
13
  /** The branch Worlds already under `dir` (`<dir>/<org>/<world>/.volter/world.json`). */
14
14
  function branchRoots(dir) {
@@ -110,6 +110,10 @@ export async function serveWorldView(name, opts = {}) {
110
110
  const at = new URL(request.url);
111
111
  const path = at.pathname;
112
112
  // a World's own origin serves that World (and the ping and the console) and no other
113
+ // a site the World's twins answer, under the World's local origin (`<hostname>.<world>--<org>.localhost`)
114
+ const site = origins && !proxied ? siteOfHost(at.hostname, worlds.keys()) : undefined;
115
+ if (site)
116
+ return worlds.get(site.world).site(request, site.site);
113
117
  const own = origins ? (proxied && at.host === proxied.host ? base : worldOfHost(at.hostname, worlds.keys())) : undefined;
114
118
  if (own === null)
115
119
  return Response.json({ error: `no world at ${at.host}` }, { status: 404 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "3.0.6",
3
+ "version": "3.0.8",
4
4
  "description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
5
5
  "keywords": [
6
6
  "twin",
@@ -68,14 +68,14 @@
68
68
  "node": ">=22.3"
69
69
  },
70
70
  "peerDependencies": {
71
- "@volter/world-core": "3.0.6",
72
- "@volter/world-console": "3.0.1"
71
+ "@volter/world-core": "3.0.8",
72
+ "@volter/world-console": "3.0.3"
73
73
  },
74
74
  "dependencies": {
75
75
  "@electric-sql/pglite": "0.5.8",
76
76
  "@electric-sql/pglite-pgvector": "0.0.9",
77
77
  "@volter/world-access": "3.0.1",
78
- "@volter/world-core": "3.0.6",
78
+ "@volter/world-core": "3.0.8",
79
79
  "pg-gateway": "0.3.0-beta.4",
80
80
  "smol-toml": "^1.8.0",
81
81
  "yaml": "2.9.0"
package/src/index.ts CHANGED
@@ -168,7 +168,7 @@ export { landReceipts, mountWorld, READ_ONLY_HEADER, worldOriginLabel, readServe
168
168
  export type { BranchDoors, BranchRow, DoorHost, MountedWorld, ServeRecord, WorldLayout } from './served-world.ts';
169
169
  export { LocalBranches } from './local-branches.ts';
170
170
  export { serveWorldView, VIEW_CONSOLE_BASE } from './world-view.ts';
171
- export { LOCAL_ORIGIN_BASE, localWorldOrigin, pathOfWorld, worldDoorUnderConsole, worldOfHost } from './world-origins.ts';
171
+ export { LOCAL_ORIGIN_BASE, localWorldOrigin, pathOfWorld, siteOfHost, worldDoorUnderConsole, worldOfHost } from './world-origins.ts';
172
172
  export type { ViewConsole, WorldView } from './world-view.ts';
173
173
  export type { LocalBranchesOptions } from './local-branches.ts';
174
174
  export type { ServedWorld } from './served-world.ts';
package/src/pack-facts.ts CHANGED
@@ -22,6 +22,8 @@ export type PackFacts = {
22
22
  endpointEnvNone?: string;
23
23
  /** The door that issues the application a credential the twin accepts (TwinPack.credentialDoor). */
24
24
  credentialDoor?: { path: string; body: Record<string, unknown>; fill: Record<string, string> };
25
+ /** The door listing the hostnames the twin answers while it runs (TwinPack.hostsClaimed). */
26
+ hostsClaimed?: { door: string; note?: string };
25
27
  credentialIssuer?: string;
26
28
  /** The pack's colocatable `create<Name>TwinServer` factory export, derived (or descriptor-
27
29
  * declared under ambiguity) by scripts/pack-facts.ts. Consumed by `init` to emit
@@ -26,7 +26,7 @@ import { getActiveWorldStore, withHistoryLocks, captureHistory, historyAtInstant
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, 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';
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, TWIN_SITES_HEADER, validateRemoteOrigin, type SealedCredential, type TwinStreamConnection, type TwinStreamSink } from '@volter/world-core/runtime';
30
30
  import { APP_READ_ENDPOINT_ENV } from './init.ts';
31
31
  import { packFacts } from './pack-facts.ts';
32
32
  import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass, type TrustedIssuer, type WorldCredentials } from '@volter/world-access';
@@ -115,7 +115,7 @@ function configRefFor(worldRoot: string, name: string): string {
115
115
  /** A world mounted by a host. `token`/`readToken` read the CURRENT tokens (rotate changes them);
116
116
  * `twins()` names the world's twins once booted; `rotate()` mints both tokens anew — the old die
117
117
  * with the call — and keeps them beside the world like `boot` does. */
118
- export type MountedWorld = { name: string; served: string; root: string; readonly token: string; readonly readToken: string; twins: () => string[]; rotate: () => { token: string; readToken: string }; handle: (request: Request) => Promise<Response>; boot: (url: string) => Promise<void>; stop: () => Promise<void> };
118
+ export type MountedWorld = { name: string; served: string; root: string; readonly token: string; readonly readToken: string; twins: () => string[]; rotate: () => { token: string; readToken: string }; handle: (request: Request) => Promise<Response>; /** a hostname this World's twins answer, as a browser reads it (siteOfHost) */ site: (request: Request, host: string) => Promise<Response>; boot: (url: string) => Promise<void>; stop: () => Promise<void> };
119
119
  /** A browser session a World keeps: its scope, when it ends, and the person a pass named, when one did. */
120
120
  type HeldSession = { scope: 'read' | 'write'; until: number; /** opened by this machine's own page (localSession) */ local?: true; who?: string; /** the platform whose pass opened it */ issuer?: string; /** the person's subject there */ sub?: string; /** the read key a shared link opened it with: it lasts only while that key does */ key?: string };
121
121
 
@@ -169,6 +169,7 @@ export async function mountWorld(name: string, opts: { /** the platforms whose p
169
169
  twins: () => world.twins(),
170
170
  rotate: () => { world.retoken(mintToken('tok_'), mintToken('tok_r_')); keep(); return { token: world.token, readToken: world.readToken }; },
171
171
  handle: (request) => world.handle(request),
172
+ site: (request, host) => world.site(request, host),
172
173
  async boot(url) {
173
174
  keep();
174
175
  const port = Number(new URL(url).port) || 0;
@@ -671,6 +672,43 @@ export class WorldDoors {
671
672
  return stateServiceOf(this.controlRoot(vendor), vendor);
672
673
  }
673
674
 
675
+ /** The hostnames each of this World's twins answers while it runs (its hostsClaimed door). */
676
+ async claimedHosts(origin: string): Promise<Array<{ vendor: string; hosts: string[] }>> {
677
+ const out: Array<{ vendor: string; hosts: string[] }> = [];
678
+ for (const vendor of Object.keys(this.up().services)) {
679
+ const door = packFacts()[vendor]?.hostsClaimed?.door;
680
+ const twin = door ? this.host.twinFetch(vendor) : undefined;
681
+ if (!door || !twin) continue;
682
+ const listed = await twin(new Request(`${origin}${door}`, { headers: { [READ_ONLY_HEADER]: '1' } })).then((r) => (r.ok ? r.json() as Promise<{ hosts?: unknown }> : null)).catch(() => null);
683
+ const hosts = Array.isArray(listed?.hosts) ? (listed.hosts as unknown[]).map((h) => String(h).toLowerCase()) : [];
684
+ if (hosts.length) out.push({ vendor, hosts });
685
+ }
686
+ return out;
687
+ }
688
+
689
+ /** A hostname one of this World's twins answers (its hostsClaimed door lists it), read as a browser reads it there:
690
+ * the request goes to that twin with the hostname as its original host, as the injector routes it inside the World.
691
+ * Reads only: the site is the twin's state as the World holds it, never a way to write it. */
692
+ async site(request: Request, host: string): Promise<Response> {
693
+ if (request.method !== 'GET' && request.method !== 'HEAD') return Response.json({ error: 'a site in a World is read: GET and HEAD' }, { status: 405 });
694
+ const url = new URL(request.url);
695
+ for (const { vendor, hosts } of await this.claimedHosts(url.origin)) {
696
+ const twin = this.host.twinFetch(vendor);
697
+ if (!twin || !hosts.includes(host)) continue;
698
+ const headers = new Headers();
699
+ for (const name of ['accept', 'accept-language', 'if-none-match', 'range', 'user-agent']) { const v = request.headers.get(name); if (v !== null) headers.set(name, v); }
700
+ headers.set('x-volter-twin-original-host', host);
701
+ headers.set(READ_ONLY_HEADER, '1');
702
+ const answer = await twin(new Request(`${url.origin}${url.pathname}${url.search}`, { method: request.method, headers, redirect: 'manual' }));
703
+ // a redirect the site answers names its own hostname: in the World it stays under the World's origin
704
+ const out = new Headers(answer.headers);
705
+ const location = out.get('location');
706
+ if (location) { try { const to = new URL(location, `https://${host}`); if (to.hostname === host) out.set('location', `${to.pathname}${to.search}${to.hash}`); } catch { /* left as answered */ } }
707
+ return new Response(request.method === 'HEAD' ? null : answer.body, { status: answer.status, statusText: answer.statusText, headers: out });
708
+ }
709
+ return Response.json({ error: `no twin of ${this.served} answers ${host}` }, { status: 404 });
710
+ }
711
+
674
712
  async handle(request: Request): Promise<Response> {
675
713
  try {
676
714
  if (!this.host.ready() && !new URL(request.url).pathname.startsWith('/-/ping')) return Response.json({ error: 'booting', world: this.served }, { status: 503, headers: { 'retry-after': '1' } });
@@ -789,6 +827,11 @@ export class WorldDoors {
789
827
  // the twin is mounted under this World's path: live URLs it mints must carry it (twinPublicBase)
790
828
  headers.set(TWIN_PREFIX_HEADER, `/${this.served}/${vendor}`);
791
829
  headers.set('x-forwarded-host', url.host); headers.set('x-forwarded-proto', url.protocol.replace(':', ''));
830
+ // where this World shows the hostnames its twins answer, for a page that links one (twinSiteUrl): under its own
831
+ // origin on this machine (siteOfHost); a World without one shows none
832
+ const own = this.host.browserOrigin?.() ?? null;
833
+ const sites = own ? new URL(own) : null;
834
+ if (sites && sites.hostname.endsWith('.localhost')) headers.set(TWIN_SITES_HEADER, `${sites.protocol}//{host}.${sites.host}`); else headers.delete(TWIN_SITES_HEADER);
792
835
  const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
793
836
  const answer = await twin(new Request(`${url.origin}/${tail.join('/')}${url.search}`, { method: request.method, headers, ...(body === undefined ? {} : { body }), redirect: 'manual' }));
794
837
  // an upgrade the twin answered (a tunnel's control or visitor socket) goes back as it is
@@ -830,7 +873,7 @@ export class WorldDoors {
830
873
  // what leaves for the vendor before the seal is applied: no World token in any header it can arrive
831
874
  // under (an SDK's own `Authorization: Bearer` included), no cookie, no routing headers of this World
832
875
  const caller = new Headers(request.headers);
833
- for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, CALLER_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie']) caller.delete(h);
876
+ for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, CALLER_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie']) caller.delete(h);
834
877
  // a secret this World minted (`tok_…`, live, revoked or expired) or one that opens it never leaves
835
878
  const authorization = caller.get('authorization');
836
879
  const offered = authorization ? presentedSecrets(new Headers({ authorization }), { token: TOKEN_HEADER, twinsKey: TWINS_KEY_HEADER }) : [];
@@ -977,7 +1020,9 @@ export class WorldDoors {
977
1020
  if (kind === 'timeline' && !name && request.method === 'GET') return this.timeline(url);
978
1021
  if (kind === 'diff' && !name && request.method === 'GET') {
979
1022
  const delta = diffWorld(this.name, { root: this.worldRoot });
980
- return Response.json({ world: this.served, base: { id: delta.base.id, at: delta.base.createdAt ?? null }, changes: delta.actions.map((a) => ({ twin: a.service, entry: a.action })) });
1023
+ // what another World pushed here is in its changeset already: never this World's uncut changes
1024
+ const received = new Set(listWorldChangesets({ root: this.worldRoot, world: this.name }).filter((l) => l.changeset.pushedBy).flatMap((l) => l.changeset.actions.map((a) => a.action.id)));
1025
+ return Response.json({ world: this.served, base: { id: delta.base.id, at: delta.base.createdAt ?? null }, changes: delta.actions.filter((a) => !received.has(a.action.id)).map((a) => ({ twin: a.service, entry: a.action })) });
981
1026
  }
982
1027
 
983
1028
  if (kind === 'log' && name && request.method === 'GET') {
@@ -36,3 +36,19 @@ export function worldDoorUnderConsole(path: string, served: (name: string) => bo
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}`;
38
38
  }
39
+
40
+ /** A site a World's twins answer, reached under the World's origin: `<hostname>.<world>--<org>.localhost` names the
41
+ * hostname `<hostname>` as that World's twins answer it (a Workers Custom Domain, an R2 public domain). The World and
42
+ * the hostname, or undefined for any other host. Each site is an origin of its own, apart from the World's session. */
43
+ export function siteOfHost(hostname: string, served: Iterable<string>, base = LOCAL_ORIGIN_BASE): { world: string; site: string } | undefined {
44
+ const suffix = `.${base}`;
45
+ if (!hostname.endsWith(suffix)) return undefined;
46
+ const rest = hostname.slice(0, -suffix.length);
47
+ for (const name of served) {
48
+ const label = worldOriginLabel(name);
49
+ if (!label || !rest.endsWith(`.${label}`)) continue;
50
+ const site = rest.slice(0, -(label.length + 1));
51
+ if (site.includes('.')) return { world: name, site };
52
+ }
53
+ return undefined;
54
+ }
package/src/world-view.ts CHANGED
@@ -8,7 +8,7 @@ import { join, resolve } from 'node:path';
8
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
- import { localWorldOrigin, pathOfWorld, worldDoorUnderConsole, worldOfHost } from './world-origins.ts';
11
+ import { localWorldOrigin, pathOfWorld, siteOfHost, worldDoorUnderConsole, worldOfHost } from './world-origins.ts';
12
12
 
13
13
  /** A console as a host mounts it (`@volter/world-console`): answer a request under `base`, or null. */
14
14
  export type ViewConsole = { handle: (request: Request, base: string, opts?: { worldOrigin?: boolean }) => Promise<Response | null> };
@@ -99,6 +99,9 @@ export async function serveWorldView(name: string, opts: { /** the person signed
99
99
  : incoming;
100
100
  const at = new URL(request.url); const path = at.pathname;
101
101
  // a World's own origin serves that World (and the ping and the console) and no other
102
+ // a site the World's twins answer, under the World's local origin (`<hostname>.<world>--<org>.localhost`)
103
+ const site = origins && !proxied ? siteOfHost(at.hostname, worlds.keys()) : undefined;
104
+ if (site) return worlds.get(site.world)!.site(request, site.site);
102
105
  const own = origins ? (proxied && at.host === proxied.host ? base! : worldOfHost(at.hostname, worlds.keys())) : undefined;
103
106
  if (own === null) return Response.json({ error: `no world at ${at.host}` }, { status: 404 });
104
107
  // the bare address, typed into a browser, is the World's page: at its own origin when it has one