@volter/twin-standard 1.0.30 → 1.0.32
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/archive-source.d.ts +13 -0
- package/dist/src/archive-source.js +176 -0
- package/dist/src/check-sources.d.ts +19 -2
- package/dist/src/check-sources.js +95 -37
- package/dist/src/cli.js +52 -9
- package/dist/src/clients/ai-gateway.d.ts +1 -0
- package/dist/src/clients/ai-gateway.js +3 -0
- package/dist/src/clients/anthropic-messages.d.ts +10 -0
- package/dist/src/clients/anthropic-messages.js +52 -0
- package/dist/src/clients/anthropic.d.ts +1 -0
- package/dist/src/clients/anthropic.js +3 -0
- package/dist/src/clients/aws.d.ts +2 -0
- package/dist/src/clients/aws.js +137 -0
- package/dist/src/clients/cerebras.d.ts +1 -0
- package/dist/src/clients/cerebras.js +3 -0
- package/dist/src/clients/clerk.d.ts +2 -0
- package/dist/src/clients/clerk.js +99 -0
- package/dist/src/clients/cloudflare.d.ts +2 -0
- package/dist/src/clients/cloudflare.js +86 -0
- package/dist/src/clients/elevenlabs.d.ts +2 -0
- package/dist/src/clients/elevenlabs.js +39 -0
- package/dist/src/clients/gemini.d.ts +2 -0
- package/dist/src/clients/gemini.js +92 -0
- package/dist/src/clients/github.d.ts +2 -0
- package/dist/src/clients/github.js +71 -0
- package/dist/src/clients/groq.d.ts +1 -0
- package/dist/src/clients/groq.js +3 -0
- package/dist/src/clients/harness.d.ts +36 -0
- package/dist/src/clients/harness.js +74 -0
- package/dist/src/clients/merge.d.ts +1 -0
- package/dist/src/clients/merge.js +8 -0
- package/dist/src/clients/openai-compatible.d.ts +9 -0
- package/dist/src/clients/openai-compatible.js +51 -0
- package/dist/src/clients/openai.d.ts +2 -0
- package/dist/src/clients/openai.js +65 -0
- package/dist/src/clients/openrouter.d.ts +1 -0
- package/dist/src/clients/openrouter.js +3 -0
- package/dist/src/clients/perplexity.d.ts +1 -0
- package/dist/src/clients/perplexity.js +3 -0
- package/dist/src/clients/planetscale.d.ts +2 -0
- package/dist/src/clients/planetscale.js +109 -0
- package/dist/src/clients/posthog.d.ts +2 -0
- package/dist/src/clients/posthog.js +145 -0
- package/dist/src/clients/run.d.ts +1 -0
- package/dist/src/clients/run.js +25 -0
- package/dist/src/clients/sentry.d.ts +2 -0
- package/dist/src/clients/sentry.js +167 -0
- package/dist/src/clients/slack.d.ts +2 -0
- package/dist/src/clients/slack.js +107 -0
- package/dist/src/clients/stripe.d.ts +2 -0
- package/dist/src/clients/stripe.js +161 -0
- package/dist/src/clients/tinybird.d.ts +2 -0
- package/dist/src/clients/tinybird.js +71 -0
- package/dist/src/clients/tunnel.d.ts +2 -0
- package/dist/src/clients/tunnel.js +99 -0
- package/dist/src/clients/upstash.d.ts +2 -0
- package/dist/src/clients/upstash.js +143 -0
- package/dist/src/clients/volteridentity.d.ts +2 -0
- package/dist/src/clients/volteridentity.js +128 -0
- package/dist/src/clients/xai.d.ts +2 -0
- package/dist/src/clients/xai.js +23 -0
- package/dist/src/conformance.d.ts +33 -0
- package/dist/src/conformance.js +520 -0
- package/dist/src/derive.js +113 -22
- package/dist/src/gate.js +22 -16
- package/dist/src/index.d.ts +5 -3
- package/dist/src/index.js +5 -3
- package/dist/src/lanes.d.ts +2 -0
- package/dist/src/lanes.js +7 -0
- package/dist/src/p3-rules.js +0 -2
- package/dist/src/protocol-3.d.ts +1 -0
- package/dist/src/protocol-3.js +76 -30
- package/dist/src/published.d.ts +6 -0
- package/dist/src/published.js +65 -11
- package/dist/src/spec-documents.js +2 -1
- package/dist/src/spec-ir-client.d.ts +5 -1
- package/dist/src/spec-ir-client.js +12 -2
- package/dist/src/spec-ir.d.ts +7 -2
- package/dist/src/spec-ir.js +42 -10
- package/dist/src/verdict.d.ts +14 -0
- package/dist/src/verdict.js +30 -0
- package/dist/src/walk/behavior-journey.d.ts +47 -0
- package/dist/src/walk/behavior-journey.js +1138 -0
- package/dist/src/walk/git-client.d.ts +26 -0
- package/dist/src/walk/git-client.js +139 -0
- package/dist/src/walk/journey-kit.d.ts +445 -0
- package/dist/src/walk/journey-kit.js +743 -0
- package/dist/src/walk/shape.d.ts +54 -0
- package/dist/src/walk/shape.js +210 -0
- package/dist/src/walk/sse-example.d.ts +2 -0
- package/dist/src/walk/sse-example.js +32 -0
- package/dist/src/walk/vendor-fetch.d.ts +20 -0
- package/dist/src/walk/vendor-fetch.js +92 -0
- package/dist/src/walk/walk-database.d.ts +12 -0
- package/dist/src/walk/walk-database.js +96 -0
- package/dist/src/walk/xml-view.d.ts +2 -0
- package/dist/src/walk/xml-view.js +55 -0
- package/package.json +39 -3
- package/src/archive-source.ts +141 -0
- package/src/check-sources.ts +83 -33
- package/src/cli.ts +40 -8
- package/src/clients/ai-gateway.ts +4 -0
- package/src/clients/anthropic-messages.ts +60 -0
- package/src/clients/anthropic.ts +4 -0
- package/src/clients/aws.ts +119 -0
- package/src/clients/cerebras.ts +4 -0
- package/src/clients/clerk.ts +97 -0
- package/src/clients/cloudflare.ts +80 -0
- package/src/clients/elevenlabs.ts +38 -0
- package/src/clients/gemini.ts +89 -0
- package/src/clients/github.ts +67 -0
- package/src/clients/groq.ts +4 -0
- package/src/clients/harness.ts +101 -0
- package/src/clients/merge.ts +9 -0
- package/src/clients/openai-compatible.ts +59 -0
- package/src/clients/openai.ts +66 -0
- package/src/clients/openrouter.ts +4 -0
- package/src/clients/perplexity.ts +4 -0
- package/src/clients/planetscale.ts +98 -0
- package/src/clients/posthog.ts +132 -0
- package/src/clients/run.ts +18 -0
- package/src/clients/sentry.ts +157 -0
- package/src/clients/slack.ts +98 -0
- package/src/clients/stripe.ts +129 -0
- package/src/clients/tinybird.ts +69 -0
- package/src/clients/tunnel.ts +79 -0
- package/src/clients/upstash.ts +137 -0
- package/src/clients/volteridentity.ts +108 -0
- package/src/clients/xai.ts +25 -0
- package/src/conformance.ts +446 -0
- package/src/derive.ts +87 -16
- package/src/gate.ts +20 -14
- package/src/index.ts +5 -3
- package/src/lanes.ts +7 -0
- package/src/p3-rules.ts +0 -2
- package/src/protocol-3.ts +71 -27
- package/src/published.ts +59 -11
- package/src/spec-documents.ts +2 -1
- package/src/spec-ir-client.ts +15 -4
- package/src/spec-ir.ts +43 -5
- package/src/verdict.ts +28 -0
- package/src/walk/behavior-journey.ts +806 -0
- package/src/walk/git-client.ts +138 -0
- package/src/walk/journey-kit.ts +858 -0
- package/src/walk/shape.ts +208 -0
- package/src/walk/sse-example.ts +25 -0
- package/src/walk/vendor-fetch.ts +80 -0
- package/src/walk/walk-database.ts +97 -0
- package/src/walk/xml-view.ts +44 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export type ShapeOperation = {
|
|
2
|
+
id: string;
|
|
3
|
+
method: string;
|
|
4
|
+
path: string;
|
|
5
|
+
class: string;
|
|
6
|
+
resource?: string;
|
|
7
|
+
successStatus?: number;
|
|
8
|
+
responseMedia?: Record<string, string[]>;
|
|
9
|
+
defaultResponseMedia?: Record<string, string>;
|
|
10
|
+
answers?: {
|
|
11
|
+
resource: string;
|
|
12
|
+
list: boolean;
|
|
13
|
+
key?: string;
|
|
14
|
+
extends?: string[];
|
|
15
|
+
alternatives?: string[];
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
export type ShapeChecker = {
|
|
19
|
+
/** The spec operation a request matches, if any. An RPC wire answers a method whatever the HTTP method. */
|
|
20
|
+
operationOf: (method: string, path: string, headers?: Record<string, string>) => {
|
|
21
|
+
op: ShapeOperation;
|
|
22
|
+
params: Record<string, string>;
|
|
23
|
+
} | undefined;
|
|
24
|
+
/** A request's URL as the unit routes it (a virtual-hosted S3 request's bucket moved from its host into its path). */
|
|
25
|
+
pathOf?: (path: string) => string;
|
|
26
|
+
/** What in an answer the spec does not give the operation's resource (or its view). */
|
|
27
|
+
failures: (op: ShapeOperation, body: unknown, mediaType?: string, accept?: string, status?: number, exchange?: {
|
|
28
|
+
method: string;
|
|
29
|
+
responseText: string;
|
|
30
|
+
}) => string[];
|
|
31
|
+
/** Whether a request pins an API version before the spec's, whose answers the spec does not describe (a Stripe
|
|
32
|
+
* mobile SDK's `Stripe-Version: 2024-06-20`), by the version header the manifest declares. */
|
|
33
|
+
pinsOlder: (headers: Record<string, string> | undefined) => boolean;
|
|
34
|
+
};
|
|
35
|
+
/** The directories under a pack that are lanes: a derived unit with its own generated surface
|
|
36
|
+
* (`../twin-packs-p3/aws/secretsmanager/`, `../twin-packs-p3/planetscale/api/`). */
|
|
37
|
+
export declare function lanesOf(pkg: string): string[];
|
|
38
|
+
/** A derived pack's shape checker, or undefined for a pack with no generated surface. A pack's walk reaches its lanes'
|
|
39
|
+
* operations through the pack's router (a psdb life creates its database's passwords through the management API), so
|
|
40
|
+
* a request is matched against the pack's own surface, then each lane's, and judged by the surface it matched. */
|
|
41
|
+
export declare function shapeChecker(pkg: string): Promise<ShapeChecker | undefined>;
|
|
42
|
+
/** Where one connection stands for the check: the command whose intermediate reply the next lines answer. */
|
|
43
|
+
export type LineConnection = {
|
|
44
|
+
pending?: string;
|
|
45
|
+
};
|
|
46
|
+
export type LineShape = {
|
|
47
|
+
/** What is wrong with the replies one line drew (null: the connection's opening), as the spec's
|
|
48
|
+
* command-reply table reads them; moves `conn` past an intermediate reply. */
|
|
49
|
+
check: (conn: LineConnection, line: string | null, codes: number[]) => string[];
|
|
50
|
+
};
|
|
51
|
+
/** The SHAPE of a line protocol (scripts/derive-pack.ts from an RFC's command-reply table): each reply a
|
|
52
|
+
* code the spec allows the command it answers, or one any command may answer. A line after an
|
|
53
|
+
* intermediate reply (DATA's payload, a SASL response) is answered as that command's `then`. */
|
|
54
|
+
export declare function lineShape(pkg: string): LineShape | undefined;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
// SHAPE — an answer carries only the fields the spec gives what the operation answers, and a state
|
|
10
|
+
// candidate only the values the spec lists (docs/contributing/architecture.md, "Protocol 3",
|
|
11
|
+
// State and The report), and every field the spec requires. The walk (behavior-journey.ts) runs it on every successful step of a derived
|
|
12
|
+
// pack, so a walk an author iterates with fails where the score would; score-pack reads it from there.
|
|
13
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { compileSurface, matchOperation } from '@volter/world-core/runtime';
|
|
16
|
+
import { binaryMedia } from "../spec-ir.js";
|
|
17
|
+
/** Whether a resource is one of the objects `said` names (a union's `object` lists every alternative's). */
|
|
18
|
+
const isObject = (r, said) => typeof said === 'string' && (r.fields.find((f) => f.name === 'object')?.enum ?? []).includes(said);
|
|
19
|
+
/** The directories under a pack that are lanes: a derived unit with its own generated surface
|
|
20
|
+
* (`../twin-packs-p3/aws/secretsmanager/`, `../twin-packs-p3/planetscale/api/`). */
|
|
21
|
+
export function lanesOf(pkg) {
|
|
22
|
+
if (!existsSync(pkg))
|
|
23
|
+
return [];
|
|
24
|
+
return readdirSync(pkg, { withFileTypes: true })
|
|
25
|
+
.filter((e) => e.isDirectory() && !['src', 'spec', 'journeys', 'node_modules', 'scripts', 'test-fixtures'].includes(e.name) && existsSync(join(pkg, e.name, 'src', 'generated', 'surface.gen.json')))
|
|
26
|
+
.map((e) => join(pkg, e.name));
|
|
27
|
+
}
|
|
28
|
+
/** A derived pack's shape checker, or undefined for a pack with no generated surface. A pack's walk reaches its lanes'
|
|
29
|
+
* operations through the pack's router (a psdb life creates its database's passwords through the management API), so
|
|
30
|
+
* a request is matched against the pack's own surface, then each lane's, and judged by the surface it matched. */
|
|
31
|
+
export async function shapeChecker(pkg) {
|
|
32
|
+
const units = (await Promise.all([pkg, ...lanesOf(pkg)].map((dir) => unitChecker(dir)))).filter((c) => !!c);
|
|
33
|
+
if (!units.length)
|
|
34
|
+
return undefined;
|
|
35
|
+
if (units.length === 1)
|
|
36
|
+
return units[0];
|
|
37
|
+
const owner = new WeakMap();
|
|
38
|
+
return {
|
|
39
|
+
pinsOlder: (headers) => units[0].pinsOlder(headers),
|
|
40
|
+
pathOf: (path) => units.find((u) => u.operationOf('GET', path) || u.operationOf('PUT', path))?.pathOf?.(path) ?? path,
|
|
41
|
+
operationOf: (method, path, headers) => {
|
|
42
|
+
for (const u of units) {
|
|
43
|
+
const hit = u.operationOf(method, path, headers);
|
|
44
|
+
if (hit) {
|
|
45
|
+
owner.set(hit.op, u);
|
|
46
|
+
return hit;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
},
|
|
51
|
+
failures: (op, body, mediaType, accept, status, exchange) => (owner.get(op) ?? units[0]).failures(op, body, mediaType, accept, status, exchange),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Accept's most specific media range decides its quality; an explicit q=0 overrides a wildcard. */
|
|
55
|
+
function acceptsMedia(accept, media, defaultMedia) {
|
|
56
|
+
if (accept === undefined)
|
|
57
|
+
return media === defaultMedia?.toLowerCase();
|
|
58
|
+
const [type] = media.split('/');
|
|
59
|
+
const matches = accept.toLowerCase().split(',').flatMap((part) => {
|
|
60
|
+
const [range, ...parameters] = part.trim().split(';').map((value) => value.trim());
|
|
61
|
+
const specificity = range === media ? 2 : range === `${type}/*` ? 1 : range === '*/*' ? 0 : -1;
|
|
62
|
+
if (specificity < 0)
|
|
63
|
+
return [];
|
|
64
|
+
const quality = parameters.find((value) => value.startsWith('q='));
|
|
65
|
+
const q = quality === undefined ? 1 : Number(quality.slice(2));
|
|
66
|
+
return [{ specificity, q: Number.isFinite(q) && q >= 0 && q <= 1 ? q : 0 }];
|
|
67
|
+
}).sort((a, b) => b.specificity - a.specificity || b.q - a.q);
|
|
68
|
+
return (matches[0]?.q ?? 0) > 0;
|
|
69
|
+
}
|
|
70
|
+
async function unitChecker(pkg) {
|
|
71
|
+
const surfacePath = join(pkg, 'src', 'generated', 'surface.gen.json');
|
|
72
|
+
if (!existsSync(surfacePath))
|
|
73
|
+
return undefined;
|
|
74
|
+
const surface = JSON.parse(readFileSync(surfacePath, 'utf8'));
|
|
75
|
+
// a line protocol's surface is its commands' replies (lineShape); a command table's commands ride in a request's body
|
|
76
|
+
// (Upstash's REST envelope), which a check by method and path cannot see: its replies are not shape-checked yet, and
|
|
77
|
+
// its examples assert what each answers
|
|
78
|
+
if (['command-reply', 'command-table'].includes(String(surface.format)))
|
|
79
|
+
return undefined;
|
|
80
|
+
const manifestModule = existsSync(join(pkg, 'src', 'manifest.ts')) ? (await import(__rewriteRelativeImportExtension(join(pkg, 'src', 'manifest.ts')))) : {};
|
|
81
|
+
// a unit whose host can name part of what its path names (S3's virtual-hosted bucket) is routed as the kernel routes it: a host whose label is part of the path (the manifest's pathFromHost, S3's virtual-hosted
|
|
82
|
+
// addressing) puts it in front of the path
|
|
83
|
+
const hostPath = manifestModule.manifest?.pathFromHost ? new RegExp(manifestModule.manifest.pathFromHost, 'i') : undefined;
|
|
84
|
+
const pathStyle = (u) => {
|
|
85
|
+
const url = new URL(u);
|
|
86
|
+
const label = hostPath?.exec(url.hostname)?.[1];
|
|
87
|
+
return label ? `${url.protocol}//${url.host}/${label}${url.pathname === '/' ? '' : url.pathname}${url.search}` : u;
|
|
88
|
+
};
|
|
89
|
+
// a lane is routed by host ("Other wires: lanes"): one whose manifest names the hosts it serves is judged on those
|
|
90
|
+
// alone, so a page or door of its pack on another host (a dashboard, a World door) is never read as one of its operations
|
|
91
|
+
const hosts = manifestModule.manifest?.hosts;
|
|
92
|
+
const served = (hostname) => !hosts || hosts.some((h) => (h.host ? hostname === h.host : !!h.suffix && hostname.endsWith(h.suffix)));
|
|
93
|
+
const versionHeader = manifestModule.manifest?.version?.header.toLowerCase();
|
|
94
|
+
const specDate = /^\d{4}-\d{2}-\d{2}/.exec(String(surface.version))?.[0];
|
|
95
|
+
// the path parameters whose values hold slashes (a git ref, a file path): the surface's own `spanning`, and the
|
|
96
|
+
// manifest's where the spec cannot say
|
|
97
|
+
const spans = [...(surface.spanning ?? []), ...(manifestModule.manifest?.spanning ?? [])];
|
|
98
|
+
const routes = compileSurface({ ...surface, ...(spans.length ? { spanning: spans } : {}) });
|
|
99
|
+
const resources = new Map(surface.resources.map((r) => [r.name, r]));
|
|
100
|
+
return {
|
|
101
|
+
pinsOlder: (headers) => {
|
|
102
|
+
if (!versionHeader || !specDate || !headers)
|
|
103
|
+
return false;
|
|
104
|
+
const pinned = Object.entries(headers).find(([k]) => k.toLowerCase() === versionHeader)?.[1];
|
|
105
|
+
return typeof pinned === 'string' && pinned.slice(0, 10) < specDate;
|
|
106
|
+
},
|
|
107
|
+
pathOf: (path) => (/^https?:\/\//.test(path) ? pathStyle(path) : path),
|
|
108
|
+
operationOf: (method, path, headers) => {
|
|
109
|
+
const url = new URL(/^https?:\/\//.test(path) ? pathStyle(path) : path, 'http://twin.local');
|
|
110
|
+
if (/^https?:\/\//.test(path) && !served(new URL(path).hostname))
|
|
111
|
+
return undefined;
|
|
112
|
+
// a World door the manifest declares is that door, never the vendor's operation at a path it would also match
|
|
113
|
+
if (manifestModule.manifest?.doors?.some((door) => door.method.toUpperCase() === method.toUpperCase() && door.path === url.pathname))
|
|
114
|
+
return undefined;
|
|
115
|
+
const h = new Headers(headers ?? {});
|
|
116
|
+
const hit = matchOperation(routes, method, url.pathname, url.searchParams, h) ?? [...routes.keys()].map((m) => matchOperation(routes, m, url.pathname, url.searchParams, h)).find(Boolean);
|
|
117
|
+
return hit ? { op: hit.operation, params: hit.params } : undefined;
|
|
118
|
+
},
|
|
119
|
+
failures: (op, body, mediaType, accept, status = op.successStatus ?? 200, exchange) => {
|
|
120
|
+
// HEAD's headers describe GET's representation, while HTTP forbids response content.
|
|
121
|
+
if ((exchange?.method ?? op.method).toUpperCase() === 'HEAD') {
|
|
122
|
+
const empty = exchange ? exchange.responseText.length === 0 : body === undefined || body === null || body === '';
|
|
123
|
+
return empty ? [] : ['a HEAD response carries content, which HTTP forbids'];
|
|
124
|
+
}
|
|
125
|
+
const media = mediaType?.split(';')[0]?.trim().toLowerCase();
|
|
126
|
+
const binary = media !== undefined && binaryMedia(media);
|
|
127
|
+
const declared = op.responseMedia?.[String(status)] ?? op.responseMedia?.[`${Math.floor(status / 100)}XX`] ?? op.responseMedia?.default ?? [];
|
|
128
|
+
const defaultMedia = op.defaultResponseMedia?.[String(status)] ?? op.defaultResponseMedia?.[`${Math.floor(status / 100)}XX`] ?? op.defaultResponseMedia?.default;
|
|
129
|
+
if (binary && declared.some((value) => value.toLowerCase() === media) && acceptsMedia(accept, media, defaultMedia))
|
|
130
|
+
return [];
|
|
131
|
+
const primary = op.answers ? resources.get(op.answers.resource) : undefined;
|
|
132
|
+
if (!primary)
|
|
133
|
+
return [];
|
|
134
|
+
if (!body || typeof body !== 'object')
|
|
135
|
+
return binary ? [`the answer is not the operation's ${primary.name} resource`] : [];
|
|
136
|
+
// an answer that may be one of several resources (a card or a bank account) is judged as the one it says it is
|
|
137
|
+
const alternatives = [primary, ...(op.answers.alternatives ?? []).map((n) => resources.get(n)).filter((x) => !!x)];
|
|
138
|
+
const holder = op.answers.key ? body[op.answers.key] : body;
|
|
139
|
+
const items = op.answers.list ? (Array.isArray(holder) ? holder : (holder?.data ?? [])) : [holder];
|
|
140
|
+
const out = [];
|
|
141
|
+
for (const item of items) {
|
|
142
|
+
if (!item || typeof item !== 'object')
|
|
143
|
+
continue;
|
|
144
|
+
const said = item.object;
|
|
145
|
+
// a deleted object is judged as the deleted form of what it says it is
|
|
146
|
+
const gone = item.deleted === true;
|
|
147
|
+
const judged = (r) => {
|
|
148
|
+
const found = [];
|
|
149
|
+
const known = new Map([...r.fields.map((f) => [f.name, f]), ...(op.answers.extends ?? []).map((name) => [name, {}])]);
|
|
150
|
+
for (const [k, v] of Object.entries(item)) {
|
|
151
|
+
const f = known.get(k);
|
|
152
|
+
const local = manifestModule.manifest?.worldFields?.[r.name]?.[k];
|
|
153
|
+
if (local) {
|
|
154
|
+
if (!k.startsWith('twin_') || !local.why?.trim() || f)
|
|
155
|
+
found.push(`${r.name}.${k}: a World field must have a twin_ name and reason and must not replace a vendor field`);
|
|
156
|
+
const type = Array.isArray(v) ? 'array' : v === null ? 'null' : typeof v;
|
|
157
|
+
if (type !== local.type)
|
|
158
|
+
found.push(`${r.name}.${k}: World field is ${type}, expected ${local.type}`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (!f)
|
|
162
|
+
found.push(`${r.name} answers \`${k}\`, which the spec does not give it`);
|
|
163
|
+
else if (f.enum && typeof v === 'string' && !f.enum.includes(v))
|
|
164
|
+
found.push(`${r.name}.${k} is \`${v}\`, not one of the spec's ${f.enum.join(', ')}`);
|
|
165
|
+
}
|
|
166
|
+
for (const f of r.fields)
|
|
167
|
+
if (f.required && !(f.name in item))
|
|
168
|
+
found.push(`${r.name} leaves out \`${f.name}\`, which the spec requires`);
|
|
169
|
+
return found;
|
|
170
|
+
};
|
|
171
|
+
const named = alternatives.find((a) => isObject(a, said) && gone === a.fields.some((f) => f.name === 'deleted'))
|
|
172
|
+
?? alternatives.find((a) => isObject(a, said));
|
|
173
|
+
// an item that names no `object` is judged as the alternative its own values say it is: one whose every enum field
|
|
174
|
+
// the item carries holds that value (a key's `kty`), the one it fits best where several do
|
|
175
|
+
const consistent = (a) => a.fields.every((f) => !f.enum || typeof item[f.name] !== 'string' || f.enum.includes(item[f.name]));
|
|
176
|
+
const r = named ?? (alternatives.length > 1
|
|
177
|
+
? [...alternatives].filter(consistent).sort((a, b) => judged(a).length - judged(b).length)[0] ?? primary
|
|
178
|
+
: primary);
|
|
179
|
+
out.push(...judged(r));
|
|
180
|
+
}
|
|
181
|
+
return [...new Set(out)];
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/** The SHAPE of a line protocol (scripts/derive-pack.ts from an RFC's command-reply table): each reply a
|
|
186
|
+
* code the spec allows the command it answers, or one any command may answer. A line after an
|
|
187
|
+
* intermediate reply (DATA's payload, a SASL response) is answered as that command's `then`. */
|
|
188
|
+
export function lineShape(pkg) {
|
|
189
|
+
const surfacePath = join(pkg, 'src', 'generated', 'surface.gen.json');
|
|
190
|
+
if (!existsSync(surfacePath))
|
|
191
|
+
return undefined;
|
|
192
|
+
const surface = JSON.parse(readFileSync(surfacePath, 'utf8'));
|
|
193
|
+
if (surface.format !== 'command-reply')
|
|
194
|
+
return undefined;
|
|
195
|
+
const commands = new Map(surface.commands.map((c) => [c.id, c]));
|
|
196
|
+
return {
|
|
197
|
+
check: (conn, line, codes) => {
|
|
198
|
+
const pending = conn.pending ? commands.get(conn.pending) : undefined;
|
|
199
|
+
const verb = line === null ? 'CONNECTION ESTABLISHMENT' : line.trim().split(/\s/)[0].toUpperCase();
|
|
200
|
+
const command = pending ?? commands.get(verb);
|
|
201
|
+
const replies = pending?.then ?? command;
|
|
202
|
+
const allowed = new Set([...surface.anyCommand, ...(replies ? [...replies.intermediate, ...replies.success, ...replies.error] : [])]);
|
|
203
|
+
const out = codes.filter((c) => !allowed.has(c)).map((c) => `${pending ? `the lines after ${pending.id}` : verb} answered ${c}, which the spec does not give it`);
|
|
204
|
+
const last = codes.at(-1);
|
|
205
|
+
if (last !== undefined)
|
|
206
|
+
conn.pending = command && replies?.intermediate.includes(last) ? command.id : undefined;
|
|
207
|
+
return out;
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { parseExactJson } from '@volter/world-core/exact-json';
|
|
2
|
+
/** Match a published SDK-decoded SSE sample to a real HTTP frame; preserve non-SSE answers and mismatches. */
|
|
3
|
+
export function sseExampleAnswer(example, answer, matches) {
|
|
4
|
+
if (typeof answer !== 'string' || !example || typeof example !== 'object')
|
|
5
|
+
return answer;
|
|
6
|
+
const wrapped = 'data' in example;
|
|
7
|
+
if (!wrapped && !('type' in example) && !('object' in example))
|
|
8
|
+
return answer;
|
|
9
|
+
const sample = (wrapped ? example : { data: example });
|
|
10
|
+
const frames = [];
|
|
11
|
+
for (const block of answer.replace(/\r\n/g, '\n').split('\n\n')) {
|
|
12
|
+
const lines = block.split('\n');
|
|
13
|
+
const data = lines.filter((s) => s.startsWith('data:')).map((s) => s.slice(5).replace(/^ /, '')).join('\n');
|
|
14
|
+
if (!data || data === '[DONE]')
|
|
15
|
+
continue;
|
|
16
|
+
const event = lines.find((s) => s.startsWith('event:'))?.slice(6).trim();
|
|
17
|
+
try {
|
|
18
|
+
frames.push({ data: parseExactJson(data), ...(event ? { event } : {}) });
|
|
19
|
+
}
|
|
20
|
+
catch { /* not a decoded JSON frame */ }
|
|
21
|
+
}
|
|
22
|
+
const relevant = frames.filter((f) => {
|
|
23
|
+
if (sample.event && f.event !== sample.event)
|
|
24
|
+
return false;
|
|
25
|
+
const want = sample.data;
|
|
26
|
+
const got = f.data;
|
|
27
|
+
return !want || typeof want !== 'object' || !got || typeof got !== 'object'
|
|
28
|
+
|| (!want.type || want.type === got.type) && (!want.object || want.object === got.object);
|
|
29
|
+
});
|
|
30
|
+
const candidates = relevant.map((frame) => wrapped ? frame : frame.data);
|
|
31
|
+
return candidates.find(matches) ?? candidates[0] ?? answer;
|
|
32
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type TwinStream } from '@volter/world-core/runtime';
|
|
2
|
+
import type { PackFetchOptions } from '@volter/world-core';
|
|
3
|
+
type ApplicationStandIn = NonNullable<PackFetchOptions['application']>;
|
|
4
|
+
/** A pack's fetch over a World's root; a walk whose life names a scenario loads it as the World's (`scenarioPath`), and a
|
|
5
|
+
* life's application answers stand in for the application the vendor asks (`application`, this fetch's alone). */
|
|
6
|
+
export type VendorFetchFactory = (root: string, options?: {
|
|
7
|
+
scenarioPath?: string;
|
|
8
|
+
application?: ApplicationStandIn;
|
|
9
|
+
}) => (request: Request) => Response | Promise<Response>;
|
|
10
|
+
export declare function vendorFetch(twinDir: string, vendor: string): Promise<VendorFetchFactory | undefined>;
|
|
11
|
+
/** Named views of an answer: what a journey step's `view` asserts against. The kit's own: `jwt:<field>`, the payload of the
|
|
12
|
+
* JSON Web Token at that field of the answer, decoded as the application verifying it reads it (a session token's
|
|
13
|
+
* claims). A pack's own (`answerViews` in its index) beside them. */
|
|
14
|
+
export type AnswerViews = Record<string, (body: unknown) => unknown>;
|
|
15
|
+
export declare function vendorAnswerViews(twinDir: string, vendor: string): Promise<AnswerViews>;
|
|
16
|
+
/** A vendor's byte-stream wire (a line protocol), in process: the pack's `create<Name>TwinStream`, what a
|
|
17
|
+
* hosted World hands a WebSocket's bytes. The walk drives it for a journey's line steps. */
|
|
18
|
+
export type VendorStreamFactory = (root: string, options?: Record<string, unknown>) => TwinStream;
|
|
19
|
+
export declare function vendorStream(twinDir: string, vendor: string): Promise<VendorStreamFactory | undefined>;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
// A vendor's wire, in process: the pack's own fetch factory (`create<Name>Fetch`), found on its
|
|
10
|
+
// index. What the catalog's walkers drive (behavior journeys, the spec census probe, the invariants)
|
|
11
|
+
// since the worker render left with the v1 host.
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import packFacts from '@volter/world-core/generated/pack-facts.json' with { type: 'json' };
|
|
14
|
+
import { startPgliteHost } from "./walk-database.js";
|
|
15
|
+
import { worldNow } from '@volter/world-core/runtime';
|
|
16
|
+
export async function vendorFetch(twinDir, vendor) {
|
|
17
|
+
let mod;
|
|
18
|
+
try {
|
|
19
|
+
mod = (await import(__rewriteRelativeImportExtension(join(twinDir, vendor, 'src', 'index.ts'))));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
// a pack exports create<Name>Fetch (its generated fetch.ts)
|
|
25
|
+
const name = Object.keys(mod).find((k) => /^create\w+?Fetch$/.test(k) && !/TwinFetch$/.test(k) && typeof mod[k] === 'function');
|
|
26
|
+
if (!name)
|
|
27
|
+
return undefined;
|
|
28
|
+
const factory = mod[name];
|
|
29
|
+
if (!declaresManagedDatabase(vendor))
|
|
30
|
+
return (root, options = {}) => factory({ root, ...options });
|
|
31
|
+
// A pack whose data plane is the World's managed Postgres is walked over one, as a World binds it: each World (each
|
|
32
|
+
// root) its own fresh database, started on its first request and stopped when the walk's process ends, its clock the
|
|
33
|
+
// walked World's (set before each request: the walk moves the World clock between steps).
|
|
34
|
+
return (root, options = {}) => {
|
|
35
|
+
const bare = factory({ root, ...options });
|
|
36
|
+
let bound;
|
|
37
|
+
let host;
|
|
38
|
+
const bind = () => (bound ??= startPgliteHost().then((started) => { hosts.push(started); host = started; return factory({ root, database: started.url, ...options }); }));
|
|
39
|
+
const served = (request) => bind().then((fetch) => { host.clock(worldNow()); return fetch(request); });
|
|
40
|
+
// the World's database itself, for what an application sends it over its own wire (a SQL step: journey-kit.ts)
|
|
41
|
+
const managedDatabaseUrl = () => bind().then(() => { host.clock(worldNow()); return host.url; });
|
|
42
|
+
// the walk's end stops its World's database, so the next World walked declares it at the same port
|
|
43
|
+
const stopDatabase = async () => { if (bound === undefined)
|
|
44
|
+
return; await bound.catch(() => undefined); await host?.stop(); };
|
|
45
|
+
return Object.assign(served, bare, { managedDatabaseUrl, stopDatabase });
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const hosts = [];
|
|
49
|
+
process.once('exit', () => { for (const host of hosts) {
|
|
50
|
+
try {
|
|
51
|
+
process.kill(host.pid, 'SIGTERM');
|
|
52
|
+
}
|
|
53
|
+
catch { /* ended */ }
|
|
54
|
+
} });
|
|
55
|
+
/** Whether a pack declares the World's managed database (its descriptor's `managedDatabase`, in the pack facts: this
|
|
56
|
+
* checkout's kernel's, as the walk's other readers of them import them). */
|
|
57
|
+
function declaresManagedDatabase(vendor) {
|
|
58
|
+
return packFacts.packs[vendor.split('/')[0]]?.managedDatabase !== undefined;
|
|
59
|
+
}
|
|
60
|
+
const jwtView = (field) => (body) => {
|
|
61
|
+
const token = body?.[field];
|
|
62
|
+
if (typeof token !== 'string')
|
|
63
|
+
return null;
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(Buffer.from(token.split('.')[1] ?? '', 'base64url').toString('utf8'));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
export async function vendorAnswerViews(twinDir, vendor) {
|
|
72
|
+
let own = {};
|
|
73
|
+
try {
|
|
74
|
+
own = (await import(__rewriteRelativeImportExtension(join(twinDir, vendor, 'src', 'index.ts')))).answerViews ?? {};
|
|
75
|
+
}
|
|
76
|
+
catch { /* none of its own */ }
|
|
77
|
+
return new Proxy(own, { get: (views, name) => (typeof name === 'string' && name.startsWith('jwt:') ? jwtView(name.slice(4)) : views[name]) });
|
|
78
|
+
}
|
|
79
|
+
export async function vendorStream(twinDir, vendor) {
|
|
80
|
+
let mod;
|
|
81
|
+
try {
|
|
82
|
+
mod = (await import(__rewriteRelativeImportExtension(join(twinDir, vendor, 'src', 'index.ts'))));
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
const name = Object.keys(mod).find((k) => /^create\w+TwinStream$/.test(k) && typeof mod[k] === 'function');
|
|
88
|
+
if (!name)
|
|
89
|
+
return undefined;
|
|
90
|
+
const factory = mod[name];
|
|
91
|
+
return (root, options = {}) => factory({ ...options, root });
|
|
92
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** `clock(at)` sets the World clock the database reads (pglite-host.mjs: the World's time), frozen at the instant `at`. */
|
|
2
|
+
export type PgliteHost = {
|
|
3
|
+
url: string;
|
|
4
|
+
port: number;
|
|
5
|
+
pid: number;
|
|
6
|
+
clock: (at: string) => void;
|
|
7
|
+
stop: () => Promise<void>;
|
|
8
|
+
};
|
|
9
|
+
/** Start a PGlite host under node (PGlite's WASM init does not complete under bun) and wait until it serves. Its clock
|
|
10
|
+
* is a file of the host's own, which the walk sets to the walked World's clock before each request (`clock`); its
|
|
11
|
+
* port is the same for every walked World. Database randomness is not pinned. */
|
|
12
|
+
export declare function startPgliteHost(): Promise<PgliteHost>;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// The walker's managed database: the World's own containerless Postgres (world-runtime's pglite-host.mjs, the PGlite host a World
|
|
2
|
+
// without a container runtime serves its postgres service with), started on a free loopback port in a temp dir and
|
|
3
|
+
// stopped by its own PID, for a pack that declares `managedDatabase` (packages/twin-standard/src/walk/vendor-fetch.ts). Not on the serve path.
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { createServer, connect } from 'node:net';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
const HOST_SCRIPT = createRequire(import.meta.url).resolve('@volter/world-runtime/src/pglite-host.mjs');
|
|
11
|
+
async function freePort() {
|
|
12
|
+
return new Promise((done, fail) => {
|
|
13
|
+
const server = createServer();
|
|
14
|
+
server.once('error', fail);
|
|
15
|
+
server.listen(0, '127.0.0.1', () => {
|
|
16
|
+
const port = server.address().port;
|
|
17
|
+
server.close(() => done(port));
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
function answers(port) {
|
|
22
|
+
return new Promise((done) => {
|
|
23
|
+
const socket = connect(port, '127.0.0.1');
|
|
24
|
+
socket.once('connect', () => { socket.destroy(); done(true); });
|
|
25
|
+
socket.once('error', () => done(false));
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
// The port a walked World declares its postgres service at, as a World's infrastructure file declares one: the same for
|
|
29
|
+
// every World this process walks, so two fresh Worlds hand an application the same database URL (R9), each World's
|
|
30
|
+
// host on it once the one before has stopped (a walk stops its World's host when it ends). A World walked while another
|
|
31
|
+
// still holds it gets a port of its own.
|
|
32
|
+
let declaredPort;
|
|
33
|
+
let declaredHeld = false;
|
|
34
|
+
async function released(port) {
|
|
35
|
+
for (const deadline = Date.now() + 5000; Date.now() < deadline; await new Promise((r) => setTimeout(r, 100)))
|
|
36
|
+
if (!(await answers(port)))
|
|
37
|
+
return true;
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
/** Start a PGlite host under node (PGlite's WASM init does not complete under bun) and wait until it serves. Its clock
|
|
41
|
+
* is a file of the host's own, which the walk sets to the walked World's clock before each request (`clock`); its
|
|
42
|
+
* port is the same for every walked World. Database randomness is not pinned. */
|
|
43
|
+
export async function startPgliteHost() {
|
|
44
|
+
const dir = mkdtempSync(join(tmpdir(), 'walk-database-'));
|
|
45
|
+
const clockFile = join(dir, 'world-clock');
|
|
46
|
+
const declared = declaredPort !== undefined && !declaredHeld && (await released(declaredPort));
|
|
47
|
+
const port = declared ? declaredPort : await freePort();
|
|
48
|
+
declaredPort ??= port;
|
|
49
|
+
const holdsDeclared = port === declaredPort;
|
|
50
|
+
if (holdsDeclared)
|
|
51
|
+
declaredHeld = true;
|
|
52
|
+
// The host runs under a small guard that ends it (and removes its data) when this process's end closes the guard's
|
|
53
|
+
// stdin: a test runner or a manifest run that exits without its teardown leaves no PGlite host behind.
|
|
54
|
+
const guard = `
|
|
55
|
+
import { spawn } from 'node:child_process'; import { rmSync } from 'node:fs';
|
|
56
|
+
const [script, port, data, dir] = process.argv.slice(1);
|
|
57
|
+
const host = spawn(process.execPath, [script, '--port', port, '--data', data], { stdio: ['ignore', 'ignore', 'inherit'] });
|
|
58
|
+
const end = () => { try { host.kill('SIGTERM'); } catch {} setTimeout(() => { try { rmSync(dir, { recursive: true, force: true }); } catch {} process.exit(0); }, 500); };
|
|
59
|
+
process.stdin.on('end', end); process.stdin.on('close', end); process.stdin.resume();
|
|
60
|
+
process.on('SIGTERM', end);
|
|
61
|
+
host.on('exit', (code) => { try { rmSync(dir, { recursive: true, force: true }); } catch {} process.exit(code ?? 0); });`;
|
|
62
|
+
const child = spawn('node', ['--input-type=module', '-e', guard, HOST_SCRIPT, String(port), join(dir, 'pgdata'), dir], { stdio: ['pipe', 'ignore', 'pipe'], env: { ...process.env, TWIN_WORLD_CLOCK_FILE: clockFile } });
|
|
63
|
+
let stderr = '';
|
|
64
|
+
child.stderr?.on('data', (chunk) => { stderr += chunk.toString('utf8'); });
|
|
65
|
+
// the host never keeps the walk's process alive: its end closes the guard's stdin, which ends the host (a walk that
|
|
66
|
+
// finished otherwise waited on these pipes forever, and grade-pack on its 600 s timeout)
|
|
67
|
+
child.unref();
|
|
68
|
+
child.stdin?.unref?.();
|
|
69
|
+
child.stderr?.unref?.();
|
|
70
|
+
const deadline = Date.now() + 60_000;
|
|
71
|
+
while (!(await answers(port))) {
|
|
72
|
+
if (child.exitCode !== null)
|
|
73
|
+
throw new Error(`pglite-host exited ${child.exitCode}: ${stderr}`);
|
|
74
|
+
if (Date.now() > deadline) {
|
|
75
|
+
child.kill('SIGTERM');
|
|
76
|
+
throw new Error(`pglite-host did not listen within 60s: ${stderr}`);
|
|
77
|
+
}
|
|
78
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
url: `postgresql://postgres:postgres@127.0.0.1:${port}/postgres`,
|
|
82
|
+
port,
|
|
83
|
+
pid: child.pid,
|
|
84
|
+
clock: (at) => writeFileSync(clockFile, `${at}\n`),
|
|
85
|
+
stop: async () => {
|
|
86
|
+
if (child.exitCode === null && child.pid !== undefined) {
|
|
87
|
+
const ended = new Promise((r) => child.once('exit', r));
|
|
88
|
+
process.kill(child.pid, 'SIGTERM');
|
|
89
|
+
await Promise.race([ended, new Promise((r) => setTimeout(r, 5000))]);
|
|
90
|
+
}
|
|
91
|
+
rmSync(dir, { recursive: true, force: true });
|
|
92
|
+
if (holdsDeclared)
|
|
93
|
+
declaredHeld = false;
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// An XML answer as the members of its root element (S3's `<ListBucketResult><Name>…</Name><Contents>…</Contents>…`,
|
|
2
|
+
// its `<Error><Code>…`): each child element by its name, a repeated one as a list, an element with no children as its
|
|
3
|
+
// text. What a restXml output structure's members are on the wire, so a journey asserts and captures them (`Contents.0.Key`)
|
|
4
|
+
// and SHAPE checks them as it checks a JSON answer's. Attributes and namespaces are not members and are dropped. XML
|
|
5
|
+
// cannot say a lone element is a list: one `<Contents>` reads as an object, two as a list. A root with no children is
|
|
6
|
+
// its one member, by its name.
|
|
7
|
+
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
|
|
8
|
+
const decode = (s) => s.replace(/&(#x[0-9a-f]+|#\d+|\w+);/gi, (m, e) => (e[0] === '#' ? String.fromCodePoint(e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : Number(e.slice(1))) : ENTITIES[e] ?? m));
|
|
9
|
+
/** The root element's members, or undefined when the text is not one XML document. */
|
|
10
|
+
export function xmlMembers(text) {
|
|
11
|
+
const src = text.replace(/<\?[\s\S]*?\?>/g, '').replace(/<!--[\s\S]*?-->/g, '').replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, (_, c) => c.replace(/&/g, '&').replace(/</g, '<'));
|
|
12
|
+
const stack = [{ name: '', children: [], text: '' }];
|
|
13
|
+
const tag = /<(\/?)([\w:.-]+)[^>]*?(\/?)>|([^<]+)/g;
|
|
14
|
+
for (let m = tag.exec(src); m; m = tag.exec(src)) {
|
|
15
|
+
const top = stack.at(-1);
|
|
16
|
+
if (m[4] !== undefined) {
|
|
17
|
+
top.text += m[4];
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const name = m[2].replace(/^[\w.-]+:/, '');
|
|
21
|
+
if (m[1]) {
|
|
22
|
+
if (stack.length < 2 || top.name !== name)
|
|
23
|
+
return undefined;
|
|
24
|
+
stack.pop();
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const node = { name, children: [], text: '' };
|
|
28
|
+
top.children.push(node);
|
|
29
|
+
if (!m[3])
|
|
30
|
+
stack.push(node);
|
|
31
|
+
}
|
|
32
|
+
const root = stack.length === 1 && stack[0].children.length === 1 ? stack[0].children[0] : undefined;
|
|
33
|
+
if (!root)
|
|
34
|
+
return undefined;
|
|
35
|
+
// an element with no children is its text; a name that repeats among siblings is a list
|
|
36
|
+
const value = (n) => {
|
|
37
|
+
if (!n.children.length)
|
|
38
|
+
return decode(n.text.trim());
|
|
39
|
+
const count = new Map();
|
|
40
|
+
for (const c of n.children)
|
|
41
|
+
count.set(c.name, (count.get(c.name) ?? 0) + 1);
|
|
42
|
+
const out = {};
|
|
43
|
+
for (const c of n.children) {
|
|
44
|
+
if ((count.get(c.name) ?? 0) > 1)
|
|
45
|
+
(out[c.name] ??= []).push(value(c));
|
|
46
|
+
else
|
|
47
|
+
out[c.name] = value(c);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
};
|
|
51
|
+
// a document whose root is a leaf (S3's `<LocationConstraint>auto</LocationConstraint>`) is that one member
|
|
52
|
+
if (!root.children.length)
|
|
53
|
+
return { [root.name]: value(root) };
|
|
54
|
+
return value(root);
|
|
55
|
+
}
|