@remit/queue-sidecar 0.0.1

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/src/server.ts ADDED
@@ -0,0 +1,408 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { IncomingMessage, ServerResponse } from "node:http";
3
+ import { createServer, type Server } from "node:http";
4
+ import {
5
+ type BatchErrorEntry,
6
+ type BatchSuccessEntry,
7
+ errorResponse,
8
+ queryResponse,
9
+ queryResponseNoResult,
10
+ queueAttributesResult,
11
+ queueUrlResult,
12
+ receiveMessageResult,
13
+ sendMessageBatchResult,
14
+ sendMessageResult,
15
+ } from "./protocol.js";
16
+ import {
17
+ QueueDoesNotExistError,
18
+ type QueueStore,
19
+ SqsError,
20
+ validateFifoSend,
21
+ } from "./store.js";
22
+
23
+ export interface SidecarLog {
24
+ info: (fields: Record<string, unknown>, message: string) => void;
25
+ error: (fields: Record<string, unknown>, message: string) => void;
26
+ }
27
+
28
+ export interface CreateSidecarOptions {
29
+ readonly store: QueueStore;
30
+ readonly accountId?: string;
31
+ readonly log?: SidecarLog;
32
+ /** Poll cadence while a ReceiveMessage long-poll waits for a message. */
33
+ readonly longPollIntervalMs?: number;
34
+ }
35
+
36
+ interface DispatchResult {
37
+ readonly status: number;
38
+ readonly body: string;
39
+ }
40
+
41
+ const DEFAULT_ACCOUNT_ID = "000000000000";
42
+ const DEFAULT_LONG_POLL_INTERVAL_MS = 250;
43
+ const MAX_WAIT_TIME_SECONDS = 20;
44
+
45
+ const noopLog: SidecarLog = { info: () => {}, error: () => {} };
46
+
47
+ const delay = (ms: number): Promise<void> =>
48
+ new Promise((resolve) => setTimeout(resolve, ms));
49
+
50
+ const readBody = (req: IncomingMessage): Promise<string> =>
51
+ new Promise((resolve, reject) => {
52
+ const chunks: Buffer[] = [];
53
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
54
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
55
+ req.on("error", reject);
56
+ });
57
+
58
+ const queueNameFromUrl = (queueUrl: string): string => {
59
+ const segments = queueUrl.split("?")[0].split("/").filter(Boolean);
60
+ const name = segments.at(-1);
61
+ if (!name) throw new InvalidRequestError(`malformed QueueUrl: ${queueUrl}`);
62
+ return decodeURIComponent(name);
63
+ };
64
+
65
+ class InvalidRequestError extends SqsError {
66
+ constructor(message: string) {
67
+ super("InvalidParameterValue", message);
68
+ }
69
+ }
70
+
71
+ const collectIndexed = (params: URLSearchParams, prefix: string): string[] => {
72
+ const values: string[] = [];
73
+ for (let index = 1; ; index += 1) {
74
+ const value = params.get(`${prefix}.${index}`);
75
+ if (value === null) break;
76
+ values.push(value);
77
+ }
78
+ return values;
79
+ };
80
+
81
+ interface ParsedBatchEntry {
82
+ id: string;
83
+ body: string;
84
+ groupId?: string;
85
+ deduplicationId?: string;
86
+ }
87
+
88
+ const parseBatchEntries = (
89
+ params: URLSearchParams,
90
+ prefix: string,
91
+ ): ParsedBatchEntry[] => {
92
+ const indices = new Set<string>();
93
+ for (const key of params.keys()) {
94
+ const match = key.match(new RegExp(`^${prefix}\\.(\\d+)\\.`));
95
+ if (match) indices.add(match[1]);
96
+ }
97
+ return [...indices]
98
+ .sort((a, b) => Number(a) - Number(b))
99
+ .map((index) => {
100
+ const id = params.get(`${prefix}.${index}.Id`);
101
+ const body = params.get(`${prefix}.${index}.MessageBody`);
102
+ if (id === null || body === null) {
103
+ throw new InvalidRequestError(
104
+ `batch entry ${index} missing Id or MessageBody`,
105
+ );
106
+ }
107
+ return {
108
+ id,
109
+ body,
110
+ groupId: params.get(`${prefix}.${index}.MessageGroupId`) ?? undefined,
111
+ deduplicationId:
112
+ params.get(`${prefix}.${index}.MessageDeduplicationId`) ?? undefined,
113
+ };
114
+ });
115
+ };
116
+
117
+ export const createSidecarServer = (options: CreateSidecarOptions): Server => {
118
+ const { store } = options;
119
+ const accountId = options.accountId ?? DEFAULT_ACCOUNT_ID;
120
+ const log = options.log ?? noopLog;
121
+ const longPollIntervalMs =
122
+ options.longPollIntervalMs ?? DEFAULT_LONG_POLL_INTERVAL_MS;
123
+
124
+ let closing = false;
125
+
126
+ const queueUrlFor = (host: string, queueName: string): string =>
127
+ `http://${host}/${accountId}/${queueName}`;
128
+
129
+ const requireQueueName = (params: URLSearchParams): string => {
130
+ const queueUrl = params.get("QueueUrl");
131
+ if (queueUrl) return queueNameFromUrl(queueUrl);
132
+ const queueName = params.get("QueueName");
133
+ if (queueName) return queueName;
134
+ throw new InvalidRequestError("request is missing QueueUrl or QueueName");
135
+ };
136
+
137
+ const receiveWithLongPoll = async (
138
+ queueName: string,
139
+ maxMessages: number,
140
+ visibilityTimeoutSeconds: number | undefined,
141
+ waitSeconds: number,
142
+ ): Promise<ReturnType<QueueStore["receiveMessages"]>> => {
143
+ const deadline = Date.now() + waitSeconds * 1000;
144
+ for (;;) {
145
+ const messages = store.receiveMessages({
146
+ queueName,
147
+ maxMessages,
148
+ visibilityTimeoutSeconds,
149
+ });
150
+ if (messages.length > 0 || Date.now() >= deadline || closing) {
151
+ return messages;
152
+ }
153
+ await delay(Math.min(longPollIntervalMs, deadline - Date.now()));
154
+ }
155
+ };
156
+
157
+ const dispatch = async (
158
+ action: string,
159
+ params: URLSearchParams,
160
+ host: string,
161
+ ): Promise<DispatchResult> => {
162
+ const requestId = randomUUID();
163
+
164
+ if (action === "CreateQueue") {
165
+ const name = params.get("QueueName");
166
+ if (!name)
167
+ throw new InvalidRequestError("CreateQueue requires QueueName");
168
+ const existing = store.getQueue(name);
169
+ const fifo =
170
+ params.get("Attribute.1.Name") === "FifoQueue"
171
+ ? params.get("Attribute.1.Value") === "true"
172
+ : name.endsWith(".fifo");
173
+ if (!existing) {
174
+ store.upsertQueue({
175
+ name,
176
+ fifo,
177
+ visibilityTimeoutSeconds: 30,
178
+ contentBasedDeduplication: false,
179
+ });
180
+ }
181
+ return {
182
+ status: 200,
183
+ body: queryResponse(
184
+ action,
185
+ queueUrlResult(queueUrlFor(host, name)),
186
+ requestId,
187
+ ),
188
+ };
189
+ }
190
+
191
+ if (action === "GetQueueUrl") {
192
+ const name = params.get("QueueName");
193
+ if (!name)
194
+ throw new InvalidRequestError("GetQueueUrl requires QueueName");
195
+ if (!store.getQueue(name)) throw new QueueDoesNotExistError(name);
196
+ return {
197
+ status: 200,
198
+ body: queryResponse(
199
+ action,
200
+ queueUrlResult(queueUrlFor(host, name)),
201
+ requestId,
202
+ ),
203
+ };
204
+ }
205
+
206
+ const queueName = requireQueueName(params);
207
+
208
+ if (action === "SendMessage") {
209
+ const body = params.get("MessageBody");
210
+ if (body === null) {
211
+ throw new InvalidRequestError("SendMessage requires MessageBody");
212
+ }
213
+ const result = store.sendMessage({
214
+ queueName,
215
+ body,
216
+ groupId: params.get("MessageGroupId") ?? undefined,
217
+ deduplicationId: params.get("MessageDeduplicationId") ?? undefined,
218
+ });
219
+ return {
220
+ status: 200,
221
+ body: queryResponse(action, sendMessageResult(result), requestId),
222
+ };
223
+ }
224
+
225
+ if (action === "SendMessageBatch") {
226
+ const entries = parseBatchEntries(params, "SendMessageBatchRequestEntry");
227
+ // A missing/unknown queue faults the whole request (SQS returns an
228
+ // error, not per-entry failures); a bad individual entry becomes a
229
+ // per-entry failure so the rest of the batch still lands.
230
+ const queue = store.getQueue(queueName);
231
+ if (!queue) throw new QueueDoesNotExistError(queueName);
232
+
233
+ const successful: BatchSuccessEntry[] = [];
234
+ const failed: BatchErrorEntry[] = [];
235
+ for (const entry of entries) {
236
+ const invalid = validateFifoSend(queue, entry);
237
+ if (invalid) {
238
+ failed.push({
239
+ id: entry.id,
240
+ code: invalid.code,
241
+ message: invalid.message,
242
+ senderFault: invalid.senderFault,
243
+ });
244
+ continue;
245
+ }
246
+ const result = store.sendMessage({
247
+ queueName,
248
+ body: entry.body,
249
+ groupId: entry.groupId,
250
+ deduplicationId: entry.deduplicationId,
251
+ });
252
+ successful.push({
253
+ id: entry.id,
254
+ messageId: result.messageId,
255
+ md5OfBody: result.md5OfBody,
256
+ sequenceNumber: result.sequenceNumber,
257
+ });
258
+ }
259
+ return {
260
+ status: 200,
261
+ body: queryResponse(
262
+ action,
263
+ sendMessageBatchResult(successful, failed),
264
+ requestId,
265
+ ),
266
+ };
267
+ }
268
+
269
+ if (action === "ReceiveMessage") {
270
+ const maxMessages = Number(params.get("MaxNumberOfMessages") ?? "1");
271
+ const visibilityTimeoutParam = params.get("VisibilityTimeout");
272
+ const waitSeconds = Math.min(
273
+ Number(params.get("WaitTimeSeconds") ?? "0"),
274
+ MAX_WAIT_TIME_SECONDS,
275
+ );
276
+ const messages = await receiveWithLongPoll(
277
+ queueName,
278
+ maxMessages,
279
+ visibilityTimeoutParam === null
280
+ ? undefined
281
+ : Number(visibilityTimeoutParam),
282
+ waitSeconds,
283
+ );
284
+ return {
285
+ status: 200,
286
+ body: queryResponse(
287
+ action,
288
+ receiveMessageResult(
289
+ messages.map((message) => ({
290
+ messageId: message.messageId,
291
+ receiptHandle: message.receiptHandle,
292
+ md5OfBody: message.md5OfBody,
293
+ body: message.body,
294
+ attributes: {
295
+ ApproximateReceiveCount: String(message.receiveCount),
296
+ SentTimestamp: String(message.sentTimestamp),
297
+ ApproximateFirstReceiveTimestamp: String(
298
+ message.firstReceivedTimestamp,
299
+ ),
300
+ ...(message.groupId ? { MessageGroupId: message.groupId } : {}),
301
+ ...(message.sequenceNumber
302
+ ? { SequenceNumber: message.sequenceNumber }
303
+ : {}),
304
+ },
305
+ })),
306
+ ),
307
+ requestId,
308
+ ),
309
+ };
310
+ }
311
+
312
+ if (action === "DeleteMessage") {
313
+ const receiptHandle = params.get("ReceiptHandle");
314
+ if (!receiptHandle) {
315
+ throw new InvalidRequestError("DeleteMessage requires ReceiptHandle");
316
+ }
317
+ store.deleteMessage(queueName, receiptHandle);
318
+ return { status: 200, body: queryResponseNoResult(action, requestId) };
319
+ }
320
+
321
+ if (action === "PurgeQueue") {
322
+ store.purgeQueue(queueName);
323
+ return { status: 200, body: queryResponseNoResult(action, requestId) };
324
+ }
325
+
326
+ if (action === "GetQueueAttributes") {
327
+ const attributes = store.getQueueAttributes(queueName);
328
+ const available: Record<string, string> = {
329
+ ApproximateNumberOfMessages: String(
330
+ attributes.approximateNumberOfMessages,
331
+ ),
332
+ ApproximateNumberOfMessagesNotVisible: String(
333
+ attributes.approximateNumberOfMessagesNotVisible,
334
+ ),
335
+ VisibilityTimeout: String(attributes.visibilityTimeout),
336
+ ...(attributes.fifoQueue ? { FifoQueue: "true" } : {}),
337
+ ...(attributes.redrivePolicy
338
+ ? { RedrivePolicy: attributes.redrivePolicy }
339
+ : {}),
340
+ };
341
+ const requested = collectIndexed(params, "AttributeName");
342
+ const wantAll = requested.length === 0 || requested.includes("All");
343
+ const values = wantAll
344
+ ? available
345
+ : Object.fromEntries(
346
+ Object.entries(available).filter(([name]) =>
347
+ requested.includes(name),
348
+ ),
349
+ );
350
+ return {
351
+ status: 200,
352
+ body: queryResponse(action, queueAttributesResult(values), requestId),
353
+ };
354
+ }
355
+
356
+ throw new InvalidRequestError(`unsupported action: ${action}`);
357
+ };
358
+
359
+ const respond = (res: ServerResponse, result: DispatchResult): void => {
360
+ res.writeHead(result.status, { "content-type": "text/xml" });
361
+ res.end(result.body);
362
+ };
363
+
364
+ const respondError = (res: ServerResponse, error: unknown): void => {
365
+ const requestId = randomUUID();
366
+ if (error instanceof SqsError) {
367
+ res.writeHead(error.senderFault ? 400 : 500, {
368
+ "content-type": "text/xml",
369
+ });
370
+ res.end(
371
+ errorResponse(error.code, error.message, requestId, error.senderFault),
372
+ );
373
+ return;
374
+ }
375
+ const message = error instanceof Error ? error.message : "internal error";
376
+ log.error({ error: message }, "sidecar: request failed");
377
+ res.writeHead(500, { "content-type": "text/xml" });
378
+ res.end(errorResponse("InternalFailure", message, requestId, false));
379
+ };
380
+
381
+ const handler = (req: IncomingMessage, res: ServerResponse): void => {
382
+ if (req.method === "GET" && req.url === "/health") {
383
+ res.writeHead(200, { "content-type": "text/plain" });
384
+ res.end("ok");
385
+ return;
386
+ }
387
+ if (req.method !== "POST") {
388
+ respondError(res, new InvalidRequestError("only POST is supported"));
389
+ return;
390
+ }
391
+ const host = req.headers.host ?? `localhost`;
392
+ readBody(req)
393
+ .then((body) => {
394
+ const params = new URLSearchParams(body);
395
+ const action = params.get("Action");
396
+ if (!action) throw new InvalidRequestError("request is missing Action");
397
+ return dispatch(action, params, host);
398
+ })
399
+ .then((result) => respond(res, result))
400
+ .catch((error) => respondError(res, error));
401
+ };
402
+
403
+ const server = createServer(handler);
404
+ server.on("close", () => {
405
+ closing = true;
406
+ });
407
+ return server;
408
+ };