@volter/world-core 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.
@@ -337,10 +337,13 @@
337
337
  ],
338
338
  "sdks": [
339
339
  "cloudflare",
340
- "aws4fetch"
340
+ "aws4fetch",
341
+ "react-turnstile",
342
+ "@marsidev/react-turnstile"
341
343
  ],
342
344
  "envStems": [
343
- "CLOUDFLARE"
345
+ "CLOUDFLARE",
346
+ "TURNSTILE"
344
347
  ],
345
348
  "tools": [
346
349
  {
@@ -355,7 +358,11 @@
355
358
  },
356
359
  {
357
360
  "host": "dash.cloudflare.com",
358
- "pathPattern": "^/(login|oauth2/|profile/api-tokens|_twin/users/|[0-9a-f]{32}/r2/api-tokens)"
361
+ "pathPattern": "^/($|login|profile/api-tokens|_twin/users/|[0-9a-f]{32}/(r2/api-tokens|workers-and-pages))"
362
+ },
363
+ {
364
+ "host": "challenges.cloudflare.com",
365
+ "pathPattern": "^/turnstile/v0/"
359
366
  },
360
367
  {
361
368
  "suffix": ".r2.cloudflarestorage.com"
@@ -363,7 +370,7 @@
363
370
  ],
364
371
  "hostsClaimed": {
365
372
  "door": "/_twin/hosts",
366
- "note": "R2 custom domains registered to a bucket; public read still requires enabled and active ownership/TLS."
373
+ "note": "R2 custom domains registered to a bucket (public read still requires enabled and active ownership/TLS), and Workers Custom Domains, answered with the Worker static assets."
367
374
  },
368
375
  "archetype": "crud",
369
376
  "protocol": {
@@ -380,8 +387,8 @@
380
387
  "worker_deployment",
381
388
  "worker_secret",
382
389
  "zone",
383
- "dns_record",
384
390
  "custom_hostname",
391
+ "turnstile_widget",
385
392
  "r2_bucket",
386
393
  "r2_object",
387
394
  "r2_multipart",
@@ -401,9 +408,12 @@
401
408
  },
402
409
  "fill": {
403
410
  "CLOUDFLARE_API_TOKEN": "api_token",
411
+ "CLOUDFLARE_API_KEY": "api_token",
404
412
  "CLOUDFLARE_ACCOUNT_ID": "account_id",
405
413
  "R2_ACCESS_KEY_ID": "r2_access_key_id",
406
414
  "R2_SECRET_ACCESS_KEY": "r2_secret_access_key",
415
+ "CLOUDFLARE_ACCESS_KEY": "r2_access_key_id",
416
+ "CLOUDFLARE_SECRET_ACCESS_KEY": "r2_secret_access_key",
407
417
  "R2_ENDPOINT": "r2_endpoint"
408
418
  }
409
419
  },
@@ -111,6 +111,13 @@ export type DerivedFetch = ((request: Request) => Promise<Response>) & {
111
111
  owners(): Record<string, DerivedOwner>;
112
112
  /** The vendor's sockets (sockets.ts), for the serve seam to upgrade to, when its manifest declares any. */
113
113
  upgrade?: WebSocketUpgrade;
114
+ /** The workspace screens it draws, its lanes' too (`GET /twin`'s `screens`). */
115
+ workspaces?(): Workspace[];
116
+ };
117
+ /** A workspace screen a pack draws, as `GET /twin` lists it: its id and path (Viewing a World). */
118
+ export type Workspace = {
119
+ id: string;
120
+ path: string;
114
121
  };
115
122
  export declare function createDerivedFetch(options: DerivedFetchOptions): DerivedFetch;
116
123
  export {};
@@ -36,6 +36,7 @@ export { VendorUnreachableError } from './vendor-call.js';
36
36
  export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER } from './request-scope.js';
37
37
  export { serveHttp, serveStream, type HttpHandler, type HttpServer, type ServeHttpOptions } from './serve-http.js';
38
38
  export type { TwinStream, TwinStreamConnection, TwinStreamSink } from './twin-fetch.js';
39
+ export { twinSiteUrl } from './twin-fetch.js';
39
40
  export { statefulTwinManifest, twinManifest } from './scenario.js';
40
41
  export { assetContentType, packAsset } from './pack-assets.js';
41
42
  export { bytesResponse, contentTypeOf, fileResponse } from './file-response.js';
package/dist/src/index.js CHANGED
@@ -28,6 +28,8 @@ export { VendorUnreachableError } from "./vendor-call.js";
28
28
  export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER } from "./request-scope.js";
29
29
  // ── the serve factory ──────────────────────────────────────────────────────────────────────────
30
30
  export { serveHttp, serveStream } from "./serve-http.js";
31
+ // where a page links a hostname its twin answers, inside the World that shows it
32
+ export { twinSiteUrl } from "./twin-fetch.js";
31
33
  export { statefulTwinManifest, twinManifest } from "./scenario.js";
32
34
  export { assetContentType, packAsset } from "./pack-assets.js";
33
35
  export { bytesResponse, contentTypeOf, fileResponse } from "./file-response.js";
@@ -1,5 +1,5 @@
1
1
  import { type DerivedManifest, type Handler, type HandlerContext, type VendorManifest } from './derived-core.js';
2
- import { type DerivedFetch, type DerivedOwner, type DerivedSurface } from './derived.js';
2
+ import { type DerivedFetch, type DerivedOwner, type DerivedSurface, type Workspace } from './derived.js';
3
3
  import { type PackScenarioAdapter } from './scenario.js';
4
4
  import { type SocketEngine } from './sockets.js';
5
5
  import { type GraphqlPart } from './graphql-wire.js';
@@ -74,4 +74,5 @@ export type PackFetchOptions = {
74
74
  export declare function createPackFetch(parts: PackParts, options?: PackFetchOptions): DerivedFetch;
75
75
  export declare function createVendorFetch(manifest: VendorManifest, lanes: Record<string, LanePart>): ((request: Request) => Promise<Response>) & {
76
76
  owners: () => Record<string, DerivedOwner>;
77
+ workspaces: () => Workspace[];
77
78
  };
@@ -208,6 +208,9 @@ export function createPackFetch(parts, options = {}) {
208
208
  const hosts = [s.host, ...(s.hosts ?? [])].map(hostMatcher);
209
209
  return { decl: s, host: (h) => hosts.map((match) => match(h)).find(Boolean), path: pathMatcher(s.path, true), answer: parts.screens[s.id] };
210
210
  });
211
+ // the workspaces a person can be shown (Viewing a World): each built workspace screen's id and path, the pack's own
212
+ // and its lanes'
213
+ const workspaces = () => [...(m.screens ?? []).filter((s) => s.kind === 'workspace' && s.status === 'done' && parts.screens?.[s.id]).map((s) => ({ id: s.id, path: s.path })), ...lanesWorkspaces(parts.lanes ?? {})];
211
214
  // a context the kernel opens for what no surface operation names: a door, a screen, the clock
212
215
  const open = (request, id, method, path, params) => contextFor(m, { request, operation: { id, method, path, class: method === 'GET' ? 'retrieve' : 'action' }, params }, scope);
213
216
  const refusedReadOnly = () => vendorError(m, m.readOnly);
@@ -265,8 +268,7 @@ export function createPackFetch(parts, options = {}) {
265
268
  if (request.method === 'GET' && path === '/twin' && m.discovery) {
266
269
  const d = m.discovery;
267
270
  // the workspaces a person can be shown (Viewing a World): each built workspace screen's id and path
268
- const workspaces = (m.screens ?? []).filter((s) => s.kind === 'workspace' && s.status === 'done' && parts.screens?.[s.id]).map((s) => ({ id: s.id, path: s.path }));
269
- const shown = workspaces.length ? { screens: workspaces } : {};
271
+ const shown = workspaces().length ? { screens: workspaces() } : {};
270
272
  // a model vendor's: its state, and what its scenario scripts, with the scenario's live counts
271
273
  if (parts.scenario && d.behavior) {
272
274
  return Response.json({ ...shown, ...twinManifest({
@@ -324,8 +326,14 @@ export function createPackFetch(parts, options = {}) {
324
326
  // reached at a World's place for the twin (`x-forwarded-prefix`, served-world's wire), the host is the World's: a
325
327
  // workspace is shown there by its path alone, its root too (the console frames it so)
326
328
  const placed = request.headers.has(TWIN_PREFIX_HEADER);
329
+ // a person's browser opening a page there (a navigation, a form, the console's frame) is asking for a screen: one
330
+ // whose path names it is taken before an API path of the same shape (a dashboard's /<account>/workers-and-pages
331
+ // over R2's /{Bucket}/{Key}), the root workspace at the root alone
332
+ const page = placed && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '');
333
+ const rooted = (x) => x.s.decl.path.replace(/\/+$/, '') === '';
327
334
  const byPath = hits.find((x) => x.onHost && exact(x.s))
328
- ?? (apiPath ? undefined : hits.find((x) => x.onHost) ?? hits.find((x) => x.s.decl.path.replace(/\/+$/, '') !== '')
335
+ ?? (page ? hits.find((x) => !rooted(x)) ?? (path === '/' ? hits.find((x) => x.s.decl.kind === 'workspace') : undefined) : undefined)
336
+ ?? (apiPath ? undefined : hits.find((x) => x.onHost) ?? hits.find((x) => !rooted(x))
329
337
  ?? (placed ? hits.find((x) => x.s.decl.kind === 'workspace') : undefined));
330
338
  if (byPath) {
331
339
  if (readOnly && request.method !== 'GET' && request.method !== 'HEAD')
@@ -388,7 +396,7 @@ export function createPackFetch(parts, options = {}) {
388
396
  async (asked) => engineOf()?.serve(asked)) : undefined;
389
397
  // what serves each operation: the pack's own, and each lane's
390
398
  const owners = () => ({ ...api.owners(), ...lanesOwners(parts.lanes ?? {}) });
391
- return derivedRequestScopes(m, Object.assign(served, { owners, serves, ...(upgrade ? { upgrade } : {}) }));
399
+ return derivedRequestScopes(m, Object.assign(served, { owners, serves, workspaces, ...(upgrade ? { upgrade } : {}) }));
392
400
  }
393
401
  /** The vendor's gateway over its lanes: the lane a request goes to (a door to the lane that declares it, then the first
394
402
  * route whose conditions all hold, then the default), behind the gateway's CORS; undefined when none takes it. */
@@ -438,13 +446,18 @@ function laneRouter(vendor, decl, lanes, rootServes = () => false) {
438
446
  * discovery door, then its lanes as its gateway sends them; a request no lane takes is the vendor's not-found. */
439
447
  /** What serves each of a vendor's lanes' operations (a handler, the core, or the gap), as one map. */
440
448
  const lanesOwners = (lanes) => Object.assign({}, ...Object.values(lanes).map((l) => l.fetch.owners?.() ?? {}));
449
+ /** The workspaces a vendor's lanes draw, as one list. */
450
+ const lanesWorkspaces = (lanes) => Object.values(lanes).flatMap((l) => l.fetch.workspaces?.() ?? []);
441
451
  export function createVendorFetch(manifest, lanes) {
442
452
  const lane = laneRouter(manifest.vendor, manifest.lanes, lanes);
443
453
  return Object.assign(async (request) => {
444
454
  const path = new URL(request.url).pathname.replace(/\/+$/, '') || '/';
445
- if (request.method === 'GET' && path === '/twin' && manifest.discovery)
446
- return Response.json(statefulTwinManifest({ vendor: manifest.vendor, ...manifest.discovery }));
455
+ if (request.method === 'GET' && path === '/twin' && manifest.discovery) {
456
+ // the vendor's screens are its lanes' (a dashboard drawn by one lane is the vendor's)
457
+ const screens = lanesWorkspaces(lanes);
458
+ return Response.json({ ...statefulTwinManifest({ vendor: manifest.vendor, ...manifest.discovery }), ...(screens.length ? { screens } : {}) });
459
+ }
447
460
  const to = lane(request);
448
461
  return to ? to(request) : Response.json({ error: `${manifest.vendor}: no lane serves ${request.method} ${path}` }, { status: 404 });
449
- }, { owners: () => lanesOwners(lanes) });
462
+ }, { owners: () => lanesOwners(lanes), workspaces: () => lanesWorkspaces(lanes) });
450
463
  }
@@ -3,7 +3,7 @@ export type { PackTransport, PullPosture, PullTrigger, PullVendor, RoundTripWrit
3
3
  export { parseScenarioDocument, scenarioFaultResult, ScenarioEngine, ScenarioError, statefulTwinManifest, twinManifest } from './scenario.js';
4
4
  export { WORLD_CLOCK_ENV, worldNow } from './world-clock.js';
5
5
  export { WORLD_ENV_NAMES_ENV, worldEnvValue } from './world-env.js';
6
- export { createTwinFetchFromHandler, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, twinPublicBase, withRequestScopes } from './twin-fetch.js';
6
+ export { createTwinFetchFromHandler, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, TWIN_SITES_HEADER, twinPublicBase, twinSiteUrl, withRequestScopes } from './twin-fetch.js';
7
7
  export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER, ReadOnlyRequestError, runAsReadOnlyRequest, runAsVendorMove, writesRefused } from './request-scope.js';
8
8
  export { compileSurface, createDerivedFetch, matchOperation } from './derived.js';
9
9
  export { currentTraceparent, deliveryTraceHeaders, newTraceparent, parseTraceparent, runWithRequestTrace, runWithTraceparent, TRACEPARENT_HEADER, traceparentForDelivery, validTraceparent } from './trace-context.js';
@@ -11,7 +11,7 @@ export { clearRegistry, getPack, hasPack, listPacks, packOf, registerPack, pullP
11
11
  export { parseScenarioDocument, scenarioFaultResult, ScenarioEngine, ScenarioError, statefulTwinManifest, twinManifest } from "./scenario.js";
12
12
  export { WORLD_CLOCK_ENV, worldNow } from "./world-clock.js";
13
13
  export { WORLD_ENV_NAMES_ENV, worldEnvValue } from "./world-env.js";
14
- export { createTwinFetchFromHandler, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, twinPublicBase, withRequestScopes } from "./twin-fetch.js";
14
+ export { createTwinFetchFromHandler, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, TWIN_SITES_HEADER, twinPublicBase, twinSiteUrl, withRequestScopes } from "./twin-fetch.js";
15
15
  export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER, ReadOnlyRequestError, runAsReadOnlyRequest, runAsVendorMove, writesRefused } from "./request-scope.js";
16
16
  export { compileSurface, createDerivedFetch, matchOperation } from "./derived.js";
17
17
  export { currentTraceparent, deliveryTraceHeaders, newTraceparent, parseTraceparent, runWithRequestTrace, runWithTraceparent, TRACEPARENT_HEADER, traceparentForDelivery, validTraceparent } from "./trace-context.js";
@@ -7,6 +7,14 @@ export declare const TWIN_PREFIX_HEADER = "x-forwarded-prefix";
7
7
  * the twin under. A pack mints every live URL it hands back (an upload target, a file link, a
8
8
  * callback) from this, never from the bare origin, so the URL works behind a served World. */
9
9
  export declare function twinPublicBase(request: Request): string;
10
+ /** Where a World shows a hostname its twins answer (a Workers Custom Domain, an R2 public domain): the address a person
11
+ * opens it at, `{host}` standing for the hostname (`http://{host}.<world>--<org>.localhost:<port>`). A World that routes
12
+ * its sites to a browser sends it on every request it forwards to a twin. */
13
+ export declare const TWIN_SITES_HEADER = "x-volter-world-sites";
14
+ /** Where a page links a hostname the twin answers: the World's address for it when the World shows sites, else the
15
+ * hostname itself (reached through the World's proxy, or the vendor). A pack's screen links a site from this, as it
16
+ * mints its own URLs from twinPublicBase. */
17
+ export declare function twinSiteUrl(request: Request, hostname: string): string;
10
18
  /** A twin's BYTE-STREAM door: a pack whose clients speak a TCP protocol (SMTP, the MySQL wire) serves
11
19
  * it over whatever carries the bytes — a local listener's socket, or a WebSocket to a hosted World.
12
20
  * The pack owns framing and protocol; the host owns the transport. `open` greets through the sink;
@@ -40,6 +40,18 @@ export function twinPublicBase(request) {
40
40
  const origin = host && /^[A-Za-z0-9.-]+(:\d+)?$/.test(host) && (proto === 'http' || proto === 'https') ? `${proto}://${host}` : new URL(request.url).origin;
41
41
  return `${origin}${prefix.replace(/\/+$/, '')}`;
42
42
  }
43
+ /** Where a World shows a hostname its twins answer (a Workers Custom Domain, an R2 public domain): the address a person
44
+ * opens it at, `{host}` standing for the hostname (`http://{host}.<world>--<org>.localhost:<port>`). A World that routes
45
+ * its sites to a browser sends it on every request it forwards to a twin. */
46
+ export const TWIN_SITES_HEADER = 'x-volter-world-sites';
47
+ /** Where a page links a hostname the twin answers: the World's address for it when the World shows sites, else the
48
+ * hostname itself (reached through the World's proxy, or the vendor). A pack's screen links a site from this, as it
49
+ * mints its own URLs from twinPublicBase. */
50
+ export function twinSiteUrl(request, hostname) {
51
+ const at = request.headers.get(TWIN_SITES_HEADER);
52
+ const host = hostname.toLowerCase();
53
+ return at && /^https?:\/\/\{host\}\.[A-Za-z0-9.-]+(:\d+)?$/.test(at) && /^[a-z0-9.-]+$/.test(host) ? at.replace('{host}', host) : `https://${host}`;
54
+ }
43
55
  /** The request scopes a pack wrapped in `withRequestScopes` enforces, advertised on its `GET /twin` as
44
56
  * `requestScopes`: `read` — a request carrying `x-volter-read-only: 1` (request-scope.ts) has every
45
57
  * write it attempts refused at the kernel's write seam, whatever operation it names. A World's doors
@@ -337,10 +337,13 @@
337
337
  ],
338
338
  "sdks": [
339
339
  "cloudflare",
340
- "aws4fetch"
340
+ "aws4fetch",
341
+ "react-turnstile",
342
+ "@marsidev/react-turnstile"
341
343
  ],
342
344
  "envStems": [
343
- "CLOUDFLARE"
345
+ "CLOUDFLARE",
346
+ "TURNSTILE"
344
347
  ],
345
348
  "tools": [
346
349
  {
@@ -355,7 +358,11 @@
355
358
  },
356
359
  {
357
360
  "host": "dash.cloudflare.com",
358
- "pathPattern": "^/(login|oauth2/|profile/api-tokens|_twin/users/|[0-9a-f]{32}/r2/api-tokens)"
361
+ "pathPattern": "^/($|login|profile/api-tokens|_twin/users/|[0-9a-f]{32}/(r2/api-tokens|workers-and-pages))"
362
+ },
363
+ {
364
+ "host": "challenges.cloudflare.com",
365
+ "pathPattern": "^/turnstile/v0/"
359
366
  },
360
367
  {
361
368
  "suffix": ".r2.cloudflarestorage.com"
@@ -363,7 +370,7 @@
363
370
  ],
364
371
  "hostsClaimed": {
365
372
  "door": "/_twin/hosts",
366
- "note": "R2 custom domains registered to a bucket; public read still requires enabled and active ownership/TLS."
373
+ "note": "R2 custom domains registered to a bucket (public read still requires enabled and active ownership/TLS), and Workers Custom Domains, answered with the Worker static assets."
367
374
  },
368
375
  "archetype": "crud",
369
376
  "protocol": {
@@ -380,8 +387,8 @@
380
387
  "worker_deployment",
381
388
  "worker_secret",
382
389
  "zone",
383
- "dns_record",
384
390
  "custom_hostname",
391
+ "turnstile_widget",
385
392
  "r2_bucket",
386
393
  "r2_object",
387
394
  "r2_multipart",
@@ -401,9 +408,12 @@
401
408
  },
402
409
  "fill": {
403
410
  "CLOUDFLARE_API_TOKEN": "api_token",
411
+ "CLOUDFLARE_API_KEY": "api_token",
404
412
  "CLOUDFLARE_ACCOUNT_ID": "account_id",
405
413
  "R2_ACCESS_KEY_ID": "r2_access_key_id",
406
414
  "R2_SECRET_ACCESS_KEY": "r2_secret_access_key",
415
+ "CLOUDFLARE_ACCESS_KEY": "r2_access_key_id",
416
+ "CLOUDFLARE_SECRET_ACCESS_KEY": "r2_secret_access_key",
407
417
  "R2_ENDPOINT": "r2_endpoint"
408
418
  }
409
419
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-core",
3
- "version": "3.0.6",
3
+ "version": "3.0.8",
4
4
  "description": "The kernel of Volter World: one log per twin, branches as pointers, checkpoints, the fold that keeps a twin current, the head that performs a write against the vendor, references, and the git plane. A twin package builds on it; the runtime serves it.",
5
5
  "keywords": [
6
6
  "twin",
package/src/derived.ts CHANGED
@@ -196,8 +196,13 @@ export type DerivedFetch = ((request: Request) => Promise<Response>) & {
196
196
  owners(): Record<string, DerivedOwner>;
197
197
  /** The vendor's sockets (sockets.ts), for the serve seam to upgrade to, when its manifest declares any. */
198
198
  upgrade?: WebSocketUpgrade;
199
+ /** The workspace screens it draws, its lanes' too (`GET /twin`'s `screens`). */
200
+ workspaces?(): Workspace[];
199
201
  };
200
202
 
203
+ /** A workspace screen a pack draws, as `GET /twin` lists it: its id and path (Viewing a World). */
204
+ export type Workspace = { id: string; path: string };
205
+
201
206
  export function createDerivedFetch(options: DerivedFetchOptions): DerivedFetch {
202
207
  const handlers = options.handlers ?? {};
203
208
  const known = new Set(options.surface.operations.map((o) => o.id));
package/src/index.ts CHANGED
@@ -51,6 +51,8 @@ export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER } from './request-scope.ts'
51
51
  // ── the serve factory ──────────────────────────────────────────────────────────────────────────
52
52
  export { serveHttp, serveStream, type HttpHandler, type HttpServer, type ServeHttpOptions } from './serve-http.ts';
53
53
  export type { TwinStream, TwinStreamConnection, TwinStreamSink } from './twin-fetch.ts';
54
+ // where a page links a hostname its twin answers, inside the World that shows it
55
+ export { twinSiteUrl } from './twin-fetch.ts';
54
56
  export { statefulTwinManifest, twinManifest } from './scenario.ts';
55
57
  export { assetContentType, packAsset } from './pack-assets.ts';
56
58
  export { bytesResponse, contentTypeOf, fileResponse } from './file-response.ts';
package/src/pack-fetch.ts CHANGED
@@ -10,7 +10,7 @@ import { TWIN_PREFIX_HEADER } from './twin-fetch.ts';
10
10
  import { PREFIX_PARAMS_HEADER } from './derived.ts';
11
11
  import { withHeaders } from './with-headers.ts';
12
12
  import { authRefusal, bindSemantics, contextFor, LANE_HEADER, SCENARIO_DECISION_HEADER, coreFor, crossCutting, derivedRequestScopes, vendorError, type CoreScope, type DerivedManifest, type ErrorSpec, type Handler, type HandlerContext, type LanesDecl, type VendorManifest } from './derived-core.ts';
13
- import { compileSurface, createDerivedFetch, matchOperation, type DerivedFetch, type DerivedOperation, type DerivedOwner, type DerivedSurface } from './derived.ts';
13
+ import { compileSurface, createDerivedFetch, matchOperation, type DerivedFetch, type DerivedOperation, type DerivedOwner, type DerivedSurface, type Workspace } from './derived.ts';
14
14
  import { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER, runAsVendorMove } from './request-scope.ts';
15
15
  import { parseScenarioDocument, ScenarioEngine, ScenarioError, statefulTwinManifest, twinManifest, type PackScenarioAdapter } from './scenario.ts';
16
16
  import { getActiveWorldStore } from './world-store.ts';
@@ -242,6 +242,9 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
242
242
  const hosts = [s.host, ...(s.hosts ?? [])].map(hostMatcher);
243
243
  return { decl: s, host: (h: string) => hosts.map((match) => match(h)).find(Boolean), path: pathMatcher(s.path, true), answer: parts.screens![s.id]! };
244
244
  });
245
+ // the workspaces a person can be shown (Viewing a World): each built workspace screen's id and path, the pack's own
246
+ // and its lanes'
247
+ const workspaces = (): Workspace[] => [...(m.screens ?? []).filter((s) => s.kind === 'workspace' && s.status === 'done' && parts.screens?.[s.id]).map((s) => ({ id: s.id, path: s.path })), ...lanesWorkspaces(parts.lanes ?? {})];
245
248
  // a context the kernel opens for what no surface operation names: a door, a screen, the clock
246
249
  const open = (request: Request, id: string, method: string, path: string, params: Record<string, string>): Promise<HandlerContext> =>
247
250
  contextFor(m, { request, operation: { id, method, path, class: method === 'GET' ? 'retrieve' : 'action' } as DerivedOperation, params }, scope);
@@ -295,8 +298,7 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
295
298
  if (request.method === 'GET' && path === '/twin' && m.discovery) {
296
299
  const d = m.discovery;
297
300
  // the workspaces a person can be shown (Viewing a World): each built workspace screen's id and path
298
- const workspaces = (m.screens ?? []).filter((s) => s.kind === 'workspace' && s.status === 'done' && parts.screens?.[s.id]).map((s) => ({ id: s.id, path: s.path }));
299
- const shown = workspaces.length ? { screens: workspaces } : {};
301
+ const shown = workspaces().length ? { screens: workspaces() } : {};
300
302
  // a model vendor's: its state, and what its scenario scripts, with the scenario's live counts
301
303
  if (parts.scenario && d.behavior) {
302
304
  return Response.json({ ...shown, ...twinManifest({
@@ -342,8 +344,14 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
342
344
  // reached at a World's place for the twin (`x-forwarded-prefix`, served-world's wire), the host is the World's: a
343
345
  // workspace is shown there by its path alone, its root too (the console frames it so)
344
346
  const placed = request.headers.has(TWIN_PREFIX_HEADER);
347
+ // a person's browser opening a page there (a navigation, a form, the console's frame) is asking for a screen: one
348
+ // whose path names it is taken before an API path of the same shape (a dashboard's /<account>/workers-and-pages
349
+ // over R2's /{Bucket}/{Key}), the root workspace at the root alone
350
+ const page = placed && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '');
351
+ const rooted = (x: (typeof hits)[number]): boolean => x.s.decl.path.replace(/\/+$/, '') === '';
345
352
  const byPath = hits.find((x) => x.onHost && exact(x.s))
346
- ?? (apiPath ? undefined : hits.find((x) => x.onHost) ?? hits.find((x) => x.s.decl.path.replace(/\/+$/, '') !== '')
353
+ ?? (page ? hits.find((x) => !rooted(x)) ?? (path === '/' ? hits.find((x) => x.s.decl.kind === 'workspace') : undefined) : undefined)
354
+ ?? (apiPath ? undefined : hits.find((x) => x.onHost) ?? hits.find((x) => !rooted(x))
347
355
  ?? (placed ? hits.find((x) => x.s.decl.kind === 'workspace') : undefined));
348
356
  if (byPath) {
349
357
  if (readOnly && request.method !== 'GET' && request.method !== 'HEAD') return refusedReadOnly();
@@ -395,7 +403,7 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
395
403
  async (asked) => engineOf()?.serve(asked)) : undefined;
396
404
  // what serves each operation: the pack's own, and each lane's
397
405
  const owners = (): Record<string, DerivedOwner> => ({ ...api.owners(), ...lanesOwners(parts.lanes ?? {}) });
398
- return derivedRequestScopes(m, Object.assign(served, { owners, serves, ...(upgrade ? { upgrade } : {}) }));
406
+ return derivedRequestScopes(m, Object.assign(served, { owners, serves, workspaces, ...(upgrade ? { upgrade } : {}) }));
399
407
  }
400
408
 
401
409
  /** The vendor's gateway over its lanes: the lane a request goes to (a door to the lane that declares it, then the first
@@ -448,12 +456,20 @@ function laneRouter(vendor: string, decl: LanesDecl, lanes: Record<string, LaneP
448
456
  const lanesOwners = (lanes: Record<string, LanePart>): Record<string, DerivedOwner> =>
449
457
  Object.assign({}, ...Object.values(lanes).map((l) => (l.fetch as { owners?: () => Record<string, DerivedOwner> }).owners?.() ?? {}));
450
458
 
451
- export function createVendorFetch(manifest: VendorManifest, lanes: Record<string, LanePart>): ((request: Request) => Promise<Response>) & { owners: () => Record<string, DerivedOwner> } {
459
+ /** The workspaces a vendor's lanes draw, as one list. */
460
+ const lanesWorkspaces = (lanes: Record<string, LanePart>): Workspace[] =>
461
+ Object.values(lanes).flatMap((l) => (l.fetch as { workspaces?: () => Workspace[] }).workspaces?.() ?? []);
462
+
463
+ export function createVendorFetch(manifest: VendorManifest, lanes: Record<string, LanePart>): ((request: Request) => Promise<Response>) & { owners: () => Record<string, DerivedOwner>; workspaces: () => Workspace[] } {
452
464
  const lane = laneRouter(manifest.vendor, manifest.lanes, lanes);
453
465
  return Object.assign(async (request: Request): Promise<Response> => {
454
466
  const path = new URL(request.url).pathname.replace(/\/+$/, '') || '/';
455
- if (request.method === 'GET' && path === '/twin' && manifest.discovery) return Response.json(statefulTwinManifest({ vendor: manifest.vendor, ...manifest.discovery }));
467
+ if (request.method === 'GET' && path === '/twin' && manifest.discovery) {
468
+ // the vendor's screens are its lanes' (a dashboard drawn by one lane is the vendor's)
469
+ const screens = lanesWorkspaces(lanes);
470
+ return Response.json({ ...statefulTwinManifest({ vendor: manifest.vendor, ...manifest.discovery }), ...(screens.length ? { screens } : {}) });
471
+ }
456
472
  const to = lane(request);
457
473
  return to ? to(request) : Response.json({ error: `${manifest.vendor}: no lane serves ${request.method} ${path}` }, { status: 404 });
458
- }, { owners: () => lanesOwners(lanes) });
474
+ }, { owners: () => lanesOwners(lanes), workspaces: () => lanesWorkspaces(lanes) });
459
475
  }
package/src/runtime.ts CHANGED
@@ -12,7 +12,7 @@ export type { PackTransport, PullPosture, PullTrigger, PullVendor, RoundTripWrit
12
12
  export { parseScenarioDocument, scenarioFaultResult, ScenarioEngine, ScenarioError, statefulTwinManifest, twinManifest } from './scenario.ts';
13
13
  export { WORLD_CLOCK_ENV, worldNow } from './world-clock.ts';
14
14
  export { WORLD_ENV_NAMES_ENV, worldEnvValue } from './world-env.ts';
15
- export { createTwinFetchFromHandler, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, twinPublicBase, withRequestScopes } from './twin-fetch.ts';
15
+ export { createTwinFetchFromHandler, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, TWIN_SITES_HEADER, twinPublicBase, twinSiteUrl, withRequestScopes } from './twin-fetch.ts';
16
16
  export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER, ReadOnlyRequestError, runAsReadOnlyRequest, runAsVendorMove, writesRefused } from './request-scope.ts';
17
17
  export { compileSurface, createDerivedFetch, matchOperation } from './derived.ts';
18
18
  export { currentTraceparent, deliveryTraceHeaders, newTraceparent, parseTraceparent, runWithRequestTrace, runWithTraceparent, TRACEPARENT_HEADER, traceparentForDelivery, validTraceparent } from './trace-context.ts';
package/src/twin-fetch.ts CHANGED
@@ -43,6 +43,20 @@ export function twinPublicBase(request: Request): string {
43
43
  return `${origin}${prefix.replace(/\/+$/, '')}`;
44
44
  }
45
45
 
46
+ /** Where a World shows a hostname its twins answer (a Workers Custom Domain, an R2 public domain): the address a person
47
+ * opens it at, `{host}` standing for the hostname (`http://{host}.<world>--<org>.localhost:<port>`). A World that routes
48
+ * its sites to a browser sends it on every request it forwards to a twin. */
49
+ export const TWIN_SITES_HEADER = 'x-volter-world-sites';
50
+
51
+ /** Where a page links a hostname the twin answers: the World's address for it when the World shows sites, else the
52
+ * hostname itself (reached through the World's proxy, or the vendor). A pack's screen links a site from this, as it
53
+ * mints its own URLs from twinPublicBase. */
54
+ export function twinSiteUrl(request: Request, hostname: string): string {
55
+ const at = request.headers.get(TWIN_SITES_HEADER);
56
+ const host = hostname.toLowerCase();
57
+ return at && /^https?:\/\/\{host\}\.[A-Za-z0-9.-]+(:\d+)?$/.test(at) && /^[a-z0-9.-]+$/.test(host) ? at.replace('{host}', host) : `https://${host}`;
58
+ }
59
+
46
60
  /** A twin's BYTE-STREAM door: a pack whose clients speak a TCP protocol (SMTP, the MySQL wire) serves
47
61
  * it over whatever carries the bytes — a local listener's socket, or a WebSocket to a hosted World.
48
62
  * The pack owns framing and protocol; the host owns the transport. `open` greets through the sink;