@volter/twin-fal 0.1.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.
@@ -0,0 +1,60 @@
1
+ export type FalWebhookEvent = {
2
+ request_id: string;
3
+ gateway_request_id: string;
4
+ status: 'OK' | 'ERROR';
5
+ payload: unknown;
6
+ error?: string;
7
+ payload_error?: string;
8
+ };
9
+ export declare class FalWebhookVerificationError extends Error {
10
+ constructor(message: string);
11
+ }
12
+ /** The JWKS document served at GET /.well-known/jwks.json — stable per root, across processes. */
13
+ export declare function falJwks(root?: string): Promise<{
14
+ keys: Array<{
15
+ kty: 'OKP';
16
+ crv: 'Ed25519';
17
+ x: string;
18
+ }>;
19
+ }>;
20
+ /** Reconstruct a public key from a served JWKS entry — used to verify a signature using ONLY
21
+ * what the JWKS *endpoint* returned (not a fresh local re-derivation from `root`), so a
22
+ * signature-verification capability that routes through this has real teeth against a
23
+ * sabotaged handler (build spec §3: "through handler → teeth"). */
24
+ export declare function falWebhookPublicKeyFromJwk(jwk: {
25
+ x: string;
26
+ }): import('node:crypto').KeyObject;
27
+ /** Compute the hex ed25519 signature for a webhook delivery (real crypto, not a stub — a real
28
+ * Ed25519/JWKS verifier accepts this unchanged). */
29
+ export declare function computeFalWebhookSignature(requestId: string, userId: string, timestamp: number, rawBody: string, root?: string): Promise<string>;
30
+ /** Verify a webhook delivery against a caller-provided public key (e.g. one recovered from a
31
+ * served JWKS document via `falWebhookPublicKeyFromJwk`). Throws `FalWebhookVerificationError`
32
+ * on any missing header, stale timestamp, or signature mismatch. */
33
+ export declare function verifyFalWebhookSignatureWithKey(rawBody: string, headers: Record<string, string>, publicKey: import('node:crypto').KeyObject, opts?: {
34
+ tolerance?: number;
35
+ now?: number;
36
+ }): FalWebhookEvent;
37
+ /** Verify a webhook delivery against the root's persisted keypair directly (pure local crypto —
38
+ * does not touch the twin handler/JWKS route at all). */
39
+ export declare function verifyFalWebhook(rawBody: string, headers: Record<string, string>, root?: string, opts?: {
40
+ tolerance?: number;
41
+ now?: number;
42
+ }): Promise<FalWebhookEvent>;
43
+ /**
44
+ * Build a SIGNED webhook delivery for a completed (or failed) request: the fal webhook payload +
45
+ * the real ed25519-signed X-Fal-Webhook-* headers. Deterministic + offline.
46
+ */
47
+ export declare function buildSignedFalWebhook(args: {
48
+ requestId: string;
49
+ userId?: string;
50
+ gatewayRequestId?: string;
51
+ status: 'OK' | 'ERROR';
52
+ payload?: unknown;
53
+ error?: string;
54
+ occurredAt: string;
55
+ root?: string;
56
+ }): Promise<{
57
+ headers: Record<string, string>;
58
+ body: string;
59
+ event: FalWebhookEvent;
60
+ }>;
@@ -0,0 +1,202 @@
1
+ // fal.ai webhooks — REAL ed25519-signed delivery + JWKS, written FRESH for this pack (the build
2
+ // spec is explicit: do NOT port replicate-webhooks.ts, which is Standard-Webhooks HMAC — fal uses
3
+ // a completely different, asymmetric scheme).
4
+ //
5
+ // GROUNDED (2026-07-09, read-only) from fal.ai/docs/model-endpoints/webhooks:
6
+ // - Four request headers on every webhook delivery: `X-Fal-Webhook-Request-Id`,
7
+ // `X-Fal-Webhook-User-Id`, `X-Fal-Webhook-Timestamp` (unix seconds), `X-Fal-Webhook-Signature`
8
+ // (HEX-encoded, not base64 — unlike replicate's Standard-Webhooks base64 HMAC).
9
+ // - Signed message: `${request_id}\n${user_id}\n${timestamp}\n${sha256hex(raw_body)}` — four
10
+ // newline-joined fields, the last being a SHA-256 hex digest of the raw request body (not the
11
+ // body itself).
12
+ // - Public keys are served as a JWKS document at `https://rest.fal.ai/.well-known/jwks.json`
13
+ // (NOT rest.alpha.fal.ai) — `{keys: [{kty:'OKP', crv:'Ed25519', x: <base64url raw pubkey>}]}`
14
+ // (RFC 8037 OKP/Ed25519 JWK — modeled per the RFC's standard shape; the doc confirms the
15
+ // endpoint + field names, not byte-for-byte against a captured live document, so the exact
16
+ // document shape carries the same doc-UNVERIFIED caveat routine capture-verify would close).
17
+ // - ±300s (5 minute) timestamp leeway.
18
+ // - Payload shape: `{request_id, gateway_request_id, status: 'OK'|'ERROR', payload, error?,
19
+ // payload_error?}`.
20
+ // - Retry policy: 15s per-attempt timeout, up to 10 retries over a 2h window (`todo` —
21
+ // `fal.webhooks.retry_policy` — this pack signs/verifies deliveries; it does not implement
22
+ // the delivery/retry loop itself, the same shape replicate's `webhooks.delivery` todo takes).
23
+ //
24
+ // ENTROPY-BORN, PERSISTED KEYPAIR (the googleoauth-jwt.ts `ensureKeypair` precedent). The ed25519
25
+ // SEED is 32 bytes of REAL entropy, generated ONCE per root and PERSISTED as twin state — the
26
+ // `signing_key` resource FAL_RESOURCE_TYPES already declares, folded on first touch through the
27
+ // kernel action log (so it rides snapshots/hydrate/flush like every other fact this twin holds,
28
+ // and no `node:fs` is on the serve path). Every later read derives the keypair from that stored
29
+ // seed: the seed is wrapped in the fixed PKCS8 DER prefix for an Ed25519 private key (RFC 8410 —
30
+ // `302e020100300506032b657004220420` + 32-byte seed = 48 bytes total) and imported via
31
+ // `crypto.createPrivateKey`, so signing and the served JWKS are always the SAME key and both
32
+ // survive a process restart.
33
+ //
34
+ // This is the runtime contract's ONE NAMED EXCEPTION to R9 (keying material generated once per
35
+ // root and persisted, exactly like googleoauth's RSA pair): same-root byte-identical, cross-root
36
+ // divergent BY CONSTRUCTION. The reads that serve it — `/.well-known/jwks.json` — therefore stay
37
+ // out of this pack's replay, which gate.ts states. The seed is never derived from the root PATH:
38
+ // a key computed from where a world happens to sit is a key the environment owns, and it moves
39
+ // with the directory instead of with the state.
40
+ //
41
+ // `node:crypto` is required LAZILY (server-only), same convention as replicate-webhooks.ts, so
42
+ // this module stays browser-bundle safe.
43
+ import { applyTwinWriteAtomic, projectResources, nodeBuiltin } from '@volter/world-core';
44
+ function nodeCrypto() {
45
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
46
+ return nodeBuiltin('node:crypto');
47
+ }
48
+ const ED25519_PKCS8_PREFIX_HEX = '302e020100300506032b657004220420';
49
+ // The fixed 12-byte ASN.1 header every Ed25519 SPKI (public-key) DER encoding starts with (RFC
50
+ // 8410) — followed by exactly 32 raw public-key bytes (44 bytes total). Slicing it off recovers
51
+ // the raw key JWKS wants in its `x` field.
52
+ const ED25519_SPKI_HEADER_LEN = 12;
53
+ export class FalWebhookVerificationError extends Error {
54
+ constructor(message) {
55
+ super(message);
56
+ this.name = 'FalWebhookVerificationError';
57
+ }
58
+ }
59
+ const SERVICE = 'fal';
60
+ // The seed is stored as the pack's declared `signing_key` resource — the SAME substrate every
61
+ // other fal fact uses, no parallel side-store (D1). Subject ids are type-prefixed exactly like
62
+ // fal-twin.ts's `kid()`; `_`-prefixed fields are twin-internal and never served (fal-twin.ts's
63
+ // `view()` strips them, and no route projects this type at all).
64
+ const SIGNING_KEY_TYPE = 'signing_key';
65
+ const SIGNING_KEY_SUBJECT = 'signing_key:webhook';
66
+ const SEED_BYTES = 32; // ed25519's seed length, exactly
67
+ function storedSeed(resources) {
68
+ const row = resources.find((r) => r.type === SIGNING_KEY_TYPE && r.id === SIGNING_KEY_SUBJECT);
69
+ const seed = row?.['_seed'];
70
+ return typeof seed === 'string' && seed.length > 0 ? seed : undefined;
71
+ }
72
+ /**
73
+ * The base64 ed25519 SEED for `root` — generated with real entropy on FIRST use and persisted,
74
+ * stable forever after (across calls AND across processes). The decide-and-append runs inside the
75
+ * kernel's own action lock (`applyTwinWriteAtomic`), so two processes racing a first use agree on
76
+ * ONE seed rather than each signing with a seed the other's state does not hold.
77
+ */
78
+ async function ensureFalSigningSeed(root) {
79
+ const fast = storedSeed(projectResources(SERVICE, root));
80
+ if (fast)
81
+ return fast;
82
+ const { value } = await applyTwinWriteAtomic(SERVICE, (resources) => {
83
+ const existing = storedSeed(resources);
84
+ if (existing)
85
+ return { kind: 'skip', value: existing };
86
+ const seed = nodeCrypto().randomBytes(SEED_BYTES).toString('base64');
87
+ return {
88
+ kind: 'write',
89
+ value: seed,
90
+ write: {
91
+ operation: 'signing_key.create',
92
+ subjectType: SIGNING_KEY_TYPE,
93
+ subjectId: SIGNING_KEY_SUBJECT,
94
+ fields: { _seed: seed },
95
+ actor: { kind: 'system' },
96
+ },
97
+ };
98
+ }, root);
99
+ return value;
100
+ }
101
+ /** The per-root ed25519 keypair, derived from the PERSISTED seed. */
102
+ async function falSigningKey(root) {
103
+ const crypto = nodeCrypto();
104
+ const seed = Buffer.from(await ensureFalSigningSeed(root), 'base64');
105
+ if (seed.length !== SEED_BYTES)
106
+ throw new Error(`fal signing key: persisted seed is ${seed.length} bytes, expected ${SEED_BYTES}`);
107
+ const der = Buffer.concat([Buffer.from(ED25519_PKCS8_PREFIX_HEX, 'hex'), seed]);
108
+ const privateKey = crypto.createPrivateKey({ key: der, format: 'der', type: 'pkcs8' });
109
+ const publicKey = crypto.createPublicKey(privateKey);
110
+ return { privateKey, publicKey };
111
+ }
112
+ function rawPublicKeyBytes(publicKey) {
113
+ const spki = publicKey.export({ format: 'der', type: 'spki' });
114
+ return spki.subarray(ED25519_SPKI_HEADER_LEN);
115
+ }
116
+ /** The JWKS document served at GET /.well-known/jwks.json — stable per root, across processes. */
117
+ export async function falJwks(root) {
118
+ const { publicKey } = await falSigningKey(root);
119
+ const x = rawPublicKeyBytes(publicKey).toString('base64url');
120
+ return { keys: [{ kty: 'OKP', crv: 'Ed25519', x }] };
121
+ }
122
+ /** Reconstruct a public key from a served JWKS entry — used to verify a signature using ONLY
123
+ * what the JWKS *endpoint* returned (not a fresh local re-derivation from `root`), so a
124
+ * signature-verification capability that routes through this has real teeth against a
125
+ * sabotaged handler (build spec §3: "through handler → teeth"). */
126
+ export function falWebhookPublicKeyFromJwk(jwk) {
127
+ return nodeCrypto().createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: jwk.x }, format: 'jwk' });
128
+ }
129
+ /** Build the exact signed message fal signs: id \n user \n ts \n sha256hex(body). */
130
+ function signingMessage(requestId, userId, timestamp, rawBody) {
131
+ const bodyHash = nodeCrypto().createHash('sha256').update(rawBody, 'utf8').digest('hex');
132
+ return `${requestId}\n${userId}\n${timestamp}\n${bodyHash}`;
133
+ }
134
+ /** Compute the hex ed25519 signature for a webhook delivery (real crypto, not a stub — a real
135
+ * Ed25519/JWKS verifier accepts this unchanged). */
136
+ export async function computeFalWebhookSignature(requestId, userId, timestamp, rawBody, root) {
137
+ const { privateKey } = await falSigningKey(root);
138
+ const message = signingMessage(requestId, userId, timestamp, rawBody);
139
+ // Node's `crypto.sign(algorithm, data, key)` takes `null` for the one-shot Ed25519 signing
140
+ // algorithm (Ed25519 has no separate digest step — verified against Node's own crypto docs).
141
+ return nodeCrypto().sign(null, Buffer.from(message, 'utf8'), privateKey).toString('hex');
142
+ }
143
+ function verifyRaw(rawBody, headers, publicKey, opts) {
144
+ const lower = {};
145
+ for (const [k, v] of Object.entries(headers))
146
+ lower[k.toLowerCase()] = v;
147
+ const requestId = lower['x-fal-webhook-request-id'];
148
+ const userId = lower['x-fal-webhook-user-id'];
149
+ const ts = Number(lower['x-fal-webhook-timestamp']);
150
+ const sigHex = lower['x-fal-webhook-signature'];
151
+ if (!requestId || !userId || !sigHex || !Number.isFinite(ts)) {
152
+ throw new FalWebhookVerificationError('Missing required webhook headers (X-Fal-Webhook-Request-Id / X-Fal-Webhook-User-Id / X-Fal-Webhook-Timestamp / X-Fal-Webhook-Signature).');
153
+ }
154
+ const tolerance = opts.tolerance ?? 300;
155
+ const now = opts.now ?? Math.floor(Date.now() / 1000);
156
+ if (Math.abs(now - ts) > tolerance) {
157
+ throw new FalWebhookVerificationError(`Webhook timestamp outside the ${tolerance}s tolerance.`);
158
+ }
159
+ const message = signingMessage(requestId, userId, ts, rawBody);
160
+ let sigBuf;
161
+ try {
162
+ sigBuf = Buffer.from(sigHex, 'hex');
163
+ }
164
+ catch {
165
+ throw new FalWebhookVerificationError('Malformed hex signature.');
166
+ }
167
+ const ok = sigBuf.length > 0 && nodeCrypto().verify(null, Buffer.from(message, 'utf8'), publicKey, sigBuf);
168
+ if (!ok)
169
+ throw new FalWebhookVerificationError('Signature verification failed.');
170
+ return JSON.parse(rawBody);
171
+ }
172
+ /** Verify a webhook delivery against a caller-provided public key (e.g. one recovered from a
173
+ * served JWKS document via `falWebhookPublicKeyFromJwk`). Throws `FalWebhookVerificationError`
174
+ * on any missing header, stale timestamp, or signature mismatch. */
175
+ export function verifyFalWebhookSignatureWithKey(rawBody, headers, publicKey, opts = {}) {
176
+ return verifyRaw(rawBody, headers, publicKey, opts);
177
+ }
178
+ /** Verify a webhook delivery against the root's persisted keypair directly (pure local crypto —
179
+ * does not touch the twin handler/JWKS route at all). */
180
+ export async function verifyFalWebhook(rawBody, headers, root, opts = {}) {
181
+ const { publicKey } = await falSigningKey(root);
182
+ return verifyRaw(rawBody, headers, publicKey, opts);
183
+ }
184
+ /**
185
+ * Build a SIGNED webhook delivery for a completed (or failed) request: the fal webhook payload +
186
+ * the real ed25519-signed X-Fal-Webhook-* headers. Deterministic + offline.
187
+ */
188
+ export async function buildSignedFalWebhook(args) {
189
+ const userId = args.userId ?? 'twin-user';
190
+ const ts = Math.floor(Date.parse(args.occurredAt) / 1000);
191
+ const event = args.status === 'OK'
192
+ ? { request_id: args.requestId, gateway_request_id: args.gatewayRequestId ?? args.requestId, status: 'OK', payload: args.payload ?? null }
193
+ : { request_id: args.requestId, gateway_request_id: args.gatewayRequestId ?? args.requestId, status: 'ERROR', payload: null, error: args.error ?? 'unknown error' };
194
+ const body = JSON.stringify(event);
195
+ const headers = {
196
+ 'x-fal-webhook-request-id': args.requestId,
197
+ 'x-fal-webhook-user-id': userId,
198
+ 'x-fal-webhook-timestamp': String(ts),
199
+ 'x-fal-webhook-signature': await computeFalWebhookSignature(args.requestId, userId, ts, body, args.root),
200
+ };
201
+ return { headers, body, event };
202
+ }
@@ -0,0 +1,13 @@
1
+ export { handleFalTwinRequest, routeFalSurface, falTwinSnapshot, FAL_RESOURCE_TYPES, } from './fal-twin.js';
2
+ export type { FalRequest, FalResponse, FalResourceType, FalSurface, FalTwinSnapshot, } from './fal-twin.js';
3
+ export { createFalTwinFetch, createFalTwinServer, type FalTwinFetchOptions } from './fal-server.js';
4
+ export { canonicalInputText, createFalScenarioEngine, falScenarioAdapter, loadFalScenarioDocument } from './fal-scenario.js';
5
+ export type { FalScenarioEngine, FalScenarioRequest, FalScenarioRespond } from './fal-scenario.js';
6
+ export { mapQueueRequest, pullFalRequests, syncFalFromReal, } from './fal-connector.js';
7
+ export type { FalLikeClient, FalRequestHandle, FalRealQueueStatus, FalRealQueueLog, FalBudgetedOptions } from './fal-connector.js';
8
+ export { FAL_BUDGETED_METHODS, FAL_BUDGET_CEILING, FAL_BUDGET_MAX_RETRY_AFTER_S, FAL_BUDGET_WINDOW_MS, FAL_CALL_WEIGHTS, FAL_RATE_BUDGET, FalBudget, FalBudgetError, falBudgetPath, falCallWeight, falClientBudget, guardFalClient, } from './fal-budget.js';
9
+ export type { FalBudgetErrorKind, FalBudgetOptions, FalBudgetReservation, FalBudgetSnapshot } from './fal-budget.js';
10
+ export { falJwks, falWebhookPublicKeyFromJwk, computeFalWebhookSignature, verifyFalWebhook, verifyFalWebhookSignatureWithKey, buildSignedFalWebhook, FalWebhookVerificationError, } from './fal-webhooks.js';
11
+ export type { FalWebhookEvent } from './fal-webhooks.js';
12
+ import type { TwinPack } from '@volter/world-core';
13
+ export declare const pack: TwinPack;
@@ -0,0 +1,55 @@
1
+ // @volter/twin-fal — the fal.ai (fal.run / queue.fal.run) model-inference queue-lifecycle twin,
2
+ // built on the shared @volter/world-core kernel. REST transport over the queue.fal.run / fal.run /
3
+ // rest.fal.ai host-split shapes; state lives entirely in the kernel action log (no side-store).
4
+ // Queue lifecycle (submit → status polls with logs → response → cancel), the synchronous
5
+ // `fal.run` variant, host-split surface routing (incl. the real fal-js `x-fal-target-url` proxy
6
+ // protocol), and ed25519-signed webhooks + JWKS.
7
+ //
8
+ // THE GENERATIVE STUB: a request's output is a clearly-labeled DETERMINISTIC stub
9
+ // (`[twin-stub:fal:…]`) derived from hash(model_id, input), while the protocol envelope (status
10
+ // machine, queue_position, logs, urls, metrics) is faithful. The stub is also the ONE thing a
11
+ // scenario handler may replace: a world
12
+ // dir's handlers/fal.json scripts what a submitted request settles to, run by the ONE kernel
13
+ // engine through this pack's adapter (fal-scenario.ts) and inspectable at GET /twin/scenario.
14
+ // (Conformance/capability tooling lives in @volter/world-tooling, a dev dependency — NOT shipped.)
15
+ export { handleFalTwinRequest, routeFalSurface, falTwinSnapshot, FAL_RESOURCE_TYPES, } from "./fal-twin.js";
16
+ export { createFalTwinFetch, createFalTwinServer } from "./fal-server.js";
17
+ // The pack's HALF of the scenario system (the grammar is the kernel's ONE engine). Exported so a
18
+ // test can build an engine and `use()` handlers in-process, and so the fetch adapter can mount it.
19
+ export { canonicalInputText, createFalScenarioEngine, falScenarioAdapter, loadFalScenarioDocument } from "./fal-scenario.js";
20
+ export { mapQueueRequest, pullFalRequests, syncFalFromReal, } from "./fal-connector.js";
21
+ // The client-side rate budget — the fail-closed backstop every live call goes through. The
22
+ // MECHANISM is the kernel's shared, vendor-agnostic `RateBudget`; what lives here is this vendor's
23
+ // DECLARATION (window/ceiling/per-method weights) plus `guardFalClient`, the choke point the
24
+ // connector entrypoints apply unconditionally. Exported so an operator can inspect spend
25
+ // (`snapshot`) and a caller can catch `FalBudgetError` by type; there is deliberately no export
26
+ // that disables the guard.
27
+ export { FAL_BUDGETED_METHODS, FAL_BUDGET_CEILING, FAL_BUDGET_MAX_RETRY_AFTER_S, FAL_BUDGET_WINDOW_MS, FAL_CALL_WEIGHTS, FAL_RATE_BUDGET, FalBudget, FalBudgetError, falBudgetPath, falCallWeight, falClientBudget, guardFalClient, } from "./fal-budget.js";
28
+ export { falJwks, falWebhookPublicKeyFromJwk, computeFalWebhookSignature, verifyFalWebhook, verifyFalWebhookSignatureWithKey, buildSignedFalWebhook, FalWebhookVerificationError, } from "./fal-webhooks.js";
29
+ import { FAL_RATE_BUDGET as RATE_BUDGET } from "./fal-budget.js";
30
+ export const pack = {
31
+ // The SAME object fal-budget.ts declares at module load — one source of truth, so registering
32
+ // the pack and importing the connector can never arm two different ceilings.
33
+ rateBudget: RATE_BUDGET,
34
+ vendor: 'fal',
35
+ transport: 'rest',
36
+ archetype: 'generative',
37
+ bin: 'world-fal',
38
+ resources: ['request', 'signing_key'],
39
+ specSource: 'fal-conformance.ts (self-referential endpoint/resource inventory) — grounded read-only against fal.ai/docs/model-endpoints/{queue,webhooks} and the fal-js client source (github.com/fal-ai/fal-js) fetched during the build; see spec-sources.json.',
40
+ description: 'fal.ai model-inference queue-lifecycle twin — submit/status/response/cancel on queue.fal.run, the synchronous fal.run variant, host-split surface routing (incl. the real fal-js x-fal-target-url proxy protocol), and ed25519-signed webhooks + JWKS. Request output is a deterministic [twin-stub] value (no GPU/model weights) with a faithful protocol envelope. Kernel-backed.',
41
+ browserRouting: { apiPathPrefix: '/', loaderHost: 'https://queue.fal.run' },
42
+ // INTERCEPTION RULING — hostsNone, the pack's own home for it: host-split surface (fal.run /
43
+ // queue.fal.run) with no injector entry grounded yet — worlds wire the twin via the client's
44
+ // explicit endpoint config; a visible gap until its hosts are grounded here.
45
+ hostsNone: "host-split surface (fal.run / queue.fal.run) with no injector entry grounded yet — worlds wire the twin via the client's explicit endpoint config; a visible gap until its hosts are grounded here",
46
+ // Adoption, moved off the central maps unchanged (descriptor-first back-migration, adding-a-twin.md §3,
47
+ // 2026-08-31).
48
+ adoption: {
49
+ // Both official fal.ai distributions: the API client and the serverless framework.
50
+ pypi: ['fal-client', 'fal'],
51
+ sdks: ['@fal-ai/client', '@fal-ai/serverless-client'],
52
+ scopes: ['@fal-ai/'],
53
+ envStems: ['FAL'],
54
+ },
55
+ };
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@volter/twin-fal",
3
+ "version": "0.1.0",
4
+ "description": "Local fal.ai twin for the model-inference queue lifecycle (submit/status/response/cancel on queue.fal.run, the sync fal.run variant, host-split routing, ed25519-signed webhooks) built on @volter/world-core.",
5
+ "author": "Volter (https://github.com/volter-ai)",
6
+ "license": "Apache-2.0",
7
+ "files": [
8
+ "src",
9
+ "README.md",
10
+ "LICENSE",
11
+ "!**/*.test.ts",
12
+ "dist"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/volter-ai/twin.git",
17
+ "directory": "packages/twin/fal"
18
+ },
19
+ "homepage": "https://github.com/volter-ai/twin/tree/main/packages/twin/fal#readme",
20
+ "type": "module",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/src/index.d.ts",
24
+ "default": "./dist/src/index.js"
25
+ }
26
+ },
27
+ "bin": {
28
+ "world-fal": "dist/src/cli.js"
29
+ },
30
+ "scripts": {
31
+ "test": "bun test src/*.test.ts",
32
+ "typecheck": "tsc --noEmit",
33
+ "build": "node ../../../scripts/publish/build.mjs",
34
+ "prepack": "node ../../../scripts/publish/prepare-publish.mjs prepack",
35
+ "postpack": "node ../../../scripts/publish/prepare-publish.mjs postpack"
36
+ },
37
+ "peerDependencies": {
38
+ "@volter/world-core": "2.0.0"
39
+ },
40
+ "devDependencies": {
41
+ "@types/bun": "^1.2.20",
42
+ "@types/node": "^24.0.0",
43
+ "@volter/world-core": "2.0.0",
44
+ "@volter/world-tooling": "0.1.0",
45
+ "@fal-ai/client": "^1.10.1",
46
+ "typescript": "^5.9.0"
47
+ },
48
+ "engines": {
49
+ "node": ">=22.3"
50
+ }
51
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ import { keepProcessAlive } from '@volter/world-core/lifecycle';
3
+ // world-fal CLI: serve the KERNEL-BACKED Fal API twin, or run conformance. State
4
+ // lives in the @volter/world-core action log (no in-memory side-store). Conformance is dev-only +
5
+ // lazy-imported so the bin runs without @volter/world-tooling (E2).
6
+ import { hasFlag, optionValue } from '@volter/world-core/args';
7
+ import { createFalTwinServer } from './fal-server.ts';
8
+
9
+ const [cmd, ...rest] = process.argv.slice(2);
10
+ const port = Number(optionValue(rest, '--port', String(process.env.PORT ?? '0'))) || undefined;
11
+ const root = optionValue(rest, '--root') || undefined;
12
+ const scenario = optionValue(rest, '--scenario') || undefined;
13
+ const readOnly = hasFlag(rest, '--read-only'); // a twin accepts writes unless started read-only
14
+
15
+ if (cmd === 'serve' || cmd === undefined) {
16
+ const s = await createFalTwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}), ...(scenario ? { scenarioPath: scenario } : {}) });
17
+ process.stdout.write(`fal twin (model-inference queue lifecycle: submit/status/response/cancel, host-split, ed25519 webhooks)${readOnly ? ' [read-only]' : ''}${scenario ? ' [scenario]' : ''} at http://127.0.0.1:${s.port}\n`);
18
+ await keepProcessAlive();
19
+ } else if (cmd === 'conformance') {
20
+ const { checkFalConformance } = await import('./fal-conformance.ts');
21
+ const report = checkFalConformance();
22
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
23
+ if (!report.ok) process.exitCode = 1;
24
+ } else {
25
+ process.stdout.write('Usage: world-fal serve|conformance [--port N] [--root DIR] [--read-only] [--scenario FILE]\n');
26
+ }