@remit/sqs-client 0.0.16 → 0.0.17

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/poller.ts DELETED
@@ -1,284 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import { inspect } from "node:util";
3
- import {
4
- DeleteMessageCommand,
5
- ReceiveMessageCommand,
6
- type ReceiveMessageCommandOutput,
7
- } from "@aws-sdk/client-sqs";
8
- import type {
9
- Context,
10
- SQSBatchResponse,
11
- SQSEvent,
12
- SQSHandler,
13
- } from "aws-lambda";
14
- import {
15
- clearHeartbeats,
16
- createHeartbeat,
17
- type Heartbeat,
18
- } from "./heartbeat.js";
19
- import { createQueueProducer } from "./producer.js";
20
-
21
- /**
22
- * Production queue poller — the non-e2e counterpart of the per-worker
23
- * `e2e-processor-shim.ts` scripts. It receives from an SQS-compatible
24
- * endpoint (real SQS or ElasticMQ), wraps each batch in an `SQSEvent`, and
25
- * invokes the exact production Lambda `handler`, honouring
26
- * `batchItemFailures` the way the SQS service does: delete what succeeded,
27
- * leave failures for redelivery.
28
- *
29
- * Unlike the e2e shim (one child process per queue, via `node:cluster`),
30
- * this runs every target queue as a concurrent poll loop inside a single
31
- * process — the right shape for a container whose only job is "poll these
32
- * queues, invoke this handler, exit on SIGTERM."
33
- */
34
- export interface QueuePollerLog {
35
- info: (fields: Record<string, unknown>, message: string) => void;
36
- error: (fields: Record<string, unknown>, message: string) => void;
37
- }
38
-
39
- export interface QueuePollerTarget {
40
- readonly queueUrl: string;
41
- readonly handler: SQSHandler;
42
- /** Reported as the Lambda `Context.functionName` the handler observes. */
43
- readonly functionName: string;
44
- readonly maxMessages?: number;
45
- readonly visibilityTimeoutSeconds?: number;
46
- }
47
-
48
- export interface RunQueuePollerOptions {
49
- readonly targets: readonly QueuePollerTarget[];
50
- readonly log: QueuePollerLog;
51
- readonly signals?: readonly NodeJS.Signals[];
52
- /** One per target. Defaults to a file under `WORKER_HEARTBEAT_PREFIX`. */
53
- readonly createHeartbeat?: (name: string) => Heartbeat;
54
- }
55
-
56
- // One message per receive, not the SQS maximum of 10. Handlers process a batch
57
- // sequentially, so a batch of ten is ten handler runs between two heartbeats:
58
- // ten stalled SMTP sends at nodemailer's 300 s socket timeout is 50 minutes of
59
- // legitimate silence, and a threshold that tolerates it detects nothing. At one
60
- // message the quiet stretch a healthy loop can produce is one handler run, and
61
- // the extra receive per message is a millisecond against the local queue
62
- // sidecar — the same total work either way.
63
- const DEFAULT_MAX_MESSAGES = 1;
64
- /**
65
- * What a handler has before the queue redelivers the record underneath it.
66
- * Exported because a handler that budgets its own wait has to agree with it;
67
- * `deploy/vps/queues.json` sets the same number on the queue itself, and this
68
- * is the one that applies wherever a target passes no override.
69
- */
70
- export const DEFAULT_VISIBILITY_TIMEOUT_SECONDS = 300;
71
-
72
- const pollTarget = async (
73
- target: QueuePollerTarget,
74
- log: QueuePollerLog,
75
- isShuttingDown: () => boolean,
76
- shutdownSignal: AbortSignal,
77
- buildHeartbeat: (name: string) => Heartbeat,
78
- ): Promise<void> => {
79
- const queueName = new URL(target.queueUrl).pathname.split("/").pop();
80
- const heartbeat = buildHeartbeat(queueName ?? target.functionName);
81
- const sqs = createQueueProducer({ queueUrl: target.queueUrl });
82
- const maxMessages = target.maxMessages ?? DEFAULT_MAX_MESSAGES;
83
- const visibilityTimeout =
84
- target.visibilityTimeoutSeconds ?? DEFAULT_VISIBILITY_TIMEOUT_SECONDS;
85
- log.info({ queue: queueName, maxMessages }, "poller: started");
86
-
87
- // Always long-poll (SQS max WaitTimeSeconds). An empty long poll is free
88
- // and returns as soon as a message arrives, so this is never slower than
89
- // short-polling under load — it just stops burning a ReceiveMessage call
90
- // (and, against real SQS, its cost) every loop iteration while traffic is
91
- // continuous.
92
- const LONG_POLL_WAIT_SECONDS = 20;
93
-
94
- while (!isShuttingDown()) {
95
- // Top of the receive attempt, before the long poll: this loop's own file,
96
- // so a loop that stops turning — wedged in a socket read, or in a handler
97
- // that never returns — goes stale on its own however busy its siblings are.
98
- //
99
- // A write that fails must not take the worker down with it. A full disk is
100
- // the likeliest cause and the moment the loop should stay up, failing
101
- // records into the dead-letter queues with logs rather than crash-looping
102
- // on the monitoring. The missed beat is itself the signal: the healthcheck
103
- // goes stale, which is what this file exists to report.
104
- await heartbeat().catch((error) => {
105
- log.error(
106
- { queue: queueName, error: inspect(error) },
107
- "poller: heartbeat write failed",
108
- );
109
- });
110
-
111
- let response: ReceiveMessageCommandOutput;
112
- try {
113
- // Abort the receive on shutdown. Without this, a container stopped
114
- // while idle waits out the current 20s long poll before the loop
115
- // checks the flag again, and a self-update then burns the whole
116
- // stop grace on every idle worker — the bulk of the visible restart
117
- // downtime. The abort ends the in-flight receive at once; an
118
- // in-flight handler below is never interrupted, so a message being
119
- // processed still finishes cleanly. The heartbeat above already wrote
120
- // this iteration, so the abort exits without a further beat.
121
- response = await sqs.send(
122
- new ReceiveMessageCommand({
123
- QueueUrl: target.queueUrl,
124
- MaxNumberOfMessages: maxMessages,
125
- WaitTimeSeconds: LONG_POLL_WAIT_SECONDS,
126
- VisibilityTimeout: visibilityTimeout,
127
- MessageSystemAttributeNames: ["ApproximateReceiveCount"],
128
- }),
129
- { abortSignal: shutdownSignal },
130
- );
131
- } catch (error) {
132
- if (shutdownSignal.aborted) break;
133
- throw error;
134
- }
135
-
136
- if (!response.Messages || response.Messages.length === 0) {
137
- continue;
138
- }
139
-
140
- const messages = response.Messages.flatMap((m) =>
141
- m.Body && m.ReceiptHandle && m.MessageId
142
- ? [
143
- {
144
- messageId: m.MessageId,
145
- receiptHandle: m.ReceiptHandle,
146
- body: m.Body,
147
- receiveCount: m.Attributes?.ApproximateReceiveCount ?? "1",
148
- },
149
- ]
150
- : [],
151
- );
152
- if (messages.length === 0) continue;
153
-
154
- // One batch is one handler invocation, so the id is minted per batch: it is
155
- // the `requestId` every line of that invocation is grouped by (see
156
- // deploy/vps/README.md, "Logs"). A context built once per target would put
157
- // the whole queue's lifetime under one id, which correlates nothing.
158
- //
159
- // Minted before the poller's own bracketing lines rather than next to the
160
- // handler call: grouping on the id has to reach the line saying whether the
161
- // message was deleted or left for redelivery, which is what someone looks
162
- // for after a failure.
163
- const lambdaContext = {
164
- functionName: target.functionName,
165
- awsRequestId: randomUUID(),
166
- } as Context;
167
-
168
- log.info(
169
- {
170
- queue: queueName,
171
- count: messages.length,
172
- requestId: lambdaContext.awsRequestId,
173
- },
174
- "poller: invoking handler",
175
- );
176
-
177
- const event: SQSEvent = {
178
- Records: messages.map((m) => ({
179
- messageId: m.messageId,
180
- receiptHandle: m.receiptHandle,
181
- body: m.body,
182
- attributes: {
183
- ApproximateReceiveCount: m.receiveCount,
184
- SentTimestamp: "0",
185
- SenderId: "poller",
186
- ApproximateFirstReceiveTimestamp: "0",
187
- },
188
- messageAttributes: {},
189
- md5OfBody: "",
190
- eventSource: "aws:sqs",
191
- eventSourceARN: target.queueUrl,
192
- awsRegion: "local",
193
- })),
194
- };
195
-
196
- const result = (await target.handler(event, lambdaContext, () => {})) as
197
- | SQSBatchResponse
198
- | undefined;
199
-
200
- const failedIds = new Set(
201
- (result?.batchItemFailures ?? []).map((f) => f.itemIdentifier),
202
- );
203
- const succeeded = messages.filter((m) => !failedIds.has(m.messageId));
204
-
205
- // Batch is capped at maxMessages (one by default, 10 is the SQS ceiling),
206
- // so a handful of concurrent DeleteMessage calls is safe and avoids paying
207
- // N sequential round trips between every receive/handler cycle.
208
- await Promise.all(
209
- succeeded.map((message) =>
210
- sqs.send(
211
- new DeleteMessageCommand({
212
- QueueUrl: target.queueUrl,
213
- ReceiptHandle: message.receiptHandle,
214
- }),
215
- ),
216
- ),
217
- );
218
-
219
- log.info(
220
- {
221
- queue: queueName,
222
- deleted: succeeded.length,
223
- leftForRedelivery: failedIds.size,
224
- requestId: lambdaContext.awsRequestId,
225
- },
226
- "poller: batch processed",
227
- );
228
- }
229
-
230
- log.info({ queue: queueName }, "poller: stopped");
231
- };
232
-
233
- /**
234
- * Runs every target's poll loop until one of `signals` is received (default:
235
- * SIGINT, SIGTERM). The signal aborts each loop's in-flight
236
- * long-poll receive so an idle worker returns at once instead of waiting out
237
- * the current 20s poll; a loop mid-handler finishes that handler first. Rejects
238
- * (crashes the process) if any loop throws — a stuck poller should exit loudly,
239
- * not degrade silently.
240
- *
241
- * Every loop writes its own heartbeat file (see heartbeat.ts) at the top of its
242
- * receive attempt, which is how a loop that hangs instead of throwing — the
243
- * case this cannot make loud by itself — becomes visible.
244
- */
245
- export const runQueuePoller = async (
246
- options: RunQueuePollerOptions,
247
- ): Promise<void> => {
248
- const { targets, log } = options;
249
- const signals = options.signals ?? ["SIGINT", "SIGTERM"];
250
- const buildHeartbeat = options.createHeartbeat ?? createHeartbeat;
251
-
252
- if (targets.length === 0) {
253
- throw new Error("runQueuePoller: no targets configured");
254
- }
255
-
256
- // Files from the previous run of this service, before its loops write again:
257
- // the healthcheck reads the oldest file it finds, so one left behind by a
258
- // queue this version no longer polls would fail the check forever.
259
- await clearHeartbeats().catch((error) => {
260
- log.error({ error: inspect(error) }, "poller: heartbeat cleanup failed");
261
- });
262
-
263
- let shuttingDown = false;
264
- const isShuttingDown = () => shuttingDown;
265
- // Aborted alongside the flag so a loop currently blocked in its long-poll
266
- // receive returns at once, rather than after up to WaitTimeSeconds.
267
- const shutdown = new AbortController();
268
- const onSignal = (signal: NodeJS.Signals) => {
269
- log.info({ signal }, "poller: shutdown signal received");
270
- shuttingDown = true;
271
- shutdown.abort();
272
- };
273
- for (const signal of signals) {
274
- process.on(signal, onSignal);
275
- }
276
-
277
- log.info({ queues: targets.map((t) => t.queueUrl) }, "poller: starting");
278
-
279
- await Promise.all(
280
- targets.map((target) =>
281
- pollTarget(target, log, isShuttingDown, shutdown.signal, buildHeartbeat),
282
- ),
283
- );
284
- };
@@ -1,179 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { createServer } from "node:http";
3
- import type { AddressInfo } from "node:net";
4
- import { describe, it } from "node:test";
5
- import { ReceiveMessageCommand, type SQSClient } from "@aws-sdk/client-sqs";
6
- import { AwsQueryProtocol } from "@aws-sdk/core/protocols";
7
- import { createQueueProducer, isLocalEndpoint } from "./producer.js";
8
-
9
- // The queue URL a self-hosted compose stack hands its workers: a plain
10
- // http:// URL addressing the queue container by name, never "localhost".
11
- const composeQueueUrl = "http://queue:9324/000000000000/mailboxes";
12
- const awsQueueUrl =
13
- "https://sqs.eu-west-1.amazonaws.com/123456789012/remit-dev-mailboxes.fifo";
14
-
15
- const usesQueryProtocol = (client: SQSClient): boolean =>
16
- client.config.protocol instanceof AwsQueryProtocol;
17
-
18
- const resolvedEndpoint = async (
19
- client: SQSClient,
20
- ): Promise<string | undefined> => {
21
- const { endpoint } = client.config;
22
- if (!endpoint) return undefined;
23
- const resolved = await endpoint();
24
- return `${resolved.protocol}//${resolved.hostname}:${resolved.port}`;
25
- };
26
-
27
- describe("isLocalEndpoint", () => {
28
- it("treats a non-localhost http:// queue URL as local", () => {
29
- assert.equal(isLocalEndpoint(composeQueueUrl), true);
30
- });
31
-
32
- it("treats http://localhost as local", () => {
33
- assert.equal(
34
- isLocalEndpoint("http://localhost:9324/000000000000/mailboxes"),
35
- true,
36
- );
37
- });
38
-
39
- it("treats https://localhost as local", () => {
40
- assert.equal(
41
- isLocalEndpoint("https://localhost:9324/000000000000/mailboxes"),
42
- true,
43
- );
44
- });
45
-
46
- it("treats a real SQS queue URL as remote", () => {
47
- assert.equal(isLocalEndpoint(awsQueueUrl), false);
48
- });
49
-
50
- // A prefix match on "https://localhost" reads all of these as local. The
51
- // host is localhost in none of them.
52
- it("treats an https host that merely starts with localhost as remote", () => {
53
- assert.equal(isLocalEndpoint("https://localhost.example.com/0/q"), false);
54
- assert.equal(
55
- isLocalEndpoint("https://localhost-queue.example.com/0/q"),
56
- false,
57
- );
58
- });
59
-
60
- it("treats an https URL with localhost in the userinfo as remote", () => {
61
- assert.equal(isLocalEndpoint("https://localhost@example.com/0/q"), false);
62
- assert.equal(
63
- isLocalEndpoint("https://localhost:pass@example.com/0/q"),
64
- false,
65
- );
66
- });
67
-
68
- // The mirror image: a prefix match misses these, and they are local.
69
- it("treats a loopback https URL carrying credentials as local", () => {
70
- assert.equal(isLocalEndpoint("https://user:pass@localhost/0/q"), true);
71
- });
72
-
73
- it("ignores scheme case", () => {
74
- assert.equal(isLocalEndpoint("HTTP://queue:9324/0/q"), true);
75
- assert.equal(isLocalEndpoint("HtTpS://localhost:9324/0/q"), true);
76
- });
77
-
78
- it("treats loopback addresses over https as local", () => {
79
- assert.equal(isLocalEndpoint("https://127.0.0.1:9324/0/q"), true);
80
- assert.equal(isLocalEndpoint("https://[::1]:9324/0/q"), true);
81
- });
82
-
83
- it("treats any http URL as local regardless of host, port, or credentials", () => {
84
- assert.equal(isLocalEndpoint("http://queue/0/q"), true);
85
- assert.equal(isLocalEndpoint("http://user:pass@queue:9324/0/q"), true);
86
- assert.equal(isLocalEndpoint("http://example.com/0/q"), true);
87
- });
88
-
89
- // Must not throw: several callers construct their client at module load, so
90
- // a throw here would take the worker down at import.
91
- it("treats an unparseable queue URL as remote without throwing", () => {
92
- assert.equal(isLocalEndpoint(""), false);
93
- assert.equal(isLocalEndpoint("not a url"), false);
94
- assert.equal(isLocalEndpoint("queue:9324/0/q"), false);
95
- assert.equal(isLocalEndpoint("//queue:9324/0/q"), false);
96
- });
97
- });
98
-
99
- describe("createQueueProducer", () => {
100
- // Regression (self-host stack): a queue URL of http://queue:9324/... used to
101
- // fall through to the AWS JSON protocol, so every send hit the local queue
102
- // server with a body it cannot parse and mail never synced.
103
- it("selects the query protocol for a non-localhost http:// queue URL", async () => {
104
- const client = createQueueProducer({ queueUrl: composeQueueUrl });
105
- assert.equal(usesQueryProtocol(client), true);
106
- assert.equal(await resolvedEndpoint(client), "http://queue:9324");
107
- });
108
-
109
- it("selects the query protocol for a localhost queue URL", () => {
110
- const client = createQueueProducer({
111
- queueUrl: "http://localhost:9324/000000000000/mailboxes",
112
- });
113
- assert.equal(usesQueryProtocol(client), true);
114
- });
115
-
116
- it("leaves the default protocol and endpoint for a real AWS queue URL", async () => {
117
- const client = createQueueProducer({ queueUrl: awsQueueUrl });
118
- assert.equal(usesQueryProtocol(client), false);
119
- assert.equal(await resolvedEndpoint(client), undefined);
120
- });
121
-
122
- it("does not throw when the queue URL is unparseable", async () => {
123
- const client = createQueueProducer({ queueUrl: "not a url" });
124
- assert.equal(usesQueryProtocol(client), false);
125
- assert.equal(await resolvedEndpoint(client), undefined);
126
- });
127
-
128
- it("accepts an explicit endpoint override", async () => {
129
- const client = createQueueProducer({
130
- queueUrl: composeQueueUrl,
131
- endpoint: "http://custom-endpoint:1234",
132
- });
133
- assert.equal(usesQueryProtocol(client), true);
134
- assert.equal(await resolvedEndpoint(client), "http://custom-endpoint:1234");
135
- });
136
-
137
- it("uses localCredentials only for a local endpoint", async () => {
138
- const local = createQueueProducer({
139
- queueUrl: composeQueueUrl,
140
- localCredentials: { accessKeyId: "local", secretAccessKey: "local" },
141
- env: {},
142
- });
143
- assert.equal((await local.config.credentials()).accessKeyId, "local");
144
-
145
- const remote = createQueueProducer({
146
- queueUrl: awsQueueUrl,
147
- localCredentials: { accessKeyId: "local", secretAccessKey: "local" },
148
- env: { SQS_ACCESS_KEY_ID: "real", SQS_SECRET_ACCESS_KEY: "real" },
149
- });
150
- assert.equal((await remote.config.credentials()).accessKeyId, "real");
151
- });
152
- });
153
-
154
- describe("createQueueProducer request timeout", () => {
155
- it("fails a receive the queue server never answers", async () => {
156
- const server = createServer(() => {});
157
- await new Promise<void>((resolve) => {
158
- server.listen(0, "127.0.0.1", resolve);
159
- });
160
- const { port } = server.address() as AddressInfo;
161
- const queueUrl = `http://127.0.0.1:${port}/000000000000/hung`;
162
- const client = createQueueProducer({
163
- queueUrl,
164
- requestTimeoutMs: 100,
165
- localCredentials: { accessKeyId: "test", secretAccessKey: "test" },
166
- });
167
-
168
- try {
169
- await assert.rejects(
170
- () => client.send(new ReceiveMessageCommand({ QueueUrl: queueUrl })),
171
- { name: "TimeoutError" },
172
- );
173
- } finally {
174
- client.destroy();
175
- server.closeAllConnections();
176
- await new Promise<void>((resolve) => server.close(() => resolve()));
177
- }
178
- });
179
- });
package/src/producer.ts DELETED
@@ -1,74 +0,0 @@
1
- import { SQSClient } from "@aws-sdk/client-sqs";
2
- import { AwsQueryProtocol } from "@aws-sdk/core/protocols";
3
- import { resolveSqsCredentials, type SqsStaticCredentials } from "./index.js";
4
-
5
- export interface CreateQueueProducerOptions {
6
- readonly queueUrl: string;
7
- readonly endpoint?: string;
8
- readonly env?: NodeJS.ProcessEnv;
9
- /**
10
- * Credentials to use when the queue URL resolves to a local endpoint. A
11
- * local queue server ignores them, but the SDK still needs something to
12
- * sign with and a self-hosted stack has no credential chain to fall back on.
13
- */
14
- readonly localCredentials?: SqsStaticCredentials;
15
- readonly requestTimeoutMs?: number;
16
- }
17
-
18
- const DEFAULT_REQUEST_TIMEOUT_MS = 45_000;
19
-
20
- const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
21
-
22
- /**
23
- * Whether a queue URL addresses a local, SQS-compatible queue server
24
- * (ElasticMQ, the queue sidecar) rather than real SQS.
25
- *
26
- * Any plain `http://` URL is local: real SQS and Scaleway M&Q are always
27
- * HTTPS, and a self-hosted stack addresses its queue server by container name
28
- * (`http://queue:9324/...`), not by `localhost`. Over HTTPS only a loopback
29
- * host is local, which covers a local server behind TLS.
30
- *
31
- * The URL is parsed rather than prefix-matched. Prefix-matching the string
32
- * gets the interesting cases wrong in both directions: it reads
33
- * `https://localhost.example.com` and `https://localhost@example.com` as
34
- * local (the host is neither), and misses `https://user:pass@localhost` and
35
- * an uppercase `HTTP://` scheme (both of which are).
36
- *
37
- * A queue URL that does not parse is treated as remote. It cannot be sent to
38
- * either way, and several callers build their client at module load — throwing
39
- * here would take a worker down at import instead of surfacing the bad
40
- * configuration at the send that actually uses it.
41
- */
42
- export const isLocalEndpoint = (queueUrl: string): boolean => {
43
- let url: URL;
44
- try {
45
- url = new URL(queueUrl);
46
- } catch {
47
- return false;
48
- }
49
- if (url.protocol === "http:") return true;
50
- return url.protocol === "https:" && LOOPBACK_HOSTNAMES.has(url.hostname);
51
- };
52
-
53
- /**
54
- * Producer-side counterpart of `runQueuePoller`: the one place an
55
- * SQS-compatible client is constructed for sending. A queue URL that points at
56
- * a local endpoint (ElasticMQ) gets its origin as the SDK endpoint and the
57
- * query protocol ElasticMQ speaks; real SQS and Scaleway M&Q resolve their own
58
- * endpoint and ride the default credential chain unless
59
- * `SQS_ACCESS_KEY_ID`/`SQS_SECRET_ACCESS_KEY` are present.
60
- */
61
- export const createQueueProducer = (
62
- options: CreateQueueProducerOptions,
63
- ): SQSClient => {
64
- const { queueUrl, endpoint, env, localCredentials } = options;
65
- const requestTimeout = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
66
- const local = isLocalEndpoint(queueUrl);
67
- return new SQSClient({
68
- requestHandler: { requestTimeout, throwOnRequestTimeout: true },
69
- endpoint: endpoint ?? (local ? new URL(queueUrl).origin : undefined),
70
- ...(local && { protocol: AwsQueryProtocol }),
71
- credentials:
72
- (local ? localCredentials : undefined) ?? resolveSqsCredentials(env),
73
- });
74
- };
package/tsconfig.json DELETED
@@ -1,8 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.json",
3
- "compilerOptions": {
4
- "outDir": "dist",
5
- "rootDir": "src"
6
- },
7
- "include": ["src/**/*.ts"]
8
- }