@volter/world-runtime 3.0.5 → 3.0.7

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. */
@@ -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'))
@@ -1124,9 +1175,15 @@ export class WorldDoors {
1124
1175
  return Response.json(await this.map());
1125
1176
  if (kind === 'timeline' && !name && request.method === 'GET')
1126
1177
  return this.timeline(url);
1178
+ // the sites this World's twins answer (a Workers Custom Domain, an R2 public domain), each read in a browser at
1179
+ // `<hostname>.<the World's origin>` (siteOfHost)
1180
+ if (kind === 'sites' && !name && request.method === 'GET')
1181
+ return Response.json({ world: this.served, sites: (await this.claimedHosts(url.origin)).flatMap((c) => c.hosts.map((host) => ({ twin: c.vendor, host }))) });
1127
1182
  if (kind === 'diff' && !name && request.method === 'GET') {
1128
1183
  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 })) });
1184
+ // what another World pushed here is in its changeset already: never this World's uncut changes
1185
+ 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)));
1186
+ 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
1187
  }
1131
1188
  if (kind === 'log' && name && request.method === 'GET') {
1132
1189
  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.5",
3
+ "version": "3.0.7",
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.5",
72
- "@volter/world-console": "3.0.1"
71
+ "@volter/world-core": "3.0.7",
72
+ "@volter/world-console": "3.0.2"
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.5",
78
+ "@volter/world-core": "3.0.7",
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
@@ -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' } });
@@ -975,9 +1013,14 @@ export class WorldDoors {
975
1013
  }
976
1014
  if (kind === 'map' && !name && request.method === 'GET') return Response.json(await this.map());
977
1015
  if (kind === 'timeline' && !name && request.method === 'GET') return this.timeline(url);
1016
+ // the sites this World's twins answer (a Workers Custom Domain, an R2 public domain), each read in a browser at
1017
+ // `<hostname>.<the World's origin>` (siteOfHost)
1018
+ if (kind === 'sites' && !name && request.method === 'GET') return Response.json({ world: this.served, sites: (await this.claimedHosts(url.origin)).flatMap((c) => c.hosts.map((host) => ({ twin: c.vendor, host }))) });
978
1019
  if (kind === 'diff' && !name && request.method === 'GET') {
979
1020
  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 })) });
1021
+ // what another World pushed here is in its changeset already: never this World's uncut changes
1022
+ 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)));
1023
+ 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
1024
  }
982
1025
 
983
1026
  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