@ayepi/aws 0.1.0 → 0.3.0

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 CHANGED
@@ -49,6 +49,11 @@ const w = createWork({ queue, pubsub, store, work: [/* ... */] as const })
49
49
  and rethrown.
50
50
  - **You own the SDK clients.** The clients are optional peer deps — you construct and configure
51
51
  them (region, credentials, endpoint) and manage their lifecycle.
52
+ - **Connection pooling.** The SDK's default handler caps outbound connections at `maxSockets: 50`
53
+ per client — a common bottleneck for a busy S3+SQS work system. `@ayepi/aws/http` exports
54
+ `pooledRequestHandler({ maxSockets })` and `sharedHttpAgents(...)` — a keep-alive handler with a
55
+ higher cap to pass as `requestHandler`, or one shared pool across clients:
56
+ `new S3Client({ requestHandler: pooledRequestHandler({ maxSockets: 256 }) })`.
52
57
 
53
58
  ## Options
54
59
 
@@ -84,7 +89,7 @@ This package ships dense, machine-oriented reference docs written for **AI codin
84
89
 
85
90
  - [`ayepi-aws.md`](./ayepi-aws.md)
86
91
 
87
- They live next to the source in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/aws) and are **not** shipped in the npm tarball.
92
+ They ship with this package and also live in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/aws).
88
93
 
89
94
  ## License
90
95
 
package/ayepi-aws.md ADDED
@@ -0,0 +1,477 @@
1
+ <!--
2
+ ayepi-aws.md — reference for `@ayepi/aws`, written for coding agents.
3
+
4
+ Copy this file into any project that depends on `@ayepi/aws` (e.g. into your repo's
5
+ `docs/` or `.claude/` directory) and reference it from your agents and slash commands.
6
+ It documents the public API, the patterns the package expects, and how it works under the
7
+ hood, with copy-pasteable examples. Keep it in sync with the installed package version.
8
+ -->
9
+
10
+ # `@ayepi/aws`
11
+
12
+ AWS backends for ayepi: an **S3-backed [`@ayepi/files`](./ayepi-files.md) `FileStore` +
13
+ `Presigner`** (`@ayepi/aws/s3`) and an **SQS-backed [`@ayepi/work`](./ayepi-work.md) `Queue`**
14
+ (`@ayepi/aws/sqs`) with transparent S3 offload of large message bodies. Every AWS call goes
15
+ through [`@ayepi/core`](./ayepi-core.md) `retry`, so SQS/S3 throttling (rate limits) is absorbed
16
+ under load; on exhaustion the error is reported to an `onError` hook and rethrown. Reach for it
17
+ when you want your ayepi work queue and file storage to ride on AWS managed services rather than
18
+ self-hosted Redis/disk.
19
+
20
+ The AWS SDK v3 clients are **optional peer dependencies** (`^3`) — you install and own them
21
+ (region, credentials, endpoint, lifecycle). The package never constructs a client; it talks to
22
+ the one you pass via `client.send(command)`. Install only what you use:
23
+
24
+ ```sh
25
+ pnpm add @ayepi/aws @aws-sdk/client-s3 @aws-sdk/client-sqs @aws-sdk/lib-storage @aws-sdk/s3-request-presigner
26
+ ```
27
+
28
+ The package ships three export subpaths:
29
+
30
+ | Subpath | Exports | AWS SDK packages used |
31
+ |---|---|---|
32
+ | `@ayepi/aws/s3` | `s3Files`, `S3FilesOptions` | `@aws-sdk/client-s3`, `@aws-sdk/lib-storage`, `@aws-sdk/s3-request-presigner` |
33
+ | `@ayepi/aws/sqs` | `sqsQueue`, `SqsQueueOptions`, `LargePayloadOptions` | `@aws-sdk/client-sqs` (+ an `@ayepi/files` store for offload) |
34
+ | `@ayepi/aws` (root) | `AwsClient`, `ResilientOptions`, `makeRun` | — (shared retry seam) |
35
+
36
+ ## Public API
37
+
38
+ ### `@ayepi/aws/s3` — `s3Files(opts)`
39
+
40
+ ```ts
41
+ function s3Files(opts: S3FilesOptions): FileStore & Presigner;
42
+ ```
43
+
44
+ Creates an S3-backed [`@ayepi/files`](./ayepi-files.md) `FileStore` that also implements
45
+ `Presigner`. Pass a configured `S3Client` and a `bucket`; `prefix` namespaces every key.
46
+
47
+ ```ts
48
+ interface S3FilesOptions extends ResilientOptions {
49
+ /** A configured `@aws-sdk/client-s3` `S3Client`. */
50
+ readonly client: S3Client;
51
+ /** Target bucket. */
52
+ readonly bucket: string;
53
+ /** Key prefix prepended to every key (default `''`). */
54
+ readonly prefix?: string;
55
+ /** @internal Upload seam (default: `@aws-sdk/lib-storage` multipart `Upload`) — injectable for tests. */
56
+ readonly upload?: (key: string, body: FileBody, contentType?: string, metadata?: Record<string, string>) => Promise<void>;
57
+ /** @internal Presign seam (default: `@aws-sdk/s3-request-presigner`) — injectable for tests. */
58
+ readonly presign?: (kind: 'get' | 'put', key: string, expiresIn: number, contentType?: string) => Promise<string>;
59
+ }
60
+ ```
61
+
62
+ - `client` — a concrete `@aws-sdk/client-s3` `S3Client`. (Unlike the SQS queue's structural
63
+ `AwsClient`, the store needs the concrete client because multipart `Upload` and `getSignedUrl`
64
+ bind to it.)
65
+ - `bucket` — the target S3 bucket.
66
+ - `prefix` (default `''`) — prepended to every key on write and stripped from keys returned by
67
+ `list`, so callers work in a clean namespace (e.g. `prefix: 'docs/'`, you `get('a.txt')`,
68
+ the object lives at `docs/a.txt`).
69
+ - `retry` / `onError` — inherited from `ResilientOptions` (see the root section). Every store
70
+ operation runs inside `retry`.
71
+ - `upload` / `presign` — **internal injectable seams**. They default to the real SDK glue
72
+ (lib-storage `Upload` multipart, `s3-request-presigner` `getSignedUrl`); you only override
73
+ them in tests or to customize the SDK call. The defaults are exercised against real S3 in the
74
+ integration test, not in unit tests.
75
+
76
+ The returned object is a `FileStore & Presigner` (the `@ayepi/files` contract):
77
+
78
+ ```ts
79
+ interface FileStore {
80
+ put(key: string, body: FileBody, opts?: PutOptions): Promise<FileInfo>;
81
+ get(key: string): Promise<FileObject | undefined>;
82
+ head(key: string): Promise<FileInfo | undefined>;
83
+ delete(key: string): Promise<boolean>;
84
+ list(prefix: string, opts?: ListOptions): Promise<ListResult>;
85
+ }
86
+ interface Presigner {
87
+ presignDownload(key: string, opts?: PresignDownloadOptions): Promise<string>;
88
+ presignUpload(key: string, opts?: PresignUploadOptions): Promise<string>;
89
+ }
90
+ ```
91
+
92
+ Behavior of each method:
93
+
94
+ - **`put(key, body, opts?)`** — streams `body` to S3 via the upload seam (multipart `Upload`,
95
+ passing `ContentType` and `Metadata`), then `head`s the key and returns the resulting
96
+ `FileInfo`. `body` is any `@ayepi/files` `FileBody` (`ReadableStream` / `Uint8Array` / `Blob`
97
+ / `string`).
98
+ - **`get(key)`** — issues `GetObjectCommand`; returns a `FileObject` whose `info` is built from
99
+ `ContentLength` / `ContentType` / `ETag` / `LastModified` / `Metadata`, and whose body is lazy:
100
+ `stream()` → `transformToWebStream()`, `bytes()` → `transformToByteArray()`, `text()` →
101
+ `transformToString()`. **Read the body once** — pick one accessor. A missing key resolves to
102
+ `undefined`.
103
+ - **`head(key)`** — issues `HeadObjectCommand`; returns the `FileInfo` or `undefined` if missing.
104
+ - **`delete(key)`** — `head`s first to report prior existence, then issues `DeleteObjectCommand`;
105
+ resolves `true` if the object existed, `false` otherwise.
106
+ - **`list(prefix, opts?)`** — issues `ListObjectsV2Command` with `Prefix` = `ns + prefix`,
107
+ `MaxKeys` = `opts.limit`, `ContinuationToken` = `opts.cursor`. Returns `{ files, cursor }`
108
+ where `cursor` is the SDK's `NextContinuationToken` (absent when the listing is complete) and
109
+ each file's key has the store `prefix` **stripped**. List entries carry `size`, `etag`,
110
+ `modifiedAt` but **no `contentType`** (ListObjectsV2 doesn't return it).
111
+ - **`presignDownload(key, opts?)`** — a presigned `GET` URL (default expiry **900 s**).
112
+ - **`presignUpload(key, opts?)`** — a presigned `PUT` URL (default expiry **900 s**); `opts.contentType`
113
+ pins the `Content-Type` the upload must use.
114
+
115
+ **Not-found handling.** S3 surfaces a missing key differently per op (`NoSuchKey` on GET,
116
+ `NotFound` on HEAD). The store treats an error as not-found when its `name` is `NoSuchKey` or
117
+ `NotFound`, **or** when `$metadata.httpStatusCode` is `404` — those become `undefined`
118
+ (`get`/`head`) or `false` (`delete`). Any other error propagates (after retries).
119
+
120
+ ### `@ayepi/aws/sqs` — `sqsQueue(opts)`
121
+
122
+ ```ts
123
+ function sqsQueue(opts: SqsQueueOptions): Queue;
124
+ ```
125
+
126
+ Creates an [`@ayepi/work`](./ayepi-work-ports.md) `Queue` over SQS. SQS's native
127
+ visibility-timeout model maps directly onto the work `Queue` lease contract.
128
+
129
+ ```ts
130
+ interface SqsQueueOptions extends ResilientOptions {
131
+ /** A configured `@aws-sdk/client-sqs` `SQSClient`. */
132
+ readonly client: SQSClient;
133
+ /** The target queue URL. */
134
+ readonly queueUrl: string;
135
+ /** Long-poll seconds for `pop` (0–20, default 0). */
136
+ readonly waitTimeSeconds?: number;
137
+ /** Transparently offload large bodies to S3. */
138
+ readonly largePayload?: LargePayloadOptions;
139
+ }
140
+
141
+ interface LargePayloadOptions {
142
+ /** The store oversized bodies are written to (e.g. `s3Files({...})`). */
143
+ readonly store: FileStore;
144
+ /** Offload bodies larger than this many bytes (default ~240 KB). */
145
+ readonly threshold?: number;
146
+ /** Key prefix for offloaded bodies (default `'sqs-payloads/'`). */
147
+ readonly prefix?: string;
148
+ }
149
+ ```
150
+
151
+ - `client` — a configured `@aws-sdk/client-sqs` `SQSClient` (structurally an `AwsClient`).
152
+ - `queueUrl` — the target SQS queue URL.
153
+ - `waitTimeSeconds` (default `0`) — SQS long-poll seconds for `pop` (`0`–`20`). Set it (e.g. `10`)
154
+ to long-poll and reduce empty receives / API calls.
155
+ - `largePayload` — optional; see below.
156
+ - `retry` / `onError` — from `ResilientOptions`.
157
+
158
+ The returned [`Queue`](./ayepi-work-ports.md#queue--the-durable-work-log) maps onto SQS
159
+ (all durations in the contract are **milliseconds**; SQS wants seconds, so values are
160
+ `Math.ceil`-converted):
161
+
162
+ | `Queue` method | SQS call | Mapping |
163
+ |---|---|---|
164
+ | `push(body, { delay? })` | `SendMessage` | `MessageBody` = body (or pointer); `DelaySeconds` = `ceil(delay/1000)`, **clamped to ≤ 900 s** |
165
+ | `pop(max, visibility)` | `ReceiveMessage` | `MaxNumberOfMessages` = `min(max, 10)`; `VisibilityTimeout` = `ceil(visibility/1000)`, **clamped to ≤ 43200 s**; `WaitTimeSeconds` = `waitTimeSeconds`; reads `ApproximateReceiveCount` |
166
+ | `heartbeat(pulled, visibility)` | `ChangeMessageVisibility` | extends the lease to `ceil(visibility/1000)` s, **clamped to ≤ 43200 s** |
167
+ | `ack(pulled)` | `DeleteMessage` | permanently removes the message (+ deletes the offloaded S3 body) |
168
+ | `fail(pulled, delay?)` | `ChangeMessageVisibility` | returns the message early: `VisibilityTimeout` = `ceil((delay ?? 0)/1000)` s, **clamped to ≤ 43200 s** |
169
+
170
+ **SQS range clamping (far-future scheduling).** SQS rejects out-of-range values, so the queue
171
+ clamps each duration into SQS's allowed range: `DelaySeconds` to **0–900 s** (15 min) on `push`,
172
+ and `VisibilityTimeout` to **0–43200 s** (12 h) on `pop`/`heartbeat`/`fail`. This is what lets
173
+ the `@ayepi/work` engine schedule items **arbitrarily far** in the future on SQS: a long `delay`
174
+ or `startAt` is clamped to the cap rather than erroring, so a far-future item is delivered early,
175
+ the engine sees it isn't due yet and **puts it back** (via `fail` with the remaining delay, again
176
+ clamped), and it **bounces** — every ≤ 900 s after a `push` and every ≤ 12 h after a re-defer —
177
+ until its scheduled time finally arrives. See the engine's
178
+ [early-arrival re-defer](./ayepi-work-ports.md#early-arrival-re-defer-far-future-scheduling).
179
+ The cost is **polling**: a far-future item is received and re-deferred on that cadence the whole
180
+ time it waits (each bounce is a `ReceiveMessage` + `ChangeMessageVisibility`), so prefer modest
181
+ horizons or keep distant schedules sparse.
182
+
183
+ - **`pop`** returns at most **10** messages per call (SQS's `ReceiveMessage` cap), regardless of
184
+ `max`. Each `PulledWork.attempt` comes from the message's `ApproximateReceiveCount` (delivery
185
+ count, starting at 1); `handle` is an opaque `{ receiptHandle, s3Key? }` you round-trip to
186
+ `heartbeat`/`ack`/`fail`.
187
+ - **Dead-lettering** is the queue's own SQS **redrive policy** — configured on the queue (or its
188
+ DLQ) in AWS, not here. After `maxReceiveCount` failed deliveries SQS moves the message to the
189
+ DLQ natively. (`sqsQueue` does not implement the optional `Queue.deadLetter` hook.)
190
+
191
+ **`largePayload` — S3 offload (SQS caps a message at 256 KB).** When configured, on `push` a
192
+ body whose length exceeds `threshold` (default `240 * 1024`, comfortably under the 256 KB cap) is
193
+ written to `store` under `` `${prefix}${randomUUID()}` `` (prefix default `'sqs-payloads/'`) with
194
+ `contentType: 'application/json'`, and the SQS message carries a small JSON pointer
195
+ `{ "__ayepiS3__": "<key>" }` instead of the body. On `pop`, a message body that parses to such a
196
+ pointer is detected and the offloaded body is fetched from `store` and inlined back into
197
+ `PulledWork.body`; the `s3Key` rides along in the handle. On `ack`, the offloaded S3 object is
198
+ deleted. Notes:
199
+
200
+ - With `largePayload` **off**, a body that happens to look like a pointer is passed through
201
+ untouched (no inlining). With it **on**, a non-JSON or non-pointer body is treated as a plain
202
+ message (passthrough).
203
+ - A vanished offloaded body (key missing in `store`) inlines as an empty string `''` rather than
204
+ throwing.
205
+ - `store` is any [`@ayepi/files`](./ayepi-files.md) `FileStore` — typically an `s3Files({...})`,
206
+ but a filesystem store or your own works too.
207
+
208
+ ### `@ayepi/aws/http` — a well-pooled request handler
209
+
210
+ The SDK's default `NodeHttpHandler` caps outbound connections at **`maxSockets: 50` per client**.
211
+ Under a busy work system (S3 + SQS + more, each its own client) that cap is often the first thing to
212
+ break — calls queue behind 50 sockets and latency balloons, with no error, just slowness. This
213
+ subpath builds a keep-alive handler (and shareable agents) with a higher cap.
214
+
215
+ ```ts
216
+ import { S3Client } from '@aws-sdk/client-s3';
217
+ import { SQSClient } from '@aws-sdk/client-sqs';
218
+ import { pooledRequestHandler, sharedHttpAgents } from '@ayepi/aws/http';
219
+
220
+ // One handler per client:
221
+ const s3 = new S3Client({ region, requestHandler: pooledRequestHandler({ maxSockets: 256 }) });
222
+
223
+ // ...or share ONE pool across many clients (bounds total sockets, better reuse):
224
+ const agents = sharedHttpAgents({ maxSockets: 512 });
225
+ const s3b = new S3Client({ requestHandler: pooledRequestHandler(agents) });
226
+ const sqs = new SQSClient({ requestHandler: pooledRequestHandler(agents) });
227
+ ```
228
+
229
+ ```ts
230
+ function pooledRequestHandler(opts?: PooledHandlerOptions): NodeHttpHandler
231
+ function sharedHttpAgents(opts?: PooledAgentsOptions): { httpAgent; httpsAgent }
232
+
233
+ interface PooledAgentsOptions {
234
+ readonly maxSockets?: number; // per host (default 128; SDK default 50)
235
+ readonly maxTotalSockets?: number; // across all hosts (default: unlimited)
236
+ readonly keepAlive?: boolean; // default true — avoids TCP churn / port exhaustion
237
+ readonly keepAliveMsecs?: number; // default 1000
238
+ }
239
+ interface PooledHandlerOptions extends PooledAgentsOptions {
240
+ readonly connectionTimeout?: number; // ms
241
+ readonly requestTimeout?: number; // ms
242
+ readonly httpAgent?: HttpAgent; // bring your own (overrides the pool knobs)
243
+ readonly httpsAgent?: HttpsAgent;
244
+ }
245
+ ```
246
+
247
+ Keep-alive is on by default so connections are reused instead of churned — which also sidesteps
248
+ ephemeral-port / `TIME_WAIT` exhaustion under sustained load. Requires `@smithy/node-http-handler`
249
+ (an optional peer, already a transitive dependency of the AWS SDK clients).
250
+
251
+ ### `@ayepi/aws` (root) — the retry seam
252
+
253
+ The root subpath exports the shared resilience machinery both backends build on. You rarely call
254
+ these directly; they're documented so you understand `retry`/`onError` and can pass a structural
255
+ client.
256
+
257
+ #### `AwsClient`
258
+
259
+ ```ts
260
+ interface AwsClient {
261
+ send(command: unknown): Promise<unknown>;
262
+ }
263
+ ```
264
+
265
+ The minimal AWS SDK v3 client surface used internally — just `send(command)`. The real `S3Client`
266
+ / `SQSClient` satisfy it structurally; a test can pass `{ send: vi.fn() }`. (Presigning and
267
+ multipart upload need the *concrete* `S3Client`, which is why `s3Files` takes `S3Client` directly
268
+ rather than `AwsClient`.)
269
+
270
+ #### `ResilientOptions`
271
+
272
+ ```ts
273
+ interface ResilientOptions {
274
+ /** Retry policy for each AWS call (core `retry` — `attempts`/`base`/`factor`/`max`/`jitter`/…). Defaults absorb throttling. */
275
+ readonly retry?: Omit<RetryOptions, 'errorResult'>;
276
+ /** Notified when a call fails after exhausting retries (the error then propagates). Off by default; must not throw. */
277
+ readonly onError?: (err: unknown) => void;
278
+ }
279
+ ```
280
+
281
+ Shared by `S3FilesOptions` and `SqsQueueOptions`.
282
+
283
+ - **`retry`** — the `@ayepi/core` `RetryOptions` (minus `errorResult`, which the wrapper owns):
284
+ `attempts`, `base`, `factor`, `max`, `jitter`, `sleep`, etc. The defaults are tuned to absorb
285
+ throttling; tune `attempts` up for aggressive rate limits.
286
+ - **`onError`** — called **once** when a call finally gives up (after all retries), then the error
287
+ propagates. Off by default. It must not throw — if it does, the throw is swallowed so it can't
288
+ mask the original AWS error.
289
+
290
+ #### `makeRun(opts)`
291
+
292
+ ```ts
293
+ function makeRun(opts: ResilientOptions): <T>(fn: () => Promise<T>) => Promise<T>;
294
+ ```
295
+
296
+ Builds the retry-wrapping runner each backend uses: it runs `fn` under `@ayepi/core` `retry` with
297
+ your `retry` options, and on final failure reports the error to `onError` (guarded) before
298
+ rethrowing. Both `s3Files` and `sqsQueue` wrap **every** AWS `send` in this runner. You'd only
299
+ call `makeRun` yourself if you were building a third AWS backend on the same seam.
300
+
301
+ ## Examples
302
+
303
+ ### S3 file store — round-trip + presign
304
+
305
+ ```ts
306
+ import { S3Client } from '@aws-sdk/client-s3';
307
+ import { s3Files } from '@ayepi/aws/s3';
308
+ import { toStream, collect } from '@ayepi/files';
309
+
310
+ const files = s3Files({
311
+ client: new S3Client({ region: 'us-east-1' }),
312
+ bucket: 'my-bucket',
313
+ prefix: 'docs/', // every key namespaced under docs/
314
+ });
315
+
316
+ // write a stream with a content type + metadata
317
+ await files.put('a.txt', toStream('hello s3'), { contentType: 'text/plain', metadata: { owner: 'ada' } });
318
+
319
+ // read it back (body read once)
320
+ const obj = (await files.get('a.txt'))!;
321
+ console.log(obj.info.size); // 8
322
+ console.log(new TextDecoder().decode(await collect(obj.stream())));
323
+
324
+ // list by prefix — returned keys have the store prefix stripped
325
+ const { files: page, cursor } = await files.list('a', { limit: 50 });
326
+ console.log(page.map((f) => f.key)); // ['a.txt']
327
+
328
+ // mint a presigned download URL (default 900 s; override per call)
329
+ const url = await files.presignDownload('a.txt', { expiresIn: 300 });
330
+
331
+ // delete reports prior existence
332
+ console.log(await files.delete('a.txt')); // true
333
+ console.log(await files.get('a.txt')); // undefined
334
+ ```
335
+
336
+ ### SQS queue wired into `createWork`
337
+
338
+ ```ts
339
+ import { SQSClient } from '@aws-sdk/client-sqs';
340
+ import { sqsQueue } from '@ayepi/aws/sqs';
341
+ import { createWork } from '@ayepi/work';
342
+
343
+ const queue = sqsQueue({
344
+ client: new SQSClient({ region: 'us-east-1' }),
345
+ queueUrl: process.env.SQS_URL!,
346
+ waitTimeSeconds: 10, // long-poll
347
+ });
348
+
349
+ // supply pubsub + store from another backend (e.g. @ayepi/redis) to go fully distributed
350
+ const w = createWork({ queue, pubsub, store, work: [/* ...defineWork() */] as const });
351
+ ```
352
+
353
+ > A fully distributed `createWork` needs all three ports (`queue`, `pubsub`, `store`). `@ayepi/aws`
354
+ > supplies the **queue**; pair it with a `PubSub`/`Store` (e.g. from
355
+ > [`@ayepi/redis`](./ayepi-redis.md)). See [ayepi-work-ports.md](./ayepi-work-ports.md).
356
+
357
+ ### Large-payload offload using `s3Files` as the store
358
+
359
+ ```ts
360
+ import { S3Client } from '@aws-sdk/client-s3';
361
+ import { SQSClient } from '@aws-sdk/client-sqs';
362
+ import { s3Files } from '@ayepi/aws/s3';
363
+ import { sqsQueue } from '@ayepi/aws/sqs';
364
+
365
+ const s3 = new S3Client({ region: 'us-east-1' });
366
+ const sqs = new SQSClient({ region: 'us-east-1' });
367
+
368
+ const queue = sqsQueue({
369
+ client: sqs,
370
+ queueUrl: process.env.SQS_URL!,
371
+ largePayload: {
372
+ store: s3Files({ client: s3, bucket: 'work-payloads' }),
373
+ threshold: 240 * 1024, // offload bodies over ~240 KB (default)
374
+ prefix: 'sqs-payloads/', // S3 key prefix for offloaded bodies (default)
375
+ },
376
+ });
377
+
378
+ await queue.push('small'); // sent inline
379
+ await queue.push('x'.repeat(300_000)); // > threshold → written to S3, message carries the pointer
380
+
381
+ const items = await queue.pop(10, 30_000); // 30 s lease (ms)
382
+ for (const it of items) {
383
+ console.log(it.body); // the large body is inlined back from S3 transparently
384
+ await it.attempt; // delivery count from ApproximateReceiveCount
385
+ await queue.ack(it); // DeleteMessage + delete the offloaded S3 object
386
+ }
387
+ ```
388
+
389
+ ### Configuring retry for aggressive throttling
390
+
391
+ ```ts
392
+ import { s3Files } from '@ayepi/aws/s3';
393
+
394
+ const files = s3Files({
395
+ client: s3,
396
+ bucket: 'my-bucket',
397
+ retry: { attempts: 10 }, // more attempts before giving up (core RetryOptions)
398
+ onError: (err) => console.error('[s3] gave up after retries:', err),
399
+ });
400
+ ```
401
+
402
+ The same `retry` / `onError` options apply to `sqsQueue`. On final failure `onError` fires once
403
+ (it must not throw) and the AWS error is rethrown.
404
+
405
+ ## How it works under the hood
406
+
407
+ - **One retry seam, two backends.** Both `s3Files` and `sqsQueue` build a runner via `makeRun`
408
+ and wrap **every** `client.send(...)` in `@ayepi/core` `retry`. A throttled reply is retried per
409
+ your `retry` policy; only after attempts are exhausted does the error reach `onError` and
410
+ propagate. `onError` is guarded so a throwing reporter can't mask the AWS error.
411
+ - **S3 store is plain command dispatch.** `get`/`head`/`delete`/`list` issue
412
+ `GetObject`/`HeadObject`/`DeleteObject`/`ListObjectsV2` and map the response fields onto
413
+ `@ayepi/files` shapes; `put` uses lib-storage's multipart `Upload` then re-`head`s for the
414
+ authoritative `FileInfo`; presigning uses `s3-request-presigner`'s `getSignedUrl`. The upload
415
+ and presign steps are injectable seams (`opts.upload` / `opts.presign`) so unit tests can run
416
+ without real AWS; the real SDK glue is covered by the LocalStack integration test.
417
+ - **SQS queue is the visibility-timeout lease.** `pop`'s `ReceiveMessage` *is* the lease (hidden
418
+ for `VisibilityTimeout`); `heartbeat` extends it with `ChangeMessageVisibility`; `ack`
419
+ `DeleteMessage`s; `fail` shortens visibility so the message reappears after `delay`. A worker
420
+ that dies lets visibility lapse and SQS redelivers with a bumped `ApproximateReceiveCount` →
421
+ `attempt`. This is exactly the work `Queue` contract, served natively by SQS.
422
+ - **Large-payload offload is a pointer swap.** Over-threshold bodies are stored in the provided
423
+ `FileStore` and replaced in the message with `{ "__ayepiS3__": key }`. `pop` detects the
424
+ pointer (JSON-parse + marker check), fetches and inlines the body; `ack` deletes the S3 object.
425
+ Detection is best-effort: a body that doesn't parse to the marker shape is treated as a plain
426
+ message.
427
+ - **Dead-lettering is native.** There's no app-level DLQ here — SQS's redrive policy on the queue
428
+ moves a message to its configured dead-letter queue after `maxReceiveCount` deliveries.
429
+
430
+ ## Gotchas / constraints
431
+
432
+ - **SQS's 256 KB message limit & why offload exists.** SQS rejects messages over 256 KB. Without
433
+ `largePayload`, a large work envelope fails to `push`. Enable `largePayload` (threshold default
434
+ ~240 KB, safely under the cap) to transparently offload to S3. The offloaded objects accumulate
435
+ in your bucket — they're deleted on `ack`, but a message that never acks (e.g. ends up in the
436
+ DLQ) leaves its S3 object behind; consider a bucket lifecycle rule on the offload prefix.
437
+ - **Visibility timeout vs heartbeat.** The `visibility` you pass to `pop` is the lease length; the
438
+ engine must `heartbeat` (extend visibility) before it lapses or SQS will redeliver the still-running
439
+ item to another worker (at-least-once). Pick a `visibility` comfortably longer than your
440
+ heartbeat interval. Durations in the `Queue` contract are **milliseconds**; SQS uses seconds
441
+ (the queue `Math.ceil`-converts), and SQS's own max visibility is 12 hours.
442
+ - **`pop` returns ≤ 10 per call.** SQS caps `ReceiveMessage` at 10 messages regardless of the `max`
443
+ you request. The engine polls in a loop, so this is fine — just don't expect one `pop` to drain
444
+ a large backlog.
445
+ - **Far-future schedules bounce (and cost polling).** SQS caps a single `DelaySeconds` at 15 min and
446
+ a visibility at 12 h, so the queue clamps both. A far-future `runAt` / `WorkDelayError` deferral is
447
+ therefore honored by **re-deferral**: the item is received early, the engine re-checks `startAt` and
448
+ puts it back, and it bounces every ≤ 900 s (after `push`) / ≤ 12 h (after a re-defer) until due. It
449
+ fires at the right time, but each bounce is a `ReceiveMessage` + `ChangeMessageVisibility` — so a
450
+ large number of distant-future items incurs ongoing polling. See
451
+ [ayepi-work-ports.md → early-arrival re-defer](./ayepi-work-ports.md#early-arrival-re-defer-far-future-scheduling).
452
+ - **You own the SDK clients & their lifecycle.** `@ayepi/aws` never constructs or closes a client.
453
+ Configure region/credentials/endpoint yourself and `destroy()` the clients on shutdown. The AWS
454
+ SDK packages are optional peer deps — install the ones you use, or the import will fail.
455
+ - **DLQ is configured on the queue, not here.** Set the redrive policy (`maxReceiveCount` + DLQ
456
+ ARN) on the SQS queue in AWS. `sqsQueue` doesn't implement `Queue.deadLetter`; failures redeliver
457
+ until SQS dead-letters them natively.
458
+ - **Eventual consistency.** S3 is read-after-write consistent for new objects, but `list` and
459
+ cross-region/replication views can lag; an offloaded body written then immediately popped on a
460
+ different host is generally fine, but don't assume strong global ordering. A `get` on a key that
461
+ S3 reports as missing returns `undefined`, not an error.
462
+ - **Read an S3 `FileObject`'s body once.** `stream()` / `bytes()` / `text()` consume the same
463
+ underlying SDK stream — call exactly one. To read again, `get` again.
464
+ - **`list` entries have no `contentType`.** ListObjectsV2 doesn't return it; `head`/`get` do.
465
+ - **Silent by default.** Without `onError`, a call that exhausts retries throws but reports nothing
466
+ extra. Pass `onError` in production to surface give-ups.
467
+
468
+ ## See also
469
+
470
+ - [ayepi-files.md](./ayepi-files.md) — the `FileStore` / `Presigner` contract `s3Files`
471
+ implements, plus stream helpers (`toStream`, `collect`).
472
+ - [ayepi-work.md](./ayepi-work.md) — `createWork`, `defineWork`, and the work engine `sqsQueue`
473
+ plugs into as the `Queue` port.
474
+ - [ayepi-work-ports.md](./ayepi-work-ports.md) — the `Queue` / `Store` / `PubSub` port contracts
475
+ and the visibility-timeout lease model `sqsQueue` maps SQS onto.
476
+ - [ayepi-redis.md](./ayepi-redis.md) — a `PubSub`/`Store` (and `Broker`) backend to pair with the
477
+ SQS queue for a fully distributed `createWork`.
package/dist/http.cjs ADDED
@@ -0,0 +1,70 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let node_http = require("node:http");
3
+ let node_https = require("node:https");
4
+ let _smithy_node_http_handler = require("@smithy/node-http-handler");
5
+ //#region src/http.ts
6
+ /**
7
+ * # @ayepi/aws/http — a well-pooled request handler for the AWS SDK
8
+ *
9
+ * The AWS SDK v3's default `NodeHttpHandler` caps outbound connections at **`maxSockets: 50` per
10
+ * client**. Under a busy work system (S3 + SQS + more, each its own client) that cap is often the
11
+ * first thing to break: calls queue behind 50 sockets and latency balloons — with no error, just
12
+ * mysterious slowness. This module builds a handler (and shareable agents) with keep-alive on and a
13
+ * higher cap, to pass as `requestHandler` to your clients.
14
+ *
15
+ * ```ts
16
+ * import { S3Client } from '@aws-sdk/client-s3';
17
+ * import { SQSClient } from '@aws-sdk/client-sqs';
18
+ * import { pooledRequestHandler, sharedHttpAgents } from '@ayepi/aws/http';
19
+ *
20
+ * // One handler per client:
21
+ * const s3 = new S3Client({ region, requestHandler: pooledRequestHandler({ maxSockets: 256 }) });
22
+ *
23
+ * // ...or share ONE pool across many clients (fewer total sockets, better reuse):
24
+ * const agents = sharedHttpAgents({ maxSockets: 512 });
25
+ * const s3b = new S3Client({ requestHandler: pooledRequestHandler(agents) });
26
+ * const sqs = new SQSClient({ requestHandler: pooledRequestHandler(agents) });
27
+ * ```
28
+ *
29
+ * Keep-alive is on by default so connections are reused instead of churned — which also avoids
30
+ * ephemeral-port / `TIME_WAIT` exhaustion under sustained load. Requires `@smithy/node-http-handler`
31
+ * (a transitive dependency of the AWS SDK clients).
32
+ *
33
+ * @module
34
+ */
35
+ /** Agent constructor options derived from the pool knobs. */
36
+ function agentConf(opts) {
37
+ return {
38
+ keepAlive: opts.keepAlive ?? true,
39
+ keepAliveMsecs: opts.keepAliveMsecs ?? 1e3,
40
+ maxSockets: opts.maxSockets ?? 128,
41
+ maxTotalSockets: opts.maxTotalSockets
42
+ };
43
+ }
44
+ /**
45
+ * A pair of keep-alive `node:http` / `node:https` agents you can **share across many AWS clients**
46
+ * (pass to {@link pooledRequestHandler}). Sharing one pair bounds total sockets across S3/SQS/etc.
47
+ */
48
+ function sharedHttpAgents(opts = {}) {
49
+ const conf = agentConf(opts);
50
+ return {
51
+ httpAgent: new node_http.Agent(conf),
52
+ httpsAgent: new node_https.Agent(conf)
53
+ };
54
+ }
55
+ /**
56
+ * Build a `NodeHttpHandler` with keep-alive and a higher socket cap than the SDK default — pass it
57
+ * as `requestHandler` to an `S3Client`/`SQSClient`. Reuse one handler (or one {@link sharedHttpAgents}
58
+ * pair) across clients to bound total sockets.
59
+ */
60
+ function pooledRequestHandler(opts = {}) {
61
+ return new _smithy_node_http_handler.NodeHttpHandler({
62
+ httpAgent: opts.httpAgent ?? new node_http.Agent(agentConf(opts)),
63
+ httpsAgent: opts.httpsAgent ?? new node_https.Agent(agentConf(opts)),
64
+ connectionTimeout: opts.connectionTimeout,
65
+ requestTimeout: opts.requestTimeout
66
+ });
67
+ }
68
+ //#endregion
69
+ exports.pooledRequestHandler = pooledRequestHandler;
70
+ exports.sharedHttpAgents = sharedHttpAgents;
@@ -0,0 +1,44 @@
1
+ import { Agent } from "node:http";
2
+ import { Agent as Agent$1 } from "node:https";
3
+ import { NodeHttpHandler } from "@smithy/node-http-handler";
4
+
5
+ //#region src/http.d.ts
6
+
7
+ /** Connection-pool knobs shared by the http + https agents. */
8
+ interface PooledAgentsOptions {
9
+ /** Max sockets per host (default `128`; the SDK default is `50`). */
10
+ readonly maxSockets?: number;
11
+ /** Max sockets across all hosts (default: unlimited). */
12
+ readonly maxTotalSockets?: number;
13
+ /** Reuse connections (default `true`). Keep-alive avoids TCP churn and port exhaustion. */
14
+ readonly keepAlive?: boolean;
15
+ /** Idle time before a kept-alive socket may close (ms, default `1000`). */
16
+ readonly keepAliveMsecs?: number;
17
+ }
18
+ /** Options for {@link pooledRequestHandler}: the pool knobs plus timeouts and bring-your-own agents. */
19
+ interface PooledHandlerOptions extends PooledAgentsOptions {
20
+ /** Socket connect timeout (ms). */
21
+ readonly connectionTimeout?: number;
22
+ /** Time to wait for a response (ms). */
23
+ readonly requestTimeout?: number;
24
+ /** Bring your own http agent (overrides the pool knobs for http). */
25
+ readonly httpAgent?: Agent;
26
+ /** Bring your own https agent (overrides the pool knobs for https). */
27
+ readonly httpsAgent?: Agent$1;
28
+ }
29
+ /**
30
+ * A pair of keep-alive `node:http` / `node:https` agents you can **share across many AWS clients**
31
+ * (pass to {@link pooledRequestHandler}). Sharing one pair bounds total sockets across S3/SQS/etc.
32
+ */
33
+ declare function sharedHttpAgents(opts?: PooledAgentsOptions): {
34
+ httpAgent: Agent;
35
+ httpsAgent: Agent$1;
36
+ };
37
+ /**
38
+ * Build a `NodeHttpHandler` with keep-alive and a higher socket cap than the SDK default — pass it
39
+ * as `requestHandler` to an `S3Client`/`SQSClient`. Reuse one handler (or one {@link sharedHttpAgents}
40
+ * pair) across clients to bound total sockets.
41
+ */
42
+ declare function pooledRequestHandler(opts?: PooledHandlerOptions): NodeHttpHandler;
43
+ //#endregion
44
+ export { PooledAgentsOptions, PooledHandlerOptions, pooledRequestHandler, sharedHttpAgents };
package/dist/http.d.ts ADDED
@@ -0,0 +1,44 @@
1
+ import { Agent } from "node:http";
2
+ import { Agent as Agent$1 } from "node:https";
3
+ import { NodeHttpHandler } from "@smithy/node-http-handler";
4
+
5
+ //#region src/http.d.ts
6
+
7
+ /** Connection-pool knobs shared by the http + https agents. */
8
+ interface PooledAgentsOptions {
9
+ /** Max sockets per host (default `128`; the SDK default is `50`). */
10
+ readonly maxSockets?: number;
11
+ /** Max sockets across all hosts (default: unlimited). */
12
+ readonly maxTotalSockets?: number;
13
+ /** Reuse connections (default `true`). Keep-alive avoids TCP churn and port exhaustion. */
14
+ readonly keepAlive?: boolean;
15
+ /** Idle time before a kept-alive socket may close (ms, default `1000`). */
16
+ readonly keepAliveMsecs?: number;
17
+ }
18
+ /** Options for {@link pooledRequestHandler}: the pool knobs plus timeouts and bring-your-own agents. */
19
+ interface PooledHandlerOptions extends PooledAgentsOptions {
20
+ /** Socket connect timeout (ms). */
21
+ readonly connectionTimeout?: number;
22
+ /** Time to wait for a response (ms). */
23
+ readonly requestTimeout?: number;
24
+ /** Bring your own http agent (overrides the pool knobs for http). */
25
+ readonly httpAgent?: Agent;
26
+ /** Bring your own https agent (overrides the pool knobs for https). */
27
+ readonly httpsAgent?: Agent$1;
28
+ }
29
+ /**
30
+ * A pair of keep-alive `node:http` / `node:https` agents you can **share across many AWS clients**
31
+ * (pass to {@link pooledRequestHandler}). Sharing one pair bounds total sockets across S3/SQS/etc.
32
+ */
33
+ declare function sharedHttpAgents(opts?: PooledAgentsOptions): {
34
+ httpAgent: Agent;
35
+ httpsAgent: Agent$1;
36
+ };
37
+ /**
38
+ * Build a `NodeHttpHandler` with keep-alive and a higher socket cap than the SDK default — pass it
39
+ * as `requestHandler` to an `S3Client`/`SQSClient`. Reuse one handler (or one {@link sharedHttpAgents}
40
+ * pair) across clients to bound total sockets.
41
+ */
42
+ declare function pooledRequestHandler(opts?: PooledHandlerOptions): NodeHttpHandler;
43
+ //#endregion
44
+ export { PooledAgentsOptions, PooledHandlerOptions, pooledRequestHandler, sharedHttpAgents };
package/dist/http.js ADDED
@@ -0,0 +1,68 @@
1
+ import { Agent } from "node:http";
2
+ import { Agent as Agent$1 } from "node:https";
3
+ import { NodeHttpHandler } from "@smithy/node-http-handler";
4
+ //#region src/http.ts
5
+ /**
6
+ * # @ayepi/aws/http — a well-pooled request handler for the AWS SDK
7
+ *
8
+ * The AWS SDK v3's default `NodeHttpHandler` caps outbound connections at **`maxSockets: 50` per
9
+ * client**. Under a busy work system (S3 + SQS + more, each its own client) that cap is often the
10
+ * first thing to break: calls queue behind 50 sockets and latency balloons — with no error, just
11
+ * mysterious slowness. This module builds a handler (and shareable agents) with keep-alive on and a
12
+ * higher cap, to pass as `requestHandler` to your clients.
13
+ *
14
+ * ```ts
15
+ * import { S3Client } from '@aws-sdk/client-s3';
16
+ * import { SQSClient } from '@aws-sdk/client-sqs';
17
+ * import { pooledRequestHandler, sharedHttpAgents } from '@ayepi/aws/http';
18
+ *
19
+ * // One handler per client:
20
+ * const s3 = new S3Client({ region, requestHandler: pooledRequestHandler({ maxSockets: 256 }) });
21
+ *
22
+ * // ...or share ONE pool across many clients (fewer total sockets, better reuse):
23
+ * const agents = sharedHttpAgents({ maxSockets: 512 });
24
+ * const s3b = new S3Client({ requestHandler: pooledRequestHandler(agents) });
25
+ * const sqs = new SQSClient({ requestHandler: pooledRequestHandler(agents) });
26
+ * ```
27
+ *
28
+ * Keep-alive is on by default so connections are reused instead of churned — which also avoids
29
+ * ephemeral-port / `TIME_WAIT` exhaustion under sustained load. Requires `@smithy/node-http-handler`
30
+ * (a transitive dependency of the AWS SDK clients).
31
+ *
32
+ * @module
33
+ */
34
+ /** Agent constructor options derived from the pool knobs. */
35
+ function agentConf(opts) {
36
+ return {
37
+ keepAlive: opts.keepAlive ?? true,
38
+ keepAliveMsecs: opts.keepAliveMsecs ?? 1e3,
39
+ maxSockets: opts.maxSockets ?? 128,
40
+ maxTotalSockets: opts.maxTotalSockets
41
+ };
42
+ }
43
+ /**
44
+ * A pair of keep-alive `node:http` / `node:https` agents you can **share across many AWS clients**
45
+ * (pass to {@link pooledRequestHandler}). Sharing one pair bounds total sockets across S3/SQS/etc.
46
+ */
47
+ function sharedHttpAgents(opts = {}) {
48
+ const conf = agentConf(opts);
49
+ return {
50
+ httpAgent: new Agent(conf),
51
+ httpsAgent: new Agent$1(conf)
52
+ };
53
+ }
54
+ /**
55
+ * Build a `NodeHttpHandler` with keep-alive and a higher socket cap than the SDK default — pass it
56
+ * as `requestHandler` to an `S3Client`/`SQSClient`. Reuse one handler (or one {@link sharedHttpAgents}
57
+ * pair) across clients to bound total sockets.
58
+ */
59
+ function pooledRequestHandler(opts = {}) {
60
+ return new NodeHttpHandler({
61
+ httpAgent: opts.httpAgent ?? new Agent(agentConf(opts)),
62
+ httpsAgent: opts.httpsAgent ?? new Agent$1(agentConf(opts)),
63
+ connectionTimeout: opts.connectionTimeout,
64
+ requestTimeout: opts.requestTimeout
65
+ });
66
+ }
67
+ //#endregion
68
+ export { pooledRequestHandler, sharedHttpAgents };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ayepi/aws",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "AWS backends for ayepi — an SQS @ayepi/work queue (large payloads offloaded to S3) and an S3 @ayepi/files store, all retry-wrapped for throttle resilience",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -18,7 +18,8 @@
18
18
  "type": "module",
19
19
  "sideEffects": false,
20
20
  "files": [
21
- "dist"
21
+ "dist",
22
+ "ayepi-*.md"
22
23
  ],
23
24
  "exports": {
24
25
  ".": {
@@ -51,6 +52,16 @@
51
52
  "default": "./dist/sqs.cjs"
52
53
  }
53
54
  },
55
+ "./http": {
56
+ "import": {
57
+ "types": "./dist/http.d.ts",
58
+ "default": "./dist/http.js"
59
+ },
60
+ "require": {
61
+ "types": "./dist/http.d.cts",
62
+ "default": "./dist/http.cjs"
63
+ }
64
+ },
54
65
  "./package.json": "./package.json"
55
66
  },
56
67
  "engines": {
@@ -61,9 +72,10 @@
61
72
  "@aws-sdk/client-sqs": "^3",
62
73
  "@aws-sdk/lib-storage": "^3",
63
74
  "@aws-sdk/s3-request-presigner": "^3",
64
- "@ayepi/core": "^0.1.0",
65
- "@ayepi/files": "^0.1.0",
66
- "@ayepi/work": "^0.1.0"
75
+ "@smithy/node-http-handler": "^4",
76
+ "@ayepi/core": "^0.3.0",
77
+ "@ayepi/work": "^0.3.0",
78
+ "@ayepi/files": "^0.3.0"
67
79
  },
68
80
  "peerDependenciesMeta": {
69
81
  "@ayepi/work": {
@@ -83,6 +95,9 @@
83
95
  },
84
96
  "@aws-sdk/s3-request-presigner": {
85
97
  "optional": true
98
+ },
99
+ "@smithy/node-http-handler": {
100
+ "optional": true
86
101
  }
87
102
  },
88
103
  "devDependencies": {
@@ -90,15 +105,16 @@
90
105
  "@aws-sdk/client-sqs": "^3.700.0",
91
106
  "@aws-sdk/lib-storage": "^3.700.0",
92
107
  "@aws-sdk/s3-request-presigner": "^3.700.0",
108
+ "@smithy/node-http-handler": "^4.0.0",
93
109
  "@vitest/coverage-v8": "^2.1.8",
94
110
  "publint": "^0.3.0",
95
111
  "testcontainers": "^10.13.0",
96
112
  "tsdown": "^0.12.0",
97
113
  "vitest": "^2.1.8",
98
114
  "zod": "^4.4.3",
99
- "@ayepi/files": "0.1.0",
100
- "@ayepi/core": "0.1.0",
101
- "@ayepi/work": "0.1.0"
115
+ "@ayepi/core": "0.3.0",
116
+ "@ayepi/files": "0.3.0",
117
+ "@ayepi/work": "0.3.0"
102
118
  },
103
119
  "keywords": [
104
120
  "ayepi",