@reventlessdev/reventless-aws 3.0.0-alpha.333 → 3.0.0-alpha.335

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.
@@ -1,10 +1,11 @@
1
- // A verified SES sender identity, provisioned with the framework's house
2
- // conventions applied — the backing for the messaging capability's email channel.
1
+ // A verified SES sender identity the email channel's real transport.
3
2
  //
4
- // The identity is the whole of the provisioning: SES sends from a verified
5
- // address, and everything else a send needs travels on the message. So this
6
- // helper exists to make the sender a deploy-time decision with one owner rather
7
- // than an environment variable each stack spells for itself.
3
+ // Provisioning only. Which transport a deployment uses, and what address it
4
+ // sends from, are read by `Capability_Messaging`, which is the entry point a
5
+ // platform root calls; this module receives a resolved address and creates the
6
+ // identity for it. Split that way because a deployment can choose *not* to have
7
+ // an SES identity, and a module that read its own config could not be left
8
+ // uncalled without also leaving the config unread.
8
9
  //
9
10
  // **Verification is not instant and not automatic.** Creating the identity asks
10
11
  // AWS to send a confirmation mail to the address; until somebody follows it, SES
@@ -18,14 +19,26 @@
18
19
 
19
20
  open PulumiAws
20
21
 
21
- /** Declare the sender address and hand back the platform's messaging handle.
22
- `~email` is the `From:` every message this deployment sends carries. */
23
- let make = (
22
+ /**
23
+ Create the identity and hand back the `From:` it sends as.
24
+
25
+ `~name` is the Pulumi resource name — a URN rather than a setting, so it stays in
26
+ code: moving it to config would replace the resource whenever a stack spelled it
27
+ differently.
28
+
29
+ The identity is created for the bare address, because SES verifies an address and
30
+ not a header; the display name is applied afterwards, to the value the send path
31
+ reads. Applied over the identity's own output rather than over the configured
32
+ string, so the sender a Lambda is handed still depends on the resource existing.
33
+ */
34
+ let emailSender = (
24
35
  ~name: string,
25
- ~email: string,
36
+ ~address: string,
37
+ ~displayName: option<string>,
26
38
  ~opts: option<Pulumi.CustomResourceOptions.t>=?,
27
- ): ReventlessInfra.Platform.messagingSender => {
28
- let identity = SES.EmailIdentity.make(~name, ~args={email: email}, ~opts?)
29
-
30
- {emailSender: identity.email->Pulumi.Output.asInput}
39
+ ): Pulumi.Input.t<string> => {
40
+ let identity = SES.EmailIdentity.make(~name, ~args={email: address}, ~opts?)
41
+ identity.email
42
+ ->Pulumi.Output.apply(verified => Reventless.Messaging.fromHeader(~displayName, ~address=verified))
43
+ ->Pulumi.Output.asInput
31
44
  }
@@ -2,17 +2,16 @@
2
2
 
3
3
  import * as Aws from "@pulumi/aws";
4
4
  import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
5
+ import * as Messaging$Reventless from "@reventlessdev/reventless-spec/src/semantic/Messaging.res.mjs";
5
6
 
6
- function make(name, email, opts) {
7
+ function emailSender(name, address, displayName, opts) {
7
8
  let identity = new (Aws.ses.EmailIdentity)(name, {
8
- email: email
9
+ email: address
9
10
  }, opts !== undefined ? Primitive_option.valFromOption(opts) : undefined);
10
- return {
11
- emailSender: identity.email
12
- };
11
+ return identity.email.apply(verified => Messaging$Reventless.fromHeader(displayName, verified));
13
12
  }
14
13
 
15
14
  export {
16
- make,
15
+ emailSender,
17
16
  }
18
17
  /* @pulumi/aws Not a pure module */
@@ -349,6 +349,126 @@ let isRuntimeProvided = (specifier: string, ~pkgName: string): bool =>
349
349
  }
350
350
  )
351
351
 
