@rivet-dev/vercel-world 0.0.0-http-body-streaming.064e07d

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.
Files changed (81) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/README.md +31 -0
  3. package/dist/actors/coordinator.d.ts +114 -0
  4. package/dist/actors/coordinator.d.ts.map +1 -0
  5. package/dist/actors/coordinator.js +232 -0
  6. package/dist/actors/coordinator.js.map +1 -0
  7. package/dist/actors/db.d.ts +8 -0
  8. package/dist/actors/db.d.ts.map +1 -0
  9. package/dist/actors/db.js +282 -0
  10. package/dist/actors/db.js.map +1 -0
  11. package/dist/actors/dispatcher.d.ts +84 -0
  12. package/dist/actors/dispatcher.d.ts.map +1 -0
  13. package/dist/actors/dispatcher.js +498 -0
  14. package/dist/actors/dispatcher.js.map +1 -0
  15. package/dist/actors/hook-token.d.ts +26 -0
  16. package/dist/actors/hook-token.d.ts.map +1 -0
  17. package/dist/actors/hook-token.js +151 -0
  18. package/dist/actors/hook-token.js.map +1 -0
  19. package/dist/actors/shared.d.ts +366 -0
  20. package/dist/actors/shared.d.ts.map +1 -0
  21. package/dist/actors/shared.js +112 -0
  22. package/dist/actors/shared.js.map +1 -0
  23. package/dist/actors/streams.d.ts +26 -0
  24. package/dist/actors/streams.d.ts.map +1 -0
  25. package/dist/actors/streams.js +127 -0
  26. package/dist/actors/streams.js.map +1 -0
  27. package/dist/actors/workflow-run.d.ts +891 -0
  28. package/dist/actors/workflow-run.d.ts.map +1 -0
  29. package/dist/actors/workflow-run.js +872 -0
  30. package/dist/actors/workflow-run.js.map +1 -0
  31. package/dist/actors.d.ts +2244 -0
  32. package/dist/actors.d.ts.map +1 -0
  33. package/dist/actors.js +16 -0
  34. package/dist/actors.js.map +1 -0
  35. package/dist/codec.d.ts +4 -0
  36. package/dist/codec.d.ts.map +1 -0
  37. package/dist/codec.js +27 -0
  38. package/dist/codec.js.map +1 -0
  39. package/dist/index.d.ts +843 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +567 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/runtime.d.ts +2 -0
  44. package/dist/runtime.d.ts.map +1 -0
  45. package/dist/runtime.js +24 -0
  46. package/dist/runtime.js.map +1 -0
  47. package/package.json +51 -0
  48. package/scripts/conformance/run.ts +278 -0
  49. package/scripts/integration/run.ts +935 -0
  50. package/src/actors/coordinator.ts +360 -0
  51. package/src/actors/db.ts +291 -0
  52. package/src/actors/dispatcher.ts +787 -0
  53. package/src/actors/hook-token.ts +239 -0
  54. package/src/actors/shared.ts +153 -0
  55. package/src/actors/streams.ts +215 -0
  56. package/src/actors/workflow-run.ts +1477 -0
  57. package/src/actors.ts +18 -0
  58. package/src/codec.ts +28 -0
  59. package/src/index.ts +788 -0
  60. package/src/runtime.ts +29 -0
  61. package/tests/conformance.test.ts +8 -0
  62. package/tests/helpers/db.ts +62 -0
  63. package/tests/helpers/dispatcher-driver.ts +71 -0
  64. package/tests/helpers/harness.ts +161 -0
  65. package/tests/integration/crash-restart.test.ts +145 -0
  66. package/tests/integration/dispatcher-loop.test.ts +144 -0
  67. package/tests/integration/hook-token.test.ts +160 -0
  68. package/tests/integration/hooks.test.ts +123 -0
  69. package/tests/integration/streams.test.ts +178 -0
  70. package/tests/integration/workflow-events.test.ts +326 -0
  71. package/tests/setup.ts +10 -0
  72. package/tests/unit/codec.test.ts +73 -0
  73. package/tests/unit/coordinator-record.test.ts +177 -0
  74. package/tests/unit/db-migrations.test.ts +65 -0
  75. package/tests/unit/dispatcher-queue.test.ts +274 -0
  76. package/tests/unit/logging.test.ts +49 -0
  77. package/tests/unit/readiness.test.ts +102 -0
  78. package/tests/unit/transaction.test.ts +76 -0
  79. package/tsconfig.build.json +13 -0
  80. package/tsconfig.json +12 -0
  81. package/vitest.config.ts +32 -0
package/src/index.ts ADDED
@@ -0,0 +1,788 @@
1
+ import {
2
+ MessageId,
3
+ QueuePayloadSchema,
4
+ ValidQueueName as ValidQueueNameSchema,
5
+ SPEC_VERSION_CURRENT,
6
+ getQueueTopicPrefix,
7
+ resolveQueueNamespace,
8
+ type CreateEventParams,
9
+ type CreateEventRequest,
10
+ type EventResult,
11
+ type GetChunksOptions,
12
+ type GetEventParams,
13
+ type GetHookParams,
14
+ type GetStepParams,
15
+ type GetWorkflowRunParams,
16
+ type ListEventsByCorrelationIdParams,
17
+ type ListEventsParams,
18
+ type ListHooksParams,
19
+ type ListWorkflowRunStepsParams,
20
+ type ListWorkflowRunsParams,
21
+ type QueueOptions,
22
+ type QueuePayload,
23
+ type QueuePrefix,
24
+ type RunCreatedEventRequest,
25
+ type StreamChunksResponse,
26
+ type StreamInfoResponse,
27
+ type ValidQueueName,
28
+ type WorkflowRun,
29
+ type World,
30
+ } from "@workflow/world";
31
+ import { HookNotFoundError, WorkflowWorldError } from "@workflow/errors";
32
+ import { setup, type Registry } from "rivetkit";
33
+ import { createClient, type Client } from "rivetkit/client";
34
+ import { monotonicFactory } from "ulid";
35
+ import { registry, vercelWorldActors } from "./actors.js";
36
+
37
+ export type RivetWorldConfig = {
38
+ /** The application's combined registry, including `vercelWorldActors`. */
39
+ registry?: RegistryWithReadiness;
40
+ runtimeUrl?: string;
41
+ /**
42
+ * Advanced: inject a pre-built RivetKit client (for example, a `setupTest`
43
+ * client) instead of constructing one with RivetKit's standard defaults.
44
+ */
45
+ client?: Client<typeof registry>;
46
+ };
47
+
48
+ type HookConflictEventRequest = {
49
+ eventType: "hook_conflict";
50
+ specVersion?: number;
51
+ correlationId: string;
52
+ eventData: {
53
+ token: string;
54
+ conflictingRunId?: string;
55
+ };
56
+ };
57
+
58
+ type EventCreateInput =
59
+ | CreateEventRequest
60
+ | RunCreatedEventRequest
61
+ | HookConflictEventRequest;
62
+
63
+ function env(name: string) {
64
+ return process.env[name];
65
+ }
66
+
67
+ function required(value: string | undefined, name: string) {
68
+ if (!value) {
69
+ throw new Error(
70
+ `${name} is required for @rivet-dev/vercel-world. Set it in the app process environment.`,
71
+ );
72
+ }
73
+ return value;
74
+ }
75
+
76
+ async function resolveDerivedRunId(
77
+ lookup: () => Promise<string | null>,
78
+ ): Promise<string | null> {
79
+ // Canonical state commits before its coordinator index. Briefly tolerate that
80
+ // propagation window so create-then-get stays consistent without making writes
81
+ // depend on the coordinator.
82
+ for (const delayMs of [0, 10, 25, 50, 100]) {
83
+ if (delayMs > 0) {
84
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
85
+ }
86
+ const runId = await lookup();
87
+ if (runId) return runId;
88
+ }
89
+ return null;
90
+ }
91
+
92
+ function jsonReplacer(_key: string, value: unknown) {
93
+ if (value instanceof Uint8Array) {
94
+ return {
95
+ __type: "Uint8Array",
96
+ data: Buffer.from(value).toString("base64"),
97
+ };
98
+ }
99
+ return value;
100
+ }
101
+
102
+ function jsonReviver(_key: string, value: unknown) {
103
+ if (
104
+ value !== null &&
105
+ typeof value === "object" &&
106
+ "__type" in value &&
107
+ (value as { __type?: unknown }).__type === "Uint8Array" &&
108
+ "data" in value &&
109
+ typeof (value as { data?: unknown }).data === "string"
110
+ ) {
111
+ return new Uint8Array(
112
+ Buffer.from((value as { data: string }).data, "base64"),
113
+ );
114
+ }
115
+ return value;
116
+ }
117
+
118
+ function serializeQueuePayload(value: QueuePayload) {
119
+ return JSON.stringify(value, jsonReplacer);
120
+ }
121
+
122
+ function streamChunkToBase64(chunk: string | Uint8Array) {
123
+ const bytes =
124
+ typeof chunk === "string" ? Buffer.from(chunk) : Buffer.from(chunk);
125
+ return bytes.toString("base64");
126
+ }
127
+
128
+ function encodeStreamCursor(index: number) {
129
+ return Buffer.from(JSON.stringify({ i: Math.max(0, index) })).toString(
130
+ "base64",
131
+ );
132
+ }
133
+
134
+ function decodeStreamCursor(cursor: string | null | undefined) {
135
+ if (!cursor) return 0;
136
+ try {
137
+ const decoded = JSON.parse(Buffer.from(cursor, "base64").toString("utf8"));
138
+ return typeof decoded.i === "number" ? Math.max(0, decoded.i) : 0;
139
+ } catch {
140
+ return 0;
141
+ }
142
+ }
143
+
144
+ async function parseQueueRequestBody(req: Request) {
145
+ return JSON.parse(await req.text(), jsonReviver);
146
+ }
147
+
148
+ function partitionForQueuePayload(message: QueuePayload) {
149
+ const data = QueuePayloadSchema.parse(message);
150
+ const runId = "runId" in data ? data.runId : "workflowRunId" in data ? data.workflowRunId : undefined;
151
+ if (typeof runId !== "string" || runId.length === 0) {
152
+ throw new Error("Vercel World queue payload requires a non-empty runId");
153
+ }
154
+ return runId;
155
+ }
156
+
157
+ function resolveRuntimeUrl(configured?: string) {
158
+ const value =
159
+ configured ??
160
+ env("WORKFLOW_RUNTIME_URL") ??
161
+ env("WORKFLOW_LOCAL_BASE_URL") ??
162
+ (env("PORT") ? `http://localhost:${env("PORT")}` : undefined);
163
+ return required(value, "WORKFLOW_RUNTIME_URL");
164
+ }
165
+
166
+ function queueHeaders(opts?: QueueOptions) {
167
+ const headers = { ...(opts?.headers ?? {}) };
168
+ const secret = env("RIVET_WORKFLOW_SECRET");
169
+ if (secret && !("authorization" in headers) && !("Authorization" in headers)) {
170
+ headers.authorization = `Bearer ${secret}`;
171
+ }
172
+ return headers;
173
+ }
174
+
175
+ function verifyWorkflowBearer(req: Request) {
176
+ const secret = env("RIVET_WORKFLOW_SECRET");
177
+ if (!secret) return null;
178
+ const expected = `Bearer ${secret}`;
179
+ if (req.headers.get("authorization") === expected) return null;
180
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
181
+ }
182
+
183
+ function runActor(client: Client<typeof registry>, runId: string) {
184
+ return client.workflowRun.getOrCreate([runId]);
185
+ }
186
+
187
+ type RegistryWithReadiness = Registry<any> & {
188
+ startAndWait(): Promise<void>;
189
+ };
190
+
191
+ function withReadyHandle<T extends object>(
192
+ handle: T,
193
+ applicationRegistry: RegistryWithReadiness,
194
+ ): T {
195
+ return new Proxy(handle, {
196
+ get(target, prop, receiver) {
197
+ if (prop === "then") return undefined;
198
+ const value = Reflect.get(target, prop, receiver);
199
+ if (typeof value !== "function") return value;
200
+ // ActorHandle.connect() is intentionally synchronous. Stream setup waits
201
+ // for registry readiness before calling it, so preserve that contract.
202
+ if (prop === "connect") return value.bind(target);
203
+ return async (...args: unknown[]) => {
204
+ // Every outgoing World operation intentionally waits here. Registry
205
+ // readiness is shared, so repeated/concurrent calls are cheap and cannot
206
+ // start duplicate runtimes or let a cold request race registry startup.
207
+ await applicationRegistry.startAndWait();
208
+ return Reflect.apply(value, target, args);
209
+ };
210
+ },
211
+ }) as T;
212
+ }
213
+
214
+ function withRegistryReadiness(
215
+ client: Client<typeof registry>,
216
+ applicationRegistry: RegistryWithReadiness | undefined,
217
+ ): Client<typeof registry> {
218
+ if (!applicationRegistry) return client;
219
+ const actorNames = new Set(["workflowRun", "coordinator", "hookToken"]);
220
+ return new Proxy(client, {
221
+ get(target, prop, receiver) {
222
+ if (typeof prop !== "string" || !actorNames.has(prop)) {
223
+ const value = Reflect.get(target, prop, receiver);
224
+ return typeof value === "function" ? value.bind(target) : value;
225
+ }
226
+
227
+ const accessor = Reflect.get(target, prop, receiver) as {
228
+ get: (...args: unknown[]) => object;
229
+ getOrCreate: (...args: unknown[]) => object;
230
+ getForId: (...args: unknown[]) => object;
231
+ create: (...args: unknown[]) => Promise<object>;
232
+ };
233
+ return {
234
+ get: (...args: unknown[]) =>
235
+ withReadyHandle(accessor.get(...args), applicationRegistry),
236
+ getOrCreate: (...args: unknown[]) =>
237
+ withReadyHandle(accessor.getOrCreate(...args), applicationRegistry),
238
+ getForId: (...args: unknown[]) =>
239
+ withReadyHandle(accessor.getForId(...args), applicationRegistry),
240
+ create: async (...args: unknown[]) => {
241
+ await applicationRegistry.startAndWait();
242
+ return withReadyHandle(await accessor.create(...args), applicationRegistry);
243
+ },
244
+ };
245
+ },
246
+ }) as Client<typeof registry>;
247
+ }
248
+
249
+ const ulid = monotonicFactory();
250
+
251
+ export class RivetClientWorld {
252
+ readonly specVersion = SPEC_VERSION_CURRENT;
253
+ readonly #client: Client<typeof registry>;
254
+ readonly #registry: RegistryWithReadiness | undefined;
255
+ readonly #ownsRegistry: boolean;
256
+ readonly #runtimeUrl?: string;
257
+ readonly #injectedClient: boolean;
258
+ #startPromise: Promise<void> | undefined;
259
+
260
+ constructor(config: RivetWorldConfig = {}) {
261
+ this.#runtimeUrl = config.runtimeUrl;
262
+ this.#injectedClient = config.client != null;
263
+ this.#ownsRegistry = config.registry == null && config.client == null;
264
+ this.#registry =
265
+ config.registry ??
266
+ (config.client
267
+ ? undefined
268
+ : (setup({ use: vercelWorldActors }) as RegistryWithReadiness));
269
+ const registryConfig = this.#registry?.parseConfig();
270
+ this.#client = withRegistryReadiness(
271
+ config.client ??
272
+ createClient<typeof registry>(
273
+ registryConfig
274
+ ? {
275
+ endpoint: registryConfig.endpoint,
276
+ headers: registryConfig.headers,
277
+ namespace: registryConfig.namespace,
278
+ poolName: registryConfig.envoy.poolName,
279
+ token: registryConfig.token,
280
+ }
281
+ : undefined,
282
+ ),
283
+ this.#registry,
284
+ );
285
+ }
286
+
287
+ async start() {
288
+ this.#startPromise ??= this.#startOnce().catch((error) => {
289
+ this.#startPromise = undefined;
290
+ throw error;
291
+ });
292
+ await this.#startPromise;
293
+ }
294
+
295
+ async #startOnce() {
296
+ // Per-run alarms and the transactionally persisted initial dispatch recover
297
+ // their own work. World startup intentionally performs no global scan.
298
+ }
299
+
300
+ #workflowQueueName(workflowName: string) {
301
+ return ValidQueueNameSchema.parse(
302
+ `${getQueueTopicPrefix("workflow", resolveQueueNamespace())}${workflowName}`,
303
+ );
304
+ }
305
+
306
+ runs = {
307
+ get: async (id: string, params?: GetWorkflowRunParams) =>
308
+ runActor(this.#client, id).getRun(id, params),
309
+ list: async (params?: ListWorkflowRunsParams) =>
310
+ this.#client.coordinator.getOrCreate(["coordinator"]).listRuns(params),
311
+ };
312
+
313
+ steps = {
314
+ get: async (
315
+ runId: string | undefined,
316
+ stepId: string,
317
+ params?: GetStepParams,
318
+ ) => {
319
+ const effectiveRunId =
320
+ runId ??
321
+ (await resolveDerivedRunId(() =>
322
+ this.#client.coordinator
323
+ .getOrCreate(["coordinator"])
324
+ .getRunIdByCorrelation(stepId),
325
+ ));
326
+ if (!effectiveRunId) {
327
+ throw new WorkflowWorldError(`Step not found: ${stepId}`);
328
+ }
329
+ return runActor(this.#client, effectiveRunId).getStep(
330
+ effectiveRunId,
331
+ stepId,
332
+ params,
333
+ );
334
+ },
335
+ list: async (params: ListWorkflowRunStepsParams) =>
336
+ runActor(this.#client, params.runId).listSteps(params),
337
+ };
338
+
339
+ events = {
340
+ create: async (
341
+ runId: string | null,
342
+ data: CreateEventRequest | RunCreatedEventRequest,
343
+ params?: CreateEventParams,
344
+ ) => {
345
+ let effectiveRunId =
346
+ data.eventType === "run_created" && !runId ? `wrun_${ulid()}` : runId;
347
+ if (data.eventType !== "run_created" && !effectiveRunId) {
348
+ throw new Error("runId is required for non-run_created events");
349
+ }
350
+ let dataToStore: EventCreateInput = data;
351
+ // Token reserved by THIS call that must be released if the append below
352
+ // never lands (P0 #3): otherwise a thrown/crashed append leaves the
353
+ // token claimed with no backing `hook_created` event, leaking it past
354
+ // the 60s reconciliation grace.
355
+ let reservationToConfirm:
356
+ | { token: string; hookId: string; generation: number }
357
+ | undefined;
358
+
359
+ if (data.eventType === "hook_created") {
360
+ if (!effectiveRunId) {
361
+ throw new Error("runId is required for hook_created events");
362
+ }
363
+ const reservation = await this.#client.hookToken
364
+ .getOrCreate([data.eventData.token])
365
+ .reserve(data.eventData.token, effectiveRunId, data.correlationId);
366
+ if (reservation.ok) {
367
+ reservationToConfirm = {
368
+ token: data.eventData.token,
369
+ hookId: data.correlationId,
370
+ generation: reservation.generation,
371
+ };
372
+ } else {
373
+ dataToStore = {
374
+ eventType: "hook_conflict",
375
+ specVersion: data.specVersion,
376
+ correlationId: data.correlationId,
377
+ eventData: {
378
+ token: data.eventData.token,
379
+ ...(reservation.runId
380
+ ? { conflictingRunId: reservation.runId }
381
+ : {}),
382
+ },
383
+ };
384
+ }
385
+ }
386
+
387
+ if (!effectiveRunId) {
388
+ throw new Error("runId is required");
389
+ }
390
+ const actorRunId = effectiveRunId;
391
+ let initialDispatch:
392
+ | {
393
+ runtimeUrl: string;
394
+ queueName: string;
395
+ headers: Record<string, string>;
396
+ initial: {
397
+ messageId: string;
398
+ body: string;
399
+ idempotencyKey: string;
400
+ };
401
+ }
402
+ | undefined;
403
+ if (dataToStore.eventType === "run_created") {
404
+ const queueName = this.#workflowQueueName(dataToStore.eventData.workflowName);
405
+ const executionContext = dataToStore.eventData.executionContext;
406
+ const traceCarrier = executionContext?.traceCarrier;
407
+ const payload = {
408
+ runId: actorRunId,
409
+ ...(traceCarrier == null ? {} : { traceCarrier }),
410
+ runInput: {
411
+ input: dataToStore.eventData.input,
412
+ deploymentId: dataToStore.eventData.deploymentId,
413
+ workflowName: dataToStore.eventData.workflowName,
414
+ specVersion: dataToStore.specVersion ?? SPEC_VERSION_CURRENT,
415
+ ...(executionContext == null ? {} : { executionContext }),
416
+ ...(dataToStore.eventData.attributes == null
417
+ ? {}
418
+ : { attributes: dataToStore.eventData.attributes }),
419
+ ...(dataToStore.eventData.allowReservedAttributes == null
420
+ ? {}
421
+ : { allowReservedAttributes: true as const }),
422
+ },
423
+ };
424
+ initialDispatch = {
425
+ runtimeUrl: resolveRuntimeUrl(this.#runtimeUrl),
426
+ queueName,
427
+ headers: queueHeaders(),
428
+ initial: {
429
+ messageId: MessageId.parse(`msg_${ulid()}`),
430
+ body: serializeQueuePayload(QueuePayloadSchema.parse(payload)),
431
+ idempotencyKey: `workflow-start:${actorRunId}`,
432
+ },
433
+ };
434
+ }
435
+ let result: EventResult;
436
+ try {
437
+ result = await runActor(this.#client, actorRunId).appendEvent(
438
+ actorRunId,
439
+ dataToStore,
440
+ params,
441
+ initialDispatch,
442
+ {
443
+ ...(reservationToConfirm == null
444
+ ? {}
445
+ : { confirm: reservationToConfirm }),
446
+ },
447
+ );
448
+ } catch (error) {
449
+ if (reservationToConfirm) {
450
+ // An action error is ambiguous: postcommit work can fail after the
451
+ // canonical hook landed. Verify canonical ownership before deciding
452
+ // whether to confirm or release this generation.
453
+ try {
454
+ const owns = await runActor(this.#client, actorRunId).ownsHook(
455
+ reservationToConfirm.hookId,
456
+ reservationToConfirm.token,
457
+ );
458
+ const tokenActor = this.#client.hookToken.getOrCreate([
459
+ reservationToConfirm.token,
460
+ ]);
461
+ if (owns) {
462
+ await tokenActor.confirm(
463
+ reservationToConfirm.token,
464
+ reservationToConfirm.generation,
465
+ actorRunId,
466
+ reservationToConfirm.hookId,
467
+ );
468
+ } else {
469
+ await tokenActor.release(
470
+ reservationToConfirm.token,
471
+ reservationToConfirm.generation,
472
+ actorRunId,
473
+ reservationToConfirm.hookId,
474
+ );
475
+ }
476
+ } catch {}
477
+ }
478
+ throw error;
479
+ }
480
+ effectiveRunId = result.run?.runId ?? result.event?.runId ?? effectiveRunId;
481
+ return result;
482
+ },
483
+ get: async (runId: string, eventId: string, params?: GetEventParams) =>
484
+ runActor(this.#client, runId).getEvent(runId, eventId, params),
485
+ list: async (params: ListEventsParams) =>
486
+ runActor(this.#client, params.runId).listEvents(params),
487
+ listByCorrelationId: async (params: ListEventsByCorrelationIdParams) => {
488
+ const runId = await this.#client.coordinator
489
+ .getOrCreate(["coordinator"])
490
+ .getRunIdByCorrelation(params.correlationId);
491
+ if (!runId) return { data: [], cursor: null, hasMore: false };
492
+ return runActor(this.#client, runId).listEventsByCorrelationId(params);
493
+ },
494
+ };
495
+
496
+ hooks = {
497
+ get: async (hookId: string, params?: GetHookParams) => {
498
+ const runId = await resolveDerivedRunId(() =>
499
+ this.#client.coordinator
500
+ .getOrCreate(["coordinator"])
501
+ .getRunIdByHook(hookId),
502
+ );
503
+ if (!runId) throw new HookNotFoundError(hookId);
504
+ return runActor(this.#client, runId).getHook(hookId, params);
505
+ },
506
+ getByToken: async (token: string, params?: GetHookParams) => {
507
+ const owner = await this.#client.hookToken.getOrCreate([token]).get(token);
508
+ if (!owner) throw new HookNotFoundError(token);
509
+ return runActor(this.#client, owner.runId).getHook(owner.hookId, params);
510
+ },
511
+ list: async (params: ListHooksParams) => {
512
+ if (!params.runId) {
513
+ const page = await this.#client.coordinator
514
+ .getOrCreate(["coordinator"])
515
+ .listHookIndex(params);
516
+ const data = [];
517
+ for (const item of page.data) {
518
+ try {
519
+ data.push(
520
+ await runActor(this.#client, item.runId).getHook(
521
+ item.hookId,
522
+ params,
523
+ ),
524
+ );
525
+ } catch (error) {
526
+ if (!(error instanceof HookNotFoundError)) throw error;
527
+ }
528
+ }
529
+ return { data, cursor: page.cursor, hasMore: page.hasMore };
530
+ }
531
+ return runActor(this.#client, params.runId).listHooks(params);
532
+ },
533
+ };
534
+
535
+ async getDeploymentId() {
536
+ // Rivet currently dispatches to WORKFLOW_RUNTIME_URL, so it cannot route an
537
+ // unfinished run to immutable historical code. True deployment pinning
538
+ // requires a retained deployment URL or versioned workflow bundle loader.
539
+ return "rivet";
540
+ }
541
+
542
+ async queue(
543
+ queueName: ValidQueueName,
544
+ message: QueuePayload,
545
+ opts?: QueueOptions,
546
+ ): Promise<{ messageId: MessageId }> {
547
+ const messageId = MessageId.parse(`msg_${ulid()}`);
548
+ const runId = partitionForQueuePayload(message);
549
+ const result = await this.#client.workflowRun
550
+ .getOrCreate([runId])
551
+ .enqueue(
552
+ runId,
553
+ messageId,
554
+ queueName,
555
+ resolveRuntimeUrl(this.#runtimeUrl),
556
+ serializeQueuePayload(message),
557
+ queueHeaders(opts),
558
+ opts?.delaySeconds,
559
+ opts?.idempotencyKey ??
560
+ ("runInput" in message ? `workflow-start:${runId}` : undefined),
561
+ );
562
+ return { messageId: MessageId.parse(result.messageId) };
563
+ }
564
+
565
+ async inspectDispatcherQueue(runId: string) {
566
+ return runActor(this.#client, runId).inspectQueue();
567
+ }
568
+
569
+ async forceStaleDispatcherMessageForTesting(
570
+ runId: string,
571
+ messageId: string,
572
+ ageMs?: number,
573
+ ) {
574
+ await this.#client.workflowRun
575
+ .getOrCreate([runId])
576
+ .forceStaleInflightForTesting(runId, messageId, ageMs);
577
+ }
578
+
579
+ async expireClosedStreamForTesting(runId: string, name: string) {
580
+ return runActor(this.#client, runId).expireClosedStreamForTesting(name, runId);
581
+ }
582
+
583
+ createQueueHandler(
584
+ queueNamePrefix: QueuePrefix,
585
+ handler: (message: unknown, meta: any) => Promise<void | { timeoutSeconds: number }>,
586
+ ) {
587
+ return async (req: Request) => {
588
+ const unauthorized = verifyWorkflowBearer(req);
589
+ if (unauthorized) return unauthorized;
590
+ if (!req.body) {
591
+ return Response.json({ error: "Missing request body" }, { status: 400 });
592
+ }
593
+ const queueName = req.headers.get("x-vqs-queue-name");
594
+ const messageId = req.headers.get("x-vqs-message-id");
595
+ const attemptHeader = req.headers.get("x-vqs-message-attempt");
596
+ const attempt = Number(attemptHeader);
597
+ if (
598
+ !queueName ||
599
+ !messageId ||
600
+ attemptHeader == null ||
601
+ !Number.isSafeInteger(attempt) ||
602
+ attempt < 1
603
+ ) {
604
+ return Response.json(
605
+ { error: "Missing required headers" },
606
+ { status: 400 },
607
+ );
608
+ }
609
+ ValidQueueNameSchema.parse(queueName);
610
+ const parsedMessageId = MessageId.parse(messageId);
611
+ if (!queueName.startsWith(queueNamePrefix)) {
612
+ return Response.json({ error: "Unhandled queue" }, { status: 400 });
613
+ }
614
+ const message = await parseQueueRequestBody(req);
615
+ try {
616
+ // Stable across delivery attempts. Workflow uses the message id as a
617
+ // crash-recovery ownership token, not as an ingress deduplication key.
618
+ const result = await handler(message, {
619
+ attempt,
620
+ queueName,
621
+ messageId: parsedMessageId,
622
+ });
623
+ if (typeof result?.timeoutSeconds === "number") {
624
+ return Response.json({ timeoutSeconds: result.timeoutSeconds });
625
+ }
626
+ return Response.json({ ok: true });
627
+ } catch (error) {
628
+ return Response.json(String(error), { status: 500 });
629
+ }
630
+ };
631
+ }
632
+
633
+ async writeToStream(name: string, runId: string, chunk: string | Uint8Array) {
634
+ await runActor(this.#client, runId).writeStream(
635
+ name,
636
+ runId,
637
+ streamChunkToBase64(chunk),
638
+ false,
639
+ );
640
+ }
641
+
642
+ async closeStream(name: string, runId: string) {
643
+ await runActor(this.#client, runId).writeStream(name, runId, "", true);
644
+ }
645
+
646
+ async readFromStream(runId: string, name: string, startIndex = 0) {
647
+ const store = runActor(this.#client, runId);
648
+ let nextIndex = startIndex;
649
+ if (nextIndex < 0) {
650
+ const info = await store.getStreamInfo(name, runId);
651
+ nextIndex = Math.max(0, info.tailIndex + 1 + nextIndex);
652
+ }
653
+ let cancelled = false;
654
+ let disposeConnection: (() => Promise<void>) | undefined;
655
+ const applicationRegistry = this.#registry;
656
+
657
+ return new ReadableStream<Uint8Array>({
658
+ async start(controller) {
659
+ await applicationRegistry?.startAndWait();
660
+ // `nextIndex` is the last-consumed cursor and is the single source of
661
+ // truth for resume position: every drain reads forward from it, so a
662
+ // later (surviving) `streamAppended` re-drain catches up any chunks
663
+ // whose own broadcast was lost (best-effort delivery, SPEC §7).
664
+ let draining = false;
665
+ let pending = false;
666
+ const drainOnce = async () => {
667
+ for (;;) {
668
+ const page = await store.getStreamChunks(name, runId, nextIndex, 100);
669
+ for (const chunk of page.data) {
670
+ controller.enqueue(chunk.data);
671
+ }
672
+ nextIndex += page.data.length;
673
+ if (page.done) {
674
+ cancelled = true;
675
+ controller.close();
676
+ await disposeConnection?.();
677
+ return;
678
+ }
679
+ if (!page.hasMore) return;
680
+ }
681
+ };
682
+ // Re-drain if signaled while a drain is in flight: a naive "one drain
683
+ // at a time" guard would coalesce away the trailing notification and
684
+ // strand the chunk that arrived mid-drain. The pending flag forces
685
+ // another pass so no signal is lost.
686
+ const scheduleDrain = () => {
687
+ if (cancelled) return;
688
+ pending = true;
689
+ if (draining) return;
690
+ draining = true;
691
+ void (async () => {
692
+ try {
693
+ while (pending && !cancelled) {
694
+ pending = false;
695
+ await drainOnce();
696
+ }
697
+ } catch (error) {
698
+ cancelled = true;
699
+ controller.error(error);
700
+ await disposeConnection?.();
701
+ } finally {
702
+ draining = false;
703
+ }
704
+ })();
705
+ };
706
+
707
+ const conn = store.connect();
708
+ const unsubscribe = conn.on("streamAppended", (event) => {
709
+ if (
710
+ !cancelled &&
711
+ (event as { streamId?: string; runId?: string }).streamId === name &&
712
+ (event as { streamId?: string; runId?: string }).runId === runId
713
+ ) {
714
+ scheduleDrain();
715
+ }
716
+ });
717
+ // The actor connection may still be opening when the initial drain runs.
718
+ // Re-drain once it is live so an append that raced connection setup is
719
+ // recovered from persisted chunks even if its broadcast was missed.
720
+ const unsubscribeOpen = conn.onOpen(scheduleDrain);
721
+ disposeConnection = async () => {
722
+ unsubscribe();
723
+ unsubscribeOpen();
724
+ await conn.dispose();
725
+ };
726
+
727
+ scheduleDrain();
728
+ },
729
+ cancel() {
730
+ cancelled = true;
731
+ void disposeConnection?.();
732
+ },
733
+ });
734
+ }
735
+
736
+ async listStreamsByRunId(runId: string) {
737
+ return runActor(this.#client, runId).listStreams(runId);
738
+ }
739
+
740
+ async getStreamChunks(
741
+ name: string,
742
+ runId: string,
743
+ options?: GetChunksOptions,
744
+ ): Promise<StreamChunksResponse> {
745
+ const limit = Math.min(Math.max(options?.limit ?? 100, 1), 1000);
746
+ return runActor(this.#client, runId).getStreamChunks(
747
+ name,
748
+ runId,
749
+ decodeStreamCursor(options?.cursor),
750
+ limit,
751
+ );
752
+ }
753
+
754
+ async getStreamInfo(
755
+ name: string,
756
+ runId: string,
757
+ ): Promise<StreamInfoResponse> {
758
+ return runActor(this.#client, runId).getStreamInfo(name, runId);
759
+ }
760
+
761
+ readonly streams = {
762
+ write: (runId: string, name: string, chunk: string | Uint8Array) =>
763
+ this.writeToStream(name, runId, chunk),
764
+ close: (runId: string, name: string) => this.closeStream(name, runId),
765
+ get: (runId: string, name: string, startIndex?: number) =>
766
+ this.readFromStream(runId, name, startIndex),
767
+ list: (runId: string) => this.listStreamsByRunId(runId),
768
+ getChunks: (runId: string, name: string, options?: GetChunksOptions) =>
769
+ this.getStreamChunks(name, runId, options),
770
+ getInfo: (runId: string, name: string) =>
771
+ this.getStreamInfo(name, runId),
772
+ };
773
+
774
+ async close() {
775
+ if (this.#injectedClient) return;
776
+ await this.#client.dispose();
777
+ if (this.#ownsRegistry) await this.#registry?.shutdown();
778
+ }
779
+ }
780
+
781
+ export function createWorld(config?: RivetWorldConfig): World {
782
+ // The SDK's data-resolution overloads cannot be expressed on object-literal
783
+ // namespaces, but the actor methods honor the same runtime contract.
784
+ return new RivetClientWorld(config) as unknown as World;
785
+ }
786
+
787
+ export default createWorld;
788
+ export { registry };