@volter/twin-standard 1.0.144 → 1.0.146

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.
@@ -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
@@ -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
  }
@@ -145,7 +145,7 @@ function srcFileInForm(unit, name) {
145
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);
146
146
  }
147
147
  /** The fixed files of semantics/ besides the families' handler files and their tests. */
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', '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']);
149
149
  /** journeys/'s closed set. */
150
150
  const JOURNEY_FILES = new Set(['customer-life.outline.md', 'customer-life.outline.verdict.json', 'customer-life.json', 'customer-life.verdict.json', 'decisions.json',
151
151
  'vendor-examples.json', 'vendor-examples.verdict.json', 'demand.json', 'unreachable.json', 'score.json', 'coverage.json', 'first-use.json',
@@ -455,6 +455,12 @@ const criteria = [
455
455
  found.push('sockets.ts exports otherwise than one const engine per socket');
456
456
  continue;
457
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
+ }
458
464
  if (f === 'gap.ts') {
459
465
  const names = [...text.matchAll(/^export\s+(?:async\s+)?function\s+([A-Za-z0-9_$]+)/gm)].map((m) => m[1]);
460
466
  if (names.join() !== 'gap' || /^export\s+(const|let|var|class)\s|^export\s*\{/m.test(text))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/twin-standard",
3
- "version": "1.0.144",
3
+ "version": "1.0.146",
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.153",
79
79
  "@volter/world-runtime": "*",
80
80
  "playwright": "^1.63.0"
81
81
  },
@@ -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/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
@@ -128,7 +128,7 @@ function srcFileInForm(unit: GradeUnit, name: string): boolean {
128
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);
129
129
  }
130
130
  /** The fixed files of semantics/ besides the families' handler files and their tests. */
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', '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']);
132
132
  /** journeys/'s closed set. */
133
133
  const JOURNEY_FILES = new Set(['customer-life.outline.md', 'customer-life.outline.verdict.json', 'customer-life.json', 'customer-life.verdict.json', 'decisions.json',
134
134
  'vendor-examples.json', 'vendor-examples.verdict.json', 'demand.json', 'unreachable.json', 'score.json', 'coverage.json', 'first-use.json',
@@ -400,6 +400,11 @@ const criteria: Criterion[] = [
400
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');
401
401
  continue;
402
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
+ }
403
408
  if (f === 'gap.ts') {
404
409
  const names = [...text.matchAll(/^export\s+(?:async\s+)?function\s+([A-Za-z0-9_$]+)/gm)].map((m) => m[1]);
405
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`);