@lunora/queue 0.0.0 → 1.0.0-alpha.10

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/dist/index.mjs ADDED
@@ -0,0 +1,6 @@
1
+ export { createQueueCaptureSink, shouldCaptureQueue } from './packem_shared/createQueueCaptureSink-B8WE0eHf.mjs';
2
+ export { createQueueContext } from './packem_shared/createQueueContext-D0XCdCsd.mjs';
3
+ export { default as createQueues } from './packem_shared/createQueues-14-vSICK.mjs';
4
+ export { defineQueue, isQueueDefinition, queueBindingName, queueDefaultName } from './packem_shared/defineQueue-D40gREfg.mjs';
5
+ export { dispatchQueueBatch } from './packem_shared/dispatchQueueBatch-DSEWhEy8.mjs';
6
+ export { createQueueRunContext } from './packem_shared/createQueueRunContext-C8jboCk6.mjs';
@@ -0,0 +1,64 @@
1
+ import { LunoraError } from '@lunora/errors';
2
+
3
+ const RECORD_QUEUE_MESSAGE_OP = "__lunora_admin__:recordQueueMessage";
4
+ const DEFAULT_ROOT_SHARD = "__root__";
5
+ const DEV_ENVIRONMENT_PATTERN = /^(?:dev(?:elopment)?|local(?:host)?|test)$/iu;
6
+ const ENVIRONMENT_VARS = ["CF_ENV", "ENVIRONMENT", "NODE_ENV", "WORKER_ENV"];
7
+ const CAPTURE_FETCH_TIMEOUT_MS = 5e3;
8
+ const shouldCaptureQueue = (env) => {
9
+ const flag = env["LUNORA_QUEUE_CAPTURE"];
10
+ if (typeof flag === "string") {
11
+ return flag === "1" || flag.toLowerCase() === "true";
12
+ }
13
+ return ENVIRONMENT_VARS.some((key) => {
14
+ const value = env[key];
15
+ return typeof value === "string" && DEV_ENVIRONMENT_PATTERN.test(value);
16
+ });
17
+ };
18
+ const createQueueCaptureSink = (env, options = {}) => {
19
+ const rootShard = options.rootShard ?? DEFAULT_ROOT_SHARD;
20
+ return async (messages) => {
21
+ if (messages.length === 0) {
22
+ return;
23
+ }
24
+ const binding = env["SHARD"];
25
+ const adminToken = typeof env["LUNORA_ADMIN_TOKEN"] === "string" ? env["LUNORA_ADMIN_TOKEN"] : void 0;
26
+ if (binding === void 0 || adminToken === void 0) {
27
+ return;
28
+ }
29
+ let namespace = binding;
30
+ if (options.jurisdiction !== void 0) {
31
+ if (typeof binding.jurisdiction !== "function") {
32
+ throw new TypeError(
33
+ `@lunora/queue: Durable Object namespace does not support jurisdiction("${options.jurisdiction}") — update @cloudflare/workers-types or remove the jurisdiction option`
34
+ );
35
+ }
36
+ namespace = binding.jurisdiction(options.jurisdiction);
37
+ }
38
+ const stub = namespace.get(namespace.idFromName(rootShard));
39
+ const controller = new AbortController();
40
+ const timeout = setTimeout(() => {
41
+ controller.abort();
42
+ }, CAPTURE_FETCH_TIMEOUT_MS);
43
+ try {
44
+ const response = await stub.fetch("https://shard.internal/rpc", {
45
+ body: JSON.stringify({ args: { messages }, functionPath: RECORD_QUEUE_MESSAGE_OP }),
46
+ headers: { authorization: `Bearer ${adminToken}`, "content-type": "application/json" },
47
+ method: "POST",
48
+ signal: controller.signal
49
+ });
50
+ if (!response.ok) {
51
+ const detail = await response.text().catch(() => "");
52
+ throw new LunoraError(
53
+ "INTERNAL",
54
+ `@lunora/queue: capture write to the root shard failed (${String(response.status)} ${response.statusText})${detail === "" ? "" : `: ${detail}`}`
55
+ );
56
+ }
57
+ await response.body?.cancel();
58
+ } finally {
59
+ clearTimeout(timeout);
60
+ }
61
+ };
62
+ };
63
+
64
+ export { createQueueCaptureSink, shouldCaptureQueue };
@@ -0,0 +1,14 @@
1
+ import createQueues from './createQueues-14-vSICK.mjs';
2
+
3
+ const createQueueContext = (env, specs) => {
4
+ const bindings = {};
5
+ for (const spec of specs) {
6
+ const binding = env[spec.binding];
7
+ if (binding && typeof binding.send === "function" && typeof binding.sendBatch === "function") {
8
+ bindings[spec.exportName] = binding;
9
+ }
10
+ }
11
+ return createQueues({ bindings });
12
+ };
13
+
14
+ export { createQueueContext };
@@ -0,0 +1,94 @@
1
+ import { LunoraError } from '@lunora/errors';
2
+
3
+ const createDispatchLogger = (prefix) => {
4
+ return {
5
+ debug: (message, ...rest) => {
6
+ console.debug(prefix, message, ...rest);
7
+ },
8
+ error: (message, ...rest) => {
9
+ console.error(prefix, message, ...rest);
10
+ },
11
+ info: (message, ...rest) => {
12
+ console.info(prefix, message, ...rest);
13
+ },
14
+ warn: (message, ...rest) => {
15
+ console.warn(prefix, message, ...rest);
16
+ }
17
+ };
18
+ };
19
+
20
+ const SCHEDULER_DISPATCH_PATH = "/_lunora/scheduler/dispatch";
21
+ const trimTrailingSlashes = (value) => {
22
+ let end = value.length;
23
+ while (end > 0 && value[end - 1] === "/") {
24
+ end -= 1;
25
+ }
26
+ return value.slice(0, end);
27
+ };
28
+ const toDispatchError = (label, status, rawBody) => {
29
+ try {
30
+ const parsed = JSON.parse(rawBody);
31
+ const errorBody = parsed?.error;
32
+ if (typeof errorBody === "object" && errorBody !== null && typeof errorBody.code === "string") {
33
+ const { code, data, message } = errorBody;
34
+ return new LunoraError(code, typeof message === "string" ? message : void 0, { data, status });
35
+ }
36
+ } catch {
37
+ }
38
+ return new LunoraError("INTERNAL", `${label}: function dispatch failed (${String(status)}): ${rawBody}`, { status });
39
+ };
40
+ const createDispatchRunner = (options) => {
41
+ const { label } = options;
42
+ const globalFetch = globalThis.fetch;
43
+ const fetchImpl = options.fetchImpl ?? (typeof globalFetch === "function" ? globalFetch.bind(globalThis) : void 0);
44
+ return async (function_, args, runOptions = {}) => {
45
+ if (typeof fetchImpl !== "function") {
46
+ throw new TypeError(`${label}: no fetch implementation available — pass fetchImpl or run on a platform with global fetch`);
47
+ }
48
+ const origin = options.env.LUNORA_ORIGIN_URL;
49
+ if (typeof origin !== "string" || origin.length === 0) {
50
+ throw new LunoraError("INTERNAL", `${label}: \`LUNORA_ORIGIN_URL\` must be set on the Worker env so a handler can call back into Lunora functions`);
51
+ }
52
+ const token = options.env.LUNORA_ADMIN_TOKEN;
53
+ if (typeof token !== "string" || token.length === 0) {
54
+ throw new LunoraError("INTERNAL", `${label}: \`LUNORA_ADMIN_TOKEN\` must be set on the Worker env to authenticate function dispatch`);
55
+ }
56
+ const url = `${trimTrailingSlashes(origin)}${SCHEDULER_DISPATCH_PATH}`;
57
+ const headers = { authorization: `Bearer ${token}`, "content-type": "application/json" };
58
+ if (options.identity?.userId !== void 0) {
59
+ headers["x-lunora-userid"] = options.identity.userId;
60
+ }
61
+ if (options.identity?.claims !== void 0) {
62
+ headers["x-lunora-identity"] = JSON.stringify(options.identity.claims);
63
+ }
64
+ const response = await fetchImpl(url, {
65
+ body: JSON.stringify({ args: args ?? {}, functionPath: function_.__lunoraRef, shardKey: runOptions.shardKey }),
66
+ headers,
67
+ method: "POST"
68
+ });
69
+ if (!response.ok) {
70
+ throw toDispatchError(label, response.status, await response.text());
71
+ }
72
+ const text = await response.text();
73
+ if (text.length === 0) {
74
+ return void 0;
75
+ }
76
+ try {
77
+ return JSON.parse(text);
78
+ } catch {
79
+ throw new LunoraError("INTERNAL", `${label}: function dispatch returned a non-JSON body (${String(response.status)}): ${text}`, {
80
+ status: response.status
81
+ });
82
+ }
83
+ };
84
+ };
85
+
86
+ const createQueueRunContext = (options) => {
87
+ return {
88
+ env: options.env,
89
+ log: createDispatchLogger(`[queue:${options.exportName}]`),
90
+ run: createDispatchRunner({ env: options.env, fetchImpl: options.fetchImpl, label: "@lunora/queue" })
91
+ };
92
+ };
93
+
94
+ export { createQueueRunContext };
@@ -0,0 +1,33 @@
1
+ const producerFor = (binding) => {
2
+ return {
3
+ send: async (body, options) => {
4
+ await binding.send(body, options);
5
+ },
6
+ sendBatch: async (messages, options) => {
7
+ await binding.sendBatch(messages, options);
8
+ }
9
+ };
10
+ };
11
+ const createQueues = (options) => {
12
+ const bindings = options.bindings ?? {};
13
+ const producers = /* @__PURE__ */ Object.create(null);
14
+ for (const [exportName, binding] of Object.entries(bindings)) {
15
+ producers[exportName] = producerFor(binding);
16
+ }
17
+ const known = Object.keys(producers);
18
+ const missing = (name) => {
19
+ const suffix = known.length === 0 ? "no queues are declared" : `known queues: ${known.join(", ")}`;
20
+ const error = () => Promise.reject(new Error(`@lunora/queue: no queue named "${name}" (${suffix})`));
21
+ return { send: error, sendBatch: error };
22
+ };
23
+ return /* @__PURE__ */ new Proxy(producers, {
24
+ get(target, property) {
25
+ if (typeof property !== "string") {
26
+ return void 0;
27
+ }
28
+ return Object.hasOwn(target, property) ? target[property] : missing(property);
29
+ }
30
+ });
31
+ };
32
+
33
+ export { createQueues as default };
@@ -0,0 +1,18 @@
1
+ const queueBindingName = (exportName) => `QUEUE_${exportName.replaceAll(/(?<=[a-z0-9])(?=[A-Z])/g, "_").toUpperCase()}`;
2
+ const queueDefaultName = (exportName) => exportName.replaceAll(/(?<=[a-z0-9])(?=[A-Z])/g, "-").toLowerCase();
3
+ const defineQueue = (config) => {
4
+ const mode = config.mode ?? "push";
5
+ if (mode !== "push" && mode !== "pull") {
6
+ throw new TypeError(`defineQueue: \`mode\` must be "push" or "pull" (got ${JSON.stringify(config.mode)})`);
7
+ }
8
+ if (mode === "push" && typeof config.handler !== "function") {
9
+ throw new TypeError('defineQueue: `handler` must be a function for a push consumer (omit it only when `mode: "pull"`)');
10
+ }
11
+ if (config.name !== void 0 && (typeof config.name !== "string" || config.name.length === 0)) {
12
+ throw new TypeError("defineQueue: `name` must be a non-empty string when provided");
13
+ }
14
+ return { ...config, isLunoraQueue: true, mode };
15
+ };
16
+ const isQueueDefinition = (value) => typeof value === "object" && value !== null && value.isLunoraQueue === true;
17
+
18
+ export { defineQueue, isQueueDefinition, queueBindingName, queueDefaultName };
@@ -0,0 +1,132 @@
1
+ import { LunoraError } from '@lunora/errors';
2
+ import { createQueueRunContext } from './createQueueRunContext-C8jboCk6.mjs';
3
+
4
+ const DEFAULT_MAX_RETRIES = 3;
5
+ const timestampToMs = (value) => {
6
+ if (value instanceof Date) {
7
+ return value.getTime();
8
+ }
9
+ const asNumber = typeof value === "number" ? value : Number(value);
10
+ return Number.isFinite(asNumber) ? asNumber : 0;
11
+ };
12
+ const instrumentBatch = (batch) => {
13
+ const dispositions = /* @__PURE__ */ new Map();
14
+ const originals = batch.messages;
15
+ const wrappedMessages = originals.map((message) => {
16
+ return {
17
+ ack: () => {
18
+ dispositions.set(message, "ack");
19
+ message.ack();
20
+ },
21
+ get attempts() {
22
+ return message.attempts;
23
+ },
24
+ get body() {
25
+ return message.body;
26
+ },
27
+ get id() {
28
+ return message.id;
29
+ },
30
+ retry: (options) => {
31
+ dispositions.set(message, "retry");
32
+ message.retry(options);
33
+ },
34
+ get timestamp() {
35
+ return message.timestamp;
36
+ }
37
+ };
38
+ });
39
+ const fillUndecided = (outcome) => {
40
+ for (const message of originals) {
41
+ if (!dispositions.has(message)) {
42
+ dispositions.set(message, outcome);
43
+ }
44
+ }
45
+ };
46
+ const wrappedBatch = {
47
+ ackAll: () => {
48
+ fillUndecided("ack");
49
+ batch.ackAll();
50
+ },
51
+ messages: wrappedMessages,
52
+ queue: batch.queue,
53
+ retryAll: (options) => {
54
+ fillUndecided("retry");
55
+ batch.retryAll(options);
56
+ }
57
+ };
58
+ return { dispositions, originals, wrappedBatch };
59
+ };
60
+ const describeThrownError = (handlerError) => {
61
+ if (handlerError instanceof Error) {
62
+ return handlerError.message;
63
+ }
64
+ if (typeof handlerError === "string") {
65
+ return handlerError;
66
+ }
67
+ if (handlerError !== null && typeof handlerError === "object") {
68
+ try {
69
+ return JSON.stringify(handlerError);
70
+ } catch {
71
+ return "[unserializable thrown value]";
72
+ }
73
+ }
74
+ return String(handlerError);
75
+ };
76
+ const buildCaptureRecords = (harness, entry, queue, threw, handlerError) => {
77
+ const errorMessage = threw ? describeThrownError(handlerError) : void 0;
78
+ const maxRetries = typeof entry.definition.maxRetries === "number" ? entry.definition.maxRetries : DEFAULT_MAX_RETRIES;
79
+ return harness.originals.map((message) => {
80
+ const decided = harness.dispositions.get(message);
81
+ const outcome = decided ?? (threw ? "error" : "ack");
82
+ const attempts = typeof message.attempts === "number" ? message.attempts : 1;
83
+ return {
84
+ attempts,
85
+ body: message.body,
86
+ deadLettered: outcome !== "ack" && attempts > maxRetries,
87
+ error: outcome === "error" ? errorMessage : void 0,
88
+ exportName: entry.exportName,
89
+ messageId: message.id,
90
+ outcome,
91
+ queue,
92
+ timestamp: timestampToMs(message.timestamp)
93
+ };
94
+ });
95
+ };
96
+ const dispatchQueueBatch = async (batch, registry, options) => {
97
+ const entry = Object.hasOwn(registry, batch.queue) ? registry[batch.queue] : void 0;
98
+ if (entry === void 0) {
99
+ const known = Object.keys(registry);
100
+ const suffix = known.length === 0 ? "no push queues are declared" : `known push queues: ${known.join(", ")}`;
101
+ throw new LunoraError("INTERNAL", `@lunora/queue: received a batch for queue "${batch.queue}" but no push handler is registered (${suffix})`);
102
+ }
103
+ const { handler } = entry.definition;
104
+ if (typeof handler !== "function") {
105
+ throw new TypeError(`@lunora/queue: queue "${batch.queue}" (${entry.exportName}) has no push handler — it is declared as a pull consumer`);
106
+ }
107
+ const context = createQueueRunContext({ env: options.env, exportName: entry.exportName, fetchImpl: options.fetchImpl });
108
+ if (options.capture === void 0) {
109
+ await handler(context, batch);
110
+ return;
111
+ }
112
+ const harness = instrumentBatch(batch);
113
+ let threw = false;
114
+ let handlerError;
115
+ try {
116
+ await handler(context, harness.wrappedBatch);
117
+ } catch (error) {
118
+ threw = true;
119
+ handlerError = error;
120
+ }
121
+ try {
122
+ const records = buildCaptureRecords(harness, entry, batch.queue, threw, handlerError);
123
+ await options.capture(records);
124
+ } catch (captureError) {
125
+ console.warn("@lunora/queue: capture sink failed (delivery unaffected):", captureError);
126
+ }
127
+ if (threw) {
128
+ throw handlerError;
129
+ }
130
+ };
131
+
132
+ export { dispatchQueueBatch };
package/package.json CHANGED
@@ -1,19 +1,52 @@
1
1
  {
2
2
  "name": "@lunora/queue",
3
- "version": "0.0.0",
4
- "description": "Placeholder to reserve the npm name. Real releases are published from CI install the latest version.",
3
+ "version": "1.0.0-alpha.10",
4
+ "description": "Cloudflare Queues for Lunora: defineQueue producers + consumers, the ctx.queues surface, and the generated queue() worker handler",
5
+ "keywords": [
6
+ "background-jobs",
7
+ "cloudflare",
8
+ "lunora",
9
+ "messaging",
10
+ "queues",
11
+ "workers"
12
+ ],
13
+ "homepage": "https://lunora.sh",
14
+ "bugs": "https://github.com/anolilab/lunora/issues",
5
15
  "license": "FSL-1.1-Apache-2.0",
6
16
  "author": {
7
17
  "name": "Daniel Bannert",
8
18
  "email": "d.bannert@anolilab.de"
9
19
  },
10
- "homepage": "https://lunora.sh",
11
20
  "repository": {
12
21
  "type": "git",
13
22
  "url": "git+https://github.com/anolilab/lunora.git",
14
23
  "directory": "packages/queue"
15
24
  },
25
+ "files": [
26
+ "./dist",
27
+ "README.md",
28
+ "LICENSE.md",
29
+ "__assets__"
30
+ ],
31
+ "type": "module",
32
+ "sideEffects": false,
33
+ "main": "./dist/index.mjs",
34
+ "module": "./dist/index.mjs",
35
+ "types": "./dist/index.d.ts",
36
+ "exports": {
37
+ ".": {
38
+ "types": "./dist/index.d.ts",
39
+ "import": "./dist/index.mjs"
40
+ },
41
+ "./package.json": "./package.json"
42
+ },
16
43
  "publishConfig": {
17
44
  "access": "public"
45
+ },
46
+ "dependencies": {
47
+ "@lunora/errors": "1.0.0-alpha.8"
48
+ },
49
+ "engines": {
50
+ "node": "^22.15.0 || >=24.11.0"
18
51
  }
19
- }
52
+ }