lambder 14.1.1 → 15.0.1

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 (158) hide show
  1. package/CHANGELOG.md +407 -0
  2. package/README.md +14 -17
  3. package/dist/api/LambderApiDeclarations.d.ts +11 -5
  4. package/dist/api/LambderApiDefinition.d.ts +2 -1
  5. package/dist/api/LambderApiEnvelope.d.ts +2 -2
  6. package/dist/api/LambderApiEnvelope.js +8 -2
  7. package/dist/api/LambderApiGuards.d.ts +44 -5
  8. package/dist/api/LambderApiGuards.js +27 -0
  9. package/dist/api/LambderApiIdempotency.d.ts +14 -7
  10. package/dist/api/LambderApiIdempotency.js +39 -27
  11. package/dist/api/LambderApiPipeline.d.ts +25 -11
  12. package/dist/api/LambderApiPipeline.js +34 -17
  13. package/dist/api/LambderApiRateLimits.d.ts +28 -21
  14. package/dist/api/LambderApiRateLimits.js +55 -35
  15. package/dist/api/LambderApiRefusals.d.ts +10 -4
  16. package/dist/api/LambderApiRefusals.js +31 -5
  17. package/dist/api/LambderApiRequest.d.ts +21 -1
  18. package/dist/api/LambderApiRequest.js +31 -2
  19. package/dist/build/checkApiRefusals.d.ts +7 -2
  20. package/dist/build/checkApiRefusals.js +56 -29
  21. package/dist/build/contractPrinterProcess.d.ts +11 -0
  22. package/dist/build/contractPrinterProcess.js +17 -0
  23. package/dist/build/generateApiFiles.d.ts +26 -5
  24. package/dist/build/generateApiFiles.js +68 -7
  25. package/dist/build/writeApiSchemas.d.ts +109 -0
  26. package/dist/build/writeApiSchemas.js +308 -0
  27. package/dist/build.d.ts +3 -1
  28. package/dist/build.js +2 -0
  29. package/dist/client/LambderCaller.d.ts +25 -11
  30. package/dist/client/LambderCaller.js +57 -49
  31. package/dist/client/LambderUploadRunner.d.ts +55 -27
  32. package/dist/client/LambderUploadRunner.js +89 -34
  33. package/dist/client.d.ts +6 -6
  34. package/dist/client.js +2 -2
  35. package/dist/core/Lambder.d.ts +31 -21
  36. package/dist/core/Lambder.js +79 -23
  37. package/dist/core/LambderCallSummary.d.ts +1 -1
  38. package/dist/core/LambderContext.d.ts +59 -7
  39. package/dist/core/LambderContext.js +31 -15
  40. package/dist/core/LambderCrashHandling.d.ts +5 -0
  41. package/dist/core/LambderCrashHandling.js +11 -1
  42. package/dist/core/LambderCreateOptions.d.ts +28 -17
  43. package/dist/core/LambderCreateOptions.js +14 -7
  44. package/dist/core/LambderResolver.d.ts +3 -2
  45. package/dist/core/LambderResponseBuilder.d.ts +20 -9
  46. package/dist/core/LambderResponseBuilder.js +39 -8
  47. package/dist/core/LambderTemplatingEngine.d.ts +24 -6
  48. package/dist/core/LambderTemplatingEngine.js +23 -3
  49. package/dist/index.d.ts +15 -12
  50. package/dist/index.js +7 -5
  51. package/dist/invoke/LambderInvokeCaller.d.ts +25 -4
  52. package/dist/invoke/LambderInvokeCaller.js +47 -9
  53. package/dist/invoke/LambderInvokeOutcome.d.ts +7 -6
  54. package/dist/mock/LambderMockAdapterDoor.d.ts +40 -0
  55. package/dist/mock/LambderMockAdapterDoor.js +10 -0
  56. package/dist/mock/LambderMockApiSchemas.d.ts +28 -0
  57. package/dist/mock/LambderMockApiSchemas.js +150 -0
  58. package/dist/mock/LambderMockApp.d.ts +56 -48
  59. package/dist/mock/LambderMockApp.js +107 -62
  60. package/dist/mock/LambderMockBrowserCookies.js +18 -5
  61. package/dist/mock/LambderMockCreateOptions.d.ts +51 -4
  62. package/dist/mock/LambderMockFailureInjector.d.ts +12 -3
  63. package/dist/mock/LambderMockFailureInjector.js +12 -8
  64. package/dist/mock/LambderMockTypes.d.ts +8 -4
  65. package/dist/mock/lambderMockInvokeTransport.js +2 -9
  66. package/dist/mock/lambderMockMswHandler.d.ts +19 -13
  67. package/dist/mock/lambderMockMswHandler.js +25 -22
  68. package/dist/mock.d.ts +1 -0
  69. package/dist/secrets/LambderOneShotSecrets.d.ts +1 -1
  70. package/dist/secrets/LambderOneShotSecrets.js +6 -36
  71. package/dist/secrets/LambderPasswordHasher.d.ts +56 -0
  72. package/dist/secrets/LambderPasswordHasher.js +168 -0
  73. package/dist/session/LambderSessionController.d.ts +37 -4
  74. package/dist/session/LambderSessionController.js +36 -6
  75. package/dist/session/LambderSessionManager.d.ts +33 -3
  76. package/dist/session/LambderSessionManager.js +55 -16
  77. package/dist/shared/LambderHtml.d.ts +23 -4
  78. package/dist/shared/LambderHtml.js +17 -5
  79. package/dist/shared/LambderI18n.d.ts +106 -16
  80. package/dist/shared/LambderI18n.js +259 -74
  81. package/dist/shared/contracts/LambderCache.d.ts +14 -1
  82. package/dist/shared/contracts/LambderRateLimiter.d.ts +5 -6
  83. package/dist/shared/transport/LambderApiTransport.d.ts +3 -2
  84. package/dist/shared/util/LambderBackoffTimer.d.ts +24 -13
  85. package/dist/shared/util/LambderBackoffTimer.js +40 -12
  86. package/dist/shared/util/LambderCallAbort.d.ts +22 -6
  87. package/dist/shared/util/LambderCallAbort.js +37 -9
  88. package/dist/shared/util/LambderErrorReportMark.d.ts +4 -0
  89. package/dist/shared/util/LambderErrorReportMark.js +22 -0
  90. package/dist/shared/util/LambderKeyFieldDigest.d.ts +46 -0
  91. package/dist/shared/util/LambderKeyFieldDigest.js +75 -0
  92. package/dist/shared/util/LambderSignedClaims.d.ts +19 -0
  93. package/dist/shared/util/LambderSignedClaims.js +54 -0
  94. package/dist/shared/util/LambderSwappableInstances.d.ts +29 -0
  95. package/dist/shared/util/LambderSwappableInstances.js +54 -0
  96. package/dist/shared/util/LambderTestingDoors.d.ts +6 -2
  97. package/dist/shared/util/LambderTestingDoors.js +6 -2
  98. package/dist/shared/util/LambderTextDigest.d.ts +5 -5
  99. package/dist/shared/util/LambderTextDigest.js +5 -5
  100. package/dist/shared/wire/LambderApiContract.d.ts +1 -1
  101. package/dist/shared/wire/LambderApiOptionEntries.d.ts +9 -1
  102. package/dist/shared/wire/LambderApiOptionEntries.js +15 -0
  103. package/dist/shared/wire/LambderApiOptionValues.d.ts +3 -2
  104. package/dist/shared/wire/LambderApiOutcome.d.ts +21 -12
  105. package/dist/shared/wire/LambderApiOutcome.js +1 -1
  106. package/dist/shared/wire/LambderApiRefusal.d.ts +39 -12
  107. package/dist/shared/wire/LambderApiRefusal.js +3 -3
  108. package/dist/shared/wire/LambderApiSchemaEntries.d.ts +21 -0
  109. package/dist/shared/wire/LambderApiSchemaEntries.js +18 -0
  110. package/dist/shared/wire/LambderCallOptions.d.ts +3 -3
  111. package/dist/shared/wire/LambderCallOutcome.d.ts +2 -1
  112. package/dist/shared/wire/LambderCallOutcome.js +3 -2
  113. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +5 -3
  114. package/dist/shared/wire/LambderIdempotencyKeyScope.js +5 -3
  115. package/dist/shared/wire/LambderInvokeApiId.d.ts +3 -3
  116. package/dist/shared/wire/LambderInvokeApiId.js +3 -3
  117. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +7 -6
  118. package/dist/shared/wire/LambderOutcomeAssertions.js +1 -1
  119. package/dist/stores/LambderCacheValues.d.ts +2 -5
  120. package/dist/stores/LambderDdbCache.d.ts +15 -9
  121. package/dist/stores/LambderDdbCache.js +25 -6
  122. package/dist/stores/LambderDdbIdempotencyStore.d.ts +6 -4
  123. package/dist/stores/LambderDdbIdempotencyStore.js +32 -52
  124. package/dist/stores/LambderDdbOneShotSecretStore.d.ts +11 -0
  125. package/dist/stores/LambderDdbOneShotSecretStore.js +17 -5
  126. package/dist/stores/LambderDdbSdk.d.ts +59 -29
  127. package/dist/stores/LambderDdbSdk.js +116 -44
  128. package/dist/stores/LambderDdbSessionStore.d.ts +26 -11
  129. package/dist/stores/LambderDdbSessionStore.js +133 -68
  130. package/dist/stores/LambderMemoryCache.d.ts +2 -2
  131. package/dist/stores/LambderS3FileSource.d.ts +3 -2
  132. package/dist/stores/LambderS3FileSource.js +11 -13
  133. package/dist/stores/LambderS3UploadBucket.d.ts +12 -0
  134. package/dist/stores/LambderS3UploadBucket.js +23 -0
  135. package/dist/stores/LambderStorageBackedCache.d.ts +2 -2
  136. package/dist/stores/LambderStoredText.d.ts +26 -0
  137. package/dist/stores/LambderStoredText.js +25 -0
  138. package/dist/testing/LambderConformanceRunner.d.ts +9 -2
  139. package/dist/testing/LambderConformanceRunner.js +9 -2
  140. package/dist/testing/LambderTestApp.d.ts +65 -7
  141. package/dist/testing/LambderTestApp.js +101 -6
  142. package/dist/testing/LambderTestVisitor.d.ts +43 -12
  143. package/dist/testing/LambderTestVisitor.js +35 -11
  144. package/dist/testing/bootLambdaPackage.d.ts +105 -0
  145. package/dist/testing/bootLambdaPackage.js +265 -0
  146. package/dist/testing/lambdaPackageBootProcess.d.ts +39 -0
  147. package/dist/testing/lambdaPackageBootProcess.js +130 -0
  148. package/dist/testing/lambderCacheConformance.d.ts +26 -0
  149. package/dist/testing/lambderCacheConformance.js +287 -0
  150. package/dist/testing/lambderCacheStorageConformance.d.ts +25 -0
  151. package/dist/testing/lambderCacheStorageConformance.js +159 -0
  152. package/dist/testing/lambderSessionStoreConformance.d.ts +3 -3
  153. package/dist/testing/lambderSessionStoreConformance.js +3 -3
  154. package/dist/testing.d.ts +7 -1
  155. package/dist/testing.js +5 -0
  156. package/package.json +1 -6
  157. package/dist/shared/util/boundKeyField.d.ts +0 -20
  158. package/dist/shared/util/boundKeyField.js +0 -34
