@voyantjs/workflows 0.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.
@@ -0,0 +1,361 @@
1
+ // @voyantjs/workflows/handler
2
+ //
3
+ // The tenant side of the runtime protocol (see docs/runtime-protocol.md §2).
4
+ // The orchestrator invokes `POST /__voyant/workflow-step`; the tenant
5
+ // Worker responds by running the workflow body once (one invocation of
6
+ // the executor) and returning the executor response as JSON.
7
+ //
8
+ // export default { fetch: createStepHandler() }
9
+ //
10
+ // is enough to make a tenant Worker protocol-conformant. Auth is
11
+ // optional at the SDK level: in production, wire the HMAC verifier
12
+ // bundled by `voyant build`; for local dev, leave it unset.
13
+ //
14
+ // The executor's native response shape is returned verbatim — the wire
15
+ // document calls for compensated/compensation_failed to be folded into
16
+ // "failed" for the first draft, but since the draft is not yet locked
17
+ // and the executor-shape already round-trips losslessly, we keep the
18
+ // full discriminated union here. The orchestrator adapter can collapse.
19
+
20
+ import { executeWorkflowStep, type ExecuteWorkflowStepRequest, type ExecuteWorkflowStepResponse, type StepRunner } from "../runtime/executor.js";
21
+
22
+ export { executeWorkflowStep };
23
+ export type { StepRunner, ExecuteWorkflowStepRequest, ExecuteWorkflowStepResponse };
24
+ export type { StepJournalEntry } from "../runtime/journal.js";
25
+ import type { JournalSlice, StepJournalEntry } from "../runtime/journal.js";
26
+ import type { RuntimeEnvironment } from "../runtime/ctx.js";
27
+ import { getWorkflow } from "../workflow.js";
28
+ import type { RunTrigger } from "../types.js";
29
+ import type { RateLimiter } from "../rate-limit/index.js";
30
+ import { PROTOCOL_VERSION, type ProtocolVersion } from "../protocol/index.js";
31
+
32
+ export interface StepHandlerDeps {
33
+ /**
34
+ * Optional. Called before parsing the body. Should throw / reject
35
+ * if the request is not from a trusted orchestrator. In production
36
+ * this verifies the `X-Voyant-Dispatch-Auth` HMAC against a public
37
+ * key embedded by `voyant build`.
38
+ */
39
+ verifyRequest?: (req: Request) => void | Promise<void>;
40
+ /** Injectable clock. Defaults to Date.now. */
41
+ now?: () => number;
42
+ /** Optional structured logger. */
43
+ logger?: (level: "info" | "warn" | "error", msg: string, data?: object) => void;
44
+ /**
45
+ * Rate limiter shared across step invocations. Required when any
46
+ * registered workflow declares `options.rateLimit` on a step; see
47
+ * `createInMemoryRateLimiter` in `@voyantjs/workflows/rate-limit` for
48
+ * the reference impl. One instance per Worker process is the
49
+ * intended cardinality — state is kept in the limiter's closure.
50
+ */
51
+ rateLimiter?: RateLimiter;
52
+ /**
53
+ * Runner for steps declared with `options.runtime === "node"`.
54
+ * Leave unset for handlers that only run edge steps; any node step
55
+ * will then fail with `NODE_RUNTIME_UNAVAILABLE`.
56
+ *
57
+ * Typical impl dispatches to a separate sandboxed context:
58
+ * - Local dev: an in-process passthrough (same Node process).
59
+ * - CF production: a Cloudflare Container binding, via
60
+ * `createCfContainerStepRunner` from `@voyantjs/workflows-orchestrator-cloudflare`.
61
+ *
62
+ * This is bring-your-own because the right dispatch shape depends on
63
+ * the target runtime; the executor only cares that a runner exists.
64
+ */
65
+ nodeStepRunner?: StepRunner;
66
+ }
67
+
68
+ /** The HTTP request body the orchestrator sends. */
69
+ export interface WorkflowStepRequest {
70
+ protocolVersion: ProtocolVersion;
71
+ runId: string;
72
+ workflowId: string;
73
+ workflowVersion: string;
74
+ invocationCount: number;
75
+ input: unknown;
76
+ journal: JournalSlice;
77
+ environment: "production" | "preview" | "development";
78
+ deadline: number;
79
+ tenantMeta: {
80
+ tenantId: string;
81
+ projectId: string;
82
+ organizationId: string;
83
+ projectSlug?: string;
84
+ organizationSlug?: string;
85
+ };
86
+ runMeta: {
87
+ number: number;
88
+ attempt: number;
89
+ triggeredBy: RunTrigger;
90
+ tags: string[];
91
+ startedAt: number;
92
+ };
93
+ }
94
+
95
+ /** The JSON response body the tenant returns. */
96
+ export type WorkflowStepResponse = ExecuteWorkflowStepResponse;
97
+
98
+ /** Error-response envelope used for HTTP 4xx/5xx. */
99
+ export interface StepHandlerError {
100
+ error: string;
101
+ message: string;
102
+ details?: unknown;
103
+ }
104
+
105
+ /** Build an HTTP fetch-style handler. */
106
+ export function createStepHandler(
107
+ deps: StepHandlerDeps = {},
108
+ ): (req: Request) => Promise<Response> {
109
+ return async (req) => {
110
+ if (req.method !== "POST") {
111
+ return jsonResponse(405, errorBody("method_not_allowed", "POST required"));
112
+ }
113
+ try {
114
+ if (deps.verifyRequest) await deps.verifyRequest(req);
115
+ } catch (err) {
116
+ deps.logger?.("warn", "step handler: auth rejected", {
117
+ error: err instanceof Error ? err.message : String(err),
118
+ });
119
+ return jsonResponse(401, errorBody("unauthorized", errMessage(err)));
120
+ }
121
+ let raw: unknown;
122
+ try {
123
+ raw = await req.json();
124
+ } catch (err) {
125
+ return jsonResponse(400, errorBody("invalid_json", errMessage(err)));
126
+ }
127
+ // The incoming Request carries its own AbortSignal; threading it
128
+ // through lets `ctx.signal` observe client-side aborts (orchestrator
129
+ // cancellations, closed fetches, etc.) during step execution.
130
+ const out = await runStepInner(raw, deps, { signal: req.signal });
131
+ return jsonResponse(out.status, out.body);
132
+ };
133
+ }
134
+
135
+ /** Per-invocation options available to callers of the transport-free entry point. */
136
+ export interface StepRequestOptions {
137
+ /** AbortSignal forwarded to `ctx.signal` inside the step body. */
138
+ signal?: AbortSignal;
139
+ /**
140
+ * Fires synchronously from `ctx.stream.*` as each chunk is produced.
141
+ * Used by orchestrators that want to broadcast chunks live
142
+ * (dashboards, queues) before the invocation returns.
143
+ */
144
+ onStreamChunk?: (
145
+ chunk: import("../runtime/executor.js").StreamChunk,
146
+ ) => void;
147
+ }
148
+
149
+ /**
150
+ * Transport-free entry point. Callers that already parsed the body
151
+ * (e.g. local orchestrator in-memory, tests) invoke this directly.
152
+ * Returns either the step response or an error envelope with the HTTP
153
+ * status the caller should use.
154
+ */
155
+ export async function handleStepRequest(
156
+ raw: unknown,
157
+ deps: StepHandlerDeps = {},
158
+ opts: StepRequestOptions = {},
159
+ ): Promise<
160
+ | { status: number; body: WorkflowStepResponse }
161
+ | { status: number; body: StepHandlerError }
162
+ > {
163
+ return runStepInner(raw, deps, opts);
164
+ }
165
+
166
+ async function runStepInner(
167
+ raw: unknown,
168
+ deps: StepHandlerDeps,
169
+ opts: StepRequestOptions = {},
170
+ ): Promise<
171
+ | { status: number; body: WorkflowStepResponse }
172
+ | { status: number; body: StepHandlerError }
173
+ > {
174
+ const parsed = parseRequest(raw);
175
+ if (!parsed.ok) return { status: 400, body: errorBody("invalid_request", parsed.message) };
176
+
177
+ const reqBody = parsed.value;
178
+ if (reqBody.protocolVersion !== PROTOCOL_VERSION) {
179
+ return {
180
+ status: 426,
181
+ body: errorBody(
182
+ "protocol_version_mismatch",
183
+ `tenant supports protocol ${PROTOCOL_VERSION}, got ${String(reqBody.protocolVersion)}`,
184
+ ),
185
+ };
186
+ }
187
+
188
+ const def = getWorkflow(reqBody.workflowId);
189
+ if (!def) {
190
+ return {
191
+ status: 404,
192
+ body: errorBody("workflow_not_found", `workflow "${reqBody.workflowId}" is not registered in this bundle`),
193
+ };
194
+ }
195
+
196
+ const now = deps.now ?? (() => Date.now());
197
+ const stepRunner = createInProcessStepRunner(now);
198
+
199
+ const runtimeEnv: RuntimeEnvironment = {
200
+ run: {
201
+ id: reqBody.runId,
202
+ number: reqBody.runMeta.number,
203
+ attempt: reqBody.runMeta.attempt,
204
+ triggeredBy: reqBody.runMeta.triggeredBy,
205
+ tags: reqBody.runMeta.tags,
206
+ startedAt: reqBody.runMeta.startedAt,
207
+ },
208
+ workflow: { id: reqBody.workflowId, version: reqBody.workflowVersion },
209
+ environment: { name: reqBody.environment },
210
+ project: {
211
+ id: reqBody.tenantMeta.projectId,
212
+ slug: reqBody.tenantMeta.projectSlug ?? reqBody.tenantMeta.projectId,
213
+ },
214
+ organization: {
215
+ id: reqBody.tenantMeta.organizationId,
216
+ slug: reqBody.tenantMeta.organizationSlug ?? reqBody.tenantMeta.organizationId,
217
+ },
218
+ };
219
+
220
+ try {
221
+ const response = await executeWorkflowStep(def, {
222
+ runId: reqBody.runId,
223
+ workflowId: reqBody.workflowId,
224
+ workflowVersion: reqBody.workflowVersion,
225
+ input: reqBody.input,
226
+ journal: reqBody.journal,
227
+ invocationCount: reqBody.invocationCount,
228
+ environment: runtimeEnv,
229
+ triggeredBy: reqBody.runMeta.triggeredBy,
230
+ runStartedAt: reqBody.runMeta.startedAt,
231
+ tags: reqBody.runMeta.tags,
232
+ stepRunner,
233
+ nodeStepRunner: deps.nodeStepRunner,
234
+ rateLimiter: deps.rateLimiter,
235
+ now,
236
+ abortSignal: opts.signal,
237
+ onStreamChunk: opts.onStreamChunk,
238
+ });
239
+ return { status: 200, body: response };
240
+ } catch (err) {
241
+ deps.logger?.("error", "step handler: executor threw", {
242
+ error: err instanceof Error ? err.message : String(err),
243
+ });
244
+ return {
245
+ status: 500,
246
+ body: errorBody("executor_error", errMessage(err)),
247
+ };
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Build a step runner that executes the step body in the same
253
+ * process. Suitable for `runtime: "edge"`. Container-runtime steps
254
+ * will swap this for a dispatching runner that POSTs to a pod.
255
+ */
256
+ function createInProcessStepRunner(now: () => number): StepRunner {
257
+ return async ({ stepId, attempt, fn, stepCtx }): Promise<StepJournalEntry> => {
258
+ const startedAt = now();
259
+ try {
260
+ const output = await fn(stepCtx);
261
+ return {
262
+ attempt,
263
+ status: "ok",
264
+ output,
265
+ startedAt,
266
+ finishedAt: now(),
267
+ };
268
+ } catch (err) {
269
+ const e = err as Error;
270
+ const code = typeof (err as { code?: unknown }).code === "string"
271
+ ? (err as { code: string }).code
272
+ : "UNKNOWN";
273
+ const retryAfter = (err as { retryAfter?: unknown }).retryAfter;
274
+ return {
275
+ attempt,
276
+ status: "err",
277
+ error: {
278
+ category: "USER_ERROR",
279
+ code,
280
+ message: e?.message ?? String(err),
281
+ name: e?.name,
282
+ stack: e?.stack,
283
+ data: retryAfter !== undefined ? { retryAfter } : undefined,
284
+ },
285
+ startedAt,
286
+ finishedAt: now(),
287
+ };
288
+ }
289
+ };
290
+ }
291
+
292
+ // ---- Parsing ----
293
+
294
+ function parseRequest(
295
+ raw: unknown,
296
+ ): { ok: true; value: WorkflowStepRequest } | { ok: false; message: string } {
297
+ if (raw === null || typeof raw !== "object") {
298
+ return { ok: false, message: "body must be a JSON object" };
299
+ }
300
+ const r = raw as Record<string, unknown>;
301
+ const required: (keyof WorkflowStepRequest)[] = [
302
+ "protocolVersion",
303
+ "runId",
304
+ "workflowId",
305
+ "workflowVersion",
306
+ "invocationCount",
307
+ "journal",
308
+ "environment",
309
+ "deadline",
310
+ "tenantMeta",
311
+ "runMeta",
312
+ ];
313
+ for (const k of required) {
314
+ if (!(k in r)) return { ok: false, message: `missing required field "${k}"` };
315
+ }
316
+ if (typeof r.protocolVersion !== "number") {
317
+ return { ok: false, message: "`protocolVersion` must be a number" };
318
+ }
319
+ if (typeof r.runId !== "string" || r.runId.length === 0) {
320
+ return { ok: false, message: "`runId` must be a non-empty string" };
321
+ }
322
+ if (typeof r.workflowId !== "string" || r.workflowId.length === 0) {
323
+ return { ok: false, message: "`workflowId` must be a non-empty string" };
324
+ }
325
+ if (typeof r.invocationCount !== "number" || r.invocationCount < 1) {
326
+ return { ok: false, message: "`invocationCount` must be >= 1" };
327
+ }
328
+ if (!r.journal || typeof r.journal !== "object") {
329
+ return { ok: false, message: "`journal` must be an object" };
330
+ }
331
+ const env = r.environment;
332
+ if (env !== "production" && env !== "preview" && env !== "development") {
333
+ return { ok: false, message: "`environment` must be production | preview | development" };
334
+ }
335
+ if (!r.tenantMeta || typeof r.tenantMeta !== "object") {
336
+ return { ok: false, message: "`tenantMeta` must be an object" };
337
+ }
338
+ if (!r.runMeta || typeof r.runMeta !== "object") {
339
+ return { ok: false, message: "`runMeta` must be an object" };
340
+ }
341
+ return { ok: true, value: r as unknown as WorkflowStepRequest };
342
+ }
343
+
344
+ // ---- Helpers ----
345
+
346
+ function jsonResponse(status: number, body: unknown): Response {
347
+ return new Response(JSON.stringify(body), {
348
+ status,
349
+ headers: { "content-type": "application/json; charset=utf-8" },
350
+ });
351
+ }
352
+
353
+ function errorBody(error: string, message: string, details?: unknown): StepHandlerError {
354
+ const out: StepHandlerError = { error, message };
355
+ if (details !== undefined) out.details = details;
356
+ return out;
357
+ }
358
+
359
+ function errMessage(err: unknown): string {
360
+ return err instanceof Error ? err.message : String(err);
361
+ }
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ // @voyantjs/workflows
2
+ //
3
+ // Authoring SDK for Voyant Workflows. Full contract in:
4
+ // docs/sdk-surface.md §2–§8
5
+ // docs/design.md §3–§4
6
+
7
+ export * from "./types.js";
8
+ export * from "./workflow.js";
9
+ export * from "./trigger.js";
10
+ export * from "./conditions.js";
11
+ export {
12
+ FatalError,
13
+ RetryableError,
14
+ TimeoutError,
15
+ HookConflictError,
16
+ QuotaExceededError,
17
+ ValidationError,
18
+ } from "@voyantjs/workflows-errors";
@@ -0,0 +1,133 @@
1
+ // @voyantjs/workflows/protocol
2
+ //
3
+ // Wire-protocol types shared with the orchestrator. Full contract
4
+ // in docs/runtime-protocol.md; types here are exported so callers
5
+ // (test harness, adapters, dashboards) can build and inspect wire
6
+ // payloads without reaching into runtime internals.
7
+
8
+ export type ProtocolVersion = 1;
9
+ export const PROTOCOL_VERSION: ProtocolVersion = 1;
10
+
11
+ // Journal types: shape of the tenant-side view of a run's state.
12
+ // Re-exported so orchestrators and tools can build/inspect journals
13
+ // without reaching into the runtime subpath.
14
+ export type {
15
+ JournalSlice,
16
+ StepJournalEntry,
17
+ WaitpointResolutionEntry,
18
+ CompensationJournalEntry,
19
+ } from "../runtime/journal.js";
20
+
21
+ export type ExecutionStatus =
22
+ | "CREATED"
23
+ | "QUEUED"
24
+ | "EXECUTING"
25
+ | "EXECUTING_WITH_WAITPOINTS"
26
+ | "SUSPENDED"
27
+ | "PENDING_CANCEL"
28
+ | "FINISHED";
29
+
30
+ export type WaitpointKind = "DATETIME" | "EVENT" | "SIGNAL" | "RUN" | "MANUAL";
31
+
32
+ export interface SerializedError {
33
+ category: "USER_ERROR" | "RUNTIME_ERROR";
34
+ code: string;
35
+ message: string;
36
+ name?: string;
37
+ stack?: string;
38
+ cause?: SerializedError;
39
+ data?: Record<string, unknown>;
40
+ }
41
+
42
+ export type PayloadLocation = "INLINE" | "EXTERNAL";
43
+
44
+ export interface WorkflowManifest {
45
+ schemaVersion: 1;
46
+ projectId: string;
47
+ versionId: string;
48
+ builtAt: number;
49
+ builderVersion: string;
50
+ capabilities: string[];
51
+ workflows: WorkflowManifestEntry[];
52
+ eventFilters: EventFilterManifestEntry[];
53
+ bindings: Record<string, { type: "d1" | "r2" | "kv" | "queue"; name: string }>;
54
+ environments: Record<string, { customDomain?: string }>;
55
+ }
56
+
57
+ export interface WorkflowManifestEntry {
58
+ id: string;
59
+ version: string;
60
+ inputSchema?: unknown;
61
+ outputSchema?: unknown;
62
+ steps: ManifestStep[];
63
+ schedules: ManifestSchedule[];
64
+ defaultRuntime: "edge" | "node";
65
+ hasCompensation: boolean;
66
+ sourceLocation: { file: string; line: number };
67
+ }
68
+
69
+ export interface ManifestStep {
70
+ id: string;
71
+ runtime: "edge" | "node";
72
+ hasCompensation: boolean;
73
+ sourceLocation: { file: string; line: number };
74
+ }
75
+
76
+ export interface ManifestSchedule {
77
+ cron?: string;
78
+ every?: string;
79
+ at?: string;
80
+ timezone?: string;
81
+ environments?: ("production" | "preview" | "development")[];
82
+ name?: string;
83
+ }
84
+
85
+ export interface EventFilterManifestEntry {
86
+ id: string;
87
+ eventType: string;
88
+ scope?: string;
89
+ matchExpression?: string;
90
+ payloadHash: string;
91
+ targetWorkflowId: string;
92
+ }
93
+
94
+ // WebSocket stream events — full union in docs/runtime-protocol.md §6.2.
95
+ export type StreamEvent =
96
+ | { kind: "step.started"; eventId: string; at: number; stepId: string; runtime: "edge" | "node"; machine?: string }
97
+ | { kind: "step.ok"; eventId: string; at: number; stepId: string; attempt: number; durationMs: number; output?: unknown }
98
+ | { kind: "step.err"; eventId: string; at: number; stepId: string; attempt: number; error: SerializedError }
99
+ | { kind: "step.skipped"; eventId: string; at: number; stepId: string; reason: string }
100
+ | { kind: "step.compensated"; eventId: string; at: number; stepId: string; status: "ok" | "err"; error?: SerializedError }
101
+ | { kind: "waitpoint.registered"; eventId: string; at: number; waitpointId: string; waitpointKind: WaitpointKind; meta: Record<string, unknown> }
102
+ | { kind: "waitpoint.resolved"; eventId: string; at: number; waitpointId: string; payload?: unknown; source: "live" | "inbox" | "replay" }
103
+ | { kind: "metadata.changed"; eventId: string; at: number; metadata: Record<string, unknown> }
104
+ | { kind: "stream.chunk"; eventId: string; at: number; streamId: string; chunk: unknown; encoding: "json" | "text" | "base64"; final: boolean }
105
+ | { kind: "log"; eventId: string; at: number; level: "info" | "warn" | "error"; message: string; stepId?: string; data?: object }
106
+ | { kind: "version.rebased"; eventId: string; at: number; fromVersion: string; toVersion: string }
107
+ | { kind: "run.cancelled"; eventId: string; at: number; reason?: string }
108
+ | { kind: "run.finished"; eventId: string; at: number; status: string; output?: unknown; error?: SerializedError };
109
+
110
+ // Shared envelope for journal events written by the orchestrator,
111
+ // the tenant worker, or a node-runtime container. Concrete `kind`
112
+ // discriminants are owned by the emitting layer.
113
+ export interface JournalEventEnvelope<TKind extends string = string, TData = unknown> {
114
+ eventId: string;
115
+ runId: string;
116
+ createdAt: number;
117
+ kind: TKind;
118
+ data: TData;
119
+ snapshotId?: string;
120
+ writtenBy: "orchestrator" | "tenant" | "node";
121
+ }
122
+
123
+ export interface PublicAccessTokenClaims {
124
+ sub: "pat";
125
+ tenantId: string;
126
+ environment: "production" | "preview" | "development";
127
+ scope: ("read" | "trigger" | "cancel")[];
128
+ target:
129
+ | { kind: "run"; runId: string }
130
+ | { kind: "workflow"; workflowId: string }
131
+ | { kind: "tag"; tag: string };
132
+ exp: number;
133
+ }
@@ -0,0 +1,182 @@
1
+ // @voyantjs/workflows/rate-limit
2
+ //
3
+ // Reference rate limiter used by `ctx.step({ rateLimit: ... })`.
4
+ //
5
+ // A RateLimiter is a small interface: `acquire()` blocks (or throws,
6
+ // depending on `onLimit`) until `units` are available under `key` for
7
+ // a sliding window of `windowMs`. One shared limiter instance lives
8
+ // per tenant Worker process — callers wire it into `createStepHandler`
9
+ // via `{ rateLimiter: createInMemoryRateLimiter() }`.
10
+ //
11
+ // The in-memory impl is a token bucket: `capacity = limit`, refill
12
+ // rate = `limit / windowMs`. It's suitable for local dev and
13
+ // single-process deployments. Multi-region / sharded deployments
14
+ // should swap in a Durable-Object or Redis-backed implementation that
15
+ // shares state across isolates.
16
+
17
+ import type { Duration } from "../types.js";
18
+
19
+ export interface AcquireArgs {
20
+ /** Bucket key — usually a tenant id, a url host, a user id, etc. */
21
+ key: string;
22
+ /** Maximum units the bucket can hold. */
23
+ limit: number;
24
+ /** Units the current call consumes. */
25
+ units: number;
26
+ /** Refill window in ms. `limit` tokens per `windowMs`. */
27
+ windowMs: number;
28
+ /** `queue` → wait until capacity; `fail` → throw immediately. */
29
+ onLimit: "queue" | "fail";
30
+ /** Forwarded from the run; limiter observes aborts during queue waits. */
31
+ signal?: AbortSignal;
32
+ }
33
+
34
+ export interface RateLimiter {
35
+ acquire(args: AcquireArgs): Promise<void>;
36
+ }
37
+
38
+ /** Error thrown when `onLimit === "fail"` and the bucket is empty. */
39
+ export class RateLimitExceededError extends Error {
40
+ readonly code = "RATE_LIMITED";
41
+ readonly retryAfterMs: number;
42
+ constructor(key: string, retryAfterMs: number) {
43
+ super(`rate limit exceeded for key "${key}" (retry after ${retryAfterMs}ms)`);
44
+ this.name = "RateLimitExceededError";
45
+ this.retryAfterMs = retryAfterMs;
46
+ }
47
+ }
48
+
49
+ interface Bucket {
50
+ tokens: number;
51
+ capacity: number;
52
+ refillPerMs: number;
53
+ lastRefillAt: number;
54
+ }
55
+
56
+ export interface InMemoryLimiterOptions {
57
+ /** Injectable clock, ms since epoch. Defaults to Date.now. */
58
+ now?: () => number;
59
+ /** Injectable delay; defaults to setTimeout. Tests override this. */
60
+ delay?: (ms: number, signal?: AbortSignal) => Promise<void>;
61
+ }
62
+
63
+ /**
64
+ * Token-bucket rate limiter held in-process. Independent buckets per
65
+ * `key`; bucket parameters (`capacity`, `refillPerMs`) come from the
66
+ * `limit` / `windowMs` of the first `acquire` call and are updated on
67
+ * subsequent calls that change them.
68
+ */
69
+ export function createInMemoryRateLimiter(
70
+ opts: InMemoryLimiterOptions = {},
71
+ ): RateLimiter {
72
+ const now = opts.now ?? (() => Date.now());
73
+ const delay = opts.delay ?? defaultDelay;
74
+ const buckets = new Map<string, Bucket>();
75
+
76
+ return {
77
+ async acquire(args) {
78
+ if (args.units <= 0) return;
79
+ if (args.limit <= 0) {
80
+ throw new Error(
81
+ `rate-limit: "limit" must be > 0 (got ${args.limit}) for key "${args.key}"`,
82
+ );
83
+ }
84
+ if (args.windowMs <= 0) {
85
+ throw new Error(
86
+ `rate-limit: "windowMs" must be > 0 (got ${args.windowMs}) for key "${args.key}"`,
87
+ );
88
+ }
89
+ if (args.units > args.limit) {
90
+ // The step will never be admissible — short-circuit regardless
91
+ // of onLimit to avoid hanging indefinitely under queue mode.
92
+ throw new Error(
93
+ `rate-limit: units (${args.units}) > limit (${args.limit}) for key "${args.key}" — step can never be admitted`,
94
+ );
95
+ }
96
+
97
+ while (true) {
98
+ const bucket = refill(buckets, args, now());
99
+ if (bucket.tokens >= args.units) {
100
+ bucket.tokens -= args.units;
101
+ return;
102
+ }
103
+ const missing = args.units - bucket.tokens;
104
+ const waitMs = Math.ceil(missing / bucket.refillPerMs);
105
+ if (args.onLimit === "fail") {
106
+ throw new RateLimitExceededError(args.key, waitMs);
107
+ }
108
+ await delay(waitMs, args.signal);
109
+ if (args.signal?.aborted) {
110
+ throw args.signal.reason ?? new Error("rate-limit: aborted while queued");
111
+ }
112
+ }
113
+ },
114
+ };
115
+ }
116
+
117
+ function refill(
118
+ buckets: Map<string, Bucket>,
119
+ args: AcquireArgs,
120
+ nowMs: number,
121
+ ): Bucket {
122
+ const refillPerMs = args.limit / args.windowMs;
123
+ let b = buckets.get(args.key);
124
+ if (!b) {
125
+ b = {
126
+ tokens: args.limit,
127
+ capacity: args.limit,
128
+ refillPerMs,
129
+ lastRefillAt: nowMs,
130
+ };
131
+ buckets.set(args.key, b);
132
+ return b;
133
+ }
134
+ // Re-parameterize if the caller changed limit / windowMs. Clamp
135
+ // tokens to the new capacity so a shrink doesn't leave stale excess.
136
+ if (b.capacity !== args.limit || b.refillPerMs !== refillPerMs) {
137
+ b.capacity = args.limit;
138
+ b.refillPerMs = refillPerMs;
139
+ if (b.tokens > b.capacity) b.tokens = b.capacity;
140
+ }
141
+ const elapsed = Math.max(0, nowMs - b.lastRefillAt);
142
+ if (elapsed > 0) {
143
+ b.tokens = Math.min(b.capacity, b.tokens + elapsed * b.refillPerMs);
144
+ b.lastRefillAt = nowMs;
145
+ }
146
+ return b;
147
+ }
148
+
149
+ function defaultDelay(ms: number, signal?: AbortSignal): Promise<void> {
150
+ return new Promise((resolve, reject) => {
151
+ if (signal?.aborted) {
152
+ reject(signal.reason ?? new Error("aborted"));
153
+ return;
154
+ }
155
+ const timer = setTimeout(() => {
156
+ signal?.removeEventListener("abort", onAbort);
157
+ resolve();
158
+ }, ms);
159
+ const onAbort = () => {
160
+ clearTimeout(timer);
161
+ reject(signal?.reason ?? new Error("aborted"));
162
+ };
163
+ signal?.addEventListener("abort", onAbort, { once: true });
164
+ });
165
+ }
166
+
167
+ /** Normalize a Duration to milliseconds. Same units as `toMs` in ctx.ts. */
168
+ export function durationToMs(d: Duration): number {
169
+ if (typeof d === "number") return d;
170
+ const m = /^(\d+)(ms|s|m|h|d|w)$/.exec(d);
171
+ if (!m) throw new Error(`rate-limit: invalid duration "${d}"`);
172
+ const n = Number(m[1]);
173
+ switch (m[2]) {
174
+ case "ms": return n;
175
+ case "s": return n * 1_000;
176
+ case "m": return n * 60_000;
177
+ case "h": return n * 3_600_000;
178
+ case "d": return n * 86_400_000;
179
+ case "w": return n * 604_800_000;
180
+ default: throw new Error(`rate-limit: invalid duration "${d}"`);
181
+ }
182
+ }