@nebutra/queue 0.1.0 → 0.1.2
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 +69 -2
- package/dist/factory.d.ts +42 -0
- package/dist/factory.d.ts.map +1 -0
- package/dist/factory.js +156 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/middleware/qstash-verify.d.ts +16 -0
- package/dist/middleware/qstash-verify.d.ts.map +1 -0
- package/dist/middleware/qstash-verify.js +87 -0
- package/dist/providers/bullmq.d.ts +32 -0
- package/dist/providers/bullmq.d.ts.map +1 -0
- package/dist/providers/bullmq.js +258 -0
- package/dist/providers/memory.d.ts +23 -0
- package/dist/providers/memory.d.ts.map +1 -0
- package/dist/providers/memory.js +220 -0
- package/dist/providers/qstash.d.ts +26 -0
- package/dist/providers/qstash.d.ts.map +1 -0
- package/dist/providers/qstash.js +226 -0
- package/dist/providers/sqs.d.ts +18 -0
- package/dist/providers/sqs.d.ts.map +1 -0
- package/dist/providers/sqs.js +197 -0
- package/dist/queuebase-webhook.d.ts +45 -0
- package/dist/queuebase-webhook.d.ts.map +1 -0
- package/dist/queuebase-webhook.js +113 -0
- package/dist/queuebase.d.ts +27 -0
- package/dist/queuebase.d.ts.map +1 -0
- package/dist/queuebase.js +48 -0
- package/dist/scheduled/index.d.ts +15 -0
- package/dist/scheduled/index.d.ts.map +1 -0
- package/dist/scheduled/index.js +25 -0
- package/dist/scheduled/jobs/invitation-cleanup.d.ts +40 -0
- package/dist/scheduled/jobs/invitation-cleanup.d.ts.map +1 -0
- package/dist/scheduled/jobs/invitation-cleanup.js +44 -0
- package/dist/scheduled/jobs/session-cleanup.d.ts +24 -0
- package/dist/scheduled/jobs/session-cleanup.d.ts.map +1 -0
- package/dist/scheduled/jobs/session-cleanup.js +32 -0
- package/dist/scheduled/scheduler.d.ts +44 -0
- package/dist/scheduled/scheduler.d.ts.map +1 -0
- package/dist/scheduled/scheduler.js +52 -0
- package/dist/types.d.ts +202 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +32 -0
- package/package.json +19 -6
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { logger } from "@nebutra/logger";
|
|
2
|
+
import { Client } from "@upstash/qstash";
|
|
3
|
+
import { JobPayloadSchema } from "../types";
|
|
4
|
+
// =============================================================================
|
|
5
|
+
// QStash Provider — Upstash serverless message queue
|
|
6
|
+
// =============================================================================
|
|
7
|
+
// QStash works by POSTing job payloads to your API endpoints.
|
|
8
|
+
// You register HTTP route handlers (e.g. /api/queue/:type) that QStash calls.
|
|
9
|
+
//
|
|
10
|
+
// Architecture:
|
|
11
|
+
// enqueue() → QStash API (publish) → HTTP POST to callbackBaseUrl/api/queue/:type
|
|
12
|
+
// Your API route verifies the QStash signature and processes the job.
|
|
13
|
+
// =============================================================================
|
|
14
|
+
/**
|
|
15
|
+
* Handlers registered via `registerHandler` are stored here so the
|
|
16
|
+
* HTTP verification middleware can look them up at request time.
|
|
17
|
+
*/
|
|
18
|
+
const handlerRegistry = new Map();
|
|
19
|
+
/**
|
|
20
|
+
* Get a registered handler by its composite key (`queue:type`).
|
|
21
|
+
* Used by the QStash webhook verification middleware.
|
|
22
|
+
*/
|
|
23
|
+
export function getQStashHandler(queue, type) {
|
|
24
|
+
return handlerRegistry.get(`${queue}:${type}`);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Get ALL registered handlers (for introspection / health checks).
|
|
28
|
+
*/
|
|
29
|
+
export function getQStashHandlerKeys() {
|
|
30
|
+
return [...handlerRegistry.keys()];
|
|
31
|
+
}
|
|
32
|
+
function readString(value) {
|
|
33
|
+
if (typeof value === "string" && value.length > 0)
|
|
34
|
+
return value;
|
|
35
|
+
if (Array.isArray(value))
|
|
36
|
+
return readString(value[0]);
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
function readNumber(value) {
|
|
40
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
41
|
+
}
|
|
42
|
+
function readHeader(headers, name) {
|
|
43
|
+
if (!headers || typeof headers !== "object")
|
|
44
|
+
return undefined;
|
|
45
|
+
const entries = Object.entries(headers);
|
|
46
|
+
const match = entries.find(([key]) => key.toLowerCase() === name.toLowerCase());
|
|
47
|
+
return match ? readString(match[1]) : undefined;
|
|
48
|
+
}
|
|
49
|
+
function parseOriginalJob(record) {
|
|
50
|
+
const rawBody = readString(record.body);
|
|
51
|
+
if (!rawBody)
|
|
52
|
+
return undefined;
|
|
53
|
+
try {
|
|
54
|
+
return JobPayloadSchema.parse(JSON.parse(rawBody));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function readFailedAt(record) {
|
|
61
|
+
const failedAt = readString(record.failedAt) ?? readString(record.time);
|
|
62
|
+
if (failedAt)
|
|
63
|
+
return failedAt;
|
|
64
|
+
const timestamp = readNumber(record.failedAt) ?? readNumber(record.time) ?? readNumber(record.createdAt);
|
|
65
|
+
return timestamp ? new Date(timestamp).toISOString() : new Date().toISOString();
|
|
66
|
+
}
|
|
67
|
+
function readFailedReason(record) {
|
|
68
|
+
return (readString(record.failedReason) ??
|
|
69
|
+
readString(record.error) ??
|
|
70
|
+
readString(record.responseBody) ??
|
|
71
|
+
(readNumber(record.responseStatus) ? `HTTP ${readNumber(record.responseStatus)}` : undefined) ??
|
|
72
|
+
"QStash delivery failed");
|
|
73
|
+
}
|
|
74
|
+
export function toQStashDeadLetterJob(record) {
|
|
75
|
+
const originalJob = parseOriginalJob(record);
|
|
76
|
+
if (!originalJob)
|
|
77
|
+
return undefined;
|
|
78
|
+
const headerJobId = readHeader(record.header, "x-nebutra-job-id");
|
|
79
|
+
const headerJobType = readHeader(record.header, "x-nebutra-job-type");
|
|
80
|
+
const maxRetries = readNumber(record.maxRetries) ?? originalJob.options?.maxRetries ?? 0;
|
|
81
|
+
return {
|
|
82
|
+
id: originalJob.id || headerJobId || readString(record.messageId) || "unknown",
|
|
83
|
+
queue: originalJob.queue,
|
|
84
|
+
type: originalJob.type || headerJobType || "unknown",
|
|
85
|
+
originalJob,
|
|
86
|
+
attempts: readNumber(record.attempts) ?? maxRetries + 1,
|
|
87
|
+
maxRetries,
|
|
88
|
+
failedReason: readFailedReason(record),
|
|
89
|
+
provider: "qstash",
|
|
90
|
+
failedAt: readFailedAt(record),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
export class QStashProvider {
|
|
94
|
+
name = "qstash";
|
|
95
|
+
client;
|
|
96
|
+
callbackBaseUrl;
|
|
97
|
+
token;
|
|
98
|
+
dlqEndpoint;
|
|
99
|
+
dlqFetcher;
|
|
100
|
+
constructor(config) {
|
|
101
|
+
const token = config.token ?? process.env.QSTASH_TOKEN;
|
|
102
|
+
if (!token) {
|
|
103
|
+
throw new Error("QStash token not configured. Set QSTASH_TOKEN env var or pass `token` in config.");
|
|
104
|
+
}
|
|
105
|
+
this.token = token;
|
|
106
|
+
this.client = new Client({ token });
|
|
107
|
+
this.callbackBaseUrl = config.callbackBaseUrl.replace(/\/$/, "");
|
|
108
|
+
this.dlqEndpoint = config.dlqEndpoint;
|
|
109
|
+
this.dlqFetcher = config.dlqFetcher;
|
|
110
|
+
logger.info("[queue:qstash] Provider initialised", {
|
|
111
|
+
callbackBaseUrl: this.callbackBaseUrl,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
// ── Enqueue ─────────────────────────────────────────────────────────────
|
|
115
|
+
async enqueue(job) {
|
|
116
|
+
const destination = `${this.callbackBaseUrl}/api/queue/${job.queue}/${job.type}`;
|
|
117
|
+
try {
|
|
118
|
+
const response = await this.client.publishJSON({
|
|
119
|
+
url: destination,
|
|
120
|
+
body: job,
|
|
121
|
+
...(job.options?.idempotencyKey !== undefined
|
|
122
|
+
? { deduplicationId: job.options.idempotencyKey }
|
|
123
|
+
: {}),
|
|
124
|
+
...(job.options?.delaySec ? { delay: job.options.delaySec } : {}),
|
|
125
|
+
retries: job.options?.maxRetries ?? 3,
|
|
126
|
+
...(job.options?.cron ? { cron: job.options.cron } : {}),
|
|
127
|
+
headers: {
|
|
128
|
+
"x-nebutra-queue": job.queue,
|
|
129
|
+
"x-nebutra-job-type": job.type,
|
|
130
|
+
"x-nebutra-job-id": job.id,
|
|
131
|
+
...(job.options?.tenantId ? { "x-nebutra-tenant-id": job.options.tenantId } : {}),
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
const messageId = "messageId" in response ? response.messageId : job.id;
|
|
135
|
+
logger.info("[queue:qstash] Job enqueued", {
|
|
136
|
+
jobId: job.id,
|
|
137
|
+
messageId,
|
|
138
|
+
destination,
|
|
139
|
+
});
|
|
140
|
+
return {
|
|
141
|
+
jobId: messageId,
|
|
142
|
+
accepted: true,
|
|
143
|
+
provider: "qstash",
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
logger.error("[queue:qstash] Failed to enqueue job", {
|
|
148
|
+
jobId: job.id,
|
|
149
|
+
error: error instanceof Error ? error.message : String(error),
|
|
150
|
+
});
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async enqueueBatch(jobs) {
|
|
155
|
+
// QStash supports batch publish — use it for efficiency
|
|
156
|
+
try {
|
|
157
|
+
const messages = jobs.map((job) => ({
|
|
158
|
+
url: `${this.callbackBaseUrl}/api/queue/${job.queue}/${job.type}`,
|
|
159
|
+
body: JSON.stringify(job),
|
|
160
|
+
headers: {
|
|
161
|
+
"Content-Type": "application/json",
|
|
162
|
+
"x-nebutra-queue": job.queue,
|
|
163
|
+
"x-nebutra-job-type": job.type,
|
|
164
|
+
"x-nebutra-job-id": job.id,
|
|
165
|
+
...(job.options?.tenantId ? { "x-nebutra-tenant-id": job.options.tenantId } : {}),
|
|
166
|
+
},
|
|
167
|
+
...(job.options?.idempotencyKey !== undefined
|
|
168
|
+
? { deduplicationId: job.options.idempotencyKey }
|
|
169
|
+
: {}),
|
|
170
|
+
retries: job.options?.maxRetries ?? 3,
|
|
171
|
+
}));
|
|
172
|
+
const responses = await this.client.batchJSON(messages);
|
|
173
|
+
logger.info("[queue:qstash] Batch enqueued", { count: jobs.length });
|
|
174
|
+
return responses.map((r, idx) => ({
|
|
175
|
+
jobId: "messageId" in r ? r.messageId : (jobs[idx]?.id ?? ""),
|
|
176
|
+
accepted: true,
|
|
177
|
+
provider: "qstash",
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
logger.error("[queue:qstash] Batch enqueue failed, falling back to sequential", {
|
|
182
|
+
error: error instanceof Error ? error.message : String(error),
|
|
183
|
+
});
|
|
184
|
+
// Fallback: sequential enqueue
|
|
185
|
+
const results = [];
|
|
186
|
+
for (const job of jobs) {
|
|
187
|
+
results.push(await this.enqueue(job));
|
|
188
|
+
}
|
|
189
|
+
return results;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// ── Handler Registration ────────────────────────────────────────────────
|
|
193
|
+
registerHandler(queue, type, handler) {
|
|
194
|
+
const key = `${queue}:${type}`;
|
|
195
|
+
handlerRegistry.set(key, handler);
|
|
196
|
+
logger.info("[queue:qstash] Handler registered", { key });
|
|
197
|
+
}
|
|
198
|
+
async getDeadLetteredJobs(queue) {
|
|
199
|
+
if (!this.dlqFetcher) {
|
|
200
|
+
logger.warn("[queue:qstash] DLQ fetcher not configured; returning empty dead-letter list");
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
const records = await this.dlqFetcher({
|
|
205
|
+
token: this.token,
|
|
206
|
+
...(this.dlqEndpoint !== undefined ? { endpoint: this.dlqEndpoint } : {}),
|
|
207
|
+
...(queue !== undefined ? { queue } : {}),
|
|
208
|
+
});
|
|
209
|
+
return records
|
|
210
|
+
.map(toQStashDeadLetterJob)
|
|
211
|
+
.filter((job) => Boolean(job))
|
|
212
|
+
.filter((job) => queue === undefined || job.queue === queue);
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
logger.error("[queue:qstash] Failed to fetch dead-letter jobs", {
|
|
216
|
+
error: error instanceof Error ? error.message : String(error),
|
|
217
|
+
});
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// ── Lifecycle ───────────────────────────────────────────────────────────
|
|
222
|
+
async close() {
|
|
223
|
+
handlerRegistry.clear();
|
|
224
|
+
logger.info("[queue:qstash] Provider closed");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { JobHandler, JobPayload, JobResult, QueueProvider, SQSProviderConfig } from "../types";
|
|
2
|
+
export declare class SQSProvider implements QueueProvider {
|
|
3
|
+
readonly name: "sqs";
|
|
4
|
+
private client;
|
|
5
|
+
private queueUrl;
|
|
6
|
+
private waitTimeSeconds;
|
|
7
|
+
private maxMessages;
|
|
8
|
+
private visibilityTimeoutSeconds;
|
|
9
|
+
constructor(config: Omit<SQSProviderConfig, "provider">);
|
|
10
|
+
enqueue(job: JobPayload): Promise<JobResult>;
|
|
11
|
+
enqueueBatch(jobs: JobPayload[]): Promise<JobResult[]>;
|
|
12
|
+
registerHandler<T extends Record<string, unknown>>(queue: string, type: string, handler: JobHandler<T>): void;
|
|
13
|
+
private pollLoop;
|
|
14
|
+
private dispatch;
|
|
15
|
+
private deleteMessage;
|
|
16
|
+
close(): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=sqs.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sqs.d.ts","sourceRoot":"","sources":["../../src/providers/sqs.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,SAAS,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AA6BpG,qBAAa,WAAY,YAAW,aAAa;IAC/C,QAAQ,CAAC,IAAI,EAAG,KAAK,CAAU;IAE/B,OAAO,CAAC,MAAM,CAAY;IAC1B,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,wBAAwB,CAAS;gBAE7B,MAAM,EAAE,IAAI,CAAC,iBAAiB,EAAE,UAAU,CAAC;IA+BjD,OAAO,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC;IAmC5C,YAAY,CAAC,IAAI,EAAE,UAAU,EAAE,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IA6C5D,eAAe,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/C,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,UAAU,CAAC,CAAC,CAAC,GACrB,IAAI;YAgBO,QAAQ;YAgCR,QAAQ;YAmCR,aAAa;IAcrB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAM7B"}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { DeleteMessageCommand, ReceiveMessageCommand, SendMessageBatchCommand, SendMessageCommand, SQSClient, } from "@aws-sdk/client-sqs";
|
|
2
|
+
import { logger } from "@nebutra/logger";
|
|
3
|
+
const handlerRegistry = new Map();
|
|
4
|
+
function handlerKey(queue, type) {
|
|
5
|
+
return `${queue}:${type}`;
|
|
6
|
+
}
|
|
7
|
+
export class SQSProvider {
|
|
8
|
+
name = "sqs";
|
|
9
|
+
client;
|
|
10
|
+
queueUrl;
|
|
11
|
+
waitTimeSeconds;
|
|
12
|
+
maxMessages;
|
|
13
|
+
visibilityTimeoutSeconds;
|
|
14
|
+
constructor(config) {
|
|
15
|
+
const region = config.region ?? process.env.AWS_REGION;
|
|
16
|
+
const queueUrl = config.queueUrl ?? process.env.AWS_SQS_QUEUE_URL;
|
|
17
|
+
if (!region) {
|
|
18
|
+
throw new Error("SQS region not configured. Set AWS_REGION env var or pass `region`.");
|
|
19
|
+
}
|
|
20
|
+
if (!queueUrl) {
|
|
21
|
+
throw new Error("SQS queue URL not configured. Set AWS_SQS_QUEUE_URL env var or pass `queueUrl`.");
|
|
22
|
+
}
|
|
23
|
+
const accessKeyId = config.accessKeyId ?? process.env.AWS_ACCESS_KEY_ID;
|
|
24
|
+
const secretAccessKey = config.secretAccessKey ?? process.env.AWS_SECRET_ACCESS_KEY;
|
|
25
|
+
this.client = new SQSClient({
|
|
26
|
+
region,
|
|
27
|
+
// If explicit creds provided, use them; otherwise AWS SDK uses default
|
|
28
|
+
// chain (IAM role on EC2/ECS/Lambda, ~/.aws/credentials, env, etc.).
|
|
29
|
+
...(accessKeyId && secretAccessKey ? { credentials: { accessKeyId, secretAccessKey } } : {}),
|
|
30
|
+
});
|
|
31
|
+
this.queueUrl = queueUrl;
|
|
32
|
+
this.waitTimeSeconds = config.waitTimeSeconds ?? 20;
|
|
33
|
+
this.maxMessages = config.maxMessages ?? 10;
|
|
34
|
+
this.visibilityTimeoutSeconds = config.visibilityTimeoutSeconds ?? 30;
|
|
35
|
+
logger.info("[queue:sqs] Provider initialised", { region, queueUrl: queueUrl.slice(0, 60) });
|
|
36
|
+
}
|
|
37
|
+
// ── Enqueue ─────────────────────────────────────────────────────────────
|
|
38
|
+
async enqueue(job) {
|
|
39
|
+
try {
|
|
40
|
+
const response = await this.client.send(new SendMessageCommand({
|
|
41
|
+
QueueUrl: this.queueUrl,
|
|
42
|
+
MessageBody: JSON.stringify(job),
|
|
43
|
+
...(job.options?.delaySec ? { DelaySeconds: Math.min(job.options.delaySec, 900) } : {}),
|
|
44
|
+
...(job.options?.idempotencyKey
|
|
45
|
+
? {
|
|
46
|
+
// FIFO queues use deduplication ID; standard queues ignore it.
|
|
47
|
+
MessageDeduplicationId: job.options.idempotencyKey,
|
|
48
|
+
MessageGroupId: job.queue,
|
|
49
|
+
}
|
|
50
|
+
: {}),
|
|
51
|
+
}));
|
|
52
|
+
const messageId = response.MessageId ?? job.id;
|
|
53
|
+
logger.info("[queue:sqs] Job enqueued", { jobId: job.id, messageId });
|
|
54
|
+
return {
|
|
55
|
+
jobId: messageId,
|
|
56
|
+
accepted: true,
|
|
57
|
+
provider: "sqs",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
logger.error("[queue:sqs] Failed to enqueue job", {
|
|
62
|
+
jobId: job.id,
|
|
63
|
+
error: error instanceof Error ? error.message : String(error),
|
|
64
|
+
});
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async enqueueBatch(jobs) {
|
|
69
|
+
// SQS SendMessageBatch caps at 10 entries per request — chunk if needed.
|
|
70
|
+
const results = [];
|
|
71
|
+
for (let i = 0; i < jobs.length; i += 10) {
|
|
72
|
+
const chunk = jobs.slice(i, i + 10);
|
|
73
|
+
try {
|
|
74
|
+
const response = await this.client.send(new SendMessageBatchCommand({
|
|
75
|
+
QueueUrl: this.queueUrl,
|
|
76
|
+
Entries: chunk.map((job, idx) => ({
|
|
77
|
+
Id: `msg-${idx}-${Date.now()}`,
|
|
78
|
+
MessageBody: JSON.stringify(job),
|
|
79
|
+
...(job.options?.delaySec
|
|
80
|
+
? { DelaySeconds: Math.min(job.options.delaySec, 900) }
|
|
81
|
+
: {}),
|
|
82
|
+
})),
|
|
83
|
+
}));
|
|
84
|
+
const successful = response.Successful ?? [];
|
|
85
|
+
for (let j = 0; j < chunk.length; j++) {
|
|
86
|
+
const ok = successful[j];
|
|
87
|
+
results.push({
|
|
88
|
+
jobId: ok?.MessageId ?? chunk[j]?.id ?? "",
|
|
89
|
+
accepted: Boolean(ok),
|
|
90
|
+
provider: "sqs",
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
logger.error("[queue:sqs] Batch enqueue failed, falling back to sequential", {
|
|
96
|
+
error: error instanceof Error ? error.message : String(error),
|
|
97
|
+
});
|
|
98
|
+
for (const job of chunk) {
|
|
99
|
+
try {
|
|
100
|
+
results.push(await this.enqueue(job));
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
results.push({ jobId: job.id, accepted: false, provider: "sqs" });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return results;
|
|
109
|
+
}
|
|
110
|
+
// ── Handler Registration & Poll Loop ─────────────────────────────────────
|
|
111
|
+
registerHandler(queue, type, handler) {
|
|
112
|
+
const key = handlerKey(queue, type);
|
|
113
|
+
if (handlerRegistry.has(key)) {
|
|
114
|
+
logger.warn("[queue:sqs] Handler already registered, replacing", { key });
|
|
115
|
+
const existing = handlerRegistry.get(key);
|
|
116
|
+
existing?.abort.abort();
|
|
117
|
+
}
|
|
118
|
+
const abort = new AbortController();
|
|
119
|
+
handlerRegistry.set(key, { handler: handler, abort });
|
|
120
|
+
void this.pollLoop(queue, type, handler, abort.signal);
|
|
121
|
+
logger.info("[queue:sqs] Handler registered + poll loop started", { key });
|
|
122
|
+
}
|
|
123
|
+
async pollLoop(queue, type, handler, signal) {
|
|
124
|
+
while (!signal.aborted) {
|
|
125
|
+
try {
|
|
126
|
+
const response = await this.client.send(new ReceiveMessageCommand({
|
|
127
|
+
QueueUrl: this.queueUrl,
|
|
128
|
+
MaxNumberOfMessages: this.maxMessages,
|
|
129
|
+
WaitTimeSeconds: this.waitTimeSeconds,
|
|
130
|
+
VisibilityTimeout: this.visibilityTimeoutSeconds,
|
|
131
|
+
}));
|
|
132
|
+
const messages = response.Messages ?? [];
|
|
133
|
+
for (const msg of messages) {
|
|
134
|
+
if (signal.aborted)
|
|
135
|
+
break;
|
|
136
|
+
await this.dispatch(msg, queue, type, handler);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (signal.aborted)
|
|
141
|
+
break;
|
|
142
|
+
logger.error("[queue:sqs] Poll error, sleeping 5s", {
|
|
143
|
+
error: error instanceof Error ? error.message : String(error),
|
|
144
|
+
});
|
|
145
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async dispatch(msg, queue, type, handler) {
|
|
150
|
+
if (!msg.Body || !msg.ReceiptHandle)
|
|
151
|
+
return;
|
|
152
|
+
let job;
|
|
153
|
+
try {
|
|
154
|
+
job = JSON.parse(msg.Body);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
logger.error("[queue:sqs] Malformed message body, deleting", { messageId: msg.MessageId });
|
|
158
|
+
await this.deleteMessage(msg.ReceiptHandle);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (job.queue !== queue || job.type !== type) {
|
|
162
|
+
// Not for this handler — leave it for whoever owns it. Visibility timeout
|
|
163
|
+
// will expire and another consumer can pick it up.
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
await handler(job);
|
|
168
|
+
await this.deleteMessage(msg.ReceiptHandle);
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
logger.error("[queue:sqs] Handler failed; message will be retried by SQS", {
|
|
172
|
+
jobId: job.id,
|
|
173
|
+
error: error instanceof Error ? error.message : String(error),
|
|
174
|
+
});
|
|
175
|
+
// Don't delete — SQS will redeliver after visibility timeout.
|
|
176
|
+
// Configure a DLQ on the SQS queue side for terminal failure handling.
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async deleteMessage(receiptHandle) {
|
|
180
|
+
try {
|
|
181
|
+
await this.client.send(new DeleteMessageCommand({ QueueUrl: this.queueUrl, ReceiptHandle: receiptHandle }));
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
logger.error("[queue:sqs] DeleteMessage failed", {
|
|
185
|
+
error: error instanceof Error ? error.message : String(error),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// ── Lifecycle ───────────────────────────────────────────────────────────
|
|
190
|
+
async close() {
|
|
191
|
+
for (const entry of handlerRegistry.values())
|
|
192
|
+
entry.abort.abort();
|
|
193
|
+
handlerRegistry.clear();
|
|
194
|
+
this.client.destroy();
|
|
195
|
+
logger.info("[queue:sqs] Provider closed");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export type QueuebaseBackoff = "fixed" | "exponential" | "linear";
|
|
3
|
+
export interface QueuebaseScheduleConfig {
|
|
4
|
+
cron: string;
|
|
5
|
+
timezone?: string;
|
|
6
|
+
overlap?: "allow" | "skip";
|
|
7
|
+
}
|
|
8
|
+
export type QueuebaseSchedule = string | QueuebaseScheduleConfig;
|
|
9
|
+
export interface QueuebaseJobContext<TInput> {
|
|
10
|
+
input: TInput;
|
|
11
|
+
jobId: string;
|
|
12
|
+
attempt: number;
|
|
13
|
+
maxAttempts: number;
|
|
14
|
+
fail: (reason: string) => never;
|
|
15
|
+
}
|
|
16
|
+
export interface QueuebaseJobDefinition<TSchema extends z.ZodTypeAny = z.ZodTypeAny, TResult = unknown> {
|
|
17
|
+
input: TSchema;
|
|
18
|
+
handler: (context: QueuebaseJobContext<z.output<TSchema>>) => Promise<TResult> | TResult;
|
|
19
|
+
defaults?: {
|
|
20
|
+
retries?: number;
|
|
21
|
+
backoff?: QueuebaseBackoff;
|
|
22
|
+
};
|
|
23
|
+
schedule?: QueuebaseSchedule;
|
|
24
|
+
}
|
|
25
|
+
export type QueuebaseJobRouter = Record<string, QueuebaseJobDefinition>;
|
|
26
|
+
export interface QueuebaseWebhookOptions {
|
|
27
|
+
webhookSecret?: string;
|
|
28
|
+
signatureHeader?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface QueuebaseScheduleMetadata {
|
|
31
|
+
name: string;
|
|
32
|
+
schedule: QueuebaseSchedule;
|
|
33
|
+
}
|
|
34
|
+
export declare function defineQueueJob<TSchema extends z.ZodTypeAny, TResult>(definition: QueuebaseJobDefinition<TSchema, TResult>): QueuebaseJobDefinition<TSchema, TResult>;
|
|
35
|
+
export declare function createJobRouter<TRouter extends QueuebaseJobRouter>(router: TRouter): TRouter;
|
|
36
|
+
export declare function createQueuebaseWebhookHandler<TRouter extends QueuebaseJobRouter>(router: TRouter, options?: QueuebaseWebhookOptions): (request: Request) => Promise<Response>;
|
|
37
|
+
export declare function listQueuebaseSchedules<TRouter extends QueuebaseJobRouter>(router: TRouter): QueuebaseScheduleMetadata[];
|
|
38
|
+
export declare const queuebaseJobs: {
|
|
39
|
+
invitationCleanup: QueuebaseJobDefinition<z.ZodObject<{}, z.core.$strip>, import("./scheduled").ScheduledJobResult>;
|
|
40
|
+
sessionCleanup: QueuebaseJobDefinition<z.ZodObject<{}, z.core.$strip>, import("./scheduled").ScheduledJobResult>;
|
|
41
|
+
};
|
|
42
|
+
export type QueuebaseJobs = typeof queuebaseJobs;
|
|
43
|
+
export declare const queuebaseWebhookHandler: (request: Request) => Promise<Response>;
|
|
44
|
+
export declare function verifyQueuebaseSignature(body: string, signature: string | null | undefined, secret: string): boolean;
|
|
45
|
+
//# sourceMappingURL=queuebase-webhook.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"queuebase-webhook.d.ts","sourceRoot":"","sources":["../src/queuebase-webhook.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,MAAM,MAAM,gBAAgB,GAAG,OAAO,GAAG,aAAa,GAAG,QAAQ,CAAC;AAElE,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;CAC5B;AAED,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,uBAAuB,CAAC;AAEjE,MAAM,WAAW,mBAAmB,CAAC,MAAM;IACzC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,KAAK,CAAC;CACjC;AAED,MAAM,WAAW,sBAAsB,CACrC,OAAO,SAAS,CAAC,CAAC,UAAU,GAAG,CAAC,CAAC,UAAU,EAC3C,OAAO,GAAG,OAAO;IAEjB,KAAK,EAAE,OAAO,CAAC;IACf,OAAO,EAAE,CAAC,OAAO,EAAE,mBAAmB,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,KAAK,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC;IACzF,QAAQ,CAAC,EAAE;QACT,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,OAAO,CAAC,EAAE,gBAAgB,CAAC;KAC5B,CAAC;IACF,QAAQ,CAAC,EAAE,iBAAiB,CAAC;CAC9B;AAED,MAAM,MAAM,kBAAkB,GAAG,MAAM,CAAC,MAAM,EAAE,sBAAsB,CAAC,CAAC;AAExE,MAAM,WAAW,uBAAuB;IACtC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,yBAAyB;IACxC,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,iBAAiB,CAAC;CAC7B;AAqBD,wBAAgB,cAAc,CAAC,OAAO,SAAS,CAAC,CAAC,UAAU,EAAE,OAAO,EAClE,UAAU,EAAE,sBAAsB,CAAC,OAAO,EAAE,OAAO,CAAC,GACnD,sBAAsB,CAAC,OAAO,EAAE,OAAO,CAAC,CAE1C;AAED,wBAAgB,eAAe,CAAC,OAAO,SAAS,kBAAkB,EAAE,MAAM,EAAE,OAAO,GAAG,OAAO,CAE5F;AAED,wBAAgB,6BAA6B,CAAC,OAAO,SAAS,kBAAkB,EAC9E,MAAM,EAAE,OAAO,EACf,OAAO,GAAE,uBAA4B,GACpC,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAkEzC;AAED,wBAAgB,sBAAsB,CAAC,OAAO,SAAS,kBAAkB,EACvE,MAAM,EAAE,OAAO,GACd,yBAAyB,EAAE,CAQ7B;AAED,eAAO,MAAM,aAAa;;;CAWxB,CAAC;AAEH,MAAM,MAAM,aAAa,GAAG,OAAO,aAAa,CAAC;AAEjD,eAAO,MAAM,uBAAuB,YA/FvB,OAAO,KAAK,OAAO,CAAC,QAAQ,CAmGvC,CAAC;AAEH,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACpC,MAAM,EAAE,MAAM,GACb,OAAO,CAUT"}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { invitationCleanup, sessionCleanup } from "./scheduled";
|
|
4
|
+
class QueuebaseFailure extends Error {
|
|
5
|
+
reason;
|
|
6
|
+
constructor(reason) {
|
|
7
|
+
super(reason);
|
|
8
|
+
this.reason = reason;
|
|
9
|
+
this.name = "QueuebaseFailure";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function defineQueueJob(definition) {
|
|
13
|
+
return definition;
|
|
14
|
+
}
|
|
15
|
+
export function createJobRouter(router) {
|
|
16
|
+
return router;
|
|
17
|
+
}
|
|
18
|
+
export function createQueuebaseWebhookHandler(router, options = {}) {
|
|
19
|
+
return async (request) => {
|
|
20
|
+
if (request.method !== "POST") {
|
|
21
|
+
return Response.json({ ok: false, error: "Method not allowed" }, { status: 405 });
|
|
22
|
+
}
|
|
23
|
+
const body = await request.text();
|
|
24
|
+
const signatureHeader = options.signatureHeader ?? "x-queuebase-signature";
|
|
25
|
+
const secret = options.webhookSecret;
|
|
26
|
+
if (secret && !verifyQueuebaseSignature(body, request.headers.get(signatureHeader), secret)) {
|
|
27
|
+
return Response.json({ ok: false, error: "Invalid webhook signature" }, { status: 401 });
|
|
28
|
+
}
|
|
29
|
+
let payload;
|
|
30
|
+
try {
|
|
31
|
+
payload = JSON.parse(body);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return Response.json({ ok: false, error: "Invalid JSON payload" }, { status: 400 });
|
|
35
|
+
}
|
|
36
|
+
const jobName = readString(payload.jobName ?? payload.name ?? payload.type);
|
|
37
|
+
if (!jobName) {
|
|
38
|
+
return Response.json({ ok: false, error: "Missing jobName" }, { status: 400 });
|
|
39
|
+
}
|
|
40
|
+
const definition = router[jobName];
|
|
41
|
+
if (!definition) {
|
|
42
|
+
return Response.json({ ok: false, jobName, error: `Unknown job '${jobName}'` }, { status: 404 });
|
|
43
|
+
}
|
|
44
|
+
const parsed = definition.input.safeParse(payload.input ?? payload.payload ?? {});
|
|
45
|
+
if (!parsed.success) {
|
|
46
|
+
return Response.json({ ok: false, jobName, error: `Invalid input for job ${jobName}: ${parsed.error.message}` }, { status: 400 });
|
|
47
|
+
}
|
|
48
|
+
const jobId = readString(payload.jobId ?? payload.id) ?? crypto.randomUUID();
|
|
49
|
+
const attempt = readNumber(payload.attempt) ?? 1;
|
|
50
|
+
const maxAttempts = readNumber(payload.maxAttempts) ?? definition.defaults?.retries ?? 1;
|
|
51
|
+
try {
|
|
52
|
+
const result = await definition.handler({
|
|
53
|
+
input: parsed.data,
|
|
54
|
+
jobId,
|
|
55
|
+
attempt,
|
|
56
|
+
maxAttempts,
|
|
57
|
+
fail: (reason) => {
|
|
58
|
+
throw new QueuebaseFailure(reason);
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
return Response.json({ ok: true, jobName, result });
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
const message = error instanceof QueuebaseFailure
|
|
65
|
+
? error.reason
|
|
66
|
+
: error instanceof Error
|
|
67
|
+
? error.message
|
|
68
|
+
: String(error);
|
|
69
|
+
return Response.json({ ok: false, jobName, error: message }, { status: 500 });
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export function listQueuebaseSchedules(router) {
|
|
74
|
+
return Object.entries(router)
|
|
75
|
+
.filter((entry) => {
|
|
76
|
+
return entry[1].schedule !== undefined;
|
|
77
|
+
})
|
|
78
|
+
.map(([name, definition]) => ({ name, schedule: definition.schedule }));
|
|
79
|
+
}
|
|
80
|
+
export const queuebaseJobs = createJobRouter({
|
|
81
|
+
invitationCleanup: defineQueueJob({
|
|
82
|
+
input: z.object({}),
|
|
83
|
+
schedule: { cron: invitationCleanup.cron, timezone: "UTC", overlap: "skip" },
|
|
84
|
+
handler: async () => invitationCleanup.handler(),
|
|
85
|
+
}),
|
|
86
|
+
sessionCleanup: defineQueueJob({
|
|
87
|
+
input: z.object({}),
|
|
88
|
+
schedule: { cron: sessionCleanup.cron, timezone: "UTC", overlap: "skip" },
|
|
89
|
+
handler: async () => sessionCleanup.handler(),
|
|
90
|
+
}),
|
|
91
|
+
});
|
|
92
|
+
export const queuebaseWebhookHandler = createQueuebaseWebhookHandler(queuebaseJobs, {
|
|
93
|
+
...(process.env.QUEUEBASE_WEBHOOK_SECRET
|
|
94
|
+
? { webhookSecret: process.env.QUEUEBASE_WEBHOOK_SECRET }
|
|
95
|
+
: {}),
|
|
96
|
+
});
|
|
97
|
+
export function verifyQueuebaseSignature(body, signature, secret) {
|
|
98
|
+
if (!signature)
|
|
99
|
+
return false;
|
|
100
|
+
const raw = signature.startsWith("sha256=") ? signature.slice("sha256=".length) : signature;
|
|
101
|
+
if (!/^[a-f0-9]{64}$/i.test(raw))
|
|
102
|
+
return false;
|
|
103
|
+
const expected = createHmac("sha256", secret).update(body).digest("hex");
|
|
104
|
+
const actualBuffer = Buffer.from(raw, "hex");
|
|
105
|
+
const expectedBuffer = Buffer.from(expected, "hex");
|
|
106
|
+
return (actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer));
|
|
107
|
+
}
|
|
108
|
+
function readString(value) {
|
|
109
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
110
|
+
}
|
|
111
|
+
function readNumber(value) {
|
|
112
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
113
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import type { QueuebaseJobRouter } from "./queuebase-webhook";
|
|
3
|
+
import type { JobOptions, JobResult, QueueProvider } from "./types";
|
|
4
|
+
export type { QueuebaseBackoff, QueuebaseJobContext, QueuebaseJobDefinition, QueuebaseJobRouter, QueuebaseJobs, QueuebaseSchedule, QueuebaseScheduleConfig, QueuebaseScheduleMetadata, QueuebaseWebhookOptions, } from "./queuebase-webhook";
|
|
5
|
+
export { createJobRouter, createQueuebaseWebhookHandler, defineQueueJob, listQueuebaseSchedules, queuebaseJobs, queuebaseWebhookHandler, verifyQueuebaseSignature, } from "./queuebase-webhook";
|
|
6
|
+
export interface QueuebaseClientOptions {
|
|
7
|
+
callbackUrl: string;
|
|
8
|
+
apiUrl?: string;
|
|
9
|
+
apiKey?: string;
|
|
10
|
+
queue?: QueueProvider;
|
|
11
|
+
}
|
|
12
|
+
export type QueuebaseClient<TRouter extends QueuebaseJobRouter> = {
|
|
13
|
+
[K in keyof TRouter]: {
|
|
14
|
+
enqueue: (input: z.input<TRouter[K]["input"]>, options?: JobOptions) => Promise<QueuebaseEnqueueResult>;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
export interface QueuebaseEnqueueResult extends JobResult {
|
|
18
|
+
jobName: string;
|
|
19
|
+
callbackUrl: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function createJobClient<TRouter extends QueuebaseJobRouter>(router: TRouter, options: QueuebaseClientOptions): QueuebaseClient<TRouter>;
|
|
22
|
+
export declare function getQueuebaseCallbackUrl(baseUrl?: string): string;
|
|
23
|
+
export declare const queuebaseJobClient: QueuebaseClient<{
|
|
24
|
+
invitationCleanup: import("./queuebase-webhook").QueuebaseJobDefinition<z.ZodObject<{}, z.core.$strip>, import("./scheduled").ScheduledJobResult>;
|
|
25
|
+
sessionCleanup: import("./queuebase-webhook").QueuebaseJobDefinition<z.ZodObject<{}, z.core.$strip>, import("./scheduled").ScheduledJobResult>;
|
|
26
|
+
}>;
|
|
27
|
+
//# sourceMappingURL=queuebase.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"queuebase.d.ts","sourceRoot":"","sources":["../src/queuebase.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAC7B,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAE9D,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAEpE,YAAY,EACV,gBAAgB,EAChB,mBAAmB,EACnB,sBAAsB,EACtB,kBAAkB,EAClB,aAAa,EACb,iBAAiB,EACjB,uBAAuB,EACvB,yBAAyB,EACzB,uBAAuB,GACxB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,eAAe,EACf,6BAA6B,EAC7B,cAAc,EACd,sBAAsB,EACtB,aAAa,EACb,uBAAuB,EACvB,wBAAwB,GACzB,MAAM,qBAAqB,CAAC;AAE7B,MAAM,WAAW,sBAAsB;IACrC,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,aAAa,CAAC;CACvB;AAED,MAAM,MAAM,eAAe,CAAC,OAAO,SAAS,kBAAkB,IAAI;KAC/D,CAAC,IAAI,MAAM,OAAO,GAAG;QACpB,OAAO,EAAE,CACP,KAAK,EAAE,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,EACnC,OAAO,CAAC,EAAE,UAAU,KACjB,OAAO,CAAC,sBAAsB,CAAC,CAAC;KACtC;CACF,CAAC;AAEF,MAAM,WAAW,sBAAuB,SAAQ,SAAS;IACvD,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,wBAAgB,eAAe,CAAC,OAAO,SAAS,kBAAkB,EAChE,MAAM,EAAE,OAAO,EACf,OAAO,EAAE,sBAAsB,GAC9B,eAAe,CAAC,OAAO,CAAC,CAoC1B;AAED,wBAAgB,uBAAuB,CAAC,OAAO,SAAsB,GAAG,MAAM,CAE7E;AAED,eAAO,MAAM,kBAAkB;;;EAI7B,CAAC"}
|