@@ -1,5 +1,4 @@
1
1
  import { joinKeyFields } from "../shared/util/joinKeyFields.js";
2
- import { boundKeyField } from "../shared/util/boundKeyField.js";
3
2
  import { RATE_LIMIT_WINDOWS, } from "../shared/contracts/LambderRateLimiter.js";
4
3
  import { LambderApiRefusal, LAMBDER_REFUSAL_CODES } from "../shared/wire/LambderApiRefusal.js";
5
4
  import { parsePreflightSlice } from "./LambderApiValidationRefusal.js";
@@ -7,29 +6,43 @@ import { assertNonNegativeInteger } from "../shared/util/LambderOptionChecks.js"
7
6
  import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
8
7
  import { DEFAULT_IPV6_RATE_LIMIT_PREFIX, rateLimitSubjectOf } from "../shared/util/LambderClientIp.js";
9
8
  const RATE_LIMIT_WINDOW_KEYS = RATE_LIMIT_WINDOWS.map((window) => window.key);
10
- /** Refusal a rate-limited request answers unless the policy or the API's override names its own. */
11
- export const DEFAULT_RATE_LIMIT_REFUSAL = { type: "warning", code: LAMBDER_REFUSAL_CODES.rateLimited, content: "Too many requests. Please try again later." };
9
+ /** The words a rate-limited call answers with unless the policy or the API's override writes its own (the `refusal` option of each). */
10
+ export const DEFAULT_RATE_LIMIT_REFUSAL = { type: "warning", content: "Too many requests. Please try again later." };
12
11
  /**
13
12
  * The refusal a rate-limited call answers with: a 429 envelope carrying the
14
- * framework code, whatever message a policy or an API wrote, and a
15
- * Retry-After header. The engine throws it; the mock runtime's failure
16
- * injection throws the same one, so an injected rate limit is
17
- * indistinguishable from a real one.
13
+ * framework code, the words a policy or an API wrote, the policy's name and
14
+ * the seconds to wait as its data, and a Retry-After header of those same
15
+ * seconds. The engine throws it; the mock runtime's failure injection throws
16
+ * the same one, so an injected rate limit is indistinguishable from a real
17
+ * one.
18
18
  *
19
- * The code goes on after the message, so a message that carries one anyway
20
- * (a plain-JS caller, a cast) cannot replace it: a rate limit is never a code
21
- * an endpoint declares, and the pipeline would refuse to send one it did not.
19
+ * Only the words are read off the message, so one that carries a code or
20
+ * data anyway (a plain-JS caller, a cast) cannot replace them: a rate limit
21
+ * is never a code an endpoint declares, and the pipeline sends one only with
22
+ * the data a client narrows it by.
22
23
  */