352
+ // Memo for isFrameworkPackage — one require.resolve per package name per deploy.
353
+ let frameworkPackageCache: dict<bool> = Dict.make()
354
+
355
+ /**
356
+ * Whether the framework's own module resolution reaches this package: the
357
+ * deploy-time stand-in for "the Lambda layer already carries it", since the
358
+ * layer is built from reventless-aws's dependency closure.
359
+ *
360
+ * Deliberately framework-rooted only — no Pulumi-project fallback. The question
361
+ * here is the opposite of `resolvePackageRoot`'s: a package the *project*
362
+ * resolves but the framework does not is precisely a user package, which is
363
+ * what the closure walk below must treat as a starting point rather than as
364
+ * something the runtime provides.
365
+ */
366
+ let isFrameworkPackage = (pkgName: string): bool =>
367
+ switch frameworkPackageCache->Dict.get(pkgName) {
368
+ | Some(known) => known
369
+ | None =>
370
+ let known = try {
371
+ let _ = localRequire->NodeModule.requireResolve(pkgName ++ "/package.json")
372
+ true
373
+ } catch {
374
+ | _ => false
375
+ }
376
+ frameworkPackageCache->Dict.set(pkgName, known)
377
+ known
378
+ }
379
+
380
+ // The distinct bare packages a package's own bundled files import, memoised per
381
+ // package root: the same file set the archive carries, so what is scanned is
382
+ // what is deployed.
383
+ let importedPackagesCache: dict<array<string>> = Dict.make()
384
+ let importedPackages = (packageRoot: string): array<string> =>
385
+ switch importedPackagesCache->Dict.get(packageRoot) {
386
+ | Some(names) => names
387
+ | None =>
388
+ let assets: dict<Pulumi.Archive.assetOrArchive> = Dict.make()
389
+ let paths: array<(string, string)> = []
390
+ walkDir(~dir=packageRoot, ~prefix="", ~assets, ~paths)
391
+ let names: array<string> = []
392
+ paths->Array.forEach(((relPath, absPath)) =>
393
+ if relPath->String.endsWith(".mjs") || relPath->String.endsWith(".js") {
394
+ NodeFs.readFileSync(absPath)
395
+ ->staticImportSpecifiers
396
+ ->Array.forEach(specifier =>
397
+ if isBareSpecifier(specifier) {
398
+ let name = extractPackageName(specifier)
399
+ if !(names->Array.includes(name)) {
400
+ names->Array.push(name)
401
+ }
402
+ }
403
+ )
404
+ }
405
+ )
406
+ importedPackagesCache->Dict.set(packageRoot, names)
407
+ names
408
+ }
409
+
410
+ /**
411
+ * Resolve a package root as the importing package would — from that package's
412
+ * own directory. Under pnpm a plugin's dependency is reachable only from the
413
+ * plugin, never from the framework or the Pulumi project, so this is the one
414
+ * rooting that finds it. `None` when nothing is installed there: the archive
415
+ * cannot carry what is not on disk, and a package the code truly imported at
416
+ * runtime would already be failing today.
417
+ */
418
+ let resolvePackageRootFrom = (~fromRoot: string, pkgName: string): option<string> =>
419
+ try {
420
+ Some(
421
+ NodePath.dirname(
422
+ NodeModule.createRequire(NodePath.join([fromRoot, "index.js"]))
423
+ ->NodeModule.requireResolve(pkgName ++ "/package.json"),
424
+ ),
425
+ )
426
+ } catch {
427
+ | _ => None
428
+ }
429
+
430
+ /**
431
+ * Carry every package a bundled *user* package imports into the archive, and
432
+ * every package those import in turn.
433
+ *
434
+ * A plugin package rides in `/var/task/node_modules/<plugin>`, but its own
435
+ * dependencies do not: the layer holds reventless-aws's closure, which a domain
436
+ * trait or any other plugin-level library is by definition outside of. The
437
+ * import then fails at the first command the slice handles — a green deploy and
438
+ * a `Cannot find package` at runtime.
439
+ *
440
+ * Framework packages are the starting points that are skipped, not walked:
441
+ * their closure is the layer's, and their sources reach deploy-time-only
442
+ * imports (the @pulumi bindings) that no Lambda loads — walking them would add
443
+ * tens of megabytes to every archive.
444
+ */
445
+ let addImportedPackageClosure = (packageDirs: dict<string>): unit => {
446
+ let pending =
447
+ packageDirs->Dict.toArray->Array.filter(((pkgName, _)) => !isFrameworkPackage(pkgName))
448
+ let next = ref(0)
449
+ while next.contents < pending->Array.length {
450
+ let (_, pkgRoot) = pending->Array.getUnsafe(next.contents)
451
+ next := next.contents + 1
452
+ importedPackages(pkgRoot)->Array.forEach(dep =>
453
+ if (
454
+ !(packageDirs->Dict.has(dep)) &&
455
+ !(dep->String.startsWith("node:")) &&
456
+ !(nodeBuiltins->Set.has(dep)) &&
457
+ !(dep->String.startsWith("@aws-sdk/")) &&
458
+ !(dep->String.startsWith("@smithy/")) &&
459
+ !isFrameworkPackage(dep)
460
+ ) {
461
+ switch resolvePackageRootFrom(~fromRoot=pkgRoot, dep) {
462
+ | Some(depRoot) =>
463
+ packageDirs->Dict.set(dep, depRoot)
464
+ pending->Array.push((dep, depRoot))
465
+ | None => ()
466
+ }
467
+ }
468
+ )
469
+ }
470
+ }
471
+
352
472
  /**
353
473
  * Deploy-time guard: every bare package statically imported by a bundled
354
474
  * runtime-extension (or companion) package must either be in the archive
@@ -451,7 +571,12 @@ let buildCodeArchive = (
451
571
  // deploy whose extensions are silently skipped.
452
572
  if bundleRuntimeExtensions {
453
573
  let extensionPackages = allPackageDirs->addRuntimeExtensionPackages
574
+ // The closure runs first so a companion package that is simply a static
575
+ // import of the extension is already bundled by the time the guard looks.
576
+ allPackageDirs->addImportedPackageClosure
454
577
  assertRuntimeExtensionImportsResolvable(~extensionPackages, ~bundledPackages=allPackageDirs)
578
+ } else {
579
+ allPackageDirs->addImportedPackageClosure
455
580
  }
456
581
  let packageContentHashes: ref<array<string>> = ref([])
457
582
  allPackageDirs->Dict.forEachWithKey((pkgRoot, pkgName) => {
@@ -232,6 +232,87 @@ function isRuntimeProvided(specifier, pkgName) {
232
232
  }
233
233
  }
234
234
 
235
+ let frameworkPackageCache = {};
236
+
237
+ function isFrameworkPackage(pkgName) {
238
+ let known = frameworkPackageCache[pkgName];
239
+ if (known !== undefined) {
240
+ return known;
241
+ }
242
+ let known$1;
243
+ try {
244
+ localRequire.resolve(pkgName + "/package.json");
245
+ known$1 = true;
246
+ } catch (exn) {
247
+ known$1 = false;
248
+ }
249
+ frameworkPackageCache[pkgName] = known$1;
250
+ return known$1;
251
+ }
252
+
253
+ let importedPackagesCache = {};
254
+
255
+ function importedPackages(packageRoot) {
256
+ let names = importedPackagesCache[packageRoot];
257
+ if (names !== undefined) {
258
+ return names;
259
+ }
260
+ let assets = {};
261
+ let paths = [];
262
+ walkDir(packageRoot, "", assets, paths);
263
+ let names$1 = [];
264
+ paths.forEach(param => {
265
+ let relPath = param[0];
266
+ if (relPath.endsWith(".mjs") || relPath.endsWith(".js")) {
267
+ staticImportSpecifiers(Nodefs.readFileSync(param[1], "utf8")).forEach(specifier => {
268
+ if (!isBareSpecifier(specifier)) {
269
+ return;
270
+ }
271
+ let name = extractPackageName(specifier);
272
+ if (!names$1.includes(name)) {
273
+ names$1.push(name);
274
+ return;
275
+ }
276
+ });
277
+ return;
278
+ }
279
+ });
280
+ importedPackagesCache[packageRoot] = names$1;
281
+ return names$1;
282
+ }
283
+
284
+ function resolvePackageRootFrom(fromRoot, pkgName) {
285
+ try {
286
+ return Nodepath.dirname(Nodemodule.createRequire(Nodepath.join(fromRoot, "index.js")).resolve(pkgName + "/package.json"));
287
+ } catch (exn) {
288
+ return;
289
+ }
290
+ }
291
+
292
+ function addImportedPackageClosure(packageDirs) {
293
+ let pending = Object.entries(packageDirs).filter(param => !isFrameworkPackage(param[0]));
294
+ let next = 0;
295
+ while (next < pending.length) {
296
+ let match = pending[next];
297
+ let pkgRoot = match[1];
298
+ next = next + 1 | 0;
299
+ importedPackages(pkgRoot).forEach(dep => {
300
+ if (dep in packageDirs || dep.startsWith("node:") || nodeBuiltins.has(dep) || dep.startsWith("@aws-sdk/") || dep.startsWith("@smithy/") || isFrameworkPackage(dep)) {
301
+ return;
302
+ }
303
+ let depRoot = resolvePackageRootFrom(pkgRoot, dep);
304
+ if (depRoot !== undefined) {
305
+ packageDirs[dep] = depRoot;
306
+ pending.push([
307
+ dep,
308
+ depRoot
309
+ ]);
310
+ return;
311
+ }
312
+ });
313
+ };
314
+ }
315
+
235
316
  function assertRuntimeExtensionImportsResolvable(extensionPackages, bundledPackages) {
236
317
  Stdlib_Dict.forEachWithKey(extensionPackages, (pkgRoot, pkgName) => {
237
318
  let assets = {};
@@ -275,7 +356,10 @@ function buildCodeArchive(entryPointModule, packageDirs, extraStringAssetsOpt, b
275
356
  }
276
357
  if (bundleRuntimeExtensions) {
277
358
  let extensionPackages = addRuntimeExtensionPackages(allPackageDirs);
359
+ addImportedPackageClosure(allPackageDirs);
278
360
  assertRuntimeExtensionImportsResolvable(extensionPackages, allPackageDirs);
361
+ } else {
362
+ addImportedPackageClosure(allPackageDirs);
279
363
  }
280
364
  let packageContentHashes = {
281
365
  contents: []
@@ -326,6 +410,12 @@ export {
326
410
  isBareSpecifier,
327
411
  nodeBuiltins,
328
412
  isRuntimeProvided,
413
+ frameworkPackageCache,
414
+ isFrameworkPackage,
415
+ importedPackagesCache,
416
+ importedPackages,
417
+ resolvePackageRootFrom,
418
+ addImportedPackageClosure,
329
419
  assertRuntimeExtensionImportsResolvable,
330
420
  buildCodeArchive,
331
421
  }
@@ -0,0 +1,100 @@
1
+ open JestGlobals
2
+
3
+ // Which transport a stack mails through, and who it sends as. There is no
4
+ // default address: a placeholder would provision an identity nobody can verify,
5
+ // so "unset" has to survive the whole way to the deploy gate as absence rather
6
+ // than as an address.
7
+
8
+ module Messaging = Capability_Messaging
9
+
10
+ let withEnv = (key, value, f) => {
11
+ Dict.set(NodeProcess.env, key, value)
12
+ let result = f()
13
+ Dict.delete(NodeProcess.env, key)
14
+ result
15
+ }
16
+
17
+ describe("Capability_Messaging — reading a configured value", () => {
18
+ // Only the env-var rung is reachable here: the rungs below it end at
19
+ // `Pulumi.Config`, which needs a deploy-time runtime this suite does not have.
20
+ // `configured` returns before touching it whenever the variable is set.
21
+ testSync("an address set in the environment is the sender", () => {
22
+ let sender = withEnv("REVENTLESS_MESSAGING_EMAIL_SENDER", "mail@shop.test", () =>
23
+ Messaging.configured(Messaging.emailSenderKey)
24
+ )
25
+ expect(sender)->toEqual(Some("mail@shop.test"))
26
+ })
27
+
28
+ // A stray `REVENTLESS_MESSAGING_EMAIL_SENDER=` in CI, or a blank line in the
29
+ // sidecar. Reading it as an address asks SES for an identity with none.
30
+ testSync("an empty value is not an address", () => {
31
+ let sender = withEnv("REVENTLESS_MESSAGING_EMAIL_SENDER", "", () =>
32
+ Messaging.configured(Messaging.emailSenderKey)
33
+ )
34
+ expect(sender)->toEqual(None)
35
+ })
36
+
37
+ // The one that is not obviously empty: a key left with a space after the colon.
38
+ // It reached SES as an identity request for " " before this was trimmed.
39
+ testSync("a whitespace-only value is not an address either", () => {
40
+ let sender = withEnv("REVENTLESS_MESSAGING_EMAIL_SENDER", " ", () =>
41
+ Messaging.configured(Messaging.emailSenderKey)
42
+ )
43
+ expect(sender)->toEqual(None)
44
+ })
45
+
46
+ testSync("a surviving value is the trimmed one", () => {
47
+ let sender = withEnv("REVENTLESS_MESSAGING_EMAIL_SENDER", " mail@shop.test ", () =>
48
+ Messaging.configured(Messaging.emailSenderKey)
49
+ )
50
+ expect(sender)->toEqual(Some("mail@shop.test"))
51
+ })
52
+
53
+ testSync("the sms sender reads off its own key", () => {
54
+ let sender = withEnv("REVENTLESS_MESSAGING_SMS_SENDER", "+15550100", () =>
55
+ Messaging.configured(Messaging.smsSenderKey)
56
+ )
57
+ expect(sender)->toEqual(Some("+15550100"))
58
+ })
59
+ })
60
+
61
+ describe("Capability_Messaging — choosing a transport", () => {
62
+ testSync("ses and log are the two a stack can name", () => {
63
+ let pair: (Messaging.emailProvider, Messaging.emailProvider) = (Ses, Log)
64
+ expect((Messaging.parseEmailProvider("ses"), Messaging.parseEmailProvider("log")))->toEqual(pair)
65
+ })
66
+
67
+ // Config files are written by hand; a capitalised or padded value means what it
68
+ // says, and refusing it would be pedantry rather than safety.
69
+ testSync("the value is read case- and whitespace-insensitively", () => {
70
+ expect(Messaging.parseEmailProvider(" LOG "))->toEqual(Messaging.Log)
71
+ })
72
+
73
+ // The one that matters: defaulting a typo to SES would provision a real
74
+ // identity for a stack that asked to only log.
75
+ testSync("an unrecognised transport is refused, not defaulted", () => {
76
+ let outcome = try {
77
+ let _ = Messaging.parseEmailProvider("smtp")
78
+ None
79
+ } catch {
80
+ | JsExn(e) => e->JsExn.message
81
+ }
82
+ expect(outcome->Option.getOr("")->String.includes("is not an email provider"))->toBe(true)
83
+ })
84
+
85
+ // SES is the default so that a deployment saying nothing still mails: a stack
86
+ // that means not to send says so, rather than silence meaning it.
87
+ testSync("a named transport wins over the default", () => {
88
+ let chosen = withEnv("REVENTLESS_MESSAGING_EMAIL_PROVIDER", "log", () => Messaging.emailProvider())
89
+ let expected: Messaging.emailProvider = Log
90
+ expect(chosen)->toEqual(expected)
91
+ })
92
+ })
93
+
94
+ describe("Capability_Messaging — the log transport's default sender", () => {
95
+ // Hard-coded where the SES address is required, because a logged message
96
+ // reaches nobody. `.test` can never resolve, so it cannot be a real inbox.
97
+ testSync("is a reserved address", () => {
98
+ expect(Messaging.logDefaultAddress->String.endsWith(".test"))->toBe(true)
99
+ })
100
+ })
@@ -0,0 +1,85 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
4
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
5
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
7
+ import * as Capability_Messaging$ReventlessAws from "../src/capability/Capability_Messaging.res.mjs";
8
+
9
+ function withEnv(key, value, f) {
10
+ process.env[key] = value;
11
+ let result = f();
12
+ Stdlib_Dict.$$delete(process.env, key);
13
+ return result;
14
+ }
15
+
16
+ globalThis.describe("Capability_Messaging — reading a configured value", () => {
17
+ globalThis.test("an address set in the environment is the sender", () => {
18
+ let sender = withEnv("REVENTLESS_MESSAGING_EMAIL_SENDER", "mail@shop.test", () => Capability_Messaging$ReventlessAws.configured(Capability_Messaging$ReventlessAws.emailSenderKey));
19
+ globalThis.expect(sender).toEqual("mail@shop.test");
20
+ });
21
+ globalThis.test("an empty value is not an address", () => {
22
+ let sender = withEnv("REVENTLESS_MESSAGING_EMAIL_SENDER", "", () => Capability_Messaging$ReventlessAws.configured(Capability_Messaging$ReventlessAws.emailSenderKey));
23
+ globalThis.expect(sender).toEqual(undefined);
24
+ });
25
+ globalThis.test("a whitespace-only value is not an address either", () => {
26
+ let sender = withEnv("REVENTLESS_MESSAGING_EMAIL_SENDER", " ", () => Capability_Messaging$ReventlessAws.configured(Capability_Messaging$ReventlessAws.emailSenderKey));
27
+ globalThis.expect(sender).toEqual(undefined);
28
+ });
29
+ globalThis.test("a surviving value is the trimmed one", () => {
30
+ let sender = withEnv("REVENTLESS_MESSAGING_EMAIL_SENDER", " mail@shop.test ", () => Capability_Messaging$ReventlessAws.configured(Capability_Messaging$ReventlessAws.emailSenderKey));
31
+ globalThis.expect(sender).toEqual("mail@shop.test");
32
+ });
33
+ globalThis.test("the sms sender reads off its own key", () => {
34
+ let sender = withEnv("REVENTLESS_MESSAGING_SMS_SENDER", "+15550100", () => Capability_Messaging$ReventlessAws.configured(Capability_Messaging$ReventlessAws.smsSenderKey));
35
+ globalThis.expect(sender).toEqual("+15550100");
36
+ });
37
+ });
38
+
39
+ globalThis.describe("Capability_Messaging — choosing a transport", () => {
40
+ globalThis.test("ses and log are the two a stack can name", () => {
41
+ globalThis.expect([
42
+ Capability_Messaging$ReventlessAws.parseEmailProvider("ses"),
43
+ Capability_Messaging$ReventlessAws.parseEmailProvider("log")
44
+ ]).toEqual([
45
+ "Ses",
46
+ "Log"
47
+ ]);
48
+ });
49
+ globalThis.test("the value is read case- and whitespace-insensitively", () => {
50
+ globalThis.expect(Capability_Messaging$ReventlessAws.parseEmailProvider(" LOG ")).toEqual("Log");
51
+ });
52
+ globalThis.test("an unrecognised transport is refused, not defaulted", () => {
53
+ let outcome;
54
+ try {
55
+ Capability_Messaging$ReventlessAws.parseEmailProvider("smtp");
56
+ outcome = undefined;
57
+ } catch (raw_e) {
58
+ let e = Primitive_exceptions.internalToException(raw_e);
59
+ if (e.RE_EXN_ID === "JsExn") {
60
+ outcome = Stdlib_JsExn.message(e._1);
61
+ } else {
62
+ throw e;
63
+ }
64
+ }
65
+ globalThis.expect(Stdlib_Option.getOr(outcome, "").includes("is not an email provider")).toBe(true);
66
+ });
67
+ globalThis.test("a named transport wins over the default", () => {
68
+ let chosen = withEnv("REVENTLESS_MESSAGING_EMAIL_PROVIDER", "log", () => Capability_Messaging$ReventlessAws.emailProvider());
69
+ globalThis.expect(chosen).toEqual("Log");
70
+ });
71
+ });
72
+
73
+ globalThis.describe("Capability_Messaging — the log transport's default sender", () => {
74
+ globalThis.test("is a reserved address", () => {
75
+ globalThis.expect(Capability_Messaging$ReventlessAws.logDefaultAddress.endsWith(".test")).toBe(true);
76
+ });
77
+ });
78
+
79
+ let Messaging;
80
+
81
+ export {
82
+ Messaging,
83
+ withEnv,
84
+ }
85
+ /* Not a pure module */
@@ -0,0 +1,111 @@
1
+ open JestGlobals
2
+
3
+ // A plugin package rides in the code archive, but its own dependencies used to
4
+ // stay behind: the layer carries reventless-aws's closure, and a domain trait
5
+ // (or any other plugin-level library) is outside it. The archive build now
6
+ // walks what a bundled user package actually imports.
7
+
8
+ let mkTmpRoot = (prefix: string) =>
9
+ NodeFs.realpathSync(NodeFs.mkdtempSync(NodePath.join([NodeOs.tmpdir(), prefix])))
10
+
11
+ let writePkg = (~root: string, ~name: string, files: array<(string, string)>): string => {
12
+ NodeFs.mkdirSync(root, {recursive: true})
13
+ NodeFs.writeFileSync(
14
+ NodePath.join([root, "package.json"]),
15
+ `{"name":"${name}","version":"1.0.0"}`,
16
+ )
17
+ files->Array.forEach(((relPath, content)) => {
18
+ let abs = NodePath.join([root, relPath])
19
+ NodeFs.mkdirSync(NodePath.dirname(abs), {recursive: true})
20
+ NodeFs.writeFileSync(abs, content)
21
+ })
22
+ root
23
+ }
24
+
25
+ // A user package whose slice imports a trait, the trait imports a rules
26
+ // package, and both are installed only under the user package — the pnpm shape,
27
+ // where nothing but the importer can resolve them.
28
+ let hostName = "@fixture/closure-host"
29
+ let traitName = "@fixture/closure-trait"
30
+ let rulesName = "@fixture/closure-rules"
31
+
32
+ let hostRoot = mkTmpRoot("bundle-closure-")
33
+ let traitRoot = NodePath.join([hostRoot, "node_modules", traitName])
34
+ let rulesRoot = NodePath.join([hostRoot, "node_modules", rulesName])
35
+
36
+ let _ = writePkg(
37
+ ~root=hostRoot,
38
+ ~name=hostName,
39
+ [
40
+ (
41
+ "src/Slice.res.mjs",
42
+ `import * as Trait from "${traitName}";
43
+ import * as Sury from "sury";
44
+ import * as Fs from "node:fs";
45
+ import * as Path from "path";
46
+ import * as Ddb from "@aws-sdk/client-dynamodb";
47
+ import * as Missing from "@fixture/never-installed";
48
+ export const handle = () => Trait.rule(Sury, Fs, Path, Ddb, Missing);
49
+ `,
50
+ ),
51
+ ],
52
+ )
53
+ let _ = writePkg(
54
+ ~root=traitRoot,
55
+ ~name=traitName,
56
+ [
57
+ (
58
+ "src/Trait.res.mjs",
59
+ `import * as Rules from "${rulesName}";
60
+ export const rule = () => Rules.check();
61
+ `,
62
+ ),
63
+ ],
64
+ )
65
+ let _ = writePkg(~root=rulesRoot, ~name=rulesName, [("src/Rules.res.mjs", `export const check = () => true;
66
+ `)])
67
+
68
+ let closureOf = (packageDirs: dict<string>) => {
69
+ Util_Bundle.addImportedPackageClosure(packageDirs)
70
+ packageDirs
71
+ }
72
+
73
+ describe("Util_Bundle — bundled user packages carry their imports", () => {
74
+ afterAll(() => NodeFs.rmSync(hostRoot, {recursive: true, force: true}))
75
+
76
+ testSync("a dependency only the importing package can resolve is added", () => {
77
+ let packageDirs = Dict.fromArray([(hostName, hostRoot)])
78
+ expect(closureOf(packageDirs)->Dict.get(traitName))->toEqual(Some(traitRoot))
79
+ })
80
+
81
+ testSync("the walk is transitive — the dependency's own imports come too", () => {
82
+ let packageDirs = Dict.fromArray([(hostName, hostRoot)])
83
+ expect(closureOf(packageDirs)->Dict.get(rulesName))->toEqual(Some(rulesRoot))
84
+ })
85
+
86
+ testSync("packages the deployed runtime provides are left out", () => {
87
+ let packageDirs = Dict.fromArray([(hostName, hostRoot)])
88
+ let bundled = closureOf(packageDirs)->Dict.keysToArray
89
+ // sury rides in the layer (framework-resolvable); node builtins and the
90
+ // @aws-sdk scope resolve from the runtime's own dirs.
91
+ expect(bundled->Array.includes("sury"))->toBe(false)
92
+ expect(bundled->Array.includes("fs"))->toBe(false)
93
+ expect(bundled->Array.includes("path"))->toBe(false)
94
+ expect(bundled->Array.includes("@aws-sdk/client-dynamodb"))->toBe(false)
95
+ })
96
+
97
+ testSync("an import that resolves nowhere is skipped, not thrown on", () => {
98
+ let packageDirs = Dict.fromArray([(hostName, hostRoot)])
99
+ let bundled = closureOf(packageDirs)->Dict.keysToArray
100
+ expect(bundled->Array.includes("@fixture/never-installed"))->toBe(false)
101
+ })
102
+
103
+ testSync("framework packages are starting points, not walked", () => {
104
+ // Walking reventless-aws would reach its deploy-time-only imports (the
105
+ // @pulumi bindings) and add tens of megabytes to every archive.
106
+ let packageDirs = Dict.fromArray([
107
+ ("@reventlessdev/reventless-aws", Util_Bundle.resolvePackageRoot("@reventlessdev/reventless-aws")),
108
+ ])
109
+ expect(closureOf(packageDirs)->Dict.keysToArray->Array.length)->toBe(1)
110
+ })
111
+ })