@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.
- package/dist/src/conformance.js +5 -3
- package/dist/src/create-pack.d.ts +4 -1
- package/dist/src/create-pack.js +36 -3
- package/dist/src/derive.js +8 -8
- package/dist/src/pack-facts.d.ts +1 -0
- package/dist/src/pack-facts.js +2 -0
- package/dist/src/protocol-3.js +17 -8
- package/dist/src/walk/journey-kit.js +2 -1
- package/package.json +2 -2
- package/src/conformance.ts +5 -3
- package/src/create-pack.ts +33 -3
- package/src/derive.ts +8 -8
- package/src/pack-facts.ts +2 -1
- package/src/protocol-3.ts +17 -8
- package/src/walk/journey-kit.ts +2 -1
package/dist/src/conformance.js
CHANGED
|
@@ -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) =>
|
|
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 =
|
|
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>;
|
package/dist/src/create-pack.js
CHANGED
|
@@ -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':
|
|
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
|
package/dist/src/derive.js
CHANGED
|
@@ -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
|
|
50
|
-
const severalDocuments = specPath
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
package/dist/src/pack-facts.d.ts
CHANGED
package/dist/src/pack-facts.js
CHANGED
|
@@ -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
|
}
|
package/dist/src/protocol-3.js
CHANGED
|
@@ -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 =
|
|
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) =>
|
|
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(
|
|
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(
|
|
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) =>
|
|
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 = `./${
|
|
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.
|
|
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.
|
|
78
|
+
"@volter/world-core": "3.0.152",
|
|
79
79
|
"@volter/world-runtime": "*",
|
|
80
80
|
"playwright": "^1.63.0"
|
|
81
81
|
},
|
package/src/conformance.ts
CHANGED
|
@@ -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 =>
|
|
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 =
|
|
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)`);
|
package/src/create-pack.ts
CHANGED
|
@@ -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':
|
|
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
|
|
44
|
-
const severalDocuments = specPath
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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) =>
|
|
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(
|
|
104
|
+
if (excludeArtifacts && isPackArtifact(rel(base, path))) continue;
|
|
101
105
|
if (statSync(path).isDirectory()) out.push(...allFilesUnder(path, base, excludeArtifacts));
|
|
102
|
-
else out.push(
|
|
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) =>
|
|
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 = `./${
|
|
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();
|
package/src/walk/journey-kit.ts
CHANGED
|
@@ -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
|
-
|
|
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; }
|