23
- export const rateLimitRefusal = (detail, retryAfterSeconds, message) => new LambderApiRefusal(detail, {
24
- refusal: message ? { type: message.type, ...(message.title !== undefined ? { title: message.title } : {}), content: message.content, code: LAMBDER_REFUSAL_CODES.rateLimited } : DEFAULT_RATE_LIMIT_REFUSAL,
25
- statusCode: 429,
26
- headers: { "Retry-After": String(Math.max(1, Math.floor(retryAfterSeconds))) },
27
- });
24
+ export const rateLimitRefusal = (detail, data, message = DEFAULT_RATE_LIMIT_REFUSAL) => {
25
+ // The header carries whole seconds of at least one, and the data says the
26
+ // same number: a client reading either waits as long.
27
+ if (!Number.isFinite(data.retryAfterSeconds))
28
+ throw new Error(`Lambder: a rate-limit refusal waits a finite number of seconds, got ${String(data.retryAfterSeconds)}.`);
29
+ const retryAfterSeconds = Math.max(1, Math.floor(data.retryAfterSeconds));
30
+ return new LambderApiRefusal(detail, {
31
+ refusal: {
32
+ type: message.type,
33
+ ...(message.title !== undefined ? { title: message.title } : {}),
34
+ content: message.content,
35
+ code: LAMBDER_REFUSAL_CODES.rateLimited,
36
+ data: { policy: data.policy, retryAfterSeconds },
37
+ },
38
+ statusCode: 429,
39
+ headers: { "Retry-After": String(retryAfterSeconds) },
40
+ });
41
+ };
28
42
  /**
29
- * A rate-limit message carries no code of its own and no data: its code is
30
- * always the framework's, which rateLimitRefusal sets, and a code there would
31
- * read as one the app chose for clients to branch on when it never reaches
32
- * them.
43
+ * A rate-limit message carries no code of its own and no data: both are the
44
+ * framework's, which rateLimitRefusal sets, and a code there would read as
45
+ * one the app chose for clients to branch on when it never reaches them.
33
46
  */
34
47
  const assertRateLimitMessage = (where, message) => {
35
48
  if (message === undefined)
@@ -39,7 +52,7 @@ const assertRateLimitMessage = (where, message) => {
39
52
  throw new Error(`Lambder: ${where} sets a refusal code. A rate-limit refusal is always "${LAMBDER_REFUSAL_CODES.rateLimited}", so an endpoint never has to declare it; write the type, title and content only.`);
40
53
  }
41
54
  if (data !== undefined) {
42
- throw new Error(`Lambder: ${where} sets refusal data. A rate-limit refusal carries none.`);
55
+ throw new Error(`Lambder: ${where} sets refusal data. A rate-limit refusal carries the framework's: the policy that refused and the seconds to wait.`);
43
56
  }
44
57
  };
45
58
  /**
@@ -118,8 +131,13 @@ const trackerKeyOf = (name, policy, apiName, key) => policy.budget === "perPolic
118
131
  * slice) and hands the context to a custom key's handler, reading nothing
119
132
  * of the context itself but its session, so it runs unchanged under the
120
133
  * server and the mock runtime.
134
+ *
135
+ * The caller's half of every tracker key (the address, the session key, a
136
+ * custom key) reaches the limiter as a digest (LambderKeyFieldDigest), so a
137
+ * table read names the API and the policy and never who was counted.
121
138
  */
122
139
  export class LambderApiRateLimitsEngine {
140
+ keyFieldDigest;
123
141
  limiter = null;
124
142
  failOpen = true;
125
143
  ipv6PrefixLength = DEFAULT_IPV6_RATE_LIMIT_PREFIX;
@@ -134,6 +152,9 @@ export class LambderApiRateLimitsEngine {
134
152
  * second would otherwise be as many identical log lines.
135
153
  */
136
154
  loggedFailures = new WeakSet();
155
+ constructor(keyFieldDigest) {
156
+ this.keyFieldDigest = keyFieldDigest;
157
+ }
137
158
  /** True once rateLimits were configured. */
138
159
  get isConfigured() { return this.limiter !== null; }
139
160
  configure(config) {
@@ -249,14 +270,14 @@ export class LambderApiRateLimitsEngine {
249
270
  const limits = windowsOf(policy, override);
250
271
  const exceeded = await this.countAttempt(name, trackerKeyOf(name, policy, apiName, key), limits, `API "${apiName}"`);
251
272
  if (exceeded) {
252
- throw rateLimitRefusal(`Rate limited: "${apiName}" exceeded policy "${name}" (${exceeded.window}: ${exceeded.limit}).`, this.retryAfterOf(exceeded), override?.refusal ?? policy.refusal);
273
+ throw rateLimitRefusal(`Rate limited: "${apiName}" exceeded policy "${name}" (${exceeded.window}: ${exceeded.limit}).`, { policy: name, retryAfterSeconds: this.retryAfterOf(exceeded) }, override?.refusal ?? policy.refusal);
253
274
  }
254
275
  }
255
276
  }
256
277
  /**
257
278
  * One policy charged by code rather than by a declaration, for
258
279
  * `ctx.rateLimit` and `ctx.isRateLimited`. Counters, failOpen, key
259
- * bounding and refusal are those of a declared limit; only who knows the
280
+ * digests and refusal are those of a declared limit; only who knows the
260
281
  * key differs. A policy without `per` takes the key the code passes, a
261
282
  * `per: "ip"` or `per: "session"` one reads it off the request, and one
262
283
  * keyed by an API payload is refused, since only its APIs know the key.
@@ -276,7 +297,7 @@ export class LambderApiRateLimitsEngine {
276
297
  const retryAfterSeconds = this.retryAfterOf(exceeded);
277
298
  return {
278
299
  checkResult: { ...exceeded, retryAfterSeconds },
279
- refusal: rateLimitRefusal(`Rate limited: ${where} exceeded policy "${name}" (${exceeded.window}: ${exceeded.limit}).`, retryAfterSeconds, policy.refusal),
300
+ refusal: rateLimitRefusal(`Rate limited: ${where} exceeded policy "${name}" (${exceeded.window}: ${exceeded.limit}).`, { policy: name, retryAfterSeconds }, policy.refusal),
280
301
  };
281
302
  }
282
303
  /**
@@ -292,10 +313,10 @@ export class LambderApiRateLimitsEngine {
292
313
  /**
293
314
  * Counts one attempt, or lets it through when the limiter itself fails
294
315
  * and failOpen is on. The log line names the policy and its windows,
295
- * never the tracker key: the key carries whatever a custom handler
296
- * returned, which the docs' own example makes an email address. A
297
- * failure the limiter throws again (see loggedFailures) is not logged
298
- * again.
316
+ * never the tracker key: digested as its caller's half is, the key is
317
+ * still the same for every attempt of one caller, and a log is read by
318
+ * more people than the table. A failure the limiter throws again (see
319
+ * loggedFailures) is not logged again.
299
320
  */
300
321
  async countAttempt(name, trackerKey, limits, where) {
301
322
  try {
@@ -319,7 +340,7 @@ export class LambderApiRateLimitsEngine {
319
340
  if (per === undefined) {
320
341
  if (subject.key === undefined)
321
342
  throw new Error(`Lambder: rate-limit policy "${name}" declares no per, so the code charging it passes the key: ctx.rateLimit("${name}", key).`);
322
- return await boundKeyField("custom", subject.key);
343
+ return await this.keyFieldDigest.digestOf("custom", subject.key);
323
344
  }
324
345
  if (per !== "ip" && per !== "session") {
325
346
  throw new Error(`Lambder: rate-limit policy "${name}" derives its key from an API's payload, so only the APIs declaring it can charge it.`);
@@ -332,21 +353,20 @@ export class LambderApiRateLimitsEngine {
332
353
  if (per === "ip" || per === "session")
333
354
  return await this.requestKeyOf(name, per, request.ip, ctx.session);
334
355
  const payload = per.apiInput ? await parsePreflightSlice(per.apiInput, request.payload) : undefined;
335
- return await boundKeyField("custom", await per.handler(ctx, payload));
356
+ return await this.keyFieldDigest.digestOf("custom", await per.handler(ctx, payload));
336
357
  }
337
358
  /**
338
359
  * The key a `per: "ip"` or `per: "session"` policy counts under: what the
339
- * request carries, however the policy is charged. A session key is
340
- * bounded like a custom one (see boundKeyField); an address needs no
341
- * bound, since normalizeClientIp caps it at 45 characters whichever
342
- * header or gateway field named it.
360
+ * request carries, however the policy is charged, digested as a custom
361
+ * key is. The address is the subject rateLimitSubjectOf counts (an IPv6
362
+ * caller's /64), so the digest of one subscriber's every address is one.
343
363
  */
344
364
  async requestKeyOf(name, per, ip, session) {
345
365
  if (per === "ip")
346
- return `ip:${rateLimitSubjectOf(ip, this.ipv6PrefixLength)}`;
366
+ return await this.keyFieldDigest.digestOf("ip", rateLimitSubjectOf(ip, this.ipv6PrefixLength));
347
367
  const sessionKey = session?.sessionKey;
348
368
  if (!sessionKey)
349
369
  throw new Error(`Lambder: rate-limit policy "${name}" is keyed per "session", and the request charging it has no session.`);
350
- return await boundKeyField("session", sessionKey);
370
+ return await this.keyFieldDigest.digestOf("session", sessionKey);
351
371
  }
352
372
  }
@@ -130,6 +130,8 @@ type LambderRefusalViolation = {
130
130
  thrown: unknown;
131
131
  } | {
132
132
  notObject: string;
133
+ } | {
134
+ notRateLimitData: true;
133
135
  };
134
136
  /**
135
137
  * A refusal an endpoint is not allowed to send, so it was not sent and the
@@ -137,8 +139,10 @@ type LambderRefusalViolation = {
137
139
  * the endpoint does not declare (neither
138
140
  * in its own `refusals` option nor through a guard), data on a refusal whose
139
141
  * code declares none, no data on one whose code carries data, data its code's
140
- * schema rejects or that is not an object or an array, or a declared code
141
- * raised with a status or flag of its own, which its declaration owns.
142
+ * schema rejects or that is not an object or an array, a framework code
143
+ * without the data it carries (`lambder/rate-limited`'s policy and wait), or
144
+ * a declared code raised with a status or flag of its own, which its
145
+ * declaration owns.
142
146
  *
143
147
  * What makes a client's refusal type exact: a reader narrows `code` to the
144
148
  * endpoint's declared codes and the framework's own, and relies on this to
@@ -162,8 +166,10 @@ export declare class LambderApiRefusalValidationError extends Error {
162
166
  * here first.
163
167
  *
164
168
  * - An uncoded refusal, or one carrying a framework code, goes out as it is,
165
- * provided it carries no data. Where the app requires codes, an uncoded
166
- * refusal is refused too; a framework code still passes.
169
+ * provided it carries the data its code does: the policy and the seconds
170
+ * to wait for `lambder/rate-limited`, none for every other. Where the app
171
+ * requires codes, an uncoded refusal is refused too; a framework code
172
+ * still passes.
167
173
  * - A declared code leaves with its declaration's status and flags, and
168
174
  * with data exactly when the code carries data, parsed through
169
175
  * the code's schema as an output is: undeclared fields stripped, defaults
@@ -1,4 +1,4 @@
1
- import { LambderApiRefusal, isLambderRefusalCode } from "../shared/wire/LambderApiRefusal.js";
1
+ import { LambderApiRefusal, LAMBDER_REFUSAL_CODES, isLambderRefusalCode } from "../shared/wire/LambderApiRefusal.js";
2
2
  import { describePayloadKind, isObjectPayload } from "../shared/wire/LambderObjectPayload.js";
3
3
  import { mergeNamedMaps } from "../shared/util/LambderNamedMaps.js";
4
4
  /**
@@ -22,14 +22,27 @@ export const allowedRefusalOf = (declaration) => {
22
22
  };
23
23
  return declaration.data === undefined ? { ...leaves, data: false } : { ...leaves, data: true, schema: declaration.data };
24
24
  };
25
+ /**
26
+ * Whether a `lambder/rate-limited` refusal's data is the shape a client
27
+ * narrows it to: the policy's name, and the whole seconds of at least one
28
+ * that the Retry-After header carries.
29
+ */
30
+ const isRateLimitRefusalData = (data) => {
31
+ if (data === null || typeof data !== "object")
32
+ return false;
33
+ const { policy, retryAfterSeconds } = data;
34
+ return typeof policy === "string" && Number.isInteger(retryAfterSeconds) && retryAfterSeconds >= 1;
35
+ };
25
36
  /**
26
37
  * A refusal an endpoint is not allowed to send, so it was not sent and the
27
38
  * call is answered as a crash: no code where the app requires one, a code
28
39
  * the endpoint does not declare (neither
29
40
  * in its own `refusals` option nor through a guard), data on a refusal whose
30
41
  * code declares none, no data on one whose code carries data, data its code's
31
- * schema rejects or that is not an object or an array, or a declared code
32
- * raised with a status or flag of its own, which its declaration owns.
42
+ * schema rejects or that is not an object or an array, a framework code
43
+ * without the data it carries (`lambder/rate-limited`'s policy and wait), or
44
+ * a declared code raised with a status or flag of its own, which its
45
+ * declaration owns.
33
46
  *
34
47
  * What makes a client's refusal type exact: a reader narrows `code` to the
35
48
  * endpoint's declared codes and the framework's own, and relies on this to
@@ -76,6 +89,10 @@ export class LambderApiRefusalValidationError extends Error {
76
89
  message = `Lambder: API "${apiName}" refused with the code "${String(code)}" and data its schema threw on while parsing it (a transform that threw, `
77
90
  + "or an async step: a refusal's data is parsed synchronously), so the refusal was not sent.";
78
91
  }
92
+ else if ("notRateLimitData" in violation) {
93
+ message = `Lambder: API "${apiName}" refused with the code "${String(code)}" and data that is not { policy, retryAfterSeconds }, so the refusal was not sent. `
94
+ + "Refuse a rate limit through ctx.rateLimit, or throw rateLimitRefusal(), which writes the data a client narrows it by.";
95
+ }
79
96
  else {
80
97
  message = `Lambder: API "${apiName}" refused with the code "${String(code)}" and data that is ${violation.notObject}, and a refusal's data is an object or an array, so the refusal was not sent.`;
81
98
  }
@@ -94,8 +111,10 @@ export class LambderApiRefusalValidationError extends Error {
94
111
  * here first.
95
112
  *
96
113
  * - An uncoded refusal, or one carrying a framework code, goes out as it is,
97
- * provided it carries no data. Where the app requires codes, an uncoded
98
- * refusal is refused too; a framework code still passes.
114
+ * provided it carries the data its code does: the policy and the seconds
115
+ * to wait for `lambder/rate-limited`, none for every other. Where the app
116
+ * requires codes, an uncoded refusal is refused too; a framework code
117
+ * still passes.
99
118
  * - A declared code leaves with its declaration's status and flags, and
100
119
  * with data exactly when the code carries data, parsed through
101
120
  * the code's schema as an output is: undeclared fields stripped, defaults
@@ -111,6 +130,13 @@ export const checkedRefusal = (apiName, endpoint, thrown) => {
111
130
  if (endpoint === undefined)
112
131
  return thrown;
113
132
  const { code, data } = thrown.refusal;
133
+ if (code === LAMBDER_REFUSAL_CODES.rateLimited) {
134
+ if (data === undefined)
135
+ throw new LambderApiRefusalValidationError(apiName, thrown, { missingData: code });
136
+ if (!isRateLimitRefusalData(data))
137
+ throw new LambderApiRefusalValidationError(apiName, thrown, { notRateLimitData: true });
138
+ return thrown;
139
+ }
114
140
  if (code === undefined || isLambderRefusalCode(code)) {
115
141
  if (code === undefined && endpoint.codeRequired)
116
142
  throw new LambderApiRefusalValidationError(apiName, thrown, { uncoded: true });
@@ -27,6 +27,13 @@ export type LambderApiRequest = {
27
27
  * expired, which reloads it.
28
28
  */
29
29
  retiredPath?: true;
30
+ /**
31
+ * Set when the call's body is no envelope: not a JSON object (a number,
32
+ * a string, a boolean, null, an array) or not JSON at all. The
33
+ * client-facing reason, which the pipeline answers with the
34
+ * invalid-payload refusal (prepare) before anything reads the call.
35
+ */
36
+ invalidEnvelope?: string;
30
37
  /** The caller's apiVersion, informational; null when it sent none. */
31
38
  version: string | null;
32
39
  /** The signature the caller carries for this endpoint (see LambderApiSignatureMap), for the signature gate; null when it sent none. */
@@ -91,10 +98,23 @@ export declare const isApiCallContentType: (lowercasedHeaders: Record<string, st
91
98
  * comes from where the call was posted, never from the body. Everything is
92
99
  * taken as posted: a malformed idempotencyKey or guardInputs value is the
93
100
  * engines' to refuse, with the client-facing message they already give.
101
+ *
102
+ * `posted` is the body as JSON parsed it; undefined (no body) reads as an
103
+ * empty envelope. A body that is not a JSON object, or one the adapter could
104
+ * not parse (`bodyNotJson`), is flagged on the request as invalidEnvelope
105
+ * and read as an empty envelope, so every adapter refuses it alike, in the
106
+ * pipeline, rather than each deciding what such a body means.
94
107
  */
95
- export declare const readApiEnvelope: (post: Record<string, unknown> | null | undefined, info: LambderApiRequestInfo, apiName: string, flags?: {
108
+ export declare const readApiEnvelope: (posted: unknown, info: LambderApiRequestInfo, apiName: string, flags?: {
96
109
  retiredPath?: true;
110
+ bodyNotJson?: true;
97
111
  }) => LambderApiRequest;
112
+ /**
113
+ * readApiEnvelope over a body's text, for an adapter that holds the text and
114
+ * nothing parsed from it: an empty body reads as an empty envelope, and text
115
+ * that is not JSON is flagged as no envelope, as the server flags it.
116
+ */
117
+ export declare const readApiEnvelopeText: (text: string, info: LambderApiRequestInfo, apiName: string) => LambderApiRequest;
98
118
  /** Outcome of restoring a compressed request payload; the message is client-facing. */
99
119
  export type LambderRestorePayloadResult = {
100
120
  ok: true;
@@ -42,20 +42,34 @@ export const lowercaseHeaderNames = (headers) => {
42
42
  * read the same rule here.
43
43
  */
44
44
  export const isApiCallContentType = (lowercasedHeaders) => (lowercasedHeaders["content-type"] ?? "").split(";")[0].trim().toLowerCase() === "application/json";
45
+ /** The one shape an API call's body may take: a JSON object, the envelope every Lambder caller posts. */
46
+ const isEnvelopeObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
47
+ /** Why a body that did parse is no envelope, client-facing: its kind, never its value. */
48
+ const notAnEnvelopeMessage = (posted) => `Request body must be a JSON object, got ${Array.isArray(posted) ? "an array" : posted === null ? "null" : `a ${typeof posted}`}.`;
45
49
  /**
46
50
  * Reads the posted envelope of a call to `apiName` into a request; the name
47
51
  * comes from where the call was posted, never from the body. Everything is
48
52
  * taken as posted: a malformed idempotencyKey or guardInputs value is the
49
53
  * engines' to refuse, with the client-facing message they already give.
54
+ *
55
+ * `posted` is the body as JSON parsed it; undefined (no body) reads as an
56
+ * empty envelope. A body that is not a JSON object, or one the adapter could
57
+ * not parse (`bodyNotJson`), is flagged on the request as invalidEnvelope
58
+ * and read as an empty envelope, so every adapter refuses it alike, in the
59
+ * pipeline, rather than each deciding what such a body means.
50
60
  */
51
- export const readApiEnvelope = (post, info, apiName, flags = {}) => {
52
- post ??= {};
61
+ export const readApiEnvelope = (posted, info, apiName, flags = {}) => {
62
+ const invalidEnvelope = flags.bodyNotJson ? "Request body must be a JSON object, and it is not valid JSON."
63
+ : posted === undefined || isEnvelopeObject(posted) ? null
64
+ : notAnEnvelopeMessage(posted);
65
+ const post = isEnvelopeObject(posted) ? posted : {};
53
66
  const hasGzip = post[COMPRESSED_PAYLOAD_GZ_FIELD] !== undefined;
54
67
  const hasBrotli = post[COMPRESSED_PAYLOAD_BR_FIELD] !== undefined;
55
68
  const guardInputs = post.guardInputs;
56
69
  return {
57
70
  apiName,
58
71
  ...(flags.retiredPath ? { retiredPath: true } : {}),
72
+ ...(invalidEnvelope !== null ? { invalidEnvelope } : {}),
59
73
  version: typeof post.version === "string" ? post.version : null,
60
74
  signature: typeof post.signature === "string" ? post.signature : null,
61
75
  token: typeof post.token === "string" ? post.token : "",
@@ -76,6 +90,21 @@ export const readApiEnvelope = (post, info, apiName, flags = {}) => {
76
90
  ...(info.signal ? { signal: info.signal } : {}),
77
91
  };
78
92
  };
93
+ /**
94
+ * readApiEnvelope over a body's text, for an adapter that holds the text and
95
+ * nothing parsed from it: an empty body reads as an empty envelope, and text
96
+ * that is not JSON is flagged as no envelope, as the server flags it.
97
+ */
98
+ export const readApiEnvelopeText = (text, info, apiName) => {
99
+ let posted;
100
+ try {
101
+ posted = JSON.parse(text || "{}");
102
+ }
103
+ catch {
104
+ return readApiEnvelope(undefined, info, apiName, { bodyNotJson: true });
105
+ }
106
+ return readApiEnvelope(posted, info, apiName);
107
+ };
79
108
  /**
80
109
  * Restores a payload the caller sent compressed (`payloadGz` or `payloadBr`,
81
110
  * beside `payloadBytes`) onto request.payload, so every later stage
@@ -5,6 +5,8 @@ export type LambderApiRefusalCheckOptions = {
5
5
  files?: string[];
6
6
  /** Whether a refusal with no code is a finding, as it is a crash in an app whose vocabulary requires codes (declareRefusals's requireCodes). Default: true. */
7
7
  requireCodes?: boolean;
8
+ /** Whether a handler handed no typed refuse is a finding, since nothing it reaches could be checked. Default: true; false lists such handlers without failing. */
9
+ requireTypedRefuse?: boolean;
8
10
  };
9
11
  /** What one handler's check found. */
10
12
  export type LambderRefusalCheckFinding = {
@@ -20,15 +22,18 @@ export type LambderRefusalCheckFinding = {
20
22
  * - `untraced`: a handler handed a typed refuse whose function cannot be
21
23
  * found (a parameter, a dependency's value), so nothing it reaches was
22
24
  * checked.
25
+ * - `unchecked`: a handler handed no typed refuse, built by an init that
26
+ * declared no refusal vocabulary, so there is nothing to hold what it
27
+ * reaches to. Not a finding with `requireTypedRefuse: false`.
23
28
  */
24
- problem: "undeclared" | "unused" | "uncoded" | "unreadable" | "untraced";
29
+ problem: "undeclared" | "unused" | "uncoded" | "unreadable" | "untraced" | "unchecked";
25
30
  /** The code, for `undeclared` and `unused`. */
26
31
  code?: string;
27
32
  /** Where the refusal is raised, as `file:line`, for `undeclared`, `uncoded` and `unreadable`. */
28
33
  raisedAt?: string;
29
34
  };
30
35
  export type LambderApiRefusalCheckResult = {
31
- /** False when the project could not be read, no handler in it could be checked, or any handler has a finding. */
36
+ /** False when the project could not be read, no handler in it could be checked, or any handler has a finding, an unchecked one included unless `requireTypedRefuse` is false. */
32
37
  ok: boolean;
33
38
  /** How many handlers were checked. */
34
39
  handlers: number;
@@ -27,6 +27,7 @@ const HANDLER_REGISTRATIONS = {
27
27
  export const checkApiRefusals = async (options) => {
28
28
  const ts = await loadTypeScriptCompiler("checkApiRefusals reads the project");
29
29
  const requireCodes = options.requireCodes ?? true;
30
+ const requireTypedRefuse = options.requireTypedRefuse ?? true;
30
31
  const configPath = resolve(options.tsconfig);
31
32
  const read = ts.readConfigFile(configPath, ts.sys.readFile);
32
33
  if (read.error)
@@ -106,27 +107,42 @@ export const checkApiRefusals = async (options) => {
106
107
  return [{ at, unreadable: true }];
107
108
  return values.map((value) => ({ at, code: value }));
108
109
  };
110
+ /**
111
+ * Whether a signature's declaration (one of Lambder's) is a refuse
112
+ * function: a function type in LambderDeclaredRefuse, which types every
113
+ * context's, guard's and init's refuse, or the type of something Lambder
114
+ * declares as `refuse`, the free refuse() and the members an emitted
115
+ * declaration file spells out in place of the alias. Read off the
116
+ * declaration rather than off the call, so a refuse the app reaches under
117
+ * a name of its own (an import renamed, a context's refuse held in a
118
+ * variable or destructured under another name, a parameter typed as one)
119
+ * is a raise all the same.
120
+ */
121
+ const isRefuseDeclaration = (declaration) => {
122
+ let node = declaration;
123
+ while (ts.isTypeNode(node.parent))
124
+ node = node.parent;
125
+ const holder = node.parent;
126
+ if (ts.isTypeAliasDeclaration(holder))
127
+ return holder.name.text === "LambderDeclaredRefuse";
128
+ return (ts.isVariableDeclaration(holder) || ts.isPropertySignature(holder) || ts.isPropertyDeclaration(holder))
129
+ && holder.type === node && ts.isIdentifier(holder.name) && holder.name.text === "refuse";
130
+ };
109
131
  /**
110
132
  * The refusal a call or construction raises, when it is one of Lambder's
111
- * refuse functions or its refusal class's constructor. The constructor is
112
- * told by the signature the call resolves to rather than by the name
113
- * written, so it counts constructed as itself, as a class of the app's
114
- * own that declares no constructor and inherits it, or as the super() of
115
- * one that declares its own, which is then raised wherever that class is
116
- * constructed.
133
+ * refuse functions or its refusal class's constructor. Both are told by
134
+ * the signature the call resolves to rather than by the name written, so
135
+ * a refuse counts however the app names it, and the constructor counts
136
+ * constructed as itself, as a class of the app's own that declares no
137
+ * constructor and inherits it, or as the super() of one that declares
138
+ * its own, which is then raised wherever that class is constructed.
117
139
  */
118
140
  const raiseSitesOf = (node) => {
119
- const callee = node.expression;
120
- const constructs = ts.isNewExpression(node) || callee.kind === ts.SyntaxKind.SuperKeyword;
121
- if (!constructs) {
122
- const name = ts.isIdentifier(callee) ? callee.text : ts.isPropertyAccessExpression(callee) ? callee.name.text : null;
123
- if (name !== "refuse")
124
- return [];
125
- }
141
+ const constructs = ts.isNewExpression(node) || node.expression.kind === ts.SyntaxKind.SuperKeyword;
126
142
  const declaration = checker.getResolvedSignature(node)?.declaration;
127
- if (!isLambderDeclaration(declaration))
143
+ if (!declaration || !isLambderDeclaration(declaration))
128
144
  return [];
129
- if (constructs && !(declaration && ts.isConstructorDeclaration(declaration) && declaration.parent.name?.text === "LambderApiRefusal"))
145
+ if (constructs ? !(ts.isConstructorDeclaration(declaration) && declaration.parent.name?.text === "LambderApiRefusal") : !isRefuseDeclaration(declaration))
130
146
  return [];
131
147
  const args = node.arguments ?? ts.factory.createNodeArray();
132
148
  return constructs ? codeOfOptions(args[1], "refusal", where(node)) : codeOfOptions(args[1], "code", where(node));
@@ -269,14 +285,20 @@ export const checkApiRefusals = async (options) => {
269
285
  }
270
286
  return [...codes];
271
287
  };
272
- /** The codes an options object's `refusals` property names: one code or a list, each a literal or a constant typed as one. */
288
+ /**
289
+ * The codes an options object's `refusals` property names: one code or a
290
+ * list, each a literal or a constant typed as one. Null where it has no
291
+ * such property, which for a guard built without the vocabulary is the
292
+ * difference between declaring no codes (`refusals: []`) and saying
293
+ * nothing of what it may send.
294
+ */
273
295
  const ownRefusalsOf = (optionsNode) => {
274
296
  const options = optionsNode && unwrap(optionsNode);
275
297
  if (!options || !ts.isObjectLiteralExpression(options))
276
- return [];
298
+ return null;
277
299
  const property = options.properties.find((candidate) => ts.isPropertyAssignment(candidate) && ts.isIdentifier(candidate.name) && candidate.name.text === "refusals");
278
300
  if (!property || !ts.isPropertyAssignment(property))
279
- return [];
301
+ return null;
280
302
  const value = unwrap(property.initializer);
281
303
  const entries = ts.isArrayLiteralExpression(value) ? [...value.elements] : [value];
282
304
  return entries.flatMap((entry) => ts.isStringLiteralLike(entry) ? [entry.text] : literalsOf(checker.getTypeAtLocation(entry)) ?? []);
@@ -334,9 +356,11 @@ export const checkApiRefusals = async (options) => {
334
356
  return;
335
357
  const name = handlerNameOf(call, registration);
336
358
  const at = where(call);
337
- const allowed = allowedCodesOf(handler) ?? (registration === "guard" || registration === "lambderGuard" ? ownRefusalsOfOrNull(optionsNode) : null);
359
+ // A guard built without the vocabulary may send the codes its own
360
+ // refusals option names, which is what the server checks it against.
361
+ const allowed = allowedCodesOf(handler) ?? (registration === "guard" || registration === "lambderGuard" ? ownRefusalsOf(optionsNode) : null);
338
362
  if (allowed === null) {
339
- unchecked.push(`${name} (${at})`);
363
+ unchecked.push({ handler: name, at, problem: "unchecked" });
340
364
  return;
341
365
  }
342
366
  handlers += 1;
@@ -366,16 +390,11 @@ export const checkApiRefusals = async (options) => {
366
390
  findings.push({ handler: name, at, problem: "unreadable", raisedAt: raise.at });
367
391
  }
368
392
  }
369
- for (const code of ownRefusalsOf(optionsNode)) {
393
+ for (const code of ownRefusalsOf(optionsNode) ?? []) {
370
394
  if (!reached.has(code))
371
395
  findings.push({ handler: name, at, problem: "unused", code });
372
396
  }
373
397
  };
374
- /** A guard's `refusals` option, or null when it has none: what a guard built without the vocabulary may send. */
375
- const ownRefusalsOfOrNull = (optionsNode) => {
376
- const own = ownRefusalsOf(optionsNode);
377
- return own.length ? own : null;
378
- };
379
398
  for (const sourceFile of program.getSourceFiles()) {
380
399
  if (!isOwnFile(sourceFile))
381
400
  continue;
@@ -386,16 +405,23 @@ export const checkApiRefusals = async (options) => {
386
405
  };
387
406
  visit(sourceFile);
388
407
  }
389
- const uncheckedLine = unchecked.length ? [` ${unchecked.length} ${unchecked.length === 1 ? "handler has" : "handlers have"} no typed refuse to check against: ${unchecked.join(", ")}`] : [];
408
+ // Found, or only listed where the caller lets them stand unchecked.
409
+ if (requireTypedRefuse)
410
+ findings.push(...unchecked);
411
+ const uncheckedLine = !requireTypedRefuse && unchecked.length
412
+ ? [` ${unchecked.length} ${unchecked.length === 1 ? "handler has" : "handlers have"} no typed refuse to check against: ${unchecked.map(({ handler, at }) => `${handler} (${at})`).join(", ")}`]
413
+ : [];
390
414
  if (handlers === 0) {
391
- return failed(`✗ ${options.tsconfig}: no handler Lambder hands a typed refuse was found, so nothing was checked. Do its APIs come from an init that declares its refusals (declareRefusals), and do its files resolve lambder?`, uncheckedLine);
415
+ const nothingChecked = `✗ ${options.tsconfig}: no handler Lambder hands a typed refuse was found, so nothing was checked. Do its APIs come from an init that declares its refusals (declareRefusals), and do its files resolve lambder?`;
416
+ return { ...failed(nothingChecked, [...findings.map(describeFinding), ...uncheckedLine]), findings };
392
417
  }
393
418
  // Duplicate uncoded or unreadable findings of one site through two paths say the same thing once.
394
419
  const unique = [...new Map(findings.map((finding) => [JSON.stringify(finding), finding])).values()];
395
420
  const lines = [
396
421
  unique.length === 0
397
422
  ? `✓ ${handlers} handlers reach only the refusals they may send`
398
- : `✗ ${unique.length} refusal ${unique.length === 1 ? "finding" : "findings"} in ${new Set(unique.map((finding) => finding.handler)).size} of ${handlers} handlers`,
423
+ // Out of every handler found, the unchecked ones among them, which are never counted as checked.
424
+ : `✗ ${unique.length} refusal ${unique.length === 1 ? "finding" : "findings"} in ${new Set(unique.map((finding) => finding.handler)).size} of ${handlers + unchecked.length} handlers`,
399
425
  ...unique.map(describeFinding),
400
426
  ...uncheckedLine,
401
427
  ];
@@ -408,6 +434,7 @@ const describeFinding = (finding) => {
408
434
  case "uncoded": return ` ${finding.handler} (${finding.at}) can reach a refusal with no code (${finding.raisedAt})`;
409
435
  case "unreadable": return ` ${finding.handler} (${finding.at}) can reach a refusal whose code is not a string literal (${finding.raisedAt}), so which code it sends cannot be told`;
410
436
  case "untraced": return ` ${finding.handler} (${finding.at}) is handed a typed refuse, but its function cannot be found (a parameter, a dependency's value), so nothing it reaches was checked`;
437
+ case "unchecked": return ` ${finding.handler} (${finding.at}) is handed no typed refuse, so nothing it reaches could be checked: build it from an init that declares the refusal vocabulary (declareRefusals, on initLambderMock() as on initLambder()), or give a guard a refusals option naming the codes it may send`;
411
438
  }
412
439
  };
413
440
  const failed = (line, details = []) => ({ ok: false, handlers: 0, findings: [], lines: [line, ...details] });
@@ -0,0 +1,11 @@
1
+ import { type LambderApiContractFileOptions, type LambderApiContractFileResult } from "./writeApiContract.js";
2
+ /** writeApiContract's options as they cross the process boundary: the module as a string, a URL by its href. */
3
+ export type ContractPrinterRequest = Omit<LambderApiContractFileOptions, "module"> & {
4
+ module: string;
5
+ };
6
+ /** The result, or the messages of what was thrown, outermost first, down its chain of causes. */
7
+ export type ContractPrinterReport = {
8
+ result: LambderApiContractFileResult;
9
+ } | {
10
+ thrown: string[];
11
+ };
@@ -0,0 +1,17 @@
1
+ import { CONTRACT_REPORT_PREFIX } from "./generateApiFiles.js";
2
+ import { writeApiContract } from "./writeApiContract.js";
3
+ const request = JSON.parse(process.argv[2] ?? "null");
4
+ let report;
5
+ try {
6
+ report = { result: await writeApiContract(request) };
7
+ }
8
+ catch (err) {
9
+ const thrown = err instanceof Error ? [] : [String(err)];
10
+ for (let cause = err; cause instanceof Error && thrown.length < 8; cause = cause.cause)
11
+ thrown.push(cause.message);
12
+ report = { thrown };
13
+ }
14
+ // Waited for: a write to a pipe can still be pending when the process exits,
15
+ // and the parent reads the pipe.
16
+ await new Promise((resolveWrite) => process.stdout.write(`\n${CONTRACT_REPORT_PREFIX}${JSON.stringify(report)}\n`, () => resolveWrite()));
17
+ process.exit(0);