@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.
- package/CHANGELOG.md +21 -0
- package/package.json +8 -8
- package/src/Platform.res +71 -6
- package/src/Platform.res.mjs +63 -12
- package/src/adapter/Runtime/AutomationSliceEntryPoint_Ops.res +18 -3
- package/src/adapter/Runtime/AutomationSliceEntryPoint_Ops.res.mjs +13 -1
- package/src/capability/Capability_Messaging.res +141 -0
- package/src/capability/Capability_Messaging.res.mjs +96 -0
- package/src/capability/Capability_Messaging_Ses.res +27 -14
- package/src/capability/Capability_Messaging_Ses.res.mjs +5 -6
- package/src/util/Util_Bundle.res +125 -0
- package/src/util/Util_Bundle.res.mjs +90 -0
- package/tests/Capability_MessagingTest.res +100 -0
- package/tests/Capability_MessagingTest.res.mjs +85 -0
- package/tests/Util_BundleDependencyClosureTest.res +111 -0
- package/tests/Util_BundleDependencyClosureTest.res.mjs +129 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
// A verified SES sender identity
|
|
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
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
~
|
|
36
|
+
~address: string,
|
|
37
|
+
~displayName: option<string>,
|
|
26
38
|
~opts: option<Pulumi.CustomResourceOptions.t>=?,
|
|
27
|
-
):
|
|
28
|
-
let identity = SES.EmailIdentity.make(~name, ~args={email:
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
7
|
+
function emailSender(name, address, displayName, opts) {
|
|
7
8
|
let identity = new (Aws.ses.EmailIdentity)(name, {
|
|
8
|
-
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
|
-
|
|
15
|
+
emailSender,
|
|
17
16
|
}
|
|
18
17
|
/* @pulumi/aws Not a pure module */
|
package/src/util/Util_Bundle.res
CHANGED
|
@@ -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
|
+
})
|