@specific.dev/spectest 0.39.0 → 0.43.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.
Files changed (105) hide show
  1. package/dist/browser.d.ts +21 -8
  2. package/dist/browser.js +78 -36
  3. package/dist/components/supabase.d.ts +87 -27
  4. package/dist/components/supabase.js +352 -69
  5. package/dist/daemon.d.ts +38 -0
  6. package/dist/daemon.js +464 -987
  7. package/dist/harness/build-context.d.ts +82 -0
  8. package/dist/harness/build-context.js +113 -0
  9. package/dist/harness/buildkit-progress.d.ts +37 -0
  10. package/dist/harness/buildkit-progress.js +66 -0
  11. package/dist/harness/container-run.d.ts +89 -0
  12. package/dist/harness/container-run.js +118 -0
  13. package/dist/harness/file-mounts.d.ts +91 -0
  14. package/dist/harness/file-mounts.js +119 -0
  15. package/dist/harness/hostmatch.d.ts +65 -0
  16. package/dist/harness/hostmatch.js +108 -0
  17. package/dist/harness/http-proxy.d.ts +62 -0
  18. package/dist/harness/http-proxy.js +104 -0
  19. package/dist/harness/ingress-table.d.ts +148 -0
  20. package/dist/harness/ingress-table.js +129 -0
  21. package/dist/harness/log-delta.d.ts +54 -0
  22. package/dist/harness/log-delta.js +83 -0
  23. package/dist/harness/main.d.ts +47 -0
  24. package/dist/harness/main.js +164 -0
  25. package/dist/harness/methods.d.ts +54 -0
  26. package/dist/harness/methods.js +65 -0
  27. package/dist/harness/names-registry.d.ts +63 -0
  28. package/dist/harness/names-registry.js +90 -0
  29. package/dist/harness/protocol.d.ts +88 -0
  30. package/dist/harness/protocol.js +96 -0
  31. package/dist/harness/ready-poll.d.ts +47 -0
  32. package/dist/harness/ready-poll.js +67 -0
  33. package/dist/harness/service-graph.d.ts +29 -0
  34. package/dist/harness/service-graph.js +92 -0
  35. package/dist/harness/volume-paths.d.ts +70 -0
  36. package/dist/harness/volume-paths.js +81 -0
  37. package/dist/index.d.ts +58 -16
  38. package/dist/ingress.d.ts +1 -1
  39. package/dist/mobile.d.ts +9 -5
  40. package/dist/mobile.js +7 -6
  41. package/dist/recorder.d.ts +10 -0
  42. package/dist/resolver.js +5 -8
  43. package/dist/vendor/rrweb-plugin-console-record.umd.js +521 -0
  44. package/dist/vendor/rrweb-record.min.js +5061 -0
  45. package/package.json +7 -1
  46. package/src/aws-sigv4.ts +218 -0
  47. package/src/browser.ts +2095 -0
  48. package/src/components/aws.ts +554 -0
  49. package/src/components/email.ts +398 -0
  50. package/src/components/expo.ts +167 -0
  51. package/src/components/index.ts +81 -0
  52. package/src/components/k3s.ts +2061 -0
  53. package/src/components/postgres.ts +132 -0
  54. package/src/components/replayFake.ts +1015 -0
  55. package/src/components/s3.ts +132 -0
  56. package/src/components/supabase.ts +1699 -0
  57. package/src/daemon.ts +5537 -0
  58. package/src/harness/build-context.test.ts +0 -0
  59. package/src/harness/build-context.ts +146 -0
  60. package/src/harness/buildkit-progress.test.ts +98 -0
  61. package/src/harness/buildkit-progress.ts +74 -0
  62. package/src/harness/container-run.test.ts +209 -0
  63. package/src/harness/container-run.ts +158 -0
  64. package/src/harness/file-mounts.test.ts +185 -0
  65. package/src/harness/file-mounts.ts +145 -0
  66. package/src/harness/hostmatch.test.ts +148 -0
  67. package/src/harness/hostmatch.ts +109 -0
  68. package/src/harness/http-proxy.test.ts +156 -0
  69. package/src/harness/http-proxy.ts +119 -0
  70. package/src/harness/ingress-rebind.test.ts +125 -0
  71. package/src/harness/ingress-table.test.ts +172 -0
  72. package/src/harness/ingress-table.ts +186 -0
  73. package/src/harness/log-delta.test.ts +125 -0
  74. package/src/harness/log-delta.ts +100 -0
  75. package/src/harness/main.test.ts +211 -0
  76. package/src/harness/main.ts +196 -0
  77. package/src/harness/methods.test.ts +63 -0
  78. package/src/harness/methods.ts +92 -0
  79. package/src/harness/names-registry.test.ts +137 -0
  80. package/src/harness/names-registry.ts +108 -0
  81. package/src/harness/protocol.test.ts +148 -0
  82. package/src/harness/protocol.ts +163 -0
  83. package/src/harness/ready-poll.test.ts +172 -0
  84. package/src/harness/ready-poll.ts +93 -0
  85. package/src/harness/service-graph.test.ts +97 -0
  86. package/src/harness/service-graph.ts +97 -0
  87. package/src/harness/volume-paths.test.ts +102 -0
  88. package/src/harness/volume-paths.ts +112 -0
  89. package/src/ids.ts +89 -0
  90. package/src/index.ts +2767 -0
  91. package/src/ingress.ts +305 -0
  92. package/src/inspect.ts +739 -0
  93. package/src/locator.ts +716 -0
  94. package/src/mobile.ts +138 -0
  95. package/src/record-secrets.ts +41 -0
  96. package/src/recorder.ts +856 -0
  97. package/src/redis.ts +202 -0
  98. package/src/replay-bundle.ts +108 -0
  99. package/src/resolver.ts +348 -0
  100. package/src/s3.ts +333 -0
  101. package/src/sql.ts +243 -0
  102. package/src/terminal.ts +740 -0
  103. package/src/url-match.ts +67 -0
  104. package/src/vendor/rrweb-plugin-console-record.umd.js +521 -0
  105. package/src/vendor/rrweb-record.min.js +5061 -0
@@ -0,0 +1,554 @@
1
+ // `aws()` — the AWS cloud APIs, emulated inside the environment, answering
2
+ // at their **real** endpoints.
3
+ //
4
+ // The component runs one emulator container and claims the AWS domains for
5
+ // it: DNS, a leaf certificate minted from the in-VM root CA, and a
6
+ // TLS-terminating reverse proxy. An AWS SDK in the app under test therefore
7
+ // resolves `https://states.us-east-1.amazonaws.com` the way it does in
8
+ // production and reaches the emulator — the app needs no endpoint override,
9
+ // no test-only branch, and no knowledge that it is under test.
10
+ //
11
+ // Lambda functions are declared next to the services and deployed from the
12
+ // project's own source. Deployment happens in the service `setup` hook, which
13
+ // runs before the warm-template snapshot, so a warm start restores functions
14
+ // that are already deployed, already active, and already warm.
15
+ //
16
+ // ── Internal notes (deliberately not in the user-facing docs) ──────────────
17
+ //
18
+ // The backing image is MiniStack (MIT, github.com/ministackorg/ministack),
19
+ // pinned rather than floated. It replaced LocalStack, which folded its
20
+ // Apache-2.0 Community edition into one paid product in March 2026 (current
21
+ // images demand a LOCALSTACK_AUTH_TOKEN). MiniStack also suits fork-per-test
22
+ // far better: ~270 MB against ~1 GB and a ~2 s boot.
23
+ //
24
+ // Lambda runs in MiniStack's **docker** executor, never its default `local`
25
+ // one, because `local` has no runtime fidelity at all: it maps `python*` to
26
+ // its own `sys.executable` and `nodejs*` to its own `node`, so the declared
27
+ // `Runtime` selects only the language. A function declared `python3.11` runs
28
+ // on the emulator image's CPython 3.13/musl, and one declared `nodejs20.x`
29
+ // runs on Node 24 — which is why a native wheel built for the real target
30
+ // fails to import ("No module named 'pydantic_core._pydantic_core'"). The
31
+ // docker executor runs the handler under AWS's own Runtime Interface
32
+ // Emulator in `public.ecr.aws/lambda/<runtime>`, giving the real
33
+ // interpreter, glibc, `/var/task` and `AWS_EXECUTION_ENV`. Measured on
34
+ // aarch64: it is also ~7x FASTER per warm invoke (7 ms against 53 ms) at
35
+ // ~25-40 MiB per function container.
36
+ //
37
+ // None of this is configurable, on purpose — a knob here is a knob between
38
+ // "behaves like AWS" and "does not".
39
+ //
40
+ // The cost is a bind-mount of the VM's own docker socket, which the daemon
41
+ // already supports (`ensureVolumes` treats an existing non-directory source
42
+ // as a thing to mount as-is). The RIE containers are ordinary VM containers
43
+ // on `spectest-net`, so they ride snapshots and forks like any service: a
44
+ // per-test fork inherits its parent's already-warm function.
45
+ //
46
+ // Landmine: the runtime images are pulled from `public.ecr.aws`, which the
47
+ // in-VM dockerd reaches DIRECTLY. Its `registry-mirrors` only ever applies
48
+ // to Docker Hub, so the zot mirror (which does carry public.ecr.aws, on
49
+ // :5004) is NOT consulted. MiniStack's own `MINISTACK_IMAGE_PREFIX` cannot
50
+ // close that gap either: it prepends rather than replacing the registry
51
+ // host. Routing these pulls through zot needs a pre-pull of
52
+ // `spectest-host:5004/lambda/<rt>` retagged to the `public.ecr.aws` name.
53
+ //
54
+ // Like `email()`'s mail server, the product name stays out of every
55
+ // user-facing surface (docs, examples, error messages). Users get "the AWS
56
+ // emulator"; `image` swaps it.
57
+ //
58
+ // The emulator ignores SigV4 and does not check that a role exists, both
59
+ // verified on 1.4.8. That is what lets this component deploy over plain
60
+ // unsigned HTTP from the daemon and skip IAM entirely.
61
+
62
+ import { readFile, readdir, stat } from "node:fs/promises";
63
+ import path from "node:path";
64
+ import { deflateRawSync } from "node:zlib";
65
+
66
+ import type { ServiceDefinition, ServiceSetupContext } from "../index.js";
67
+ import { certificate, provides, proxy, SELF_SERVICE_TOKEN } from "../index.js";
68
+ import { pauseRecording, resumeRecording } from "../recorder.js";
69
+
70
+ /** Backing image, pinned internally (not part of the documented surface). */
71
+ const AWS_IMAGE = "ministackorg/ministack:1.4.8";
72
+ /** Port the emulator serves every AWS API on. Internal: callers reach the
73
+ * emulator through the AWS hostnames, never through this port. */
74
+ const AWS_PORT = 4566;
75
+
76
+ // Every AWS region, so the component can answer for all of them and the user
77
+ // never has to declare which ones the app uses. Enumeration is forced by TLS,
78
+ // not by routing: a wildcard *route* matches any depth, but a wildcard
79
+ // *certificate* covers exactly one label (RFC 6125, enforced by every
80
+ // client), so `*.amazonaws.com` cannot serve `states.us-east-1.amazonaws.com`
81
+ // — each region needs its own `*.<region>.amazonaws.com` name. They all ride
82
+ // ONE certificate as SANs and one shared upstream, so the list costs a few
83
+ // route-table entries, not a key generation each.
84
+ //
85
+ // Sourced from AWS's own published `ip-ranges.json` (2026-07-31). A region
86
+ // added later is simply not claimed: its endpoints fail to resolve, which
87
+ // says plainly what happened — add it here.
88
+ const AWS_REGIONS = [
89
+ "af-south-1", "ap-east-1", "ap-east-2", "ap-northeast-1", "ap-northeast-2",
90
+ "ap-northeast-3", "ap-south-1", "ap-south-2", "ap-southeast-1", "ap-southeast-2",
91
+ "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", "ap-southeast-6",
92
+ "ap-southeast-7", "ca-central-1", "ca-west-1", "eu-central-1", "eu-central-2",
93
+ "eu-north-1", "eu-south-1", "eu-south-2", "eu-west-1", "eu-west-2", "eu-west-3",
94
+ "eusc-de-east-1", "il-central-1", "me-central-1", "me-south-1", "me-west-1",
95
+ "mx-central-1", "sa-east-1", "sa-west-1", "us-east-1", "us-east-2",
96
+ "us-gov-east-1", "us-gov-west-1", "us-south-1", "us-west-1", "us-west-2",
97
+ ];
98
+
99
+ /** China is a separate partition on its own domain suffix. */
100
+ const AWS_CN_REGIONS = ["cn-north-1", "cn-northwest-1"];
101
+
102
+ /** Every hostname pattern the emulator answers to: the two partitions' global
103
+ * endpoints (`sts.amazonaws.com`, `iam.amazonaws.com`) plus one per region
104
+ * for the regional ones (`states.us-east-1.amazonaws.com`). */
105
+ const AWS_HOST_PATTERNS = [
106
+ "*.amazonaws.com",
107
+ ...AWS_REGIONS.map((r) => `*.${r}.amazonaws.com`),
108
+ "*.amazonaws.com.cn",
109
+ ...AWS_CN_REGIONS.map((r) => `*.${r}.amazonaws.com.cn`),
110
+ ];
111
+
112
+ /** The VM's own docker socket, bind-mounted so the emulator can start the
113
+ * official AWS runtime containers. This is the VM's dockerd, inside the
114
+ * hermetic environment — the same daemon that runs the services. */
115
+ const DOCKER_SOCKET = "/var/run/docker.sock";
116
+ /** The network the function containers join, so a handler reaches the other
117
+ * services and the AWS endpoints by the same names a test uses. */
118
+ const SPECTEST_NETWORK = "spectest-net";
119
+
120
+ /** Region the emulator's own tools default to — the AWS CLI refuses to run
121
+ * without one. Only the *container's* default; it constrains nothing about
122
+ * the app, and every region is served regardless. A `setup` hook that drives
123
+ * the CLI against another region passes `--region`. */
124
+ const CONTAINER_DEFAULT_REGION = "us-east-1";
125
+ /** Role every declared function is created with. The emulator does not
126
+ * enforce IAM, so this only has to be a well-formed ARN — which is why the
127
+ * component creates no role and takes no IAM options. A project that wants a
128
+ * real-looking role can create one itself and set `role` on the function. */
129
+ const DEFAULT_ROLE_ARN = "arn:aws:iam::000000000000:role/spectest-lambda";
130
+ /** Ready-check budget. The emulator boots in a couple of seconds; the slow
131
+ * part is a cold image pull on a machine that has never run it. */
132
+ const READY_TIMEOUT_SECS = 120;
133
+ /** Memory every declared function is created with. Kept at what this
134
+ * component has always used, rather than the emulator's own default, so the
135
+ * removal of the `memoryMb` option changed no behaviour. */
136
+ const MEMORY_MB = 512;
137
+
138
+ export interface AwsOptions {
139
+ /**
140
+ * Lambda functions to deploy from the project's own source, keyed by
141
+ * function name — the name the app (or a state machine) invokes.
142
+ *
143
+ * ```ts
144
+ * lambdas: {
145
+ * add: { source: "lambda/add" },
146
+ * resize: { source: "lambda/resize", runtime: "python3.12", timeoutSecs: 60 },
147
+ * }
148
+ * ```
149
+ *
150
+ * Each is packaged and deployed during environment bring-up, so it is part
151
+ * of the warm-template snapshot: a warm start restores the deployed
152
+ * function instead of deploying it again.
153
+ */
154
+ lambdas?: Record<string, LambdaOptions>;
155
+ }
156
+
157
+ export interface LambdaOptions {
158
+ /**
159
+ * The function's code, as a path in your repository — either a directory
160
+ * (packaged whole, recursively, with its contents at the root of the
161
+ * package) or a single file. Relative paths resolve against the project
162
+ * root, so `"lambda/add"` is the repo's own `lambda/add/`.
163
+ *
164
+ * The code is taken from the repository on purpose: the function you test
165
+ * is the function you ship. Anything the handler imports at runtime —
166
+ * including its dependencies — must be inside this path, exactly as a real
167
+ * deployment package requires.
168
+ */
169
+ source: string;
170
+ /** Entry point, as AWS spells it: `<file>.<exported function>`. Default
171
+ * `"index.handler"`. */
172
+ handler?: string;
173
+ /** Lambda runtime identifier. Default `"nodejs20.x"`. */
174
+ runtime?: string;
175
+ /** Environment variables for the function. */
176
+ env?: Record<string, string>;
177
+ /** Function timeout in seconds. Default `30`. */
178
+ timeoutSecs?: number;
179
+ /** Execution-role ARN. Defaults to a fixed placeholder — the emulator does
180
+ * not enforce IAM. Set it only when the code reads the ARN itself. */
181
+ role?: string;
182
+ }
183
+
184
+
185
+ /**
186
+ * The AWS cloud APIs, emulated in the environment and answering at their real
187
+ * endpoints. Drop into `environment.services`:
188
+ *
189
+ * ```ts
190
+ * import { aws } from "@specific.dev/spectest/components";
191
+ *
192
+ * services: {
193
+ * aws: aws({
194
+ * lambdas: {
195
+ * add: { source: "lambda/add" },
196
+ * subtract: { source: "lambda/subtract" },
197
+ * },
198
+ * }),
199
+ * app: {
200
+ * image: { type: "dockerfile", content: appDockerfile },
201
+ * // Whatever the app already reads in production. An AWS SDK needs a
202
+ * // region and credentials; the emulator accepts any credential value.
203
+ * env: {
204
+ * AWS_REGION: "us-east-1",
205
+ * AWS_ACCESS_KEY_ID: "test",
206
+ * AWS_SECRET_ACCESS_KEY: "test",
207
+ * },
208
+ * dependsOn: ["aws"],
209
+ * },
210
+ * }
211
+ * ```
212
+ *
213
+ * The app keeps its production configuration — `new SFNClient({})`, no
214
+ * endpoint — because the component owns DNS and TLS for the AWS domains. It
215
+ * answers for **every region**, so an app that talks to two of them, or that
216
+ * reads its region from configuration, needs nothing declared here.
217
+ *
218
+ * Tests reach the same cloud with the AWS CLI, which the emulator's image
219
+ * ships as `awslocal` (the CLI, already pointed at it):
220
+ *
221
+ * ```ts
222
+ * const listed = await ctx.exec("aws", "awslocal stepfunctions list-state-machines");
223
+ * expect(listed.exitCode).toBe(0);
224
+ * const machines = listed.stdout.transform("machines", (out) =>
225
+ * (JSON.parse(out) as { stateMachines: unknown[] }).stateMachines,
226
+ * );
227
+ * expect(machines).toHaveLength(1);
228
+ * ```
229
+ *
230
+ * For S3, prefer the `s3()` component and give it the endpoint your app uses
231
+ * (`s3({ hosts: ["s3.us-east-1.amazonaws.com"] })`). An exact hostname always
232
+ * beats this component's wildcard, so the two compose without further
233
+ * configuration.
234
+ */
235
+ export function aws(opts: AwsOptions = {}) {
236
+ const lambdas = opts.lambdas ?? {};
237
+
238
+ const service = {
239
+ image: { type: "registry" as const, reference: AWS_IMAGE },
240
+ env: {
241
+ AWS_DEFAULT_REGION: CONTAINER_DEFAULT_REGION,
242
+ // Run every function in the official AWS runtime image rather than in
243
+ // the emulator's own interpreter — see the note at the top of the file.
244
+ LAMBDA_EXECUTOR: "docker",
245
+ // No silent fall-back to the low-fidelity executor. If the runtime
246
+ // container cannot start, say so instead of running the handler on the
247
+ // wrong interpreter and failing later, somewhere else.
248
+ LAMBDA_STRICT: "1",
249
+ LAMBDA_DOCKER_NETWORK: SPECTEST_NETWORK,
250
+ },
251
+ volumes: [{ source: DOCKER_SOCKET, target: DOCKER_SOCKET }],
252
+ ports: [AWS_PORT],
253
+ readyCheck: {
254
+ type: "http" as const,
255
+ port: AWS_PORT,
256
+ path: "/_ministack/health",
257
+ timeoutSecs: READY_TIMEOUT_SECS,
258
+ },
259
+ ...(Object.keys(lambdas).length > 0
260
+ ? {
261
+ setup: async ({ name, projectRoot }: ServiceSetupContext) => {
262
+ for (const [fn, spec] of Object.entries(lambdas)) {
263
+ await deployLambda(name, projectRoot, fn, spec);
264
+ }
265
+ },
266
+ }
267
+ : {}),
268
+ } satisfies ServiceDefinition;
269
+
270
+ // The low-level primitives rather than the `tls` field, for one reason:
271
+ // `tls` mints a separate leaf certificate per entry, and this component
272
+ // claims several dozen names. One `certificate(...)` decl carries them all
273
+ // as SANs of a single leaf — one key generation at bring-up instead of
274
+ // dozens — while the proxies are just route-table entries.
275
+ return provides(service, [
276
+ certificate(AWS_HOST_PATTERNS),
277
+ ...AWS_HOST_PATTERNS.map((hostname) =>
278
+ proxy(hostname, { service: SELF_SERVICE_TOKEN, port: AWS_PORT }),
279
+ ),
280
+ ]);
281
+ }
282
+
283
+
284
+ // ──────────────────────────────────────────────────────────────────────────
285
+ // Talking to the emulator directly
286
+ //
287
+ // The emulator ignores request signatures, so the daemon reaches its APIs
288
+ // over plain unsigned HTTP. That keeps deployment independent of what tools
289
+ // the image happens to ship, and keeps a poll loop cheap.
290
+ // ──────────────────────────────────────────────────────────────────────────
291
+
292
+ /** One JSON-protocol AWS call (Step Functions, DynamoDB, …), by target. */
293
+ async function request(
294
+ service: string,
295
+ method: string,
296
+ urlPath: string,
297
+ init: { headers?: Record<string, string>; body?: string } = {},
298
+ ): Promise<unknown> {
299
+ // Never let the component's own traffic land on the timeline: these calls
300
+ // are bring-up and polling, not something a test asked for.
301
+ pauseRecording();
302
+ try {
303
+ const res = await fetch(`http://${service}:${AWS_PORT}${urlPath}`, {
304
+ method,
305
+ headers: { "content-type": "application/json", ...(init.headers ?? {}) },
306
+ ...(init.body !== undefined ? { body: init.body } : {}),
307
+ });
308
+ // Coerced rather than read directly: `fetch` is wrapped during a test, so
309
+ // `res.status` is a provenance carrier there and a plain number elsewhere.
310
+ const status = Number(res.status);
311
+ const text = String(await res.text());
312
+ if (status >= 400) {
313
+ const err = new Error(`aws: ${method} ${urlPath} failed: HTTP ${status} ${text.slice(0, 400)}`);
314
+ (err as Error & { status?: number }).status = status;
315
+ throw err;
316
+ }
317
+ return text === "" ? undefined : JSON.parse(text);
318
+ } finally {
319
+ resumeRecording();
320
+ }
321
+ }
322
+
323
+ // ──────────────────────────────────────────────────────────────────────────
324
+ // Lambda deployment
325
+ // ──────────────────────────────────────────────────────────────────────────
326
+
327
+ /** Refuse a package that no real deployment would accept either. */
328
+ const MAX_PACKAGE_BYTES = 64 * 1024 * 1024;
329
+
330
+ async function deployLambda(
331
+ service: string,
332
+ projectRoot: string,
333
+ name: string,
334
+ spec: LambdaOptions,
335
+ ): Promise<void> {
336
+ if (typeof spec?.source !== "string" || spec.source.trim() === "") {
337
+ throw new Error(`aws: lambda "${name}" needs a \`source\` path to its code`);
338
+ }
339
+ const zipped = await packageSource(projectRoot, name, spec.source);
340
+ if (zipped.length > MAX_PACKAGE_BYTES) {
341
+ throw new Error(
342
+ `aws: lambda "${name}" packages to ${Math.round(zipped.length / 1e6)} MB from ` +
343
+ `${spec.source} — over the ${MAX_PACKAGE_BYTES / 1e6} MB limit. Point \`source\` at ` +
344
+ `the function's own directory, not a whole repository.`,
345
+ );
346
+ }
347
+
348
+ const code = Buffer.from(zipped).toString("base64");
349
+ const config = {
350
+ Runtime: spec.runtime ?? "nodejs20.x",
351
+ Handler: spec.handler ?? "index.handler",
352
+ Role: spec.role ?? DEFAULT_ROLE_ARN,
353
+ Timeout: spec.timeoutSecs ?? 30,
354
+ // Fixed, and not an option: the emulated cloud reports this number back
355
+ // as `AWS_LAMBDA_FUNCTION_MEMORY_SIZE` but never caps the runtime
356
+ // container at it, so the number changes nothing about how a function
357
+ // behaves here.
358
+ MemorySize: MEMORY_MB,
359
+ ...(spec.env ? { Environment: { Variables: spec.env } } : {}),
360
+ };
361
+
362
+ try {
363
+ await request(service, "POST", "/2015-03-31/functions", {
364
+ body: JSON.stringify({ FunctionName: name, Code: { ZipFile: code }, ...config }),
365
+ });
366
+ } catch (err) {
367
+ // Already there: a re-run of bring-up against an emulator that kept its
368
+ // state. Update instead, so the deployed code always matches the repo.
369
+ if ((err as { status?: number }).status !== 409) throw err;
370
+ await request(service, "PUT", `/2015-03-31/functions/${encodeURIComponent(name)}/code`, {
371
+ body: JSON.stringify({ ZipFile: code }),
372
+ });
373
+ await request(service, "PUT", `/2015-03-31/functions/${encodeURIComponent(name)}/configuration`, {
374
+ body: JSON.stringify(config),
375
+ });
376
+ }
377
+
378
+ await waitActive(service, name);
379
+
380
+ // One invoke with an empty event, purely to leave a warm worker behind in
381
+ // the snapshot, so no test pays a cold start. The handler may well reject
382
+ // the event — that is fine, the worker is started either way.
383
+ try {
384
+ await request(service, "POST", `/2015-03-31/functions/${encodeURIComponent(name)}/invocations`, {
385
+ body: "{}",
386
+ });
387
+ } catch {
388
+ // Ignored on purpose: warming is an optimisation, never a gate.
389
+ }
390
+ }
391
+
392
+ /** Wait for a newly created function to leave `Pending`, exactly as a real
393
+ * deployment must. Tolerates an emulator that reports no state at all. */
394
+ async function waitActive(service: string, name: string, timeoutMs = 60_000): Promise<void> {
395
+ const deadline = Date.now() + timeoutMs;
396
+ for (;;) {
397
+ const cfg = (await request(
398
+ service,
399
+ "GET",
400
+ `/2015-03-31/functions/${encodeURIComponent(name)}/configuration`,
401
+ )) as { State?: string; StateReason?: string } | undefined;
402
+ const state = cfg?.State;
403
+ if (state === undefined || state === "Active") return;
404
+ if (state === "Failed") {
405
+ throw new Error(`aws: lambda "${name}" failed to deploy: ${cfg?.StateReason ?? "no reason given"}`);
406
+ }
407
+ if (Date.now() > deadline) {
408
+ throw new Error(`aws: lambda "${name}" was still ${state} after ${timeoutMs} ms`);
409
+ }
410
+ await new Promise((resolve) => setTimeout(resolve, 100));
411
+ }
412
+ }
413
+
414
+ // ──────────────────────────────────────────────────────────────────────────
415
+ // Packaging
416
+ //
417
+ // A deployment package is a zip, and building one in the daemon keeps the
418
+ // whole path tool-free: no zip binary in the image, no staging file, and no
419
+ // dependence on what the emulator image ships.
420
+ // ──────────────────────────────────────────────────────────────────────────
421
+
422
+ interface PackageEntry {
423
+ /** Path inside the package, always forward-slashed. */
424
+ name: string;
425
+ data: Uint8Array;
426
+ }
427
+
428
+ async function packageSource(
429
+ projectRoot: string,
430
+ fnName: string,
431
+ source: string,
432
+ ): Promise<Uint8Array> {
433
+ const root = path.isAbsolute(source) ? source : path.join(projectRoot, source);
434
+ let info;
435
+ try {
436
+ info = await stat(root);
437
+ } catch {
438
+ throw new Error(
439
+ `aws: lambda "${fnName}" has no code at ${source} (looked in ${root}). ` +
440
+ `\`source\` is a path in your repository, relative to its root.`,
441
+ );
442
+ }
443
+
444
+ const entries: PackageEntry[] = [];
445
+ if (info.isDirectory()) {
446
+ await collect(root, "", entries);
447
+ if (entries.length === 0) {
448
+ throw new Error(`aws: lambda "${fnName}" has no files in ${source}`);
449
+ }
450
+ } else {
451
+ entries.push({ name: path.basename(root), data: await readFile(root) });
452
+ }
453
+ // Sorted so the same source always packages to the same bytes.
454
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
455
+ return buildZip(entries);
456
+ }
457
+
458
+ async function collect(dir: string, prefix: string, out: PackageEntry[]): Promise<void> {
459
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
460
+ const full = path.join(dir, entry.name);
461
+ const rel = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
462
+ if (entry.isDirectory()) {
463
+ await collect(full, rel, out);
464
+ } else if (entry.isFile()) {
465
+ out.push({ name: rel, data: await readFile(full) });
466
+ }
467
+ // Symlinks and everything else are skipped: a deployment package holds
468
+ // regular files.
469
+ }
470
+ }
471
+
472
+ function crc32(buf: Uint8Array): number {
473
+ let crc = 0xffffffff;
474
+ for (let i = 0; i < buf.length; i++) {
475
+ let c = (crc ^ buf[i]) & 0xff;
476
+ for (let k = 0; k < 8; k++) c = c & 1 ? (c >>> 1) ^ 0xedb88320 : c >>> 1;
477
+ crc = (crc >>> 8) ^ c;
478
+ }
479
+ return (crc ^ 0xffffffff) >>> 0;
480
+ }
481
+
482
+ /**
483
+ * Build a zip archive. Entries are deflated (method 8), or stored (method 0)
484
+ * when deflating makes them no smaller. Timestamps are fixed, so identical
485
+ * sources always produce identical bytes.
486
+ */
487
+ function buildZip(entries: PackageEntry[]): Uint8Array {
488
+ const encoder = new TextEncoder();
489
+ const local: Uint8Array[] = [];
490
+ const central: Uint8Array[] = [];
491
+ let offset = 0;
492
+
493
+ for (const entry of entries) {
494
+ const nameBytes = encoder.encode(entry.name);
495
+ const crc = crc32(entry.data);
496
+ const deflated = new Uint8Array(deflateRawSync(entry.data));
497
+ const stored = deflated.length >= entry.data.length;
498
+ const payload = stored ? entry.data : deflated;
499
+ const method = stored ? 0 : 8;
500
+
501
+ const header = new DataView(new ArrayBuffer(30));
502
+ header.setUint32(0, 0x04034b50, true); // local file header
503
+ header.setUint16(4, 20, true); // version needed
504
+ header.setUint16(6, 0, true); // flags
505
+ header.setUint16(8, method, true);
506
+ header.setUint16(10, 0, true); // time — fixed
507
+ header.setUint16(12, 0x21, true); // date — fixed (1 Jan 1980)
508
+ header.setUint32(14, crc, true);
509
+ header.setUint32(18, payload.length, true);
510
+ header.setUint32(22, entry.data.length, true);
511
+ header.setUint16(26, nameBytes.length, true);
512
+ header.setUint16(28, 0, true); // extra length
513
+ const headerBytes = new Uint8Array(header.buffer);
514
+ local.push(headerBytes, nameBytes, payload);
515
+
516
+ const dirEntry = new DataView(new ArrayBuffer(46));
517
+ dirEntry.setUint32(0, 0x02014b50, true); // central directory header
518
+ dirEntry.setUint16(4, 20, true); // version made by
519
+ dirEntry.setUint16(6, 20, true); // version needed
520
+ dirEntry.setUint16(8, 0, true); // flags
521
+ dirEntry.setUint16(10, method, true);
522
+ dirEntry.setUint16(12, 0, true);
523
+ dirEntry.setUint16(14, 0x21, true);
524
+ dirEntry.setUint32(16, crc, true);
525
+ dirEntry.setUint32(20, payload.length, true);
526
+ dirEntry.setUint32(24, entry.data.length, true);
527
+ dirEntry.setUint16(28, nameBytes.length, true);
528
+ // Permissions in the high half of the external attributes: 0644 for a
529
+ // regular file, which is what a Lambda runtime expects to find.
530
+ dirEntry.setUint32(38, 0o100644 << 16, true);
531
+ dirEntry.setUint32(42, offset, true);
532
+ central.push(new Uint8Array(dirEntry.buffer), nameBytes);
533
+
534
+ offset += headerBytes.length + nameBytes.length + payload.length;
535
+ }
536
+
537
+ const centralSize = central.reduce((n, part) => n + part.length, 0);
538
+ const end = new DataView(new ArrayBuffer(22));
539
+ end.setUint32(0, 0x06054b50, true); // end of central directory
540
+ end.setUint16(8, entries.length, true);
541
+ end.setUint16(10, entries.length, true);
542
+ end.setUint32(12, centralSize, true);
543
+ end.setUint32(16, offset, true);
544
+
545
+ const parts = [...local, ...central, new Uint8Array(end.buffer)];
546
+ const total = parts.reduce((n, part) => n + part.length, 0);
547
+ const zipped = new Uint8Array(total);
548
+ let cursor = 0;
549
+ for (const part of parts) {
550
+ zipped.set(part, cursor);
551
+ cursor += part.length;
552
+ }
553
+ return zipped;
554
+ }