@volter/twin-standard 1.0.143 → 1.0.145

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.
@@ -23,7 +23,9 @@ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSy
23
23
  import { createRequire } from 'node:module';
24
24
  import { connect } from 'node:net';
25
25
  import { tmpdir } from 'node:os';
26
- import { basename, dirname, extname, join, matchesGlob, relative, resolve } from 'node:path';
26
+ import { basename, dirname, extname, join, matchesGlob, relative, resolve, sep } from 'node:path';
27
+ // A path relative to another with '/' between its parts on every platform (Windows' relative() joins with '\').
28
+ const rel = (from, to) => relative(from, to).split(sep).join('/');
27
29
  import { citations, sourceHeld } from "./check-sources.js";
28
30
  import { fileURLToPath } from 'node:url';
29
31
  import { servedHttpFetch } from "./clients/harness.js";
@@ -34,7 +36,7 @@ const readJson = (p) => (existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : u
34
36
  function unitsIn(pack) {
35
37
  return [pack, ...readdirSync(pack).sort().map((n) => join(pack, n)).filter((d) => !existsSync(join(d, 'package.json')) && existsSync(join(d, 'src', 'manifest.ts')))];
36
38
  }
37
- const nameOf = (pack, unit) => relative(resolve(pack, '..'), unit);
39
+ const nameOf = (pack, unit) => rel(resolve(pack, '..'), unit);
38
40
  /** The files conformance reads from a unit: its manifest, its spec's record, its vendor's life and its decision table
39
41
  * (a lane's journeys are its own `journeys/` or else its vendor's). A release publishes each (published()), and a
40
42
  * pack's standing counts it in the form only when every unit holds them (twin-world's scripts/pack-standing.ts, by
@@ -192,7 +194,7 @@ function published(pack) {
192
194
  }
193
195
  const out = [];
194
196
  for (const unit of unitsIn(pack)) {
195
- const at = relative(pack, unit);
197
+ const at = rel(pack, unit);
196
198
  const of = (p) => (at ? `${at}/${p}` : p);
197
199
  for (const need of UNIT_FILES) {
198
200
  if (existsSync(join(unit, need)) && !files.has(of(need)))
@@ -28,7 +28,10 @@ export declare function packIndexFile(dir: string, vendor: string): string;
28
28
  * itself: "Other wires"), or, for a unit whose spec is a proto, the gRPC seam over the schema derive-pack generated
29
29
  * (src/generated/proto.gen.json, imported by its literal path). `--init` writes the plain one, `--index` the gRPC one once
30
30
  * the unit's proto is derived. */
31
- export declare function serverFile(vendor: string, grpc: boolean, wire?: boolean): string;
31
+ export declare function serverFile(vendor: string, grpc: boolean, wire?: boolean, live?: boolean): string;
32
+ /** A pack's `src/cli.ts` (a fixed file): its bin, serving the twin with the options a World hands it; a unit that takes
33
+ * broadcasts over RTMP (`live`) takes its ingest's side port too (`--ingest-port`, the manifest's sidePorts). */
34
+ export declare function cliFile(vendor: string, live?: boolean): string;
32
35
  /** A pack's tsconfig.json, a fixed file: what the grade's typecheck (soundness) compiles, the pack and its lanes. */
33
36
  export declare const TSCONFIG: string;
34
37
  export declare function initFiles(vendor: string): Record<string, string>;
@@ -42,7 +42,7 @@ import { coreFor } from '@volter/world-core';
42
42
  import { decisionTableProblems } from "./conformance.js";
43
43
  import { handlerName } from "./protocol-3.js";
44
44
  import { hasOwnSurface } from "./lanes.js";
45
- const FIXED = new Set(['answer-views.ts', 'states.ts', 'index.ts', 'shared.ts', 'doors.ts', 'seed.ts', 'around.ts', 'events.ts', 'clock.ts', 'tenant.ts', 'scenario.ts', 'gap.ts', 'graphql.ts', 'sockets.ts', 'board.ts']);
45
+ const FIXED = new Set(['answer-views.ts', 'states.ts', 'index.ts', 'shared.ts', 'doors.ts', 'seed.ts', 'around.ts', 'events.ts', 'clock.ts', 'tenant.ts', 'scenario.ts', 'gap.ts', 'graphql.ts', 'sockets.ts', 'live.ts', 'board.ts']);
46
46
  /** A family file's name as a JavaScript identifier (`well-known` is `well_known`). */
47
47
  /** A family's name in code: as a handler's (a name JavaScript reserves, the family `default`, takes a trailing `_`). */
48
48
  const identifier = (family) => handlerName(family);
@@ -156,6 +156,8 @@ export function packFetchFile(dir, name) {
156
156
  const hasScenario = existsSync(join(semantics, 'scenario.ts'));
157
157
  const hasGap = existsSync(join(semantics, 'gap.ts'));
158
158
  const hasSockets = existsSync(join(dir, 'src', 'semantics', 'sockets.ts'));
159
+ // the broadcasts the vendor takes over RTMP: each ingest's dialect (world-core live)
160
+ const hasLive = existsSync(join(semantics, 'live.ts'));
159
161
  // a GraphQL API beside the REST one: its resolvers and the vendor's schema (graphql-wire.ts)
160
162
  const hasGraphql = existsSync(join(semantics, 'graphql.ts')) && existsSync(join(dir, 'src', 'generated', 'graphql-sdl.gen.json'));
161
163
  const screensDir = join(dir, 'src', 'screens');
@@ -196,6 +198,7 @@ export function packFetchFile(dir, name) {
196
198
  ...(hasScenario ? ["import { scenario } from './semantics/scenario.ts';"] : []),
197
199
  ...(hasGap ? ["import { gap } from './semantics/gap.ts';"] : []),
198
200
  ...(hasSockets ? ["import * as sockets from './semantics/sockets.ts';"] : []),
201
+ ...(hasLive ? ["import * as live from './semantics/live.ts';"] : []),
199
202
  ...(hasGraphql ? ["import { graphql } from './semantics/graphql.ts';", "import schema from './generated/graphql-sdl.gen.json' with { type: 'json' };", "import graphqlSurface from './generated/graphql.gen.json' with { type: 'json' };"] : []),
200
203
  ...(hasDoors ? ["import * as doors from './semantics/doors.ts';"] : []),
201
204
  ...(map ? [`import { ${map} } from './semantics/index.ts';`] : []),
@@ -215,6 +218,7 @@ export function packFetchFile(dir, name) {
215
218
  ...(hasScenario ? [' scenario,'] : []),
216
219
  ...(hasGap ? [' gap,'] : []),
217
220
  ...(hasSockets ? [' sockets,'] : []),
221
+ ...(hasLive ? [' live,'] : []),
218
222
  ...(hasGraphql ? [' graphql: { ...graphql, operations: graphqlSurface.operations as NonNullable<typeof graphql.operations>, sdl: schema.sdl },'] : []),
219
223
  ...(screens.length ? [` screens: { ${screens.map((f) => `${JSON.stringify(id(f))}: ${identifier(`screen_${id(f)}`)}`).join(', ')} },`] : []),
220
224
  ...(lanes.length ? [` lanes: ${laneParts},`] : []),
@@ -338,9 +342,14 @@ export function packIndexFile(dir, vendor) {
338
342
  * itself: "Other wires"), or, for a unit whose spec is a proto, the gRPC seam over the schema derive-pack generated
339
343
  * (src/generated/proto.gen.json, imported by its literal path). `--init` writes the plain one, `--index` the gRPC one once
340
344
  * the unit's proto is derived. */
341
- export function serverFile(vendor, grpc, wire = false) {
345
+ export function serverFile(vendor, grpc, wire = false, live = false) {
342
346
  const Name = pascal(vendor);
343
347
  const head = `// FETCH-FIRST (runtime contract R12b): a fixed file, the kernel's serve seam around the pack's fetch (./fetch.ts).\n`;
348
+ if (live && !grpc) {
349
+ // a unit that takes broadcasts over RTMP (its manifest's `live`) serves its fetch's ingest on a side port, `ingestPort`,
350
+ // which the manifest's sidePorts declares (architecture, "Other wires": live media)
351
+ return `${head}import { serveHttp, serveStream, StreamListenerUnavailable, type PackFetchOptions } from '@volter/world-core';\nimport { create${Name}Fetch } from './fetch.ts';\n\nexport async function create${Name}TwinServer(options: PackFetchOptions & { port?: number; ingestPort?: number } = {}): Promise<{ port: number; ingestPort: number; stop: () => void }> {\n const fetch = create${Name}Fetch(options);\n const server = await serveHttp({ port: options.port ?? 0, idleTimeout: 60, fetch });\n // the vendor's RTMP ingest beside its HTTP host, on its side port (the manifest's live and sidePorts: \`ingestPort\`).\n // A runtime with no TCP listener at all serves the twin without one (ingestPort 0); a listen that fails (the port\n // taken) fails the twin's start, as its HTTP listener's would, so the World never records an ingest nothing serves\n let ingest: { port: number; stop: () => void } | undefined;\n try { ingest = await serveStream({ port: options.ingestPort ?? 0, stream: fetch.ingest! }); }\n catch (error) {\n if (!(error instanceof StreamListenerUnavailable)) { await server.stop(true); throw error; }\n console.error(\`${vendor} twin: no RTMP ingest here (\${error.message}); serving without one\`);\n }\n return { port: server.port ?? options.port ?? 0, ingestPort: ingest?.port ?? 0, stop: () => { ingest?.stop(); void server.stop(true); } };\n}\n`;
352
+ }
344
353
  const signature = `export async function create${Name}TwinServer(options: PackFetchOptions & { port?: number } = {}): Promise<{ port: number; stop: () => void }> {\n`;
345
354
  if (grpc && wire) {
346
355
  // a unit with a wire of its own beside its API (./stream.ts: the PlanetScale twin's MySQL protocol) serves it on a side
@@ -352,6 +361,14 @@ export function serverFile(vendor, grpc, wire = false) {
352
361
  }
353
362
  return `${head}import { serveHttp, type PackFetchOptions } from '@volter/world-core';\nimport { create${Name}Fetch } from './fetch.ts';\n\n${signature} const server = await serveHttp({ port: options.port ?? 0, idleTimeout: 60, fetch: create${Name}Fetch(options) });\n return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };\n}\n`;
354
363
  }
364
+ /** A pack's `src/cli.ts` (a fixed file): its bin, serving the twin with the options a World hands it; a unit that takes
365
+ * broadcasts over RTMP (`live`) takes its ingest's side port too (`--ingest-port`, the manifest's sidePorts). */
366
+ export function cliFile(vendor, live = false) {
367
+ const Name = pascal(vendor);
368
+ const ingest = live ? `// the RTMP ingest's side port, which the World allocates (the manifest's sidePorts)\nconst ingestPort = Number(optionValue(rest, '--ingest-port', '0')) || undefined;\n` : '';
369
+ const served = live ? ` process.stdout.write(s.ingestPort ? \`${vendor} twin ingest at rtmp://127.0.0.1:\${s.ingestPort}\\n\` : '${vendor} twin: no RTMP ingest\\n');\n` : '';
370
+ return `#!/usr/bin/env node\n// world-${vendor}: serve the ${vendor} twin.\nimport { hasFlag, optionValue } from '@volter/world-core/args';\nimport { keepProcessAlive } from '@volter/world-core/lifecycle';\nimport { create${Name}TwinServer } from './server.ts';\n\nconst [cmd, ...rest] = process.argv.slice(2);\nconst port = Number(optionValue(rest, '--port', '0')) || undefined;\nconst root = optionValue(rest, '--root') || undefined;\nconst readOnly = hasFlag(rest, '--read-only');\n// the World's managed Postgres, which the runtime binds a pack declaring managedDatabase to\nconst database = optionValue(rest, '--database') || undefined;\n// the World's scenario file, for a model vendor's turns\nconst scenarioPath = optionValue(rest, '--scenario') || undefined;\n${ingest}\nif (cmd === 'serve' || cmd === undefined) {\n const s = await create${Name}TwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}), ...(database ? { database } : {}), ...(scenarioPath ? { scenarioPath } : {})${live ? ', ...(ingestPort ? { ingestPort } : {})' : ''} });\n process.stdout.write(\`${vendor} twin\${readOnly ? ' [read-only]' : ''} at http://127.0.0.1:\${s.port}\\n\`);\n${served} await keepProcessAlive();\n} else {\n process.stdout.write('Usage: world-${vendor} serve [--port N] [--root DIR] [--read-only] [--database URL] [--scenario FILE]${live ? ' [--ingest-port N]' : ''}\\n');\n}\n`;
371
+ }
355
372
  /** A vendor id as a name in code: `npm-registry` is `NpmRegistry`. */
356
373
  const pascal = (vendor) => vendor.split(/[^A-Za-z0-9]+/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join('');
357
374
  /** A pack's tsconfig.json, a fixed file: what the grade's typecheck (soundness) compiles, the pack and its lanes. */
@@ -425,7 +442,7 @@ export function initFiles(vendor) {
425
442
  'src/semantics/doors.ts': `// The World's doors (architecture, "Doors, screens and the gap"): what the vendor's own pages do that its API does\n// not, each declared in the manifest's \`doors\` (its id, method and path under \`/_twin/\`) and answered here by the\n// export its id names, over the contract's context.\nimport type { HandlerContext } from '@volter/world-core';\n\nexport type { HandlerContext };\n`,
426
443
  'src/manifest.ts': `// ${vendor}'s manifest: the vendor facts its spec does not carry (architecture, "Protocol 3"). The surface is generated\n// (./generated/surface.gen.json, from ../spec by derive-pack). Every line marked FILL is a vendor fact to state from\n// the vendor's pages, cited.\nimport type { DerivedManifest } from '@volter/world-core';\n\nexport const manifest: DerivedManifest = {\n vendor: '${vendor}',\n service: '${vendor}',\n body: { json: 'always' },\n ids: { template: '{uuid}' }, // FILL: the vendor's id form\n time: 'iso', // FILL: iso, unix or unix-ms\n error: { error: { message: '{message}', code: '{code}' } }, // FILL: the vendor's error body\n readOnly: { status: 403, message: 'This twin was started read-only; writes are refused.' }, // FILL: the vendor's refusal\n notFound: { status: 404, message: 'Not found' }, // FILL: the vendor's not-found\n list: { style: 'envelope', envelope: { data: '{data}' }, limit: { param: 'limit', default: 20, max: 100 } }, // FILL: its paging\n deleted: {},\n resources: {}, // FILL: the resources the core serves (plain create, read, update, delete, list)\n discovery: { twinOf: 'FILL: what the twin is of', stores: 'FILL: what it keeps' },\n // the descriptor, as data (twin-world's architecture, "The descriptor"): the pack registers packOf(manifest)\n descriptor: {\n protocol: '3',\n transport: 'rest',\n archetype: 'crud',\n bin: 'world-${vendor}',\n resources: [], // FILL: the stored types\n specSource: 'FILL: the vendored spec (spec/SOURCE.md)',\n description: 'FILL: one line',\n adoption: { sdks: [], envStems: [] }, // FILL: its npm clients, and each credential variable's stem (TRIPO for TRIPO_API_KEY: the suffix removed)\n hosts: [], // FILL: the vendor's hosts\n endpointEnvNone: 'FILL: the SDK\\'s base-URL variable (endpointEnv) or why none',\n },\n};\n`,
427
444
  'src/server.ts': serverFile(vendor, false),
428
- 'src/cli.ts': `#!/usr/bin/env node\n// world-${vendor}: serve the ${vendor} twin.\nimport { hasFlag, optionValue } from '@volter/world-core/args';\nimport { keepProcessAlive } from '@volter/world-core/lifecycle';\nimport { create${Name}TwinServer } from './server.ts';\n\nconst [cmd, ...rest] = process.argv.slice(2);\nconst port = Number(optionValue(rest, '--port', '0')) || undefined;\nconst root = optionValue(rest, '--root') || undefined;\nconst readOnly = hasFlag(rest, '--read-only');\n// the World's managed Postgres, which the runtime binds a pack declaring managedDatabase to\nconst database = optionValue(rest, '--database') || undefined;\n// the World's scenario file, for a model vendor's turns\nconst scenarioPath = optionValue(rest, '--scenario') || undefined;\n\nif (cmd === 'serve' || cmd === undefined) {\n const s = await create${Name}TwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}), ...(database ? { database } : {}), ...(scenarioPath ? { scenarioPath } : {}) });\n process.stdout.write(\`${vendor} twin\${readOnly ? ' [read-only]' : ''} at http://127.0.0.1:\${s.port}\\n\`);\n await keepProcessAlive();\n} else {\n process.stdout.write('Usage: world-${vendor} serve [--port N] [--root DIR] [--read-only] [--database URL] [--scenario FILE]\\n');\n}\n`,
445
+ 'src/cli.ts': cliFile(vendor),
429
446
  'src/index.ts': `// @volter/twin-${vendor} — the ${vendor} twin (Protocol 3). A fixed file: the descriptor is the manifest's.\nimport { packOf, registerPack } from '@volter/world-core';\nimport { manifest } from './manifest.ts';\n\nexport { create${Name}Fetch } from './fetch.ts';\nexport { create${Name}TwinServer } from './server.ts';\nexport { manifest };\n\n// The descriptor: twin-world's architecture, "The descriptor"; declared in manifest.ts.\nexport const pack = registerPack(packOf(manifest));\n`,
430
447
  };
431
448
  }
@@ -603,6 +620,22 @@ export async function createPack(args) {
603
620
  writeFileSync(join(dir, 'src', 'server.ts'), serverFile(vendor, true, existsSync(join(dir, 'src', 'stream.ts'))));
604
621
  console.log(`create-pack: wrote ${join(dir, 'src', 'server.ts')}`);
605
622
  }
623
+ else if (manifest?.live?.length) {
624
+ // a unit that takes broadcasts over RTMP serves its ingest beside its HTTP host, and its bin takes the ingest's
625
+ // side port: each written over its plain --init form only (never over a server of a wire of the pack's own)
626
+ const fixed = (file, forms, next) => {
627
+ const at = join(dir, 'src', file);
628
+ const now = existsSync(at) ? readFileSync(at, 'utf8').replace(/\r\n/g, '\n') : undefined;
629
+ if (now !== undefined && !forms.includes(now)) {
630
+ console.error(`create-pack: ${at} is not the generated form; serve the ingest there as architecture, "Other wires": live media, says`);
631
+ return;
632
+ }
633
+ writeFileSync(at, next);
634
+ console.log(`create-pack: wrote ${at}`);
635
+ };
636
+ fixed('server.ts', [serverFile(vendor, false), serverFile(vendor, false, false, true)], serverFile(vendor, false, false, true));
637
+ fixed('cli.ts', [cliFile(vendor), cliFile(vendor, true)], cliFile(vendor, true));
638
+ }
606
639
  }
607
640
  // a handler is an exported async function, one per operation (the handler contract); anything else a family file
608
641
  // exports is not in the map, said at once rather than found by the grade
@@ -16,7 +16,7 @@ import { parseExactJson, exactInteger } from '@volter/world-core/exact-json';
16
16
  // Usage: twin-standard derive <pack dir> [--out <dir>] (twin-world: bun scripts/derive-pack.ts <vendor>)
17
17
  import { parse as parseYaml } from 'yaml';
18
18
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
19
- import { join } from 'node:path';
19
+ import { basename, join } from 'node:path';
20
20
  import { gunzipSync } from 'node:zlib';
21
21
  import { CRUD_CLASSES, fromOpenAPI, fromSmithy, fromSwagger2, slug } from "./spec-ir.js";
22
22
  import { fromCommandReplies } from "./spec-ir-lines.js";
@@ -46,10 +46,10 @@ if (!specPath) {
46
46
  console.error(`derive-pack: no spec at ${join(PKG, 'spec')}/openapi.{json,yaml}[.gz], smithy.json[.gz], rfcNNNN.txt, proto/, commands/ or client-ops.json`);
47
47
  process.exit(1);
48
48
  }
49
- const rpcService = specPath.endsWith('/proto');
50
- const severalDocuments = specPath.endsWith('/openapi');
49
+ const rpcService = basename(specPath) === 'proto';
50
+ const severalDocuments = basename(specPath) === 'openapi';
51
51
  // a command language's table, one vendored file per command (Redis's src/commands/*.json), with its version in SOURCE.md
52
- const commandTable = specPath.endsWith('/commands');
52
+ const commandTable = basename(specPath) === 'commands';
53
53
  const protoTree = (dir, at = '') => Object.fromEntries(readdirSync(join(dir, at), { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? Object.entries(protoTree(dir, join(at, e.name))) : e.name.endsWith('.proto') ? [[join(at, e.name), readFileSync(join(dir, at, e.name), 'utf8')]] : [])));
54
54
  const bytes = rpcService || commandTable || severalDocuments ? Buffer.from('') : readFileSync(specPath);
55
55
  const text = (specPath.endsWith('.gz') ? gunzipSync(bytes) : bytes).toString('utf8');
@@ -195,7 +195,7 @@ mkdirSync(out, { recursive: true });
195
195
  // name commands and cite the RFC.
196
196
  if (lineProtocol || commandTable) {
197
197
  const spec = doc;
198
- writeFileSync(join(out, 'surface.gen.json'), `${JSON.stringify({ generatedBy: 'scripts/derive-pack.ts', spec: specPath.split('/').pop(), ...spec })}\n`);
198
+ writeFileSync(join(out, 'surface.gen.json'), `${JSON.stringify({ generatedBy: 'scripts/derive-pack.ts', spec: basename(specPath), ...spec })}\n`);
199
199
  const manifestPath = join(PKG, 'src', 'manifest.ts');
200
200
  if (existsSync(manifestPath)) {
201
201
  const { manifest } = (await import(__rewriteRelativeImportExtension(manifestPath)));
@@ -254,7 +254,7 @@ if (sdlPath) {
254
254
  for (const o of gql.operations)
255
255
  graphqlOperations.add(o.id);
256
256
  mkdirSync(out, { recursive: true });
257
- writeFileSync(join(out, 'graphql.gen.json'), `${JSON.stringify({ generatedBy: 'scripts/derive-pack.ts', spec: sdlPath.split('/').pop(), ...gql })}\n`);
257
+ writeFileSync(join(out, 'graphql.gen.json'), `${JSON.stringify({ generatedBy: 'scripts/derive-pack.ts', spec: basename(sdlPath), ...gql })}\n`);
258
258
  // the schema itself, for the pack's GraphQL wire to validate and execute against (imported lazily)
259
259
  writeFileSync(join(out, 'graphql-sdl.gen.json'), `${JSON.stringify({ generatedBy: 'scripts/derive-pack.ts', sdl })}\n`);
260
260
  const g = new Map();
@@ -266,7 +266,7 @@ if (sdlPath) {
266
266
  if (rpcService)
267
267
  writeFileSync(join(out, 'proto.gen.json'), `${JSON.stringify({ generatedBy: 'scripts/derive-pack.ts', ...protoSchema(doc) })}\n`);
268
268
  // Data, not code: the surface is JSON so it adds nothing to a typecheck and ships as an asset.
269
- writeFileSync(join(out, 'surface.gen.json'), `${JSON.stringify({ generatedBy: 'scripts/derive-pack.ts', spec: specPath.split('/').pop(), ...ir })}\n`);
269
+ writeFileSync(join(out, 'surface.gen.json'), `${JSON.stringify({ generatedBy: 'scripts/derive-pack.ts', spec: basename(specPath), ...ir })}\n`);
270
270
  // The event types a vendor delivers, when its spec enumerates them where a webhook subscription names the events it
271
271
  // wants (a request body's `enabled_events`): the pack records an event only under a type the vendor sends.
272
272
  const eventTypes = new Set();
@@ -280,7 +280,7 @@ for (const item of Object.values((doc?.paths ?? {}))) {
280
280
  }
281
281
  }
282
282
  if (eventTypes.size)
283
- writeFileSync(join(out, 'events.gen.json'), `${JSON.stringify({ generatedBy: 'scripts/derive-pack.ts', spec: specPath.split('/').pop(), types: [...eventTypes].sort() })}\n`);
283
+ writeFileSync(join(out, 'events.gen.json'), `${JSON.stringify({ generatedBy: 'scripts/derive-pack.ts', spec: basename(specPath), types: [...eventTypes].sort() })}\n`);
284
284
  // What the pack's UI needs to know about each resource its manifest declares: where it is listed,
285
285
  // read, created and moved, which values its state fields take and which moves are legal from which,
286
286
  // and which fields name another resource. Data the browser bundle reads; it never imports the manifest.
@@ -92,6 +92,7 @@ export type PackFacts = {
92
92
  option: string;
93
93
  flag: string;
94
94
  path?: string;
95
+ scheme?: string;
95
96
  note: string;
96
97
  }>;
97
98
  /** DERIVED, never declared: the pack's `create<Name>TwinServer` factory export — the
@@ -207,6 +207,8 @@ export async function compilePackFacts(dirs) {
207
207
  throw new Error(`pack-facts: ${vendor} side port ${side.name}: flag "${String(side.flag)}" is the serve flag (--name)`);
208
208
  if (side.path !== undefined && (typeof side.path !== 'string' || !side.path.startsWith('/')))
209
209
  throw new Error(`pack-facts: ${vendor} side port ${side.name}: path "${String(side.path)}" starts with /`);
210
+ if (side.scheme !== undefined && (typeof side.scheme !== 'string' || !/^[a-z][a-z0-9+.-]*$/.test(side.scheme)))
211
+ throw new Error(`pack-facts: ${vendor} side port ${side.name}: scheme "${String(side.scheme)}" is the URL's scheme (rtmp)`);
210
212
  if (typeof side.note !== 'string' || side.note.trim() === '')
211
213
  throw new Error(`pack-facts: ${vendor} side port ${side.name} has no note`);
212
214
  }
@@ -19,12 +19,15 @@ import { AsyncLocalStorage } from 'node:async_hooks';
19
19
  import { createHash } from 'node:crypto';
20
20
  import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
21
21
  import { tmpdir } from 'node:os';
22
- import { basename, dirname, join, relative } from 'node:path';
22
+ import { basename, dirname, join, relative, sep } from 'node:path';
23
23
  import { gunzipSync } from 'node:zlib';
24
24
  import ts from 'typescript';
25
25
  import { fileURLToPath } from 'node:url';
26
26
  import { sharingLanesIn } from "./lanes.js";
27
27
  import { isPackArtifact } from "./pack-files.js";
28
+ // A path relative to another with '/' between its parts on every platform: git lists files that way, and every rule
29
+ // here compares and splits on '/'; Windows' relative() joins with '\', which hid every lane's files and spec.
30
+ const rel = (from, to) => relative(from, to).split(sep).join('/');
28
31
  /** The lanes over the unit's vendor's state, when the unit is one of them ("Pack layout"), else none: a lane over its
29
32
  * vendor's state takes the vendor's states, shared helpers, manifest and engine from the vendor's src/, and nothing of
30
33
  * another lane. */
@@ -101,9 +104,9 @@ function filesUnder(dir, base = dir) {
101
104
  files = [...new Set(String(run.stdout).split('\0').filter(Boolean))];
102
105
  listing?.set(top, files);
103
106
  }
104
- const under = relative(top, dir);
107
+ const under = rel(top, dir);
105
108
  const mine = under === '' ? files : files.filter((f) => f.startsWith(`${under}/`)).map((f) => f.slice(under.length + 1));
106
- return mine.map((f) => relative(base, join(dir, f))).filter((f) => existsSync(join(base, f)));
109
+ return mine.map((f) => rel(base, join(dir, f))).filter((f) => existsSync(join(base, f)));
107
110
  }
108
111
  /** Disk files outside a checkout, or the complete output of the grader's own derivation. */
109
112
  function allFilesUnder(dir, base = dir, excludeArtifacts = false) {
@@ -112,12 +115,12 @@ function allFilesUnder(dir, base = dir, excludeArtifacts = false) {
112
115
  const out = [];
113
116
  for (const name of readdirSync(dir)) {
114
117
  const path = join(dir, name);
115
- if (excludeArtifacts && isPackArtifact(relative(base, path)))
118
+ if (excludeArtifacts && isPackArtifact(rel(base, path)))
116
119
  continue;
117
120
  if (statSync(path).isDirectory())
118
121
  out.push(...allFilesUnder(path, base, excludeArtifacts));
119
122
  else
120
- out.push(relative(base, path));
123
+ out.push(rel(base, path));
121
124
  }
122
125
  return out;
123
126
  }
@@ -142,7 +145,7 @@ function srcFileInForm(unit, name) {
142
145
  return (isLane(unit) ? ['manifest.ts', 'index.ts', 'fetch.ts', 'budget.ts'] : ['manifest.ts', 'index.ts', 'cli.ts', 'fetch.ts', 'server.ts', 'stream.ts', 'budget.ts']).includes(name);
143
146
  }
144
147
  /** The fixed files of semantics/ besides the families' handler files and their tests. */
145
- const SEMANTICS_FIXED = new Set(['answer-views.ts', 'states.ts', 'index.ts', 'shared.ts', 'doors.ts', 'seed.ts', 'around.ts', 'events.ts', 'clock.ts', 'tenant.ts', 'scenario.ts', 'gap.ts', 'graphql.ts', 'sockets.ts', 'board.ts']);
148
+ const SEMANTICS_FIXED = new Set(['answer-views.ts', 'states.ts', 'index.ts', 'shared.ts', 'doors.ts', 'seed.ts', 'around.ts', 'events.ts', 'clock.ts', 'tenant.ts', 'scenario.ts', 'gap.ts', 'graphql.ts', 'sockets.ts', 'live.ts', 'board.ts']);
146
149
  /** journeys/'s closed set. */
147
150
  const JOURNEY_FILES = new Set(['customer-life.outline.md', 'customer-life.outline.verdict.json', 'customer-life.json', 'customer-life.verdict.json', 'decisions.json',
148
151
  'vendor-examples.json', 'vendor-examples.verdict.json', 'demand.json', 'unreachable.json', 'score.json', 'coverage.json', 'first-use.json',
@@ -229,7 +232,7 @@ const criteria = [
229
232
  return fail('no vendored spec under spec/ (openapi, smithy, discovery, asyncapi or an RFC)');
230
233
  if (!existsSync(join(unit.dir, 'spec', 'SOURCE.md')))
231
234
  return fail('spec/SOURCE.md is missing');
232
- return pass(files.length > 4 ? `${files.length} documents` : files.map((f) => relative(join(unit.dir, 'spec'), f)).join(', '));
235
+ return pass(files.length > 4 ? `${files.length} documents` : files.map((f) => rel(join(unit.dir, 'spec'), f)).join(', '));
233
236
  },
234
237
  },
235
238
  {
@@ -452,6 +455,12 @@ const criteria = [
452
455
  found.push('sockets.ts exports otherwise than one const engine per socket');
453
456
  continue;
454
457
  }
458
+ if (f === 'live.ts') {
459
+ // the manifest's live (world-core live): one exported engine per ingest id, and nothing else
460
+ if (/^export\s+(?:async\s+)?function\s|^export\s+(let|var|class)\s|^export\s*\{/m.test(text))
461
+ found.push('live.ts exports otherwise than one const engine per ingest');
462
+ continue;
463
+ }
455
464
  if (f === 'gap.ts') {
456
465
  const names = [...text.matchAll(/^export\s+(?:async\s+)?function\s+([A-Za-z0-9_$]+)/gm)].map((m) => m[1]);
457
466
  if (names.join() !== 'gap' || /^export\s+(const|let|var|class)\s|^export\s*\{/m.test(text))
@@ -764,7 +773,7 @@ const criteria = [
764
773
  asks: "the pack's own tests pass",
765
774
  check(unit) {
766
775
  const pkg = packageOf(unit);
767
- const scope = `./${relative(pkg, join(unit.dir, 'src'))}/`;
776
+ const scope = `./${rel(pkg, join(unit.dir, 'src'))}/`;
768
777
  // T0's per-test timeout: the 5 s default goes red on a random pack under load (verify-pack.ts)
769
778
  const suites = () => spawnSync('bun', ['test', '--timeout', '60000', scope], { cwd: pkg, stdio: ['ignore', 'pipe', 'pipe'], timeout: 900_000 });
770
779
  let run = suites();
@@ -594,9 +594,10 @@ export function stateCoverage(lcov, files, readSource = () => undefined) {
594
594
  let current;
595
595
  for (const raw of lcov.split('\n')) {
596
596
  const line = raw.trim();
597
+ // bun's lcov names a file with the platform's separator: Windows' '\' is read as '/', as every path here is
597
598
  if (line.startsWith('SF:')) {
598
599
  current = { fns: [], hits: new Map() };
599
- records.set(line.slice(3), current);
600
+ records.set(line.slice(3).replaceAll('\\', '/'), current);
600
601
  continue;
601
602
  }
602
603
  if (!current)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/twin-standard",
3
- "version": "1.0.143",
3
+ "version": "1.0.145",
4
4
  "description": "The Protocol 3 standard for Volter twin packs: the criteria a pack is graded by, the grader, the gate a pack repository and the twin catalog run on every change and submission, and the spec derivation the grade checks against. Pin a version: every place a pack is judged judges by that standard.",
5
5
  "keywords": [
6
6
  "twin",
@@ -75,7 +75,7 @@
75
75
  "yaml": "2.9.0"
76
76
  },
77
77
  "peerDependencies": {
78
- "@volter/world-core": "3.0.151",
78
+ "@volter/world-core": "3.0.152",
79
79
  "@volter/world-runtime": "*",
80
80
  "playwright": "^1.63.0"
81
81
  },
@@ -15,7 +15,9 @@ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSy
15
15
  import { createRequire } from 'node:module';
16
16
  import { connect } from 'node:net';
17
17
  import { tmpdir } from 'node:os';
18
- import { basename, dirname, extname, join, matchesGlob, relative, resolve } from 'node:path';
18
+ import { basename, dirname, extname, join, matchesGlob, relative, resolve, sep } from 'node:path';
19
+ // A path relative to another with '/' between its parts on every platform (Windows' relative() joins with '\').
20
+ const rel = (from: string, to: string): string => relative(from, to).split(sep).join('/');
19
21
  import type { TwinStream } from '@volter/world-core';
20
22
  import { citations, sourceHeld, type SourceRecord } from './check-sources.ts';
21
23
  import { fileURLToPath } from 'node:url';
@@ -41,7 +43,7 @@ const readJson = (p: string): unknown => (existsSync(p) ? JSON.parse(readFileSyn
41
43
  function unitsIn(pack: string): string[] {
42
44
  return [pack, ...readdirSync(pack).sort().map((n) => join(pack, n)).filter((d) => !existsSync(join(d, 'package.json')) && existsSync(join(d, 'src', 'manifest.ts')))];
43
45
  }
44
- const nameOf = (pack: string, unit: string): string => relative(resolve(pack, '..'), unit);
46
+ const nameOf = (pack: string, unit: string): string => rel(resolve(pack, '..'), unit);
45
47
 
46
48
  /** The files conformance reads from a unit: its manifest, its spec's record, its vendor's life and its decision table
47
49
  * (a lane's journeys are its own `journeys/` or else its vendor's). A release publishes each (published()), and a
@@ -182,7 +184,7 @@ function published(pack: string): string[] {
182
184
  try { files = new Set(((JSON.parse(run.stdout) as Array<{ files: Array<{ path: string }> }>)[0]?.files ?? []).map((f) => f.path)); } catch { return [`npm pack lists no files (${String(run.stderr).trim().split('\n').at(-1) ?? ''})`]; }
183
185
  const out: string[] = [];
184
186
  for (const unit of unitsIn(pack)) {
185
- const at = relative(pack, unit);
187
+ const at = rel(pack, unit);
186
188
  const of = (p: string): string => (at ? `${at}/${p}` : p);
187
189
  for (const need of UNIT_FILES) {
188
190
  if (existsSync(join(unit, need)) && !files.has(of(need))) out.push(`${nameOf(pack, unit)}: ${of(need)} is not published (package.json's files)`);
@@ -35,7 +35,7 @@ import { decisionTableProblems } from './conformance.ts';
35
35
  import { handlerName } from './protocol-3.ts';
36
36
  import { hasOwnSurface } from './lanes.ts';
37
37
 
38
- const FIXED = new Set(['answer-views.ts', 'states.ts', 'index.ts', 'shared.ts', 'doors.ts', 'seed.ts', 'around.ts', 'events.ts', 'clock.ts', 'tenant.ts', 'scenario.ts', 'gap.ts', 'graphql.ts', 'sockets.ts', 'board.ts']);
38
+ const FIXED = new Set(['answer-views.ts', 'states.ts', 'index.ts', 'shared.ts', 'doors.ts', 'seed.ts', 'around.ts', 'events.ts', 'clock.ts', 'tenant.ts', 'scenario.ts', 'gap.ts', 'graphql.ts', 'sockets.ts', 'live.ts', 'board.ts']);
39
39
 
40
40
  /** A family file's name as a JavaScript identifier (`well-known` is `well_known`). */
41
41
  /** A family's name in code: as a handler's (a name JavaScript reserves, the family `default`, takes a trailing `_`). */
@@ -146,6 +146,8 @@ export function packFetchFile(dir: string, name: string): string {
146
146
  const hasScenario = existsSync(join(semantics, 'scenario.ts'));
147
147
  const hasGap = existsSync(join(semantics, 'gap.ts'));
148
148
  const hasSockets = existsSync(join(dir, 'src', 'semantics', 'sockets.ts'));
149
+ // the broadcasts the vendor takes over RTMP: each ingest's dialect (world-core live)
150
+ const hasLive = existsSync(join(semantics, 'live.ts'));
149
151
  // a GraphQL API beside the REST one: its resolvers and the vendor's schema (graphql-wire.ts)
150
152
  const hasGraphql = existsSync(join(semantics, 'graphql.ts')) && existsSync(join(dir, 'src', 'generated', 'graphql-sdl.gen.json'));
151
153
  const screensDir = join(dir, 'src', 'screens');
@@ -186,6 +188,7 @@ export function packFetchFile(dir: string, name: string): string {
186
188
  ...(hasScenario ? ["import { scenario } from './semantics/scenario.ts';"] : []),
187
189
  ...(hasGap ? ["import { gap } from './semantics/gap.ts';"] : []),
188
190
  ...(hasSockets ? ["import * as sockets from './semantics/sockets.ts';"] : []),
191
+ ...(hasLive ? ["import * as live from './semantics/live.ts';"] : []),
189
192
  ...(hasGraphql ? ["import { graphql } from './semantics/graphql.ts';", "import schema from './generated/graphql-sdl.gen.json' with { type: 'json' };", "import graphqlSurface from './generated/graphql.gen.json' with { type: 'json' };"] : []),
190
193
  ...(hasDoors ? ["import * as doors from './semantics/doors.ts';"] : []),
191
194
  ...(map ? [`import { ${map} } from './semantics/index.ts';`] : []),
@@ -205,6 +208,7 @@ export function packFetchFile(dir: string, name: string): string {
205
208
  ...(hasScenario ? [' scenario,'] : []),
206
209
  ...(hasGap ? [' gap,'] : []),
207
210
  ...(hasSockets ? [' sockets,'] : []),
211
+ ...(hasLive ? [' live,'] : []),
208
212
  ...(hasGraphql ? [' graphql: { ...graphql, operations: graphqlSurface.operations as NonNullable<typeof graphql.operations>, sdl: schema.sdl },'] : []),
209
213
  ...(screens.length ? [` screens: { ${screens.map((f) => `${JSON.stringify(id(f))}: ${identifier(`screen_${id(f)}`)}`).join(', ')} },`] : []),
210
214
  ...(lanes.length ? [` lanes: ${laneParts},`] : []),
@@ -326,9 +330,14 @@ export function packIndexFile(dir: string, vendor: string): string {
326
330
  * itself: "Other wires"), or, for a unit whose spec is a proto, the gRPC seam over the schema derive-pack generated
327
331
  * (src/generated/proto.gen.json, imported by its literal path). `--init` writes the plain one, `--index` the gRPC one once
328
332
  * the unit's proto is derived. */
329
- export function serverFile(vendor: string, grpc: boolean, wire = false): string {
333
+ export function serverFile(vendor: string, grpc: boolean, wire = false, live = false): string {
330
334
  const Name = pascal(vendor);
331
335
  const head = `// FETCH-FIRST (runtime contract R12b): a fixed file, the kernel's serve seam around the pack's fetch (./fetch.ts).\n`;
336
+ if (live && !grpc) {
337
+ // a unit that takes broadcasts over RTMP (its manifest's `live`) serves its fetch's ingest on a side port, `ingestPort`,
338
+ // which the manifest's sidePorts declares (architecture, "Other wires": live media)
339
+ return `${head}import { serveHttp, serveStream, StreamListenerUnavailable, type PackFetchOptions } from '@volter/world-core';\nimport { create${Name}Fetch } from './fetch.ts';\n\nexport async function create${Name}TwinServer(options: PackFetchOptions & { port?: number; ingestPort?: number } = {}): Promise<{ port: number; ingestPort: number; stop: () => void }> {\n const fetch = create${Name}Fetch(options);\n const server = await serveHttp({ port: options.port ?? 0, idleTimeout: 60, fetch });\n // the vendor's RTMP ingest beside its HTTP host, on its side port (the manifest's live and sidePorts: \`ingestPort\`).\n // A runtime with no TCP listener at all serves the twin without one (ingestPort 0); a listen that fails (the port\n // taken) fails the twin's start, as its HTTP listener's would, so the World never records an ingest nothing serves\n let ingest: { port: number; stop: () => void } | undefined;\n try { ingest = await serveStream({ port: options.ingestPort ?? 0, stream: fetch.ingest! }); }\n catch (error) {\n if (!(error instanceof StreamListenerUnavailable)) { await server.stop(true); throw error; }\n console.error(\`${vendor} twin: no RTMP ingest here (\${error.message}); serving without one\`);\n }\n return { port: server.port ?? options.port ?? 0, ingestPort: ingest?.port ?? 0, stop: () => { ingest?.stop(); void server.stop(true); } };\n}\n`;
340
+ }
332
341
  const signature = `export async function create${Name}TwinServer(options: PackFetchOptions & { port?: number } = {}): Promise<{ port: number; stop: () => void }> {\n`;
333
342
  if (grpc && wire) {
334
343
  // a unit with a wire of its own beside its API (./stream.ts: the PlanetScale twin's MySQL protocol) serves it on a side
@@ -341,6 +350,15 @@ export function serverFile(vendor: string, grpc: boolean, wire = false): string
341
350
  return `${head}import { serveHttp, type PackFetchOptions } from '@volter/world-core';\nimport { create${Name}Fetch } from './fetch.ts';\n\n${signature} const server = await serveHttp({ port: options.port ?? 0, idleTimeout: 60, fetch: create${Name}Fetch(options) });\n return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };\n}\n`;
342
351
  }
343
352
 
353
+ /** A pack's `src/cli.ts` (a fixed file): its bin, serving the twin with the options a World hands it; a unit that takes
354
+ * broadcasts over RTMP (`live`) takes its ingest's side port too (`--ingest-port`, the manifest's sidePorts). */
355
+ export function cliFile(vendor: string, live = false): string {
356
+ const Name = pascal(vendor);
357
+ const ingest = live ? `// the RTMP ingest's side port, which the World allocates (the manifest's sidePorts)\nconst ingestPort = Number(optionValue(rest, '--ingest-port', '0')) || undefined;\n` : '';
358
+ const served = live ? ` process.stdout.write(s.ingestPort ? \`${vendor} twin ingest at rtmp://127.0.0.1:\${s.ingestPort}\\n\` : '${vendor} twin: no RTMP ingest\\n');\n` : '';
359
+ return `#!/usr/bin/env node\n// world-${vendor}: serve the ${vendor} twin.\nimport { hasFlag, optionValue } from '@volter/world-core/args';\nimport { keepProcessAlive } from '@volter/world-core/lifecycle';\nimport { create${Name}TwinServer } from './server.ts';\n\nconst [cmd, ...rest] = process.argv.slice(2);\nconst port = Number(optionValue(rest, '--port', '0')) || undefined;\nconst root = optionValue(rest, '--root') || undefined;\nconst readOnly = hasFlag(rest, '--read-only');\n// the World's managed Postgres, which the runtime binds a pack declaring managedDatabase to\nconst database = optionValue(rest, '--database') || undefined;\n// the World's scenario file, for a model vendor's turns\nconst scenarioPath = optionValue(rest, '--scenario') || undefined;\n${ingest}\nif (cmd === 'serve' || cmd === undefined) {\n const s = await create${Name}TwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}), ...(database ? { database } : {}), ...(scenarioPath ? { scenarioPath } : {})${live ? ', ...(ingestPort ? { ingestPort } : {})' : ''} });\n process.stdout.write(\`${vendor} twin\${readOnly ? ' [read-only]' : ''} at http://127.0.0.1:\${s.port}\\n\`);\n${served} await keepProcessAlive();\n} else {\n process.stdout.write('Usage: world-${vendor} serve [--port N] [--root DIR] [--read-only] [--database URL] [--scenario FILE]${live ? ' [--ingest-port N]' : ''}\\n');\n}\n`;
360
+ }
361
+
344
362
  /** A vendor id as a name in code: `npm-registry` is `NpmRegistry`. */
345
363
  const pascal = (vendor: string): string => vendor.split(/[^A-Za-z0-9]+/).filter(Boolean).map((w) => w[0]!.toUpperCase() + w.slice(1)).join('');
346
364
 
@@ -417,7 +435,7 @@ export function initFiles(vendor: string): Record<string, string> {
417
435
  'src/semantics/doors.ts': `// The World's doors (architecture, "Doors, screens and the gap"): what the vendor's own pages do that its API does\n// not, each declared in the manifest's \`doors\` (its id, method and path under \`/_twin/\`) and answered here by the\n// export its id names, over the contract's context.\nimport type { HandlerContext } from '@volter/world-core';\n\nexport type { HandlerContext };\n`,
418
436
  'src/manifest.ts': `// ${vendor}'s manifest: the vendor facts its spec does not carry (architecture, "Protocol 3"). The surface is generated\n// (./generated/surface.gen.json, from ../spec by derive-pack). Every line marked FILL is a vendor fact to state from\n// the vendor's pages, cited.\nimport type { DerivedManifest } from '@volter/world-core';\n\nexport const manifest: DerivedManifest = {\n vendor: '${vendor}',\n service: '${vendor}',\n body: { json: 'always' },\n ids: { template: '{uuid}' }, // FILL: the vendor's id form\n time: 'iso', // FILL: iso, unix or unix-ms\n error: { error: { message: '{message}', code: '{code}' } }, // FILL: the vendor's error body\n readOnly: { status: 403, message: 'This twin was started read-only; writes are refused.' }, // FILL: the vendor's refusal\n notFound: { status: 404, message: 'Not found' }, // FILL: the vendor's not-found\n list: { style: 'envelope', envelope: { data: '{data}' }, limit: { param: 'limit', default: 20, max: 100 } }, // FILL: its paging\n deleted: {},\n resources: {}, // FILL: the resources the core serves (plain create, read, update, delete, list)\n discovery: { twinOf: 'FILL: what the twin is of', stores: 'FILL: what it keeps' },\n // the descriptor, as data (twin-world's architecture, "The descriptor"): the pack registers packOf(manifest)\n descriptor: {\n protocol: '3',\n transport: 'rest',\n archetype: 'crud',\n bin: 'world-${vendor}',\n resources: [], // FILL: the stored types\n specSource: 'FILL: the vendored spec (spec/SOURCE.md)',\n description: 'FILL: one line',\n adoption: { sdks: [], envStems: [] }, // FILL: its npm clients, and each credential variable's stem (TRIPO for TRIPO_API_KEY: the suffix removed)\n hosts: [], // FILL: the vendor's hosts\n endpointEnvNone: 'FILL: the SDK\\'s base-URL variable (endpointEnv) or why none',\n },\n};\n`,
419
437
  'src/server.ts': serverFile(vendor, false),
420
- 'src/cli.ts': `#!/usr/bin/env node\n// world-${vendor}: serve the ${vendor} twin.\nimport { hasFlag, optionValue } from '@volter/world-core/args';\nimport { keepProcessAlive } from '@volter/world-core/lifecycle';\nimport { create${Name}TwinServer } from './server.ts';\n\nconst [cmd, ...rest] = process.argv.slice(2);\nconst port = Number(optionValue(rest, '--port', '0')) || undefined;\nconst root = optionValue(rest, '--root') || undefined;\nconst readOnly = hasFlag(rest, '--read-only');\n// the World's managed Postgres, which the runtime binds a pack declaring managedDatabase to\nconst database = optionValue(rest, '--database') || undefined;\n// the World's scenario file, for a model vendor's turns\nconst scenarioPath = optionValue(rest, '--scenario') || undefined;\n\nif (cmd === 'serve' || cmd === undefined) {\n const s = await create${Name}TwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}), ...(database ? { database } : {}), ...(scenarioPath ? { scenarioPath } : {}) });\n process.stdout.write(\`${vendor} twin\${readOnly ? ' [read-only]' : ''} at http://127.0.0.1:\${s.port}\\n\`);\n await keepProcessAlive();\n} else {\n process.stdout.write('Usage: world-${vendor} serve [--port N] [--root DIR] [--read-only] [--database URL] [--scenario FILE]\\n');\n}\n`,
438
+ 'src/cli.ts': cliFile(vendor),
421
439
  'src/index.ts': `// @volter/twin-${vendor} — the ${vendor} twin (Protocol 3). A fixed file: the descriptor is the manifest's.\nimport { packOf, registerPack } from '@volter/world-core';\nimport { manifest } from './manifest.ts';\n\nexport { create${Name}Fetch } from './fetch.ts';\nexport { create${Name}TwinServer } from './server.ts';\nexport { manifest };\n\n// The descriptor: twin-world's architecture, "The descriptor"; declared in manifest.ts.\nexport const pack = registerPack(packOf(manifest));\n`,
422
440
  };
423
441
  }
@@ -561,6 +579,18 @@ export async function createPack(args: string[]): Promise<void> {
561
579
  if (!lanesOnly && existsSync(join(dir, 'src', 'generated', 'proto.gen.json'))) {
562
580
  writeFileSync(join(dir, 'src', 'server.ts'), serverFile(vendor, true, existsSync(join(dir, 'src', 'stream.ts'))));
563
581
  console.log(`create-pack: wrote ${join(dir, 'src', 'server.ts')}`);
582
+ } else if ((manifest as { live?: unknown[] } | undefined)?.live?.length) {
583
+ // a unit that takes broadcasts over RTMP serves its ingest beside its HTTP host, and its bin takes the ingest's
584
+ // side port: each written over its plain --init form only (never over a server of a wire of the pack's own)
585
+ const fixed = (file: string, forms: string[], next: string): void => {
586
+ const at = join(dir, 'src', file);
587
+ const now = existsSync(at) ? readFileSync(at, 'utf8').replace(/\r\n/g, '\n') : undefined;
588
+ if (now !== undefined && !forms.includes(now)) { console.error(`create-pack: ${at} is not the generated form; serve the ingest there as architecture, "Other wires": live media, says`); return; }
589
+ writeFileSync(at, next);
590
+ console.log(`create-pack: wrote ${at}`);
591
+ };
592
+ fixed('server.ts', [serverFile(vendor, false), serverFile(vendor, false, false, true)], serverFile(vendor, false, false, true));
593
+ fixed('cli.ts', [cliFile(vendor), cliFile(vendor, true)], cliFile(vendor, true));
564
594
  }
565
595
  }
566
596
  // a handler is an exported async function, one per operation (the handler contract); anything else a family file
package/src/derive.ts CHANGED
@@ -9,7 +9,7 @@ import { parseExactJson, exactInteger } from '@volter/world-core/exact-json';
9
9
 
10
10
  import { parse as parseYaml } from 'yaml';
11
11
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
12
- import { join } from 'node:path';
12
+ import { basename, join } from 'node:path';
13
13
  import { gunzipSync } from 'node:zlib';
14
14
  import { CRUD_CLASSES, fromOpenAPI, fromSmithy, fromSwagger2, slug, type SpecIR } from './spec-ir.ts';
15
15
  import { fromCommandReplies, type LineSpec } from './spec-ir-lines.ts';
@@ -40,10 +40,10 @@ if (!specPath) {
40
40
  console.error(`derive-pack: no spec at ${join(PKG, 'spec')}/openapi.{json,yaml}[.gz], smithy.json[.gz], rfcNNNN.txt, proto/, commands/ or client-ops.json`);
41
41
  process.exit(1);
42
42
  }
43
- const rpcService = specPath.endsWith('/proto');
44
- const severalDocuments = specPath.endsWith('/openapi');
43
+ const rpcService = basename(specPath) === 'proto';
44
+ const severalDocuments = basename(specPath) === 'openapi';
45
45
  // a command language's table, one vendored file per command (Redis's src/commands/*.json), with its version in SOURCE.md
46
- const commandTable = specPath.endsWith('/commands');
46
+ const commandTable = basename(specPath) === 'commands';
47
47
  const protoTree = (dir: string, at = ''): Record<string, string> => Object.fromEntries(readdirSync(join(dir, at), { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? Object.entries(protoTree(dir, join(at, e.name))) : e.name.endsWith('.proto') ? [[join(at, e.name), readFileSync(join(dir, at, e.name), 'utf8')]] : [])));
48
48
  const bytes = rpcService || commandTable || severalDocuments ? Buffer.from('') : readFileSync(specPath);
49
49
  const text = (specPath.endsWith('.gz') ? gunzipSync(bytes) : bytes).toString('utf8');
@@ -168,7 +168,7 @@ mkdirSync(out, { recursive: true });
168
168
  // name commands and cite the RFC.
169
169
  if (lineProtocol || commandTable) {
170
170
  const spec = doc as LineSpec | CommandTableSpec;
171
- writeFileSync(join(out, 'surface.gen.json'), `${JSON.stringify({ generatedBy: 'scripts/derive-pack.ts', spec: specPath.split('/').pop(), ...spec })}\n`);
171
+ writeFileSync(join(out, 'surface.gen.json'), `${JSON.stringify({ generatedBy: 'scripts/derive-pack.ts', spec: basename(specPath), ...spec })}\n`);
172
172
  const manifestPath = join(PKG, 'src', 'manifest.ts');
173
173
  if (existsSync(manifestPath)) {
174
174
  const { manifest } = (await import(manifestPath)) as { manifest: { resources: Record<string, { state?: Record<string, { transitions: Array<{ operation?: string; actor?: string; to?: string; source?: string }> }> }> } };
@@ -222,7 +222,7 @@ if (sdlPath) {
222
222
  graphqlSchema = graphqlLib.buildSchema(sdl, { assumeValidSDL: true });
223
223
  for (const o of gql.operations) graphqlOperations.add(o.id);
224
224
  mkdirSync(out, { recursive: true });
225
- writeFileSync(join(out, 'graphql.gen.json'), `${JSON.stringify({ generatedBy: 'scripts/derive-pack.ts', spec: sdlPath.split('/').pop(), ...gql })}\n`);
225
+ writeFileSync(join(out, 'graphql.gen.json'), `${JSON.stringify({ generatedBy: 'scripts/derive-pack.ts', spec: basename(sdlPath), ...gql })}\n`);
226
226
  // the schema itself, for the pack's GraphQL wire to validate and execute against (imported lazily)
227
227
  writeFileSync(join(out, 'graphql-sdl.gen.json'), `${JSON.stringify({ generatedBy: 'scripts/derive-pack.ts', sdl })}\n`);
228
228
  const g = new Map<string, number>();
@@ -233,7 +233,7 @@ if (sdlPath) {
233
233
  // a proto unit's schema, for the kernel's protobuf codec and gRPC wire: fields by number, enums by number, rpcs by path
234
234
  if (rpcService) writeFileSync(join(out, 'proto.gen.json'), `${JSON.stringify({ generatedBy: 'scripts/derive-pack.ts', ...protoSchema(doc) })}\n`);
235
235
  // Data, not code: the surface is JSON so it adds nothing to a typecheck and ships as an asset.
236
- writeFileSync(join(out, 'surface.gen.json'), `${JSON.stringify({ generatedBy: 'scripts/derive-pack.ts', spec: specPath.split('/').pop(), ...ir })}\n`);
236
+ writeFileSync(join(out, 'surface.gen.json'), `${JSON.stringify({ generatedBy: 'scripts/derive-pack.ts', spec: basename(specPath), ...ir })}\n`);
237
237
 
238
238
  // The event types a vendor delivers, when its spec enumerates them where a webhook subscription names the events it
239
239
  // wants (a request body's `enabled_events`): the pack records an event only under a type the vendor sends.
@@ -245,7 +245,7 @@ for (const item of Object.values((doc?.paths ?? {}) as Record<string, Record<str
245
245
  }
246
246
  }
247
247
  }
248
- if (eventTypes.size) writeFileSync(join(out, 'events.gen.json'), `${JSON.stringify({ generatedBy: 'scripts/derive-pack.ts', spec: specPath.split('/').pop(), types: [...eventTypes].sort() })}\n`);
248
+ if (eventTypes.size) writeFileSync(join(out, 'events.gen.json'), `${JSON.stringify({ generatedBy: 'scripts/derive-pack.ts', spec: basename(specPath), types: [...eventTypes].sort() })}\n`);
249
249
 
250
250
  // What the pack's UI needs to know about each resource its manifest declares: where it is listed,
251
251
  // read, created and moved, which values its state fields take and which moves are legal from which,
package/src/pack-facts.ts CHANGED
@@ -41,7 +41,7 @@ export type PackFacts = {
41
41
  /** The World's managed database the runtime binds the twin to (see TwinPack.managedDatabase). */
42
42
  managedDatabase?: { kind: 'postgres'; arg: string; note: string };
43
43
  /** Listeners the twin opens beside its main port (see TwinPack.sidePorts). */
44
- sidePorts?: Array<{ name: string; option: string; flag: string; path?: string; note: string }>;
44
+ sidePorts?: Array<{ name: string; option: string; flag: string; path?: string; scheme?: string; note: string }>;
45
45
  /** DERIVED, never declared: the pack's `create<Name>TwinServer` factory export — the
46
46
  * `({port,root,readOnly}) => {port,stop}` contract the colocated host mounts
47
47
  * (world-runtime/src/host.ts). Read off the module's own exports because the name is not
@@ -206,6 +206,7 @@ export async function compilePackFacts(dirs: Array<{ vendor: string; dir: string
206
206
  if (typeof side.option !== 'string' || !/^[a-z][A-Za-z0-9]*$/.test(side.option)) throw new Error(`pack-facts: ${vendor} side port ${side.name}: option "${String(side.option)}" is the factory's camelCase option`);
207
207
  if (typeof side.flag !== 'string' || !/^--[a-z][a-z-]*$/.test(side.flag)) throw new Error(`pack-facts: ${vendor} side port ${side.name}: flag "${String(side.flag)}" is the serve flag (--name)`);
208
208
  if (side.path !== undefined && (typeof side.path !== 'string' || !side.path.startsWith('/'))) throw new Error(`pack-facts: ${vendor} side port ${side.name}: path "${String(side.path)}" starts with /`);
209
+ if (side.scheme !== undefined && (typeof side.scheme !== 'string' || !/^[a-z][a-z0-9+.-]*$/.test(side.scheme))) throw new Error(`pack-facts: ${vendor} side port ${side.name}: scheme "${String(side.scheme)}" is the URL's scheme (rtmp)`);
209
210
  if (typeof side.note !== 'string' || side.note.trim() === '') throw new Error(`pack-facts: ${vendor} side port ${side.name} has no note`);
210
211
  }
211
212
  facts.sidePorts = pack.sidePorts;
package/src/protocol-3.ts CHANGED
@@ -11,7 +11,7 @@ import { AsyncLocalStorage } from 'node:async_hooks';
11
11
  import { createHash } from 'node:crypto';
12
12
  import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
13
13
  import { tmpdir } from 'node:os';
14
- import { basename, dirname, join, relative } from 'node:path';
14
+ import { basename, dirname, join, relative, sep } from 'node:path';
15
15
  import { gunzipSync } from 'node:zlib';
16
16
  import ts from 'typescript';
17
17
  import { fileURLToPath } from 'node:url';
@@ -19,6 +19,10 @@ import { sharingLanesIn } from './lanes.ts';
19
19
  import { isPackArtifact } from './pack-files.ts';
20
20
  import type { Criterion, GradeUnit, Standard } from './types.ts';
21
21
 
22
+ // A path relative to another with '/' between its parts on every platform: git lists files that way, and every rule
23
+ // here compares and splits on '/'; Windows' relative() joins with '\', which hid every lane's files and spec.
24
+ const rel = (from: string, to: string): string => relative(from, to).split(sep).join('/');
25
+
22
26
  /** The lanes over the unit's vendor's state, when the unit is one of them ("Pack layout"), else none: a lane over its
23
27
  * vendor's state takes the vendor's states, shared helpers, manifest and engine from the vendor's src/, and nothing of
24
28
  * another lane. */
@@ -87,9 +91,9 @@ function filesUnder(dir: string, base = dir): string[] {
87
91
  files = [...new Set(String(run.stdout).split('\0').filter(Boolean))];
88
92
  listing?.set(top, files);
89
93
  }
90
- const under = relative(top, dir);
94
+ const under = rel(top, dir);
91
95
  const mine = under === '' ? files : files.filter((f) => f.startsWith(`${under}/`)).map((f) => f.slice(under.length + 1));
92
- return mine.map((f) => relative(base, join(dir, f))).filter((f) => existsSync(join(base, f)));
96
+ return mine.map((f) => rel(base, join(dir, f))).filter((f) => existsSync(join(base, f)));
93
97
  }
94
98
  /** Disk files outside a checkout, or the complete output of the grader's own derivation. */
95
99
  function allFilesUnder(dir: string, base = dir, excludeArtifacts = false): string[] {
@@ -97,9 +101,9 @@ function allFilesUnder(dir: string, base = dir, excludeArtifacts = false): strin
97
101
  const out: string[] = [];
98
102
  for (const name of readdirSync(dir)) {
99
103
  const path = join(dir, name);
100
- if (excludeArtifacts && isPackArtifact(relative(base, path))) continue;
104
+ if (excludeArtifacts && isPackArtifact(rel(base, path))) continue;
101
105
  if (statSync(path).isDirectory()) out.push(...allFilesUnder(path, base, excludeArtifacts));
102
- else out.push(relative(base, path));
106
+ else out.push(rel(base, path));
103
107
  }
104
108
  return out;
105
109
  }
@@ -124,7 +128,7 @@ function srcFileInForm(unit: GradeUnit, name: string): boolean {
124
128
  return (isLane(unit) ? ['manifest.ts', 'index.ts', 'fetch.ts', 'budget.ts'] : ['manifest.ts', 'index.ts', 'cli.ts', 'fetch.ts', 'server.ts', 'stream.ts', 'budget.ts']).includes(name);
125
129
  }
126
130
  /** The fixed files of semantics/ besides the families' handler files and their tests. */
127
- const SEMANTICS_FIXED = new Set(['answer-views.ts', 'states.ts', 'index.ts', 'shared.ts', 'doors.ts', 'seed.ts', 'around.ts', 'events.ts', 'clock.ts', 'tenant.ts', 'scenario.ts', 'gap.ts', 'graphql.ts', 'sockets.ts', 'board.ts']);
131
+ const SEMANTICS_FIXED = new Set(['answer-views.ts', 'states.ts', 'index.ts', 'shared.ts', 'doors.ts', 'seed.ts', 'around.ts', 'events.ts', 'clock.ts', 'tenant.ts', 'scenario.ts', 'gap.ts', 'graphql.ts', 'sockets.ts', 'live.ts', 'board.ts']);
128
132
  /** journeys/'s closed set. */
129
133
  const JOURNEY_FILES = new Set(['customer-life.outline.md', 'customer-life.outline.verdict.json', 'customer-life.json', 'customer-life.verdict.json', 'decisions.json',
130
134
  'vendor-examples.json', 'vendor-examples.verdict.json', 'demand.json', 'unreachable.json', 'score.json', 'coverage.json', 'first-use.json',
@@ -210,7 +214,7 @@ const criteria: Criterion[] = [
210
214
  const files = specFiles(unit);
211
215
  if (!files.length) return fail('no vendored spec under spec/ (openapi, smithy, discovery, asyncapi or an RFC)');
212
216
  if (!existsSync(join(unit.dir, 'spec', 'SOURCE.md'))) return fail('spec/SOURCE.md is missing');
213
- return pass(files.length > 4 ? `${files.length} documents` : files.map((f) => relative(join(unit.dir, 'spec'), f)).join(', '));
217
+ return pass(files.length > 4 ? `${files.length} documents` : files.map((f) => rel(join(unit.dir, 'spec'), f)).join(', '));
214
218
  },
215
219
  },
216
220
  {
@@ -396,6 +400,11 @@ const criteria: Criterion[] = [
396
400
  if (/^export\s+(?:async\s+)?function\s|^export\s+(let|var|class)\s|^export\s*\{/m.test(text)) found.push('sockets.ts exports otherwise than one const engine per socket');
397
401
  continue;
398
402
  }
403
+ if (f === 'live.ts') {
404
+ // the manifest's live (world-core live): one exported engine per ingest id, and nothing else
405
+ if (/^export\s+(?:async\s+)?function\s|^export\s+(let|var|class)\s|^export\s*\{/m.test(text)) found.push('live.ts exports otherwise than one const engine per ingest');
406
+ continue;
407
+ }
399
408
  if (f === 'gap.ts') {
400
409
  const names = [...text.matchAll(/^export\s+(?:async\s+)?function\s+([A-Za-z0-9_$]+)/gm)].map((m) => m[1]);
401
410
  if (names.join() !== 'gap' || /^export\s+(const|let|var|class)\s|^export\s*\{/m.test(text)) found.push(`gap.ts exports ${names.join(', ') || 'nothing'}, not only gap`);
@@ -652,7 +661,7 @@ const criteria: Criterion[] = [
652
661
  asks: "the pack's own tests pass",
653
662
  check(unit) {
654
663
  const pkg = packageOf(unit);
655
- const scope = `./${relative(pkg, join(unit.dir, 'src'))}/`;
664
+ const scope = `./${rel(pkg, join(unit.dir, 'src'))}/`;
656
665
  // T0's per-test timeout: the 5 s default goes red on a random pack under load (verify-pack.ts)
657
666
  const suites = () => spawnSync('bun', ['test', '--timeout', '60000', scope], { cwd: pkg, stdio: ['ignore', 'pipe', 'pipe'], timeout: 900_000 });
658
667
  let run = suites();
@@ -757,7 +757,8 @@ export function stateCoverage(lcov: string, files: string[], readSource: (file:
757
757
  let current: Rec | undefined;
758
758
  for (const raw of lcov.split('\n')) {
759
759
  const line = raw.trim();
760
- if (line.startsWith('SF:')) { current = { fns: [], hits: new Map() }; records.set(line.slice(3), current); continue; }
760
+ // bun's lcov names a file with the platform's separator: Windows' '\' is read as '/', as every path here is
761
+ if (line.startsWith('SF:')) { current = { fns: [], hits: new Map() }; records.set(line.slice(3).replaceAll('\\', '/'), current); continue; }
761
762
  if (!current) continue;
762
763
  if (line.startsWith('FN:')) { const [l, ...name] = line.slice(3).split(','); current.fns.push({ line: Number(l), name: name.join(',') }); continue; }
763
764
  if (line.startsWith('FNF:')) { current.fnf = Number(line.slice(4)); continue; }