unfazed 0.5.0 → 0.6.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/README.md +321 -0
- package/dist/client.d.ts +1 -1
- package/dist/client.js +1 -1
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +25 -0
- package/dist/errors.js +25 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +21 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -1
- package/dist/remote/app.d.ts +18 -0
- package/dist/remote/app.js +76 -0
- package/dist/remote/app.js.map +1 -0
- package/dist/remote/documents.d.ts +175 -0
- package/dist/remote/documents.js +122 -0
- package/dist/remote/documents.js.map +1 -0
- package/dist/remote/values.d.ts +67 -0
- package/dist/remote/values.js +156 -0
- package/dist/remote/values.js.map +1 -0
- package/dist/remote/world.d.ts +50 -0
- package/dist/remote/world.js +612 -0
- package/dist/remote/world.js.map +1 -0
- package/dist/workflow/config.d.ts +23 -0
- package/dist/workflow/config.js +48 -0
- package/dist/workflow/config.js.map +1 -0
- package/dist/workflow/errors.d.ts +33 -0
- package/dist/workflow/errors.js +16 -0
- package/dist/workflow/errors.js.map +1 -0
- package/dist/workflow/index.d.ts +30 -0
- package/dist/workflow/index.js +23 -0
- package/dist/workflow/index.js.map +1 -0
- package/dist/workflow/inspect-render.d.ts +93 -0
- package/dist/workflow/inspect-render.js +302 -0
- package/dist/workflow/inspect-render.js.map +1 -0
- package/dist/workflow/inspect.d.ts +52 -0
- package/dist/workflow/inspect.js +879 -0
- package/dist/workflow/inspect.js.map +1 -0
- package/dist/workflow/migration-files.d.ts +13 -0
- package/dist/workflow/migration-files.js +52 -0
- package/dist/workflow/migration-files.js.map +1 -0
- package/dist/workflow/migration-types.d.ts +19 -0
- package/dist/workflow/migration-types.js +8 -0
- package/dist/workflow/migration-types.js.map +1 -0
- package/dist/workflow/migrations.d.ts +12 -0
- package/dist/workflow/migrations.generated.d.ts +2 -0
- package/dist/workflow/migrations.generated.js +34 -0
- package/dist/workflow/migrations.generated.js.map +1 -0
- package/dist/workflow/migrations.js +94 -0
- package/dist/workflow/migrations.js.map +1 -0
- package/dist/workflow/nitro-flow-guard.d.ts +4 -0
- package/dist/workflow/nitro-flow-guard.js +19 -0
- package/dist/workflow/nitro-flow-guard.js.map +1 -0
- package/dist/workflow/nitro-inspect.d.ts +3 -0
- package/dist/workflow/nitro-inspect.js +19 -0
- package/dist/workflow/nitro-inspect.js.map +1 -0
- package/dist/workflow/nitro-plugin.d.ts +1 -0
- package/dist/workflow/nitro-plugin.js +13 -0
- package/dist/workflow/nitro-plugin.js.map +1 -0
- package/dist/workflow/nitro.d.ts +70 -0
- package/dist/workflow/nitro.js +213 -0
- package/dist/workflow/nitro.js.map +1 -0
- package/dist/workflow/public-url.d.ts +27 -0
- package/dist/workflow/public-url.js +45 -0
- package/dist/workflow/public-url.js.map +1 -0
- package/dist/workflow/queue.d.ts +128 -0
- package/dist/workflow/queue.js +375 -0
- package/dist/workflow/queue.js.map +1 -0
- package/dist/workflow/state.d.ts +25 -0
- package/dist/workflow/state.js +15 -0
- package/dist/workflow/state.js.map +1 -0
- package/dist/workflow/transport.d.ts +12 -0
- package/dist/workflow/transport.js +29 -0
- package/dist/workflow/transport.js.map +1 -0
- package/dist/workflow/world.d.ts +123 -0
- package/dist/workflow/world.js +375 -0
- package/dist/workflow/world.js.map +1 -0
- package/package.json +66 -5
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { type MessageId, type QueueBatchResult, type QueueOptions, type QueuePrefix, ValidQueueName } from "@workflow/world";
|
|
2
|
+
/** Path of the queue API under `UNFZ_STATE_URL`. */
|
|
3
|
+
export declare const QUEUE_API_PATH = "/queue/v1";
|
|
4
|
+
export declare const MAX_MESSAGES_PER_REQUEST = 100;
|
|
5
|
+
export declare const MAX_REQUEST_BYTES: number;
|
|
6
|
+
export declare const MAX_PAYLOAD_BYTES: number;
|
|
7
|
+
/** 365 days, the longest `delay_seconds` and `timeoutSeconds` the platform takes. */
|
|
8
|
+
export declare const MAX_DELAY_SECONDS = 31536000;
|
|
9
|
+
export declare const MAX_IDEMPOTENCY_KEY_BYTES = 256;
|
|
10
|
+
export declare const MAX_QUEUE_NAME_BYTES = 1024;
|
|
11
|
+
export declare const MAX_ROUTE_PATH_BYTES = 2048;
|
|
12
|
+
/** Delivery headers, lower-cased. */
|
|
13
|
+
export declare const DELIVERY_HEADERS: {
|
|
14
|
+
readonly trigger: "x-unfz-trigger";
|
|
15
|
+
readonly token: "x-unfz-queue-token";
|
|
16
|
+
readonly queueName: "x-unfz-queue-name";
|
|
17
|
+
readonly messageId: "x-unfz-queue-message-id";
|
|
18
|
+
readonly attempt: "x-unfz-queue-attempt";
|
|
19
|
+
readonly deliveryCount: "x-unfz-queue-delivery-count";
|
|
20
|
+
readonly createdAt: "x-unfz-queue-created-at";
|
|
21
|
+
/** When the platform stops waiting: 15 minutes after the VM was ready to take the request. */
|
|
22
|
+
readonly deadline: "x-unfz-queue-deadline";
|
|
23
|
+
};
|
|
24
|
+
/** One message as `POST /queue/v1/messages` takes it. */
|
|
25
|
+
export interface WireMessage {
|
|
26
|
+
queue_name: string;
|
|
27
|
+
payload: string;
|
|
28
|
+
content_type?: string;
|
|
29
|
+
delay_seconds?: number;
|
|
30
|
+
idempotency_key?: string;
|
|
31
|
+
}
|
|
32
|
+
/** The subset of `fetch` the queue client needs; injectable for tests. */
|
|
33
|
+
export type WorkflowFetch = (input: string, init: {
|
|
34
|
+
method: string;
|
|
35
|
+
headers: Record<string, string>;
|
|
36
|
+
body?: string;
|
|
37
|
+
signal?: AbortSignal;
|
|
38
|
+
}) => Promise<{
|
|
39
|
+
status: number;
|
|
40
|
+
headers: {
|
|
41
|
+
get(name: string): string | null;
|
|
42
|
+
};
|
|
43
|
+
text(): Promise<string>;
|
|
44
|
+
}>;
|
|
45
|
+
export interface QueueClientOptions {
|
|
46
|
+
/** `UNFZ_STATE_URL`. */
|
|
47
|
+
stateUrl: string | undefined;
|
|
48
|
+
/** `UNFZ_STATE_TOKEN`. */
|
|
49
|
+
stateToken: string | undefined;
|
|
50
|
+
fetch: WorkflowFetch;
|
|
51
|
+
/** How long one call may keep retrying 503s and network failures. */
|
|
52
|
+
retryBudgetMs: number;
|
|
53
|
+
/** Timeout of a single HTTP attempt. */
|
|
54
|
+
requestTimeoutMs: number;
|
|
55
|
+
}
|
|
56
|
+
export interface QueueClient {
|
|
57
|
+
/** `POST /messages`: stores all of `messages` or none, in input order. */
|
|
58
|
+
enqueue(messages: WireMessage[]): Promise<{
|
|
59
|
+
message_id: string;
|
|
60
|
+
duplicate: boolean;
|
|
61
|
+
}[]>;
|
|
62
|
+
/** `PUT /routes`. */
|
|
63
|
+
putRoute(prefix: string, path: string): Promise<void>;
|
|
64
|
+
/** `GET /stats`; a 200 proves the queue works for this app. */
|
|
65
|
+
stats(): Promise<QueueStats>;
|
|
66
|
+
}
|
|
67
|
+
/** `GET /queue/v1/stats`. */
|
|
68
|
+
export interface QueueStats {
|
|
69
|
+
ready: number;
|
|
70
|
+
due: number;
|
|
71
|
+
leased: number;
|
|
72
|
+
dead: number;
|
|
73
|
+
oldest_due_at: string | null;
|
|
74
|
+
bytes: number;
|
|
75
|
+
/** Dead messages dropped over the dead cap (10 000 or 16 MiB, oldest first). */
|
|
76
|
+
dead_evicted?: number;
|
|
77
|
+
}
|
|
78
|
+
export declare function createQueueClient(options: QueueClientOptions): QueueClient;
|
|
79
|
+
/** Builds the wire form of one message; throws on a payload over 1 MiB. */
|
|
80
|
+
export declare function toWireMessage(queueName: string, message: unknown, opts?: QueueOptions): WireMessage;
|
|
81
|
+
/**
|
|
82
|
+
* Every message gets a key, so retrying a 503 or a lost response can never
|
|
83
|
+
* store it twice. A key over the platform's 256-byte limit is replaced by its
|
|
84
|
+
* SHA-256, which keeps it deterministic.
|
|
85
|
+
*/
|
|
86
|
+
export declare function idempotencyKey(key: string | undefined): string;
|
|
87
|
+
/** Splits wire messages into requests of at most 100 messages and ~4 MiB. */
|
|
88
|
+
export declare function chunkMessages<T extends {
|
|
89
|
+
wire: WireMessage;
|
|
90
|
+
}>(entries: T[]): T[][];
|
|
91
|
+
/**
|
|
92
|
+
* `queueBatch`: one request per chunk, in parallel. A chunk is atomic on the
|
|
93
|
+
* platform, so a failed chunk fails exactly its own entries; a message that
|
|
94
|
+
* cannot be encoded fails alone. Never rejects.
|
|
95
|
+
*/
|
|
96
|
+
export declare function enqueueBatch(client: QueueClient, queueName: string, messages: readonly {
|
|
97
|
+
message: unknown;
|
|
98
|
+
opts?: QueueOptions;
|
|
99
|
+
}[]): Promise<QueueBatchResult[]>;
|
|
100
|
+
/** What the runtime's handler receives besides the message. */
|
|
101
|
+
export interface DeliveryMeta {
|
|
102
|
+
attempt: number;
|
|
103
|
+
deliveryCount?: number;
|
|
104
|
+
createdAt?: Date;
|
|
105
|
+
queueName: ValidQueueName;
|
|
106
|
+
messageId: MessageId;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* `createQueueHandler` for platform deliveries.
|
|
110
|
+
*
|
|
111
|
+
* Refuses a request without the right `X-Unfz-Queue-Token` with 401 before it
|
|
112
|
+
* looks at anything else, so the route serves the internet nothing but that
|
|
113
|
+
* refusal (public ingress strips the header, so only the platform can send
|
|
114
|
+
* it). A message outside `prefix` answers 400, which the platform retries,
|
|
115
|
+
* rather than run under the wrong consumer.
|
|
116
|
+
*
|
|
117
|
+
* Every answer is a complete JSON body with status 200, 400, 401 or 500,
|
|
118
|
+
* never a redirect: the platform follows no redirects and retries a 3xx or a
|
|
119
|
+
* 2xx whose body breaks off. `{ timeoutSeconds }` is passed on as the runtime
|
|
120
|
+
* asked; the platform waits at least 1 s.
|
|
121
|
+
*/
|
|
122
|
+
export declare function createDeliveryHandler(expectedToken: string | undefined, prefix: QueuePrefix, handler: (message: unknown, meta: DeliveryMeta) => Promise<unknown>): (req: Request) => Promise<Response>;
|
|
123
|
+
/** Constant-time comparison of the delivery token against `UNFZ_STATE_TOKEN`. */
|
|
124
|
+
export declare function verifyQueueToken(expected: string | undefined, presented: string | null): boolean;
|
|
125
|
+
/** The deadline of the delivery being served in this async context, if any. */
|
|
126
|
+
export declare function currentDeliveryDeadline(): Date | undefined;
|
|
127
|
+
export declare function byteLength(text: string): number;
|
|
128
|
+
export declare function describe(error: unknown): string;
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { ValidQueueName, } from "@workflow/world";
|
|
3
|
+
import { UnfazedWorkflowError } from "./errors.js";
|
|
4
|
+
import { logOnce, processState } from "./state.js";
|
|
5
|
+
import { decodeMessage, encodeMessage, MESSAGE_CONTENT_TYPE, toBase64 } from "./transport.js";
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Wire contract v1 (unfazed docs/workflow-world.md, "Wire contract (v1)").
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
/** Path of the queue API under `UNFZ_STATE_URL`. */
|
|
10
|
+
export const QUEUE_API_PATH = "/queue/v1";
|
|
11
|
+
export const MAX_MESSAGES_PER_REQUEST = 100;
|
|
12
|
+
export const MAX_REQUEST_BYTES = 4 * 1024 * 1024;
|
|
13
|
+
export const MAX_PAYLOAD_BYTES = 1024 * 1024;
|
|
14
|
+
/** 365 days, the longest `delay_seconds` and `timeoutSeconds` the platform takes. */
|
|
15
|
+
export const MAX_DELAY_SECONDS = 31_536_000;
|
|
16
|
+
export const MAX_IDEMPOTENCY_KEY_BYTES = 256;
|
|
17
|
+
export const MAX_QUEUE_NAME_BYTES = 1024;
|
|
18
|
+
export const MAX_ROUTE_PATH_BYTES = 2048;
|
|
19
|
+
/** Delivery headers, lower-cased. */
|
|
20
|
+
export const DELIVERY_HEADERS = {
|
|
21
|
+
trigger: "x-unfz-trigger",
|
|
22
|
+
token: "x-unfz-queue-token",
|
|
23
|
+
queueName: "x-unfz-queue-name",
|
|
24
|
+
messageId: "x-unfz-queue-message-id",
|
|
25
|
+
attempt: "x-unfz-queue-attempt",
|
|
26
|
+
deliveryCount: "x-unfz-queue-delivery-count",
|
|
27
|
+
createdAt: "x-unfz-queue-created-at",
|
|
28
|
+
/** When the platform stops waiting: 15 minutes after the VM was ready to take the request. */
|
|
29
|
+
deadline: "x-unfz-queue-deadline",
|
|
30
|
+
};
|
|
31
|
+
const NO_PLATFORM_HINT = "unfazed/workflow delivers workflow messages through the unfazed platform queue, which " +
|
|
32
|
+
"exists only inside an unfazed service (the platform injects UNFZ_STATE_URL and " +
|
|
33
|
+
"UNFZ_STATE_TOKEN). For local development run `nitro dev`, which uses the Local World.";
|
|
34
|
+
export function createQueueClient(options) {
|
|
35
|
+
const base = options.stateUrl?.replace(/\/+$/, "");
|
|
36
|
+
function platform(what) {
|
|
37
|
+
if (!base || !options.stateToken) {
|
|
38
|
+
const missing = [!base && "UNFZ_STATE_URL", !options.stateToken && "UNFZ_STATE_TOKEN"]
|
|
39
|
+
.filter(Boolean)
|
|
40
|
+
.join(" and ");
|
|
41
|
+
throw new UnfazedWorkflowError("no_platform", `${what}: ${missing} not set. ${NO_PLATFORM_HINT}`);
|
|
42
|
+
}
|
|
43
|
+
return { base, token: options.stateToken };
|
|
44
|
+
}
|
|
45
|
+
async function call(method, path, body, what) {
|
|
46
|
+
const { base, token } = platform(what);
|
|
47
|
+
const url = `${base}${QUEUE_API_PATH}${path}`;
|
|
48
|
+
const giveUpAt = Date.now() + options.retryBudgetMs;
|
|
49
|
+
let backoffMs = 250;
|
|
50
|
+
for (;;) {
|
|
51
|
+
let status;
|
|
52
|
+
let text;
|
|
53
|
+
let retryAfter;
|
|
54
|
+
try {
|
|
55
|
+
const response = await options.fetch(url, {
|
|
56
|
+
method,
|
|
57
|
+
headers: {
|
|
58
|
+
authorization: `Bearer ${token}`,
|
|
59
|
+
accept: "application/json",
|
|
60
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
61
|
+
},
|
|
62
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
63
|
+
signal: AbortSignal.timeout(options.requestTimeoutMs),
|
|
64
|
+
});
|
|
65
|
+
status = response.status;
|
|
66
|
+
retryAfter = response.headers.get("retry-after");
|
|
67
|
+
text = await response.text();
|
|
68
|
+
}
|
|
69
|
+
catch (cause) {
|
|
70
|
+
// A network failure (the state plane restarting in a worker release
|
|
71
|
+
// switch) or a lost response. Retrying is safe: every message carries
|
|
72
|
+
// an idempotency key and route writes are idempotent.
|
|
73
|
+
if (Date.now() + backoffMs > giveUpAt) {
|
|
74
|
+
throw new UnfazedWorkflowError("unavailable", `${what}: the platform queue at ${base} did not answer (${describe(cause)})`, { retryable: true, cause });
|
|
75
|
+
}
|
|
76
|
+
await sleep(backoffMs);
|
|
77
|
+
backoffMs = Math.min(backoffMs * 2, 5_000);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (status >= 200 && status < 300)
|
|
81
|
+
return text;
|
|
82
|
+
if (status === 503) {
|
|
83
|
+
// A placement move or worker handover: wait as told, then retry with
|
|
84
|
+
// the same idempotency keys.
|
|
85
|
+
const waitMs = retryAfterMs(retryAfter) ?? backoffMs;
|
|
86
|
+
if (Date.now() + waitMs <= giveUpAt) {
|
|
87
|
+
await sleep(waitMs);
|
|
88
|
+
backoffMs = Math.min(backoffMs * 2, 5_000);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
throw errorFromResponse(status, text, what, base);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
async enqueue(messages) {
|
|
97
|
+
const text = await call("POST", "/messages", { messages }, "enqueue");
|
|
98
|
+
const parsed = parseJson(text);
|
|
99
|
+
const results = parsed?.messages;
|
|
100
|
+
if (!Array.isArray(results) ||
|
|
101
|
+
results.length !== messages.length ||
|
|
102
|
+
results.some((r) => typeof r?.message_id !== "string")) {
|
|
103
|
+
throw new UnfazedWorkflowError("http_error", `enqueue: the platform answered 200 without one message_id per message: ${truncate(text)}`, { retryable: true });
|
|
104
|
+
}
|
|
105
|
+
return results.map((r) => ({ message_id: r.message_id, duplicate: r.duplicate === true }));
|
|
106
|
+
},
|
|
107
|
+
async putRoute(prefix, path) {
|
|
108
|
+
await call("PUT", "/routes", { prefix, path }, `register route ${prefix} -> ${path}`);
|
|
109
|
+
},
|
|
110
|
+
async stats() {
|
|
111
|
+
const text = await call("GET", "/stats", undefined, "queue probe");
|
|
112
|
+
return parseJson(text);
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function errorFromResponse(status, text, what, base) {
|
|
117
|
+
const body = parseJson(text);
|
|
118
|
+
const detail = typeof body?.message === "string" ? body.message : truncate(text);
|
|
119
|
+
const fail = (code, message, retryable = false) => new UnfazedWorkflowError(code, `${what}: ${message}`, { status, retryable });
|
|
120
|
+
switch (status) {
|
|
121
|
+
case 400:
|
|
122
|
+
return fail("invalid_request", `the platform refused the request (400): ${detail}`);
|
|
123
|
+
case 401:
|
|
124
|
+
return fail("unauthorized", "the platform refused UNFZ_STATE_TOKEN (401)");
|
|
125
|
+
case 404:
|
|
126
|
+
return fail("queue_not_supported", `this unfazed worker has no workflow queue yet (404 from ${base}${QUEUE_API_PATH}). ` +
|
|
127
|
+
"Durable workflows need a worker release that ships the platform queue; " +
|
|
128
|
+
"they cannot run on this worker until it is upgraded.");
|
|
129
|
+
case 409:
|
|
130
|
+
return fail("queue_unavailable", `this app's queue cannot run here (409): ${detail}`);
|
|
131
|
+
case 413:
|
|
132
|
+
return fail("payload_too_large", `message too large (413): ${detail}`);
|
|
133
|
+
case 429:
|
|
134
|
+
// The platform's message names the limit: 100 000 pending messages,
|
|
135
|
+
// 64 MiB of live messages, or 200 000 idempotency keys.
|
|
136
|
+
return fail("quota_exceeded", `queue quota exceeded (429): ${detail}`, true);
|
|
137
|
+
case 503:
|
|
138
|
+
return fail("unavailable", `the platform queue stayed unavailable (503): ${detail}`, true);
|
|
139
|
+
default:
|
|
140
|
+
return fail("http_error", `unexpected HTTP ${status}: ${detail}`, status >= 500);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Producing: QueueOptions → wire messages.
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
/** Builds the wire form of one message; throws on a payload over 1 MiB. */
|
|
147
|
+
export function toWireMessage(queueName, message, opts) {
|
|
148
|
+
if (byteLength(queueName) > MAX_QUEUE_NAME_BYTES || queueName.length === 0) {
|
|
149
|
+
throw new UnfazedWorkflowError("invalid_request", `queue name must be 1 to ${MAX_QUEUE_NAME_BYTES} bytes`);
|
|
150
|
+
}
|
|
151
|
+
const bytes = encodeMessage(message);
|
|
152
|
+
if (bytes.byteLength > MAX_PAYLOAD_BYTES) {
|
|
153
|
+
throw new UnfazedWorkflowError("payload_too_large", `queue message for ${queueName} is ${bytes.byteLength} bytes; the platform takes at most ${MAX_PAYLOAD_BYTES}`);
|
|
154
|
+
}
|
|
155
|
+
const wire = {
|
|
156
|
+
queue_name: queueName,
|
|
157
|
+
payload: toBase64(bytes),
|
|
158
|
+
content_type: MESSAGE_CONTENT_TYPE,
|
|
159
|
+
idempotency_key: idempotencyKey(opts?.idempotencyKey),
|
|
160
|
+
};
|
|
161
|
+
const delay = opts?.delaySeconds;
|
|
162
|
+
if (typeof delay === "number" && Number.isFinite(delay) && delay > 0) {
|
|
163
|
+
// Longer sleeps wake at the cap; the runtime sees the wait unfinished and
|
|
164
|
+
// answers with the remaining `timeoutSeconds`.
|
|
165
|
+
wire.delay_seconds = Math.min(delay, MAX_DELAY_SECONDS);
|
|
166
|
+
}
|
|
167
|
+
return wire;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Every message gets a key, so retrying a 503 or a lost response can never
|
|
171
|
+
* store it twice. A key over the platform's 256-byte limit is replaced by its
|
|
172
|
+
* SHA-256, which keeps it deterministic.
|
|
173
|
+
*/
|
|
174
|
+
export function idempotencyKey(key) {
|
|
175
|
+
if (key === undefined || key.length === 0)
|
|
176
|
+
return `rnd:${randomUUID()}`;
|
|
177
|
+
if (byteLength(key) <= MAX_IDEMPOTENCY_KEY_BYTES)
|
|
178
|
+
return key;
|
|
179
|
+
return `sha256:${createHash("sha256").update(key).digest("hex")}`;
|
|
180
|
+
}
|
|
181
|
+
/** Splits wire messages into requests of at most 100 messages and ~4 MiB. */
|
|
182
|
+
export function chunkMessages(entries) {
|
|
183
|
+
const budget = MAX_REQUEST_BYTES - 64 * 1024; // the envelope and headroom
|
|
184
|
+
const chunks = [];
|
|
185
|
+
let current = [];
|
|
186
|
+
let size = 0;
|
|
187
|
+
for (const entry of entries) {
|
|
188
|
+
const entrySize = byteLength(JSON.stringify(entry.wire)) + 1;
|
|
189
|
+
if (current.length > 0 && (current.length >= MAX_MESSAGES_PER_REQUEST || size + entrySize > budget)) {
|
|
190
|
+
chunks.push(current);
|
|
191
|
+
current = [];
|
|
192
|
+
size = 0;
|
|
193
|
+
}
|
|
194
|
+
current.push(entry);
|
|
195
|
+
size += entrySize;
|
|
196
|
+
}
|
|
197
|
+
if (current.length > 0)
|
|
198
|
+
chunks.push(current);
|
|
199
|
+
return chunks;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* `queueBatch`: one request per chunk, in parallel. A chunk is atomic on the
|
|
203
|
+
* platform, so a failed chunk fails exactly its own entries; a message that
|
|
204
|
+
* cannot be encoded fails alone. Never rejects.
|
|
205
|
+
*/
|
|
206
|
+
export async function enqueueBatch(client, queueName, messages) {
|
|
207
|
+
const results = new Array(messages.length);
|
|
208
|
+
const entries = [];
|
|
209
|
+
messages.forEach((entry, index) => {
|
|
210
|
+
try {
|
|
211
|
+
entries.push({ index, wire: toWireMessage(queueName, entry.message, entry.opts) });
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
results[index] = { messageId: null, error: describe(error), retryable: false };
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
await Promise.all(chunkMessages(entries).map(async (chunk) => {
|
|
218
|
+
try {
|
|
219
|
+
const stored = await client.enqueue(chunk.map((entry) => entry.wire));
|
|
220
|
+
chunk.forEach((entry, i) => {
|
|
221
|
+
results[entry.index] = { messageId: stored[i].message_id };
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
const retryable = error instanceof UnfazedWorkflowError ? error.retryable : true;
|
|
226
|
+
for (const entry of chunk) {
|
|
227
|
+
results[entry.index] = { messageId: null, error: describe(error), retryable };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}));
|
|
231
|
+
return results;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* `createQueueHandler` for platform deliveries.
|
|
235
|
+
*
|
|
236
|
+
* Refuses a request without the right `X-Unfz-Queue-Token` with 401 before it
|
|
237
|
+
* looks at anything else, so the route serves the internet nothing but that
|
|
238
|
+
* refusal (public ingress strips the header, so only the platform can send
|
|
239
|
+
* it). A message outside `prefix` answers 400, which the platform retries,
|
|
240
|
+
* rather than run under the wrong consumer.
|
|
241
|
+
*
|
|
242
|
+
* Every answer is a complete JSON body with status 200, 400, 401 or 500,
|
|
243
|
+
* never a redirect: the platform follows no redirects and retries a 3xx or a
|
|
244
|
+
* 2xx whose body breaks off. `{ timeoutSeconds }` is passed on as the runtime
|
|
245
|
+
* asked; the platform waits at least 1 s.
|
|
246
|
+
*/
|
|
247
|
+
export function createDeliveryHandler(expectedToken, prefix, handler) {
|
|
248
|
+
return async (req) => {
|
|
249
|
+
if (!verifyQueueToken(expectedToken, req.headers.get(DELIVERY_HEADERS.token))) {
|
|
250
|
+
return Response.json({ error: "unauthorized", message: "missing or invalid queue token" }, { status: 401 });
|
|
251
|
+
}
|
|
252
|
+
const queueName = decodeHeader(req.headers.get(DELIVERY_HEADERS.queueName));
|
|
253
|
+
const messageId = req.headers.get(DELIVERY_HEADERS.messageId);
|
|
254
|
+
const attempt = positiveInt(req.headers.get(DELIVERY_HEADERS.attempt));
|
|
255
|
+
if (!queueName || !ValidQueueName.safeParse(queueName).success || !messageId || attempt === undefined) {
|
|
256
|
+
return Response.json({ error: "invalid_request", message: "missing or invalid X-Unfz-Queue-Name, -Message-Id or -Attempt" }, { status: 400 });
|
|
257
|
+
}
|
|
258
|
+
if (!queueName.startsWith(prefix)) {
|
|
259
|
+
return Response.json({ error: "Unhandled queue", detail: `This route handles queue prefix "${prefix}", not "${queueName}".` }, { status: 400 });
|
|
260
|
+
}
|
|
261
|
+
const bytes = new Uint8Array(await req.arrayBuffer());
|
|
262
|
+
let message;
|
|
263
|
+
try {
|
|
264
|
+
if (bytes.byteLength === 0)
|
|
265
|
+
throw new Error("empty body");
|
|
266
|
+
message = decodeMessage(bytes);
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return Response.json({ error: "invalid_request", message: "missing or malformed message body" }, { status: 400 });
|
|
270
|
+
}
|
|
271
|
+
const createdAt = parseDate(req.headers.get(DELIVERY_HEADERS.createdAt));
|
|
272
|
+
const deadline = parseDate(req.headers.get(DELIVERY_HEADERS.deadline));
|
|
273
|
+
const meta = {
|
|
274
|
+
attempt,
|
|
275
|
+
deliveryCount: positiveInt(req.headers.get(DELIVERY_HEADERS.deliveryCount)) ?? attempt,
|
|
276
|
+
...(createdAt ? { createdAt } : {}),
|
|
277
|
+
queueName: queueName,
|
|
278
|
+
messageId: messageId,
|
|
279
|
+
};
|
|
280
|
+
try {
|
|
281
|
+
const result = await processState.delivery.run({ ...(deadline ? { deadline } : {}) }, () => handler(message, meta));
|
|
282
|
+
const timeoutSeconds = timeoutSecondsOf(result);
|
|
283
|
+
return Response.json(timeoutSeconds === undefined ? { ok: true } : { timeoutSeconds });
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
// Any non-2xx is redelivered with attempt + 1.
|
|
287
|
+
return Response.json({ error: "handler_failed", message: describe(error) }, { status: 500 });
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
/** Constant-time comparison of the delivery token against `UNFZ_STATE_TOKEN`. */
|
|
292
|
+
export function verifyQueueToken(expected, presented) {
|
|
293
|
+
if (!expected) {
|
|
294
|
+
logOnce("no-queue-token", () => console.error("[unfazed/workflow] UNFZ_STATE_TOKEN is not set, so every queue delivery is refused with 401. " +
|
|
295
|
+
NO_PLATFORM_HINT));
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
if (presented === null)
|
|
299
|
+
return false;
|
|
300
|
+
// Hashing first makes the comparison length-independent.
|
|
301
|
+
const a = createHash("sha256").update(expected).digest();
|
|
302
|
+
const b = createHash("sha256").update(presented).digest();
|
|
303
|
+
return timingSafeEqual(a, b);
|
|
304
|
+
}
|
|
305
|
+
/** The deadline of the delivery being served in this async context, if any. */
|
|
306
|
+
export function currentDeliveryDeadline() {
|
|
307
|
+
return processState.delivery.getStore()?.deadline;
|
|
308
|
+
}
|
|
309
|
+
function timeoutSecondsOf(result) {
|
|
310
|
+
if (result === null || typeof result !== "object")
|
|
311
|
+
return undefined;
|
|
312
|
+
const value = result.timeoutSeconds;
|
|
313
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
314
|
+
return undefined;
|
|
315
|
+
return Math.min(Math.max(value, 0), MAX_DELAY_SECONDS);
|
|
316
|
+
}
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
// Small helpers.
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
function decodeHeader(value) {
|
|
321
|
+
if (value === null)
|
|
322
|
+
return undefined;
|
|
323
|
+
try {
|
|
324
|
+
return decodeURIComponent(value);
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
function positiveInt(value) {
|
|
331
|
+
if (value === null || !/^\d+$/.test(value))
|
|
332
|
+
return undefined;
|
|
333
|
+
const parsed = Number(value);
|
|
334
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
335
|
+
}
|
|
336
|
+
function parseDate(value) {
|
|
337
|
+
if (!value)
|
|
338
|
+
return undefined;
|
|
339
|
+
const date = new Date(value);
|
|
340
|
+
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
341
|
+
}
|
|
342
|
+
function retryAfterMs(value) {
|
|
343
|
+
if (value === null)
|
|
344
|
+
return undefined;
|
|
345
|
+
const seconds = Number(value);
|
|
346
|
+
if (!Number.isFinite(seconds) || seconds < 0)
|
|
347
|
+
return undefined;
|
|
348
|
+
return Math.max(100, seconds * 1000);
|
|
349
|
+
}
|
|
350
|
+
export function byteLength(text) {
|
|
351
|
+
return Buffer.byteLength(text, "utf8");
|
|
352
|
+
}
|
|
353
|
+
function parseJson(text) {
|
|
354
|
+
try {
|
|
355
|
+
return JSON.parse(text);
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
return undefined;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function truncate(text, max = 300) {
|
|
362
|
+
return text.length <= max ? text : `${text.slice(0, max)}…`;
|
|
363
|
+
}
|
|
364
|
+
export function describe(error) {
|
|
365
|
+
if (error instanceof Error) {
|
|
366
|
+
const cause = error.cause;
|
|
367
|
+
const causeText = cause instanceof Error ? `: ${cause.message}` : "";
|
|
368
|
+
return `${error.message}${causeText}`;
|
|
369
|
+
}
|
|
370
|
+
return String(error);
|
|
371
|
+
}
|
|
372
|
+
function sleep(ms) {
|
|
373
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
374
|
+
}
|
|
375
|
+
//# sourceMappingURL=queue.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"queue.js","sourceRoot":"","sources":["../../src/workflow/queue.ts"],"names":[],"mappings":"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","sourcesContent":["import { createHash, randomUUID, timingSafeEqual } from \"node:crypto\";\nimport {\n type MessageId,\n type QueueBatchResult,\n type QueueOptions,\n type QueuePrefix,\n ValidQueueName,\n} from \"@workflow/world\";\nimport { UnfazedWorkflowError, type UnfazedWorkflowErrorCode } from \"./errors.js\";\nimport { logOnce, processState } from \"./state.js\";\nimport { decodeMessage, encodeMessage, MESSAGE_CONTENT_TYPE, toBase64 } from \"./transport.js\";\n\n// ---------------------------------------------------------------------------\n// Wire contract v1 (unfazed docs/workflow-world.md, \"Wire contract (v1)\").\n// ---------------------------------------------------------------------------\n\n/** Path of the queue API under `UNFZ_STATE_URL`. */\nexport const QUEUE_API_PATH = \"/queue/v1\";\nexport const MAX_MESSAGES_PER_REQUEST = 100;\nexport const MAX_REQUEST_BYTES = 4 * 1024 * 1024;\nexport const MAX_PAYLOAD_BYTES = 1024 * 1024;\n/** 365 days, the longest `delay_seconds` and `timeoutSeconds` the platform takes. */\nexport const MAX_DELAY_SECONDS = 31_536_000;\nexport const MAX_IDEMPOTENCY_KEY_BYTES = 256;\nexport const MAX_QUEUE_NAME_BYTES = 1024;\nexport const MAX_ROUTE_PATH_BYTES = 2048;\n\n/** Delivery headers, lower-cased. */\nexport const DELIVERY_HEADERS = {\n trigger: \"x-unfz-trigger\",\n token: \"x-unfz-queue-token\",\n queueName: \"x-unfz-queue-name\",\n messageId: \"x-unfz-queue-message-id\",\n attempt: \"x-unfz-queue-attempt\",\n deliveryCount: \"x-unfz-queue-delivery-count\",\n createdAt: \"x-unfz-queue-created-at\",\n /** When the platform stops waiting: 15 minutes after the VM was ready to take the request. */\n deadline: \"x-unfz-queue-deadline\",\n} as const;\n\n/** One message as `POST /queue/v1/messages` takes it. */\nexport interface WireMessage {\n queue_name: string;\n payload: string;\n content_type?: string;\n delay_seconds?: number;\n idempotency_key?: string;\n}\n\n/** The subset of `fetch` the queue client needs; injectable for tests. */\nexport type WorkflowFetch = (\n input: string,\n init: {\n method: string;\n headers: Record<string, string>;\n body?: string;\n signal?: AbortSignal;\n },\n) => Promise<{\n status: number;\n headers: { get(name: string): string | null };\n text(): Promise<string>;\n}>;\n\nexport interface QueueClientOptions {\n /** `UNFZ_STATE_URL`. */\n stateUrl: string | undefined;\n /** `UNFZ_STATE_TOKEN`. */\n stateToken: string | undefined;\n fetch: WorkflowFetch;\n /** How long one call may keep retrying 503s and network failures. */\n retryBudgetMs: number;\n /** Timeout of a single HTTP attempt. */\n requestTimeoutMs: number;\n}\n\nexport interface QueueClient {\n /** `POST /messages`: stores all of `messages` or none, in input order. */\n enqueue(messages: WireMessage[]): Promise<{ message_id: string; duplicate: boolean }[]>;\n /** `PUT /routes`. */\n putRoute(prefix: string, path: string): Promise<void>;\n /** `GET /stats`; a 200 proves the queue works for this app. */\n stats(): Promise<QueueStats>;\n}\n\n/** `GET /queue/v1/stats`. */\nexport interface QueueStats {\n ready: number;\n due: number;\n leased: number;\n dead: number;\n oldest_due_at: string | null;\n bytes: number;\n /** Dead messages dropped over the dead cap (10 000 or 16 MiB, oldest first). */\n dead_evicted?: number;\n}\n\nconst NO_PLATFORM_HINT =\n \"unfazed/workflow delivers workflow messages through the unfazed platform queue, which \" +\n \"exists only inside an unfazed service (the platform injects UNFZ_STATE_URL and \" +\n \"UNFZ_STATE_TOKEN). For local development run `nitro dev`, which uses the Local World.\";\n\nexport function createQueueClient(options: QueueClientOptions): QueueClient {\n const base = options.stateUrl?.replace(/\\/+$/, \"\");\n\n function platform(what: string): { base: string; token: string } {\n if (!base || !options.stateToken) {\n const missing = [!base && \"UNFZ_STATE_URL\", !options.stateToken && \"UNFZ_STATE_TOKEN\"]\n .filter(Boolean)\n .join(\" and \");\n throw new UnfazedWorkflowError(\"no_platform\", `${what}: ${missing} not set. ${NO_PLATFORM_HINT}`);\n }\n return { base, token: options.stateToken };\n }\n\n async function call(method: string, path: string, body: unknown, what: string): Promise<string> {\n const { base, token } = platform(what);\n const url = `${base}${QUEUE_API_PATH}${path}`;\n const giveUpAt = Date.now() + options.retryBudgetMs;\n let backoffMs = 250;\n for (;;) {\n let status: number;\n let text: string;\n let retryAfter: string | null;\n try {\n const response = await options.fetch(url, {\n method,\n headers: {\n authorization: `Bearer ${token}`,\n accept: \"application/json\",\n ...(body === undefined ? {} : { \"content-type\": \"application/json\" }),\n },\n ...(body === undefined ? {} : { body: JSON.stringify(body) }),\n signal: AbortSignal.timeout(options.requestTimeoutMs),\n });\n status = response.status;\n retryAfter = response.headers.get(\"retry-after\");\n text = await response.text();\n } catch (cause) {\n // A network failure (the state plane restarting in a worker release\n // switch) or a lost response. Retrying is safe: every message carries\n // an idempotency key and route writes are idempotent.\n if (Date.now() + backoffMs > giveUpAt) {\n throw new UnfazedWorkflowError(\n \"unavailable\",\n `${what}: the platform queue at ${base} did not answer (${describe(cause)})`,\n { retryable: true, cause },\n );\n }\n await sleep(backoffMs);\n backoffMs = Math.min(backoffMs * 2, 5_000);\n continue;\n }\n if (status >= 200 && status < 300) return text;\n if (status === 503) {\n // A placement move or worker handover: wait as told, then retry with\n // the same idempotency keys.\n const waitMs = retryAfterMs(retryAfter) ?? backoffMs;\n if (Date.now() + waitMs <= giveUpAt) {\n await sleep(waitMs);\n backoffMs = Math.min(backoffMs * 2, 5_000);\n continue;\n }\n }\n throw errorFromResponse(status, text, what, base);\n }\n }\n\n return {\n async enqueue(messages) {\n const text = await call(\"POST\", \"/messages\", { messages }, \"enqueue\");\n const parsed = parseJson(text) as { messages?: { message_id?: unknown; duplicate?: unknown }[] } | undefined;\n const results = parsed?.messages;\n if (\n !Array.isArray(results) ||\n results.length !== messages.length ||\n results.some((r) => typeof r?.message_id !== \"string\")\n ) {\n throw new UnfazedWorkflowError(\n \"http_error\",\n `enqueue: the platform answered 200 without one message_id per message: ${truncate(text)}`,\n { retryable: true },\n );\n }\n return results.map((r) => ({ message_id: r.message_id as string, duplicate: r.duplicate === true }));\n },\n async putRoute(prefix, path) {\n await call(\"PUT\", \"/routes\", { prefix, path }, `register route ${prefix} -> ${path}`);\n },\n async stats() {\n const text = await call(\"GET\", \"/stats\", undefined, \"queue probe\");\n return parseJson(text) as QueueStats;\n },\n };\n}\n\nfunction errorFromResponse(status: number, text: string, what: string, base: string): UnfazedWorkflowError {\n const body = parseJson(text) as { error?: unknown; message?: unknown } | undefined;\n const detail = typeof body?.message === \"string\" ? body.message : truncate(text);\n const fail = (code: UnfazedWorkflowErrorCode, message: string, retryable = false) =>\n new UnfazedWorkflowError(code, `${what}: ${message}`, { status, retryable });\n switch (status) {\n case 400:\n return fail(\"invalid_request\", `the platform refused the request (400): ${detail}`);\n case 401:\n return fail(\"unauthorized\", \"the platform refused UNFZ_STATE_TOKEN (401)\");\n case 404:\n return fail(\n \"queue_not_supported\",\n `this unfazed worker has no workflow queue yet (404 from ${base}${QUEUE_API_PATH}). ` +\n \"Durable workflows need a worker release that ships the platform queue; \" +\n \"they cannot run on this worker until it is upgraded.\",\n );\n case 409:\n return fail(\"queue_unavailable\", `this app's queue cannot run here (409): ${detail}`);\n case 413:\n return fail(\"payload_too_large\", `message too large (413): ${detail}`);\n case 429:\n // The platform's message names the limit: 100 000 pending messages,\n // 64 MiB of live messages, or 200 000 idempotency keys.\n return fail(\"quota_exceeded\", `queue quota exceeded (429): ${detail}`, true);\n case 503:\n return fail(\"unavailable\", `the platform queue stayed unavailable (503): ${detail}`, true);\n default:\n return fail(\"http_error\", `unexpected HTTP ${status}: ${detail}`, status >= 500);\n }\n}\n\n// ---------------------------------------------------------------------------\n// Producing: QueueOptions → wire messages.\n// ---------------------------------------------------------------------------\n\n/** Builds the wire form of one message; throws on a payload over 1 MiB. */\nexport function toWireMessage(queueName: string, message: unknown, opts?: QueueOptions): WireMessage {\n if (byteLength(queueName) > MAX_QUEUE_NAME_BYTES || queueName.length === 0) {\n throw new UnfazedWorkflowError(\"invalid_request\", `queue name must be 1 to ${MAX_QUEUE_NAME_BYTES} bytes`);\n }\n const bytes = encodeMessage(message);\n if (bytes.byteLength > MAX_PAYLOAD_BYTES) {\n throw new UnfazedWorkflowError(\n \"payload_too_large\",\n `queue message for ${queueName} is ${bytes.byteLength} bytes; the platform takes at most ${MAX_PAYLOAD_BYTES}`,\n );\n }\n const wire: WireMessage = {\n queue_name: queueName,\n payload: toBase64(bytes),\n content_type: MESSAGE_CONTENT_TYPE,\n idempotency_key: idempotencyKey(opts?.idempotencyKey),\n };\n const delay = opts?.delaySeconds;\n if (typeof delay === \"number\" && Number.isFinite(delay) && delay > 0) {\n // Longer sleeps wake at the cap; the runtime sees the wait unfinished and\n // answers with the remaining `timeoutSeconds`.\n wire.delay_seconds = Math.min(delay, MAX_DELAY_SECONDS);\n }\n return wire;\n}\n\n/**\n * Every message gets a key, so retrying a 503 or a lost response can never\n * store it twice. A key over the platform's 256-byte limit is replaced by its\n * SHA-256, which keeps it deterministic.\n */\nexport function idempotencyKey(key: string | undefined): string {\n if (key === undefined || key.length === 0) return `rnd:${randomUUID()}`;\n if (byteLength(key) <= MAX_IDEMPOTENCY_KEY_BYTES) return key;\n return `sha256:${createHash(\"sha256\").update(key).digest(\"hex\")}`;\n}\n\n/** Splits wire messages into requests of at most 100 messages and ~4 MiB. */\nexport function chunkMessages<T extends { wire: WireMessage }>(entries: T[]): T[][] {\n const budget = MAX_REQUEST_BYTES - 64 * 1024; // the envelope and headroom\n const chunks: T[][] = [];\n let current: T[] = [];\n let size = 0;\n for (const entry of entries) {\n const entrySize = byteLength(JSON.stringify(entry.wire)) + 1;\n if (current.length > 0 && (current.length >= MAX_MESSAGES_PER_REQUEST || size + entrySize > budget)) {\n chunks.push(current);\n current = [];\n size = 0;\n }\n current.push(entry);\n size += entrySize;\n }\n if (current.length > 0) chunks.push(current);\n return chunks;\n}\n\n/**\n * `queueBatch`: one request per chunk, in parallel. A chunk is atomic on the\n * platform, so a failed chunk fails exactly its own entries; a message that\n * cannot be encoded fails alone. Never rejects.\n */\nexport async function enqueueBatch(\n client: QueueClient,\n queueName: string,\n messages: readonly { message: unknown; opts?: QueueOptions }[],\n): Promise<QueueBatchResult[]> {\n const results: QueueBatchResult[] = new Array(messages.length);\n const entries: { index: number; wire: WireMessage }[] = [];\n messages.forEach((entry, index) => {\n try {\n entries.push({ index, wire: toWireMessage(queueName, entry.message, entry.opts) });\n } catch (error) {\n results[index] = { messageId: null, error: describe(error), retryable: false };\n }\n });\n await Promise.all(\n chunkMessages(entries).map(async (chunk) => {\n try {\n const stored = await client.enqueue(chunk.map((entry) => entry.wire));\n chunk.forEach((entry, i) => {\n results[entry.index] = { messageId: stored[i]!.message_id as MessageId };\n });\n } catch (error) {\n const retryable = error instanceof UnfazedWorkflowError ? error.retryable : true;\n for (const entry of chunk) {\n results[entry.index] = { messageId: null, error: describe(error), retryable };\n }\n }\n }),\n );\n return results;\n}\n\n// ---------------------------------------------------------------------------\n// Consuming: the delivery handler behind the flow route.\n// ---------------------------------------------------------------------------\n\n/** What the runtime's handler receives besides the message. */\nexport interface DeliveryMeta {\n attempt: number;\n deliveryCount?: number;\n createdAt?: Date;\n queueName: ValidQueueName;\n messageId: MessageId;\n}\n\n/**\n * `createQueueHandler` for platform deliveries.\n *\n * Refuses a request without the right `X-Unfz-Queue-Token` with 401 before it\n * looks at anything else, so the route serves the internet nothing but that\n * refusal (public ingress strips the header, so only the platform can send\n * it). A message outside `prefix` answers 400, which the platform retries,\n * rather than run under the wrong consumer.\n *\n * Every answer is a complete JSON body with status 200, 400, 401 or 500,\n * never a redirect: the platform follows no redirects and retries a 3xx or a\n * 2xx whose body breaks off. `{ timeoutSeconds }` is passed on as the runtime\n * asked; the platform waits at least 1 s.\n */\nexport function createDeliveryHandler(\n expectedToken: string | undefined,\n prefix: QueuePrefix,\n handler: (message: unknown, meta: DeliveryMeta) => Promise<unknown>,\n): (req: Request) => Promise<Response> {\n return async (req) => {\n if (!verifyQueueToken(expectedToken, req.headers.get(DELIVERY_HEADERS.token))) {\n return Response.json({ error: \"unauthorized\", message: \"missing or invalid queue token\" }, { status: 401 });\n }\n const queueName = decodeHeader(req.headers.get(DELIVERY_HEADERS.queueName));\n const messageId = req.headers.get(DELIVERY_HEADERS.messageId);\n const attempt = positiveInt(req.headers.get(DELIVERY_HEADERS.attempt));\n if (!queueName || !ValidQueueName.safeParse(queueName).success || !messageId || attempt === undefined) {\n return Response.json(\n { error: \"invalid_request\", message: \"missing or invalid X-Unfz-Queue-Name, -Message-Id or -Attempt\" },\n { status: 400 },\n );\n }\n if (!queueName.startsWith(prefix)) {\n return Response.json(\n { error: \"Unhandled queue\", detail: `This route handles queue prefix \"${prefix}\", not \"${queueName}\".` },\n { status: 400 },\n );\n }\n const bytes = new Uint8Array(await req.arrayBuffer());\n let message: unknown;\n try {\n if (bytes.byteLength === 0) throw new Error(\"empty body\");\n message = decodeMessage(bytes);\n } catch {\n return Response.json({ error: \"invalid_request\", message: \"missing or malformed message body\" }, { status: 400 });\n }\n const createdAt = parseDate(req.headers.get(DELIVERY_HEADERS.createdAt));\n const deadline = parseDate(req.headers.get(DELIVERY_HEADERS.deadline));\n const meta: DeliveryMeta = {\n attempt,\n deliveryCount: positiveInt(req.headers.get(DELIVERY_HEADERS.deliveryCount)) ?? attempt,\n ...(createdAt ? { createdAt } : {}),\n queueName: queueName as ValidQueueName,\n messageId: messageId as MessageId,\n };\n try {\n const result = await processState.delivery.run({ ...(deadline ? { deadline } : {}) }, () =>\n handler(message, meta),\n );\n const timeoutSeconds = timeoutSecondsOf(result);\n return Response.json(timeoutSeconds === undefined ? { ok: true } : { timeoutSeconds });\n } catch (error) {\n // Any non-2xx is redelivered with attempt + 1.\n return Response.json({ error: \"handler_failed\", message: describe(error) }, { status: 500 });\n }\n };\n}\n\n/** Constant-time comparison of the delivery token against `UNFZ_STATE_TOKEN`. */\nexport function verifyQueueToken(expected: string | undefined, presented: string | null): boolean {\n if (!expected) {\n logOnce(\"no-queue-token\", () =>\n console.error(\n \"[unfazed/workflow] UNFZ_STATE_TOKEN is not set, so every queue delivery is refused with 401. \" +\n NO_PLATFORM_HINT,\n ),\n );\n return false;\n }\n if (presented === null) return false;\n // Hashing first makes the comparison length-independent.\n const a = createHash(\"sha256\").update(expected).digest();\n const b = createHash(\"sha256\").update(presented).digest();\n return timingSafeEqual(a, b);\n}\n\n/** The deadline of the delivery being served in this async context, if any. */\nexport function currentDeliveryDeadline(): Date | undefined {\n return processState.delivery.getStore()?.deadline;\n}\n\nfunction timeoutSecondsOf(result: unknown): number | undefined {\n if (result === null || typeof result !== \"object\") return undefined;\n const value = (result as { timeoutSeconds?: unknown }).timeoutSeconds;\n if (typeof value !== \"number\" || !Number.isFinite(value)) return undefined;\n return Math.min(Math.max(value, 0), MAX_DELAY_SECONDS);\n}\n\n// ---------------------------------------------------------------------------\n// Small helpers.\n// ---------------------------------------------------------------------------\n\nfunction decodeHeader(value: string | null): string | undefined {\n if (value === null) return undefined;\n try {\n return decodeURIComponent(value);\n } catch {\n return undefined;\n }\n}\n\nfunction positiveInt(value: string | null): number | undefined {\n if (value === null || !/^\\d+$/.test(value)) return undefined;\n const parsed = Number(value);\n return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;\n}\n\nfunction parseDate(value: string | null): Date | undefined {\n if (!value) return undefined;\n const date = new Date(value);\n return Number.isNaN(date.getTime()) ? undefined : date;\n}\n\nfunction retryAfterMs(value: string | null): number | undefined {\n if (value === null) return undefined;\n const seconds = Number(value);\n if (!Number.isFinite(seconds) || seconds < 0) return undefined;\n return Math.max(100, seconds * 1000);\n}\n\nexport function byteLength(text: string): number {\n return Buffer.byteLength(text, \"utf8\");\n}\n\nfunction parseJson(text: string): unknown {\n try {\n return JSON.parse(text);\n } catch {\n return undefined;\n }\n}\n\nfunction truncate(text: string, max = 300): string {\n return text.length <= max ? text : `${text.slice(0, max)}…`;\n}\n\nexport function describe(error: unknown): string {\n if (error instanceof Error) {\n const cause = (error as { cause?: unknown }).cause;\n const causeText = cause instanceof Error ? `: ${cause.message}` : \"\";\n return `${error.message}${causeText}`;\n }\n return String(error);\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n"]}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
/** What the queue handler knows about the delivery it is serving. */
|
|
3
|
+
export interface DeliveryContext {
|
|
4
|
+
/** `X-Unfz-Queue-Deadline`: when the platform stops waiting for the answer. */
|
|
5
|
+
deadline?: Date;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* State that must be one per process, not one per copy of this module.
|
|
9
|
+
*
|
|
10
|
+
* A framework can compile this package into several server layers (Next.js
|
|
11
|
+
* builds `instrument`, app-route, `ssr` and `edge` separately), and each copy
|
|
12
|
+
* has its own module scope. The delivery context in particular is set by the
|
|
13
|
+
* queue handler and read by `getRuntimeDeadline()`, which the runtime may call
|
|
14
|
+
* through another copy. The key is versioned so a future release that changes
|
|
15
|
+
* this shape never reads an older copy's object.
|
|
16
|
+
*/
|
|
17
|
+
interface ProcessState {
|
|
18
|
+
delivery: AsyncLocalStorage<DeliveryContext>;
|
|
19
|
+
/** Log-once latches. */
|
|
20
|
+
logged: Set<string>;
|
|
21
|
+
}
|
|
22
|
+
export declare const processState: ProcessState;
|
|
23
|
+
/** Logs `message` once per process for a given `key`. */
|
|
24
|
+
export declare function logOnce(key: string, log: () => void): void;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
const STATE_KEY = Symbol.for("unfazed/workflow//state/v1");
|
|
3
|
+
const store = globalThis;
|
|
4
|
+
export const processState = (store[STATE_KEY] ??= {
|
|
5
|
+
delivery: new AsyncLocalStorage(),
|
|
6
|
+
logged: new Set(),
|
|
7
|
+
});
|
|
8
|
+
/** Logs `message` once per process for a given `key`. */
|
|
9
|
+
export function logOnce(key, log) {
|
|
10
|
+
if (processState.logged.has(key))
|
|
11
|
+
return;
|
|
12
|
+
processState.logged.add(key);
|
|
13
|
+
log();
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=state.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"state.js","sourceRoot":"","sources":["../../src/workflow/state.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAwBrD,MAAM,SAAS,GAAG,MAAM,CAAC,GAAG,CAAC,4BAA4B,CAAC,CAAC;AAC3D,MAAM,KAAK,GAAG,UAA0E,CAAC;AAEzF,MAAM,CAAC,MAAM,YAAY,GAAiB,CAAC,KAAK,CAAC,SAAS,CAAC,KAAK;IAC9D,QAAQ,EAAE,IAAI,iBAAiB,EAAmB;IAClD,MAAM,EAAE,IAAI,GAAG,EAAU;CAC1B,CAAC,CAAC;AAEH,yDAAyD;AACzD,MAAM,UAAU,OAAO,CAAC,GAAW,EAAE,GAAe;IAClD,IAAI,YAAY,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC;QAAE,OAAO;IACzC,YAAY,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAC7B,GAAG,EAAE,CAAC;AACR,CAAC","sourcesContent":["import { AsyncLocalStorage } from \"node:async_hooks\";\n\n/** What the queue handler knows about the delivery it is serving. */\nexport interface DeliveryContext {\n /** `X-Unfz-Queue-Deadline`: when the platform stops waiting for the answer. */\n deadline?: Date;\n}\n\n/**\n * State that must be one per process, not one per copy of this module.\n *\n * A framework can compile this package into several server layers (Next.js\n * builds `instrument`, app-route, `ssr` and `edge` separately), and each copy\n * has its own module scope. The delivery context in particular is set by the\n * queue handler and read by `getRuntimeDeadline()`, which the runtime may call\n * through another copy. The key is versioned so a future release that changes\n * this shape never reads an older copy's object.\n */\ninterface ProcessState {\n delivery: AsyncLocalStorage<DeliveryContext>;\n /** Log-once latches. */\n logged: Set<string>;\n}\n\nconst STATE_KEY = Symbol.for(\"unfazed/workflow//state/v1\");\nconst store = globalThis as typeof globalThis & Record<symbol, ProcessState | undefined>;\n\nexport const processState: ProcessState = (store[STATE_KEY] ??= {\n delivery: new AsyncLocalStorage<DeliveryContext>(),\n logged: new Set<string>(),\n});\n\n/** Logs `message` once per process for a given `key`. */\nexport function logOnce(key: string, log: () => void): void {\n if (processState.logged.has(key)) return;\n processState.logged.add(key);\n log();\n}\n"]}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The message body codec: JSON, with `Uint8Array` values carried as
|
|
3
|
+
* `{ "__type": "Uint8Array", "data": "<base64>" }`, the same envelope the
|
|
4
|
+
* upstream Local and Postgres Worlds use. The runtime puts bytes in messages
|
|
5
|
+
* (the serialized run input of a resilient start), so plain JSON would lose
|
|
6
|
+
* them.
|
|
7
|
+
*/
|
|
8
|
+
export declare const MESSAGE_CONTENT_TYPE = "application/json";
|
|
9
|
+
export declare function encodeMessage(message: unknown): Uint8Array;
|
|
10
|
+
export declare function decodeMessage(bytes: Uint8Array): unknown;
|
|
11
|
+
export declare function toBase64(bytes: Uint8Array): string;
|
|
12
|
+
export declare function fromBase64(text: string): Uint8Array;
|