@remit/sqs-client 0.0.1 → 0.0.3

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/package.json CHANGED
@@ -1,40 +1,40 @@
1
1
  {
2
- "name": "@remit/sqs-client",
3
- "version": "0.0.1",
4
- "type": "module",
5
- "main": "src/index.ts",
6
- "types": "src/index.ts",
7
- "exports": {
8
- ".": {
9
- "types": "./src/index.ts",
10
- "default": "./src/index.ts"
11
- },
12
- "./poller": {
13
- "types": "./src/poller.ts",
14
- "default": "./src/poller.ts"
15
- },
16
- "./producer": {
17
- "types": "./src/producer.ts",
18
- "default": "./src/producer.ts"
19
- }
20
- },
21
- "scripts": {
22
- "test:typecheck": "tsgo --noEmit",
23
- "test:run": "node --env-file=../../localhost-test-unit.env --test 'src/**/*.test.ts'",
24
- "test": "npm run test:typecheck && npm run test:run"
25
- },
26
- "devDependencies": {
27
- "@aws-sdk/client-sqs": "*",
28
- "@aws-sdk/core": "*",
29
- "@types/aws-lambda": "*"
30
- },
31
- "license": "MIT",
32
- "publishConfig": {
33
- "access": "public"
34
- },
35
- "repository": {
36
- "type": "git",
37
- "url": "git+https://github.com/remit-mail/remit.git",
38
- "directory": "packages/sqs-client"
39
- }
2
+ "name": "@remit/sqs-client",
3
+ "version": "0.0.3",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./src/index.ts",
10
+ "default": "./src/index.ts"
11
+ },
12
+ "./poller": {
13
+ "types": "./src/poller.ts",
14
+ "default": "./src/poller.ts"
15
+ },
16
+ "./producer": {
17
+ "types": "./src/producer.ts",
18
+ "default": "./src/producer.ts"
19
+ }
20
+ },
21
+ "scripts": {
22
+ "test:typecheck": "tsgo --noEmit",
23
+ "test:run": "node --env-file=../../localhost-test-unit.env --test 'src/**/*.test.ts'",
24
+ "test": "npm run test:typecheck && npm run test:run"
25
+ },
26
+ "devDependencies": {
27
+ "@aws-sdk/client-sqs": "*",
28
+ "@aws-sdk/core": "*",
29
+ "@types/aws-lambda": "*"
30
+ },
31
+ "license": "MIT",
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/remit-mail/reader.git",
38
+ "directory": "packages/sqs-client"
39
+ }
40
40
  }
@@ -1,28 +1,150 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
- import { createQueueProducer } from "./producer.js";
3
+ import type { SQSClient } from "@aws-sdk/client-sqs";
4
+ import { AwsQueryProtocol } from "@aws-sdk/core/protocols";
5
+ import { createQueueProducer, isLocalEndpoint } from "./producer.js";
6
+
7
+ // The queue URL a self-hosted compose stack hands its workers: a plain
8
+ // http:// URL addressing the queue container by name, never "localhost".
9
+ const composeQueueUrl = "http://queue:9324/000000000000/mailboxes";
10
+ const awsQueueUrl =
11
+ "https://sqs.eu-west-1.amazonaws.com/123456789012/remit-dev-mailboxes.fifo";
12
+
13
+ const usesQueryProtocol = (client: SQSClient): boolean =>
14
+ client.config.protocol instanceof AwsQueryProtocol;
15
+
16
+ const resolvedEndpoint = async (
17
+ client: SQSClient,
18
+ ): Promise<string | undefined> => {
19
+ const { endpoint } = client.config;
20
+ if (!endpoint) return undefined;
21
+ const resolved = await endpoint();
22
+ return `${resolved.protocol}//${resolved.hostname}:${resolved.port}`;
23
+ };
24
+
25
+ describe("isLocalEndpoint", () => {
26
+ it("treats a non-localhost http:// queue URL as local", () => {
27
+ assert.equal(isLocalEndpoint(composeQueueUrl), true);
28
+ });
29
+
30
+ it("treats http://localhost as local", () => {
31
+ assert.equal(
32
+ isLocalEndpoint("http://localhost:9324/000000000000/mailboxes"),
33
+ true,
34
+ );
35
+ });
36
+
37
+ it("treats https://localhost as local", () => {
38
+ assert.equal(
39
+ isLocalEndpoint("https://localhost:9324/000000000000/mailboxes"),
40
+ true,
41
+ );
42
+ });
43
+
44
+ it("treats a real SQS queue URL as remote", () => {
45
+ assert.equal(isLocalEndpoint(awsQueueUrl), false);
46
+ });
47
+
48
+ // A prefix match on "https://localhost" reads all of these as local. The
49
+ // host is localhost in none of them.
50
+ it("treats an https host that merely starts with localhost as remote", () => {
51
+ assert.equal(isLocalEndpoint("https://localhost.example.com/0/q"), false);
52
+ assert.equal(
53
+ isLocalEndpoint("https://localhost-queue.example.com/0/q"),
54
+ false,
55
+ );
56
+ });
57
+
58
+ it("treats an https URL with localhost in the userinfo as remote", () => {
59
+ assert.equal(isLocalEndpoint("https://localhost@example.com/0/q"), false);
60
+ assert.equal(
61
+ isLocalEndpoint("https://localhost:pass@example.com/0/q"),
62
+ false,
63
+ );
64
+ });
65
+
66
+ // The mirror image: a prefix match misses these, and they are local.
67
+ it("treats a loopback https URL carrying credentials as local", () => {
68
+ assert.equal(isLocalEndpoint("https://user:pass@localhost/0/q"), true);
69
+ });
70
+
71
+ it("ignores scheme case", () => {
72
+ assert.equal(isLocalEndpoint("HTTP://queue:9324/0/q"), true);
73
+ assert.equal(isLocalEndpoint("HtTpS://localhost:9324/0/q"), true);
74
+ });
75
+
76
+ it("treats loopback addresses over https as local", () => {
77
+ assert.equal(isLocalEndpoint("https://127.0.0.1:9324/0/q"), true);
78
+ assert.equal(isLocalEndpoint("https://[::1]:9324/0/q"), true);
79
+ });
80
+
81
+ it("treats any http URL as local regardless of host, port, or credentials", () => {
82
+ assert.equal(isLocalEndpoint("http://queue/0/q"), true);
83
+ assert.equal(isLocalEndpoint("http://user:pass@queue:9324/0/q"), true);
84
+ assert.equal(isLocalEndpoint("http://example.com/0/q"), true);
85
+ });
86
+
87
+ // Must not throw: several callers construct their client at module load, so
88
+ // a throw here would take the worker down at import.
89
+ it("treats an unparseable queue URL as remote without throwing", () => {
90
+ assert.equal(isLocalEndpoint(""), false);
91
+ assert.equal(isLocalEndpoint("not a url"), false);
92
+ assert.equal(isLocalEndpoint("queue:9324/0/q"), false);
93
+ assert.equal(isLocalEndpoint("//queue:9324/0/q"), false);
94
+ });
95
+ });
4
96
 
5
97
  describe("createQueueProducer", () => {
6
- it("returns a real SQSClient for a local queue URL", () => {
7
- const client = createQueueProducer({
8
- queueUrl: "http://localhost:4566/000000000000/test-queue",
9
- });
10
- assert.equal(typeof client.send, "function");
98
+ // Regression (self-host stack): a queue URL of http://queue:9324/... used to
99
+ // fall through to the AWS JSON protocol, so every send hit the local queue
100
+ // server with a body it cannot parse and mail never synced.
101
+ it("selects the query protocol for a non-localhost http:// queue URL", async () => {
102
+ const client = createQueueProducer({ queueUrl: composeQueueUrl });
103
+ assert.equal(usesQueryProtocol(client), true);
104
+ assert.equal(await resolvedEndpoint(client), "http://queue:9324");
11
105
  });
12
106
 
13
- it("returns a real SQSClient for a real AWS queue URL", () => {
107
+ it("selects the query protocol for a localhost queue URL", () => {
14
108
  const client = createQueueProducer({
15
- queueUrl:
16
- "https://sqs.eu-west-1.amazonaws.com/123456789012/remit-dev-mailboxes.fifo",
109
+ queueUrl: "http://localhost:9324/000000000000/mailboxes",
17
110
  });
18
- assert.equal(typeof client.send, "function");
111
+ assert.equal(usesQueryProtocol(client), true);
19
112
  });
20
113
 
21
- it("accepts an explicit endpoint override", () => {
114
+ it("leaves the default protocol and endpoint for a real AWS queue URL", async () => {
115
+ const client = createQueueProducer({ queueUrl: awsQueueUrl });
116
+ assert.equal(usesQueryProtocol(client), false);
117
+ assert.equal(await resolvedEndpoint(client), undefined);
118
+ });
119
+
120
+ it("does not throw when the queue URL is unparseable", async () => {
121
+ const client = createQueueProducer({ queueUrl: "not a url" });
122
+ assert.equal(usesQueryProtocol(client), false);
123
+ assert.equal(await resolvedEndpoint(client), undefined);
124
+ });
125
+
126
+ it("accepts an explicit endpoint override", async () => {
22
127
  const client = createQueueProducer({
23
- queueUrl: "http://localhost:4566/000000000000/test-queue",
128
+ queueUrl: composeQueueUrl,
24
129
  endpoint: "http://custom-endpoint:1234",
25
130
  });
26
- assert.equal(typeof client.send, "function");
131
+ assert.equal(usesQueryProtocol(client), true);
132
+ assert.equal(await resolvedEndpoint(client), "http://custom-endpoint:1234");
133
+ });
134
+
135
+ it("uses localCredentials only for a local endpoint", async () => {
136
+ const local = createQueueProducer({
137
+ queueUrl: composeQueueUrl,
138
+ localCredentials: { accessKeyId: "local", secretAccessKey: "local" },
139
+ env: {},
140
+ });
141
+ assert.equal((await local.config.credentials()).accessKeyId, "local");
142
+
143
+ const remote = createQueueProducer({
144
+ queueUrl: awsQueueUrl,
145
+ localCredentials: { accessKeyId: "local", secretAccessKey: "local" },
146
+ env: { SQS_ACCESS_KEY_ID: "real", SQS_SECRET_ACCESS_KEY: "real" },
147
+ });
148
+ assert.equal((await remote.config.credentials()).accessKeyId, "real");
27
149
  });
28
150
  });
package/src/producer.ts CHANGED
@@ -1,15 +1,51 @@
1
1
  import { SQSClient } from "@aws-sdk/client-sqs";
2
2
  import { AwsQueryProtocol } from "@aws-sdk/core/protocols";
3
- import { resolveSqsCredentials } from "./index.js";
3
+ import { resolveSqsCredentials, type SqsStaticCredentials } from "./index.js";
4
4
 
5
5
  export interface CreateQueueProducerOptions {
6
6
  readonly queueUrl: string;
7
7
  readonly endpoint?: string;
8
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;
9
15
  }
10
16
 
11
- const isLocalEndpoint = (queueUrl: string): boolean =>
12
- queueUrl.startsWith("http://") || queueUrl.startsWith("https://localhost");
17
+ const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
18
+
19
+ /**
20
+ * Whether a queue URL addresses a local, SQS-compatible queue server
21
+ * (ElasticMQ, the queue sidecar) rather than real SQS.
22
+ *
23
+ * Any plain `http://` URL is local: real SQS and Scaleway M&Q are always
24
+ * HTTPS, and a self-hosted stack addresses its queue server by container name
25
+ * (`http://queue:9324/...`), not by `localhost`. Over HTTPS only a loopback
26
+ * host is local, which covers a local server behind TLS.
27
+ *
28
+ * The URL is parsed rather than prefix-matched. Prefix-matching the string
29
+ * gets the interesting cases wrong in both directions: it reads
30
+ * `https://localhost.example.com` and `https://localhost@example.com` as
31
+ * local (the host is neither), and misses `https://user:pass@localhost` and
32
+ * an uppercase `HTTP://` scheme (both of which are).
33
+ *
34
+ * A queue URL that does not parse is treated as remote. It cannot be sent to
35
+ * either way, and several callers build their client at module load — throwing
36
+ * here would take a worker down at import instead of surfacing the bad
37
+ * configuration at the send that actually uses it.
38
+ */
39
+ export const isLocalEndpoint = (queueUrl: string): boolean => {
40
+ let url: URL;
41
+ try {
42
+ url = new URL(queueUrl);
43
+ } catch {
44
+ return false;
45
+ }
46
+ if (url.protocol === "http:") return true;
47
+ return url.protocol === "https:" && LOOPBACK_HOSTNAMES.has(url.hostname);
48
+ };
13
49
 
14
50
  /**
15
51
  * Producer-side counterpart of `runQueuePoller`: the one place an
@@ -22,11 +58,12 @@ const isLocalEndpoint = (queueUrl: string): boolean =>
22
58
  export const createQueueProducer = (
23
59
  options: CreateQueueProducerOptions,
24
60
  ): SQSClient => {
25
- const { queueUrl, endpoint, env } = options;
61
+ const { queueUrl, endpoint, env, localCredentials } = options;
26
62
  const local = isLocalEndpoint(queueUrl);
27
63
  return new SQSClient({
28
64
  endpoint: endpoint ?? (local ? new URL(queueUrl).origin : undefined),
29
65
  ...(local && { protocol: AwsQueryProtocol }),
30
- credentials: resolveSqsCredentials(env),
66
+ credentials:
67
+ (local ? localCredentials : undefined) ?? resolveSqsCredentials(env),
31
68
  });
32
69
  };