@volter/world-core 2.0.0
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/LICENSE +202 -0
- package/README.md +29 -0
- package/app-route.cjs +154 -0
- package/app-route.d.cts +7 -0
- package/attach.cjs +80 -0
- package/dist/app-route.cjs +154 -0
- package/dist/app-route.d.cts +7 -0
- package/dist/attach.cjs +80 -0
- package/dist/generated/pack-facts.json +4306 -0
- package/dist/inject.cjs +1097 -0
- package/dist/network-policy.cjs +92 -0
- package/dist/network-policy.d.cts +10 -0
- package/dist/src/actions.d.ts +276 -0
- package/dist/src/actions.js +436 -0
- package/dist/src/ancestry.d.ts +22 -0
- package/dist/src/ancestry.js +238 -0
- package/dist/src/args.d.ts +3 -0
- package/dist/src/args.js +12 -0
- package/dist/src/blob-store.d.ts +55 -0
- package/dist/src/blob-store.js +186 -0
- package/dist/src/brand-tokens.d.ts +2 -0
- package/dist/src/brand-tokens.js +17 -0
- package/dist/src/changeset.d.ts +431 -0
- package/dist/src/changeset.js +0 -0
- package/dist/src/client-bundle.d.ts +1 -0
- package/dist/src/client-bundle.js +28 -0
- package/dist/src/credential.d.ts +38 -0
- package/dist/src/credential.js +114 -0
- package/dist/src/derived-core.d.ts +452 -0
- package/dist/src/derived-core.js +782 -0
- package/dist/src/derived.d.ts +84 -0
- package/dist/src/derived.js +122 -0
- package/dist/src/emit.d.ts +106 -0
- package/dist/src/emit.js +157 -0
- package/dist/src/executor.d.ts +120 -0
- package/dist/src/executor.js +387 -0
- package/dist/src/file-response.d.ts +3 -0
- package/dist/src/file-response.js +22 -0
- package/dist/src/fork.d.ts +26 -0
- package/dist/src/fork.js +68 -0
- package/dist/src/git/history.d.ts +36 -0
- package/dist/src/git/history.js +298 -0
- package/dist/src/git/index.d.ts +6 -0
- package/dist/src/git/index.js +6 -0
- package/dist/src/git/inflate.d.ts +11 -0
- package/dist/src/git/inflate.js +194 -0
- package/dist/src/git/objects.d.ts +64 -0
- package/dist/src/git/objects.js +161 -0
- package/dist/src/git/pack.d.ts +14 -0
- package/dist/src/git/pack.js +199 -0
- package/dist/src/git/refs.d.ts +19 -0
- package/dist/src/git/refs.js +35 -0
- package/dist/src/git/smart-http.d.ts +45 -0
- package/dist/src/git/smart-http.js +223 -0
- package/dist/src/hash.d.ts +38 -0
- package/dist/src/hash.js +48 -0
- package/dist/src/head.d.ts +140 -0
- package/dist/src/head.js +313 -0
- package/dist/src/history.d.ts +76 -0
- package/dist/src/history.js +322 -0
- package/dist/src/index.d.ts +73 -0
- package/dist/src/index.js +98 -0
- package/dist/src/lifecycle.d.ts +1 -0
- package/dist/src/lifecycle.js +8 -0
- package/dist/src/log.d.ts +254 -0
- package/dist/src/log.js +801 -0
- package/dist/src/mirror-shell.d.ts +2 -0
- package/dist/src/mirror-shell.js +13 -0
- package/dist/src/observe.d.ts +49 -0
- package/dist/src/observe.js +148 -0
- package/dist/src/pack-assets.d.ts +30 -0
- package/dist/src/pack-assets.js +88 -0
- package/dist/src/packRegistry.d.ts +374 -0
- package/dist/src/packRegistry.js +142 -0
- package/dist/src/placeholder-remote.d.ts +22 -0
- package/dist/src/placeholder-remote.js +86 -0
- package/dist/src/proxy.d.ts +25 -0
- package/dist/src/proxy.js +155 -0
- package/dist/src/rateBudget.d.ts +367 -0
- package/dist/src/rateBudget.js +925 -0
- package/dist/src/references.d.ts +18 -0
- package/dist/src/references.js +27 -0
- package/dist/src/remote-execute.d.ts +22 -0
- package/dist/src/remote-execute.js +1 -0
- package/dist/src/resource-blob.d.ts +10 -0
- package/dist/src/resource-blob.js +56 -0
- package/dist/src/scenario.d.ts +197 -0
- package/dist/src/scenario.js +425 -0
- package/dist/src/schemas.d.ts +78 -0
- package/dist/src/schemas.js +50 -0
- package/dist/src/serve-http.d.ts +48 -0
- package/dist/src/serve-http.js +340 -0
- package/dist/src/serve.d.ts +147 -0
- package/dist/src/serve.js +507 -0
- package/dist/src/shared-blob-index.d.ts +4 -0
- package/dist/src/shared-blob-index.js +126 -0
- package/dist/src/state-system.d.ts +70 -0
- package/dist/src/state-system.js +90 -0
- package/dist/src/storage.d.ts +101 -0
- package/dist/src/storage.js +337 -0
- package/dist/src/twin-fetch.d.ts +64 -0
- package/dist/src/twin-fetch.js +91 -0
- package/dist/src/types.d.ts +40 -0
- package/dist/src/types.js +1 -0
- package/dist/src/v1-removed.d.ts +159 -0
- package/dist/src/v1-removed.js +124 -0
- package/dist/src/volter-home.d.ts +5 -0
- package/dist/src/volter-home.js +10 -0
- package/dist/src/world-clock.d.ts +4 -0
- package/dist/src/world-clock.js +32 -0
- package/dist/src/world-env.d.ts +3 -0
- package/dist/src/world-env.js +22 -0
- package/dist/src/world-store-sql.d.ts +27 -0
- package/dist/src/world-store-sql.js +86 -0
- package/dist/src/world-store.d.ts +168 -0
- package/dist/src/world-store.js +475 -0
- package/dist/src/worldConfig.d.ts +9 -0
- package/dist/src/worldConfig.js +17 -0
- package/dist/stream-bridge.cjs +80 -0
- package/dist/vendor-hosts.cjs +200 -0
- package/generated/pack-facts.json +4306 -0
- package/inject.cjs +1097 -0
- package/network-policy.cjs +92 -0
- package/network-policy.d.cts +10 -0
- package/package.json +103 -0
- package/src/actions.ts +564 -0
- package/src/ancestry.ts +213 -0
- package/src/args.ts +14 -0
- package/src/blob-store.ts +185 -0
- package/src/brand-tokens.ts +17 -0
- package/src/changeset.ts +1032 -0
- package/src/client-bundle.ts +29 -0
- package/src/credential.ts +140 -0
- package/src/derived-core.ts +1004 -0
- package/src/derived.ts +176 -0
- package/src/emit.ts +242 -0
- package/src/executor.ts +431 -0
- package/src/file-response.ts +22 -0
- package/src/fork.ts +89 -0
- package/src/git/history.ts +177 -0
- package/src/git/index.ts +6 -0
- package/src/git/inflate.ts +125 -0
- package/src/git/objects.ts +110 -0
- package/src/git/pack.ts +105 -0
- package/src/git/refs.ts +25 -0
- package/src/git/smart-http.ts +149 -0
- package/src/hash.ts +66 -0
- package/src/head.ts +318 -0
- package/src/history.ts +246 -0
- package/src/index.ts +323 -0
- package/src/lifecycle.ts +8 -0
- package/src/log.ts +793 -0
- package/src/mirror-shell.ts +15 -0
- package/src/observe.ts +130 -0
- package/src/pack-assets.ts +81 -0
- package/src/packRegistry.ts +408 -0
- package/src/placeholder-remote.ts +81 -0
- package/src/proxy.ts +183 -0
- package/src/rateBudget.ts +1115 -0
- package/src/references.ts +46 -0
- package/src/remote-execute.ts +26 -0
- package/src/resource-blob.ts +57 -0
- package/src/scenario.ts +479 -0
- package/src/schemas.ts +56 -0
- package/src/serve-http.ts +299 -0
- package/src/serve.ts +618 -0
- package/src/shared-blob-index.ts +108 -0
- package/src/state-system.ts +115 -0
- package/src/storage.ts +407 -0
- package/src/twin-fetch.ts +147 -0
- package/src/types.ts +50 -0
- package/src/v1-removed.ts +172 -0
- package/src/volter-home.ts +11 -0
- package/src/world-clock.ts +33 -0
- package/src/world-env.ts +18 -0
- package/src/world-store-sql.ts +118 -0
- package/src/world-store.ts +572 -0
- package/src/worldConfig.ts +27 -0
- package/stream-bridge.cjs +80 -0
- package/vendor-hosts.cjs +200 -0
package/src/derived.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// THE DERIVED DISPATCH — the generated wire's router (docs/contributing/architecture.md, "The derived
|
|
2
|
+
// pack"). A derived pack's surface (`src/generated/surface.gen.json`, written by scripts/derive-pack.ts)
|
|
3
|
+
// names every operation the vendor publishes. This matches a request to one of them and hands it to its
|
|
4
|
+
// owner: the pack's semantics handler for that operationId when there is one, otherwise the pack's
|
|
5
|
+
// existing fetch, which keeps serving everything not yet moved (and every route outside the spec, such
|
|
6
|
+
// as the `/twin` door). `owners` says, per operation, which of the two serves it: the count a pack's
|
|
7
|
+
// move is measured by. Workerd-clean: no fs, no clock, nothing at import.
|
|
8
|
+
|
|
9
|
+
/** The slice of a generated surface the dispatch reads. */
|
|
10
|
+
export type DerivedOperation = {
|
|
11
|
+
id: string;
|
|
12
|
+
method: string;
|
|
13
|
+
path: string;
|
|
14
|
+
class: string;
|
|
15
|
+
resource?: string;
|
|
16
|
+
/** query values that tell this operation from another at the same method and path */
|
|
17
|
+
discriminator?: Record<string, string | null>;
|
|
18
|
+
/** request headers that name the operation, for a wire whose operations share a path (AWS JSON's X-Amz-Target) */
|
|
19
|
+
headers?: Record<string, string | null>;
|
|
20
|
+
/** the status the vendor answers success with (201 for a create, 204 for an empty answer) */
|
|
21
|
+
successStatus?: number;
|
|
22
|
+
/** a form-encoded operation's number and boolean fields, by bracket path (`items.[].quantity`, `metadata.*`) */
|
|
23
|
+
scalars?: Readonly<Record<string, string | undefined>>;
|
|
24
|
+
/** what the success answer is: a resource, a list of them, and the envelope key holding it */
|
|
25
|
+
answers?: { resource: string; list: boolean; key?: string };
|
|
26
|
+
/** the path its own server puts before it, where the operation names a server other than the surface's */
|
|
27
|
+
basePath?: string;
|
|
28
|
+
};
|
|
29
|
+
/** `basePath` is what the vendor's server URL puts before every operation path; `spanning` names the
|
|
30
|
+
* path parameters whose values may hold slashes (a git ref, a file path), which no spec says. */
|
|
31
|
+
export type DerivedSurface = { version: string; basePath?: string; spanning?: ReadonlyArray<string>; operations: ReadonlyArray<DerivedOperation> };
|
|
32
|
+
|
|
33
|
+
/** What a semantics handler receives: the request, the operation it matched, and its path parameters. */
|
|
34
|
+
export type DerivedCall = { request: Request; operation: DerivedOperation; params: Record<string, string> };
|
|
35
|
+
export type DerivedHandler = (call: DerivedCall) => Promise<Response>;
|
|
36
|
+
|
|
37
|
+
/** What the derived core made of an operation: an answer, or why it cannot model it. */
|
|
38
|
+
export type DerivedCoreOutcome = { served: Response } | { unmodeled: string };
|
|
39
|
+
|
|
40
|
+
export type DerivedFetchOptions = {
|
|
41
|
+
surface: DerivedSurface;
|
|
42
|
+
/** Semantics handlers by operationId. A handler naming an operation the surface lacks is refused. */
|
|
43
|
+
handlers?: Record<string, DerivedHandler>;
|
|
44
|
+
/** The derived core, and which operations it owns (those of the resources the manifest declares). */
|
|
45
|
+
core?: { owns: (operation: DerivedOperation) => boolean; serve: (call: DerivedCall) => Promise<DerivedCoreOutcome> };
|
|
46
|
+
/** An RPC wire: every method is reached by any HTTP method (Slack's clients POST reads too). */
|
|
47
|
+
anyMethod?: boolean;
|
|
48
|
+
/** The pack's existing fetch while it moves: everything no handler or core owns, and what the core cannot model. */
|
|
49
|
+
legacy?: (request: Request) => Promise<Response>;
|
|
50
|
+
/** What every operation a handler or the core serves passes through (read-only refusal, version
|
|
51
|
+
* checks, idempotent replay); the pack's existing fetch keeps its own while it moves. */
|
|
52
|
+
around?: (call: DerivedCall, next: () => Promise<Response>) => Promise<Response>;
|
|
53
|
+
/** The vendor's answer for an operation nothing models: the pack's gap. */
|
|
54
|
+
gap: (request: Request, operation: DerivedOperation | undefined, reason: string) => Response;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
class Unmodeled extends Error {}
|
|
58
|
+
|
|
59
|
+
type Route = { operation: DerivedOperation; pattern: RegExp; names: string[]; literals: number };
|
|
60
|
+
|
|
61
|
+
function compile(operation: DerivedOperation, basePath: string, spanning: ReadonlySet<string>): Route {
|
|
62
|
+
const names: string[] = [];
|
|
63
|
+
let literals = 0;
|
|
64
|
+
const body = ((operation.basePath ?? basePath) + operation.path)
|
|
65
|
+
.split('/')
|
|
66
|
+
.map((seg) => {
|
|
67
|
+
const m = /^\{([^}]+)\}$/.exec(seg);
|
|
68
|
+
if (m) {
|
|
69
|
+
names.push(m[1]!);
|
|
70
|
+
// a parameter the vendor lets hold slashes (a ref, a file path) takes the rest of its segments
|
|
71
|
+
return spanning.has(m[1]!) ? '(.+?)' : '([^/]+)';
|
|
72
|
+
}
|
|
73
|
+
literals++;
|
|
74
|
+
// a segment templated in part (OpenAPI's `/report.{format}`, Tinybird's `/v0/pipes/{pipe}.json`) holds its
|
|
75
|
+
// labels between its literal text, and counts as literal: it is more specific than a bare label
|
|
76
|
+
return seg.split(/(\{[^}]+\})/).map((part) => {
|
|
77
|
+
const label = /^\{([^}]+)\}$/.exec(part);
|
|
78
|
+
if (!label) return part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
79
|
+
names.push(label[1]!);
|
|
80
|
+
return '([^/]+?)';
|
|
81
|
+
}).join('');
|
|
82
|
+
})
|
|
83
|
+
.join('/');
|
|
84
|
+
return { operation, pattern: new RegExp(`^${body}/?$`), names, literals };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const compiled = new WeakMap<DerivedSurface, Map<string, Route[]>>(); // cache: compiled routes per surface; a surface is immutable generated data
|
|
88
|
+
|
|
89
|
+
/** Routes per method, most literal segments first, so `/v1/customers/search` wins over `/v1/customers/{customer}`. */
|
|
90
|
+
export function compileSurface(surface: DerivedSurface): Map<string, Route[]> {
|
|
91
|
+
const hit = compiled.get(surface);
|
|
92
|
+
if (hit) return hit;
|
|
93
|
+
const byMethod = new Map<string, Route[]>();
|
|
94
|
+
for (const operation of surface.operations) {
|
|
95
|
+
const method = operation.method.toUpperCase();
|
|
96
|
+
const list = byMethod.get(method) ?? [];
|
|
97
|
+
list.push(compile(operation, surface.basePath ?? '', new Set(surface.spanning ?? [])));
|
|
98
|
+
byMethod.set(method, list);
|
|
99
|
+
}
|
|
100
|
+
// most literal segments first; among equals, an operation with a query discriminator before the one without
|
|
101
|
+
const disc = (r: Route): number => Object.keys(r.operation.discriminator ?? {}).length + Object.keys(r.operation.headers ?? {}).length;
|
|
102
|
+
for (const list of byMethod.values()) list.sort((a, b) => b.literals - a.literals || disc(b) - disc(a) || a.operation.path.localeCompare(b.operation.path));
|
|
103
|
+
compiled.set(surface, byMethod);
|
|
104
|
+
return byMethod;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function matchOperation(routes: Map<string, Route[]>, method: string, pathname: string, query?: URLSearchParams, headers?: Headers): { operation: DerivedOperation; params: Record<string, string> } | undefined {
|
|
108
|
+
for (const route of routes.get(method.toUpperCase()) ?? []) {
|
|
109
|
+
const m = route.pattern.exec(pathname);
|
|
110
|
+
if (!m) continue;
|
|
111
|
+
// a null value asks only that the key be present (S3's UploadPart, `uploadId`)
|
|
112
|
+
if (Object.entries(route.operation.discriminator ?? {}).some(([k, v]) => (v === null ? !query?.has(k) : query?.get(k) !== v))) continue;
|
|
113
|
+
if (Object.entries(route.operation.headers ?? {}).some(([k, v]) => (v === null ? !headers?.has(k) : headers?.get(k) !== v))) continue;
|
|
114
|
+
const params: Record<string, string> = {};
|
|
115
|
+
try {
|
|
116
|
+
route.names.forEach((name, i) => {
|
|
117
|
+
params[name] = decodeURIComponent(m[i + 1]!);
|
|
118
|
+
});
|
|
119
|
+
} catch {
|
|
120
|
+
// a malformed escape matches no operation: whoever serves unmatched requests answers it
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
return { operation: route.operation, params };
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type DerivedOwner = 'handler' | 'core' | 'legacy' | 'gap';
|
|
129
|
+
|
|
130
|
+
export type DerivedFetch = ((request: Request) => Promise<Response>) & {
|
|
131
|
+
/** Per operationId, who serves it: a semantics handler, the derived core, the pack's existing
|
|
132
|
+
* fetch while it moves, or nothing (the vendor's gap). */
|
|
133
|
+
owners(): Record<string, DerivedOwner>;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export function createDerivedFetch(options: DerivedFetchOptions): DerivedFetch {
|
|
137
|
+
const handlers = options.handlers ?? {};
|
|
138
|
+
const known = new Set(options.surface.operations.map((o) => o.id));
|
|
139
|
+
const unknown = Object.keys(handlers).filter((id) => !known.has(id));
|
|
140
|
+
if (unknown.length) throw new Error(`derived dispatch: handlers name operations the surface does not have: ${unknown.sort().join(', ')}`);
|
|
141
|
+
const routes = compileSurface(options.surface);
|
|
142
|
+
const fallback = (request: Request, operation: DerivedOperation | undefined, reason: string): Promise<Response> =>
|
|
143
|
+
options.legacy ? options.legacy(request) : Promise.resolve(options.gap(request, operation, reason));
|
|
144
|
+
const fetch = (async (request: Request): Promise<Response> => {
|
|
145
|
+
const url = new URL(request.url);
|
|
146
|
+
const matched = options.anyMethod
|
|
147
|
+
? [...routes.keys()].map((m) => matchOperation(routes, m, url.pathname, url.searchParams, request.headers)).find(Boolean)
|
|
148
|
+
: matchOperation(routes, request.method, url.pathname, url.searchParams, request.headers);
|
|
149
|
+
if (!matched) return fallback(request, undefined, 'no operation at this path');
|
|
150
|
+
const call: DerivedCall = { request, operation: matched.operation, params: matched.params };
|
|
151
|
+
const around = options.around ?? ((_c: DerivedCall, next: () => Promise<Response>) => next());
|
|
152
|
+
const handler = handlers[matched.operation.id];
|
|
153
|
+
if (handler) return around(call, () => handler(call));
|
|
154
|
+
if (options.core?.owns(matched.operation)) {
|
|
155
|
+
// the core reads the body; a clone keeps it for whatever serves an operation the core cannot
|
|
156
|
+
// model, and that case leaves `around` by a throw, so nothing it records (an idempotent
|
|
157
|
+
// answer) is recorded for a request the core did not serve
|
|
158
|
+
try {
|
|
159
|
+
return await around(call, async () => {
|
|
160
|
+
const outcome = await options.core!.serve({ ...call, request: request.clone() });
|
|
161
|
+
if ('served' in outcome) return outcome.served;
|
|
162
|
+
throw new Unmodeled(outcome.unmodeled);
|
|
163
|
+
});
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (error instanceof Unmodeled) return fallback(request, matched.operation, error.message);
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return fallback(request, matched.operation, 'no handler');
|
|
170
|
+
}) as DerivedFetch;
|
|
171
|
+
fetch.owners = () =>
|
|
172
|
+
Object.fromEntries(
|
|
173
|
+
options.surface.operations.map((o) => [o.id, handlers[o.id] ? 'handler' : options.core?.owns(o) ? 'core' : options.legacy ? 'legacy' : 'gap'] as const),
|
|
174
|
+
);
|
|
175
|
+
return fetch;
|
|
176
|
+
}
|
package/src/emit.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// The DELIVER verb (`emit`) — fire a vendor-shaped, SIGNED webhook/event at the app under
|
|
2
|
+
// test, synthesized from CURRENT TWIN STATE, without hand-constructing the envelope or the
|
|
3
|
+
// signature. The engine here is vendor-agnostic; everything vendor-shaped (which event types
|
|
4
|
+
// exist, which twin resource supplies `data.object`, where registered endpoints + signing
|
|
5
|
+
// secrets live in state, how a delivery is signed) is a pack's knowledge, declared as a
|
|
6
|
+
// `TwinEmitter` and registered on its `TwinPack.emitter` (same argument as `browserRouting`:
|
|
7
|
+
// vendor knowledge in the descriptor, kernel stays vendor-agnostic).
|
|
8
|
+
//
|
|
9
|
+
// The operator surface is the PACK's bin (`world-stripe emit …`), exactly like the mirror
|
|
10
|
+
// UIs — the kernel never imports packs, so `volter-twin emit <service>` only works when a
|
|
11
|
+
// consumer registered the pack in-process, and otherwise fails loudly pointing at the
|
|
12
|
+
// vendor bin. Unlike the twin's own background emission on state changes (fire-and-forget,
|
|
13
|
+
// vendor-faithful), an explicit DELIVER is an operator asking for exactly this delivery, so
|
|
14
|
+
// every failure here is LOUD: unknown event type, unknown subject, no registered endpoint,
|
|
15
|
+
// and non-2xx delivery responses all throw / report instead of being swallowed.
|
|
16
|
+
|
|
17
|
+
/** One event type a pack can synthesize, and the twin resource type that supplies its payload. */
|
|
18
|
+
export type EmittableEvent = {
|
|
19
|
+
type: string;
|
|
20
|
+
/** twin resource type whose current state becomes the event payload (`data.object` for Stripe). */
|
|
21
|
+
subjectType: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** A delivery destination registered IN TWIN STATE (at rest — the emit CLI runs in its own
|
|
26
|
+
* process, so in-memory registries don't count; endpoints must be resolvable from the root). */
|
|
27
|
+
export type EmitEndpoint = {
|
|
28
|
+
/** the vendor resource id of the registration (e.g. Stripe `we_…`), when it has one. */
|
|
29
|
+
id?: string;
|
|
30
|
+
url: string;
|
|
31
|
+
/** event-type subscriptions; `*` wildcards supported. Absent ⇒ subscribed to everything. */
|
|
32
|
+
enabledEvents?: string[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** A fully built, signed delivery for one endpoint: exact body bytes + headers to POST. */
|
|
36
|
+
export type SynthesizedDelivery = {
|
|
37
|
+
/** the exact payload string — the signature covers THESE bytes, do not re-serialize. */
|
|
38
|
+
payload: string;
|
|
39
|
+
headers: Record<string, string>;
|
|
40
|
+
/** the synthesized event envelope (for reporting/assertions). */
|
|
41
|
+
event: Record<string, unknown>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** What a pack declares to support `emit` — see `TwinPack.emitter`. All state reads take the
|
|
45
|
+
* same `root` every pack verb takes, so the CLI composes with worlds unchanged. */
|
|
46
|
+
export type TwinEmitter = {
|
|
47
|
+
vendor: string;
|
|
48
|
+
/** the emittable catalog (drives `--list` and the unknown-event error). */
|
|
49
|
+
events(): EmittableEvent[];
|
|
50
|
+
/** endpoints registered in twin state (e.g. Stripe webhook_endpoint rows). */
|
|
51
|
+
endpoints(root?: string): EmitEndpoint[];
|
|
52
|
+
/** subject ids present in state for a subject type (drives `--list` + unknown-subject errors). */
|
|
53
|
+
subjects(subjectType: string, root?: string): string[];
|
|
54
|
+
/** synthesize the signed delivery for one endpoint. MUST throw loudly on an unknown subject. */
|
|
55
|
+
synthesize(opts: { type: string; subjectId: string; endpoint: EmitEndpoint; root?: string; occurredAt?: string }): SynthesizedDelivery;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type EmitDeliveryResult = {
|
|
59
|
+
endpoint: EmitEndpoint;
|
|
60
|
+
ok: boolean;
|
|
61
|
+
/** HTTP status when the POST completed; absent when the request itself failed. */
|
|
62
|
+
status?: number;
|
|
63
|
+
error?: string;
|
|
64
|
+
event: Record<string, unknown>;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type EmitReport = {
|
|
68
|
+
vendor: string;
|
|
69
|
+
type: string;
|
|
70
|
+
subjectId: string;
|
|
71
|
+
deliveries: EmitDeliveryResult[];
|
|
72
|
+
/** true only when EVERY delivery reached its endpoint and got a 2xx back. */
|
|
73
|
+
ok: boolean;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Vendor wildcard subscription match (Stripe semantics: `*` matches all;
|
|
77
|
+
* `invoice.*` matches every invoice event; otherwise exact). */
|
|
78
|
+
export function eventSubscriptionMatches(pattern: string, type: string): boolean {
|
|
79
|
+
if (pattern === '*') return true;
|
|
80
|
+
if (pattern.endsWith('.*')) return type.startsWith(pattern.slice(0, -1));
|
|
81
|
+
return pattern === type;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function endpointSubscribed(endpoint: EmitEndpoint, type: string): boolean {
|
|
85
|
+
if (!endpoint.enabledEvents || endpoint.enabledEvents.length === 0) return true;
|
|
86
|
+
return endpoint.enabledEvents.some((p) => eventSubscriptionMatches(p, type));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The `--list` view: everything emittable right now — event types, the subject ids present in
|
|
90
|
+
* state for each subject type, and the registered endpoints. Pure state read. */
|
|
91
|
+
export function listEmittable(emitter: TwinEmitter, root?: string): {
|
|
92
|
+
vendor: string;
|
|
93
|
+
events: Array<EmittableEvent & { subjects: string[] }>;
|
|
94
|
+
endpoints: EmitEndpoint[];
|
|
95
|
+
} {
|
|
96
|
+
const subjectsByType = new Map<string, string[]>();
|
|
97
|
+
const events = emitter.events().map((e) => {
|
|
98
|
+
let subjects = subjectsByType.get(e.subjectType);
|
|
99
|
+
if (!subjects) {
|
|
100
|
+
subjects = emitter.subjects(e.subjectType, root);
|
|
101
|
+
subjectsByType.set(e.subjectType, subjects);
|
|
102
|
+
}
|
|
103
|
+
return { ...e, subjects };
|
|
104
|
+
});
|
|
105
|
+
return { vendor: emitter.vendor, events, endpoints: emitter.endpoints(root) };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* DELIVER: synthesize the event for (`type`, `subjectId`) from current twin state and POST it,
|
|
110
|
+
* signed, to every registered endpoint subscribed to that event type (or the one endpoint
|
|
111
|
+
* `endpoint` names by url or id). Loud failures:
|
|
112
|
+
* • event type the pack can't synthesize → throws, listing the emittable types;
|
|
113
|
+
* • unknown subject id → the pack's synthesize throws (listing available ids);
|
|
114
|
+
* • no registered/subscribed endpoint → throws (register one via the vendor API first);
|
|
115
|
+
* • a delivery that errors or comes back non-2xx → reported per-endpoint, report.ok=false.
|
|
116
|
+
* `fetchFn` is the delivery seam (tests inject; default real fetch).
|
|
117
|
+
*/
|
|
118
|
+
export async function emitTwinEvent(
|
|
119
|
+
emitter: TwinEmitter,
|
|
120
|
+
opts: {
|
|
121
|
+
type: string;
|
|
122
|
+
subjectId: string;
|
|
123
|
+
root?: string;
|
|
124
|
+
/** restrict delivery to the endpoint with this url or vendor id. */
|
|
125
|
+
endpoint?: string;
|
|
126
|
+
occurredAt?: string;
|
|
127
|
+
fetchFn?: (url: string, init: { method: string; headers: Record<string, string>; body: string }) => Promise<{ status: number }>;
|
|
128
|
+
},
|
|
129
|
+
): Promise<EmitReport> {
|
|
130
|
+
const catalog = emitter.events();
|
|
131
|
+
const known = catalog.find((e) => e.type === opts.type);
|
|
132
|
+
if (!known) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`emit: the ${emitter.vendor} pack cannot synthesize "${opts.type}". Emittable event types:\n ${catalog.map((e) => `${e.type} (subject: ${e.subjectType})`).join('\n ')}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const all = emitter.endpoints(opts.root);
|
|
139
|
+
if (all.length === 0) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`emit: no ${emitter.vendor} webhook endpoint is registered in twin state — nothing to deliver to. ` +
|
|
142
|
+
`Register one through the vendor API first (for Stripe: POST /v1/webhook_endpoints with url + enabled_events).`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
let targets = all.filter((e) => endpointSubscribed(e, opts.type));
|
|
146
|
+
if (opts.endpoint) {
|
|
147
|
+
targets = targets.filter((e) => e.url === opts.endpoint || e.id === opts.endpoint);
|
|
148
|
+
if (targets.length === 0) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`emit: no registered endpoint matches "${opts.endpoint}" for event "${opts.type}". Registered endpoints:\n ${all.map((e) => `${e.id ?? '-'} ${e.url} [${(e.enabledEvents ?? ['*']).join(', ')}]`).join('\n ')}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (targets.length === 0) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`emit: ${all.length} endpoint(s) registered but none subscribes to "${opts.type}". Registered endpoints:\n ${all.map((e) => `${e.id ?? '-'} ${e.url} [${(e.enabledEvents ?? ['*']).join(', ')}]`).join('\n ')}`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const fetchFn = opts.fetchFn ?? (async (url: string, init: { method: string; headers: Record<string, string>; body: string }) => {
|
|
161
|
+
const res = await fetch(url, init);
|
|
162
|
+
return { status: res.status };
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const deliveries: EmitDeliveryResult[] = [];
|
|
166
|
+
for (const endpoint of targets) {
|
|
167
|
+
// synthesize PER ENDPOINT: signatures are per-endpoint secrets (real Stripe signs each
|
|
168
|
+
// delivery with the destination endpoint's own whsec). Unknown subject throws here — loudly.
|
|
169
|
+
const built = emitter.synthesize({ type: opts.type, subjectId: opts.subjectId, endpoint, ...(opts.root !== undefined ? { root: opts.root } : {}), ...(opts.occurredAt !== undefined ? { occurredAt: opts.occurredAt } : {}) });
|
|
170
|
+
try {
|
|
171
|
+
const res = await fetchFn(endpoint.url, { method: 'POST', headers: built.headers, body: built.payload });
|
|
172
|
+
deliveries.push({ endpoint, ok: res.status >= 200 && res.status < 300, status: res.status, event: built.event, ...(res.status >= 200 && res.status < 300 ? {} : { error: `endpoint responded ${res.status}` }) });
|
|
173
|
+
} catch (error) {
|
|
174
|
+
deliveries.push({ endpoint, ok: false, error: error instanceof Error ? error.message : String(error), event: built.event });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { vendor: emitter.vendor, type: opts.type, subjectId: opts.subjectId, deliveries, ok: deliveries.every((d) => d.ok) };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Shared CLI glue so every pack's `emit` verb behaves identically:
|
|
182
|
+
* world-<vendor> emit --list [--root DIR] [--json]
|
|
183
|
+
* world-<vendor> emit <event.type> <subject-id> [--root DIR] [--endpoint URL|ID] [--at ISO] [--json]
|
|
184
|
+
* Returns the process exit code (0 delivered; 1 loud failure / any failed delivery; 2 usage).
|
|
185
|
+
*/
|
|
186
|
+
export async function runEmitCli(
|
|
187
|
+
emitter: TwinEmitter,
|
|
188
|
+
argv: string[],
|
|
189
|
+
io: { out: (s: string) => void; err: (s: string) => void } = { out: (s) => process.stdout.write(s), err: (s) => process.stderr.write(s) },
|
|
190
|
+
): Promise<number> {
|
|
191
|
+
const flags = new Map<string, string>();
|
|
192
|
+
const positional: string[] = [];
|
|
193
|
+
let list = false;
|
|
194
|
+
let json = false;
|
|
195
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
196
|
+
const a = argv[i]!;
|
|
197
|
+
if (a === '--list') list = true;
|
|
198
|
+
else if (a === '--json') json = true;
|
|
199
|
+
else if (a.startsWith('--')) {
|
|
200
|
+
flags.set(a, argv[i + 1] ?? '');
|
|
201
|
+
i += 1;
|
|
202
|
+
} else positional.push(a);
|
|
203
|
+
}
|
|
204
|
+
const root = flags.get('--root');
|
|
205
|
+
|
|
206
|
+
if (list) {
|
|
207
|
+
const view = listEmittable(emitter, root);
|
|
208
|
+
if (json) {
|
|
209
|
+
io.out(`${JSON.stringify(view, null, 2)}\n`);
|
|
210
|
+
} else {
|
|
211
|
+
io.out(`emittable ${view.vendor} events (synthesized from current twin state):\n`);
|
|
212
|
+
for (const e of view.events) {
|
|
213
|
+
io.out(` ${e.type} (subject: ${e.subjectType}${e.subjects.length ? ` — ${e.subjects.join(', ')}` : ' — none in state'})\n`);
|
|
214
|
+
}
|
|
215
|
+
io.out(view.endpoints.length === 0
|
|
216
|
+
? 'no webhook endpoints registered — deliveries have nowhere to go.\n'
|
|
217
|
+
: `endpoints:\n${view.endpoints.map((e) => ` ${e.id ?? '-'} ${e.url} [${(e.enabledEvents ?? ['*']).join(', ')}]`).join('\n')}\n`);
|
|
218
|
+
}
|
|
219
|
+
return 0;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const [type, subjectId] = positional;
|
|
223
|
+
if (!type || !subjectId) {
|
|
224
|
+
io.err(`Usage: world-${emitter.vendor} emit --list [--root DIR] [--json]\n world-${emitter.vendor} emit <event.type> <subject-id> [--root DIR] [--endpoint URL|ID] [--at ISO] [--json]\n`);
|
|
225
|
+
return 2;
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
const endpointFlag = flags.get('--endpoint');
|
|
229
|
+
const at = flags.get('--at');
|
|
230
|
+
const report = await emitTwinEvent(emitter, { type, subjectId, ...(root !== undefined ? { root } : {}), ...(endpointFlag !== undefined ? { endpoint: endpointFlag } : {}), ...(at !== undefined ? { occurredAt: at } : {}) });
|
|
231
|
+
if (json) io.out(`${JSON.stringify(report, null, 2)}\n`);
|
|
232
|
+
else {
|
|
233
|
+
for (const d of report.deliveries) {
|
|
234
|
+
io.out(`${d.ok ? 'delivered' : 'FAILED'} ${report.type} (${String(d.event.id ?? '')}) → ${d.endpoint.url}${d.status !== undefined ? ` [${d.status}]` : ''}${d.error ? ` — ${d.error}` : ''}\n`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return report.ok ? 0 : 1;
|
|
238
|
+
} catch (error) {
|
|
239
|
+
io.err(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
240
|
+
return 1;
|
|
241
|
+
}
|
|
242
|
+
}
|