lambder 7.2.5 → 8.0.2

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 (209) hide show
  1. package/CHANGELOG.md +1021 -3
  2. package/README.md +43 -21
  3. package/dist/api/LambderApiAnswer.d.ts +18 -22
  4. package/dist/api/LambderApiAnswer.js +6 -7
  5. package/dist/api/LambderApiCallContext.d.ts +21 -8
  6. package/dist/api/LambderApiCallContext.js +22 -4
  7. package/dist/api/LambderApiDefinition.d.ts +4 -3
  8. package/dist/api/LambderApiEnvelope.d.ts +14 -9
  9. package/dist/api/LambderApiEnvelope.js +33 -34
  10. package/dist/api/LambderApiGuards.d.ts +78 -51
  11. package/dist/api/LambderApiGuards.js +34 -36
  12. package/dist/api/LambderApiIdempotency.d.ts +74 -61
  13. package/dist/api/LambderApiIdempotency.js +226 -151
  14. package/dist/api/LambderApiOutputValidationError.d.ts +32 -0
  15. package/dist/api/LambderApiOutputValidationError.js +50 -0
  16. package/dist/api/LambderApiPipeline.d.ts +77 -39
  17. package/dist/api/LambderApiPipeline.js +135 -62
  18. package/dist/api/LambderApiRateLimits.d.ts +208 -54
  19. package/dist/api/LambderApiRateLimits.js +197 -108
  20. package/dist/api/LambderApiRequest.d.ts +27 -21
  21. package/dist/api/LambderApiRequest.js +26 -19
  22. package/dist/api/LambderApiSignature.d.ts +12 -15
  23. package/dist/api/LambderApiSignature.js +28 -51
  24. package/dist/api/LambderApiValidationRefusal.d.ts +9 -9
  25. package/dist/api/LambderApiValidationRefusal.js +10 -10
  26. package/dist/build/freshProcessVerifier.d.ts +13 -0
  27. package/dist/build/freshProcessVerifier.js +19 -0
  28. package/dist/build/writeApiSignatures.d.ts +109 -0
  29. package/dist/build/writeApiSignatures.js +222 -0
  30. package/dist/build.d.ts +9 -0
  31. package/dist/build.js +8 -0
  32. package/dist/client/LambderCaller.d.ts +13 -44
  33. package/dist/client/LambderCaller.js +77 -84
  34. package/dist/client/LambderReloadLoopBreaker.d.ts +56 -26
  35. package/dist/client/LambderReloadLoopBreaker.js +90 -46
  36. package/dist/client/lambderFetchTransport.d.ts +4 -1
  37. package/dist/client/lambderFetchTransport.js +52 -28
  38. package/dist/client.d.ts +5 -3
  39. package/dist/client.js +2 -1
  40. package/dist/core/Lambder.d.ts +161 -69
  41. package/dist/core/Lambder.js +370 -226
  42. package/dist/core/LambderContext.d.ts +82 -15
  43. package/dist/core/LambderContext.js +107 -20
  44. package/dist/core/LambderCors.d.ts +21 -3
  45. package/dist/core/LambderCors.js +35 -16
  46. package/dist/core/LambderCrashHandling.d.ts +40 -0
  47. package/dist/core/LambderCrashHandling.js +97 -0
  48. package/dist/core/LambderCreateOptions.d.ts +151 -75
  49. package/dist/core/LambderCreateOptions.js +16 -23
  50. package/dist/core/LambderFiles.d.ts +28 -7
  51. package/dist/core/LambderFiles.js +73 -33
  52. package/dist/core/LambderIndexHtml.js +12 -11
  53. package/dist/core/LambderPolicyBuilders.d.ts +17 -5
  54. package/dist/core/LambderPolicyBuilders.js +17 -5
  55. package/dist/core/LambderPublicFiles.d.ts +11 -5
  56. package/dist/core/LambderPublicFiles.js +32 -4
  57. package/dist/core/LambderRequestPath.d.ts +43 -0
  58. package/dist/core/LambderRequestPath.js +63 -0
  59. package/dist/core/LambderResponse.d.ts +26 -5
  60. package/dist/core/LambderResponse.js +157 -70
  61. package/dist/core/LambderResponseBuilder.d.ts +49 -4
  62. package/dist/core/LambderResponseBuilder.js +64 -3
  63. package/dist/core/LambderRouting.d.ts +2 -3
  64. package/dist/core/LambderRouting.js +22 -7
  65. package/dist/core/LambderTemplatingEngine.js +211 -32
  66. package/dist/index.d.ts +15 -8
  67. package/dist/index.js +5 -4
  68. package/dist/invoke/LambderInvokeCaller.d.ts +37 -42
  69. package/dist/invoke/LambderInvokeCaller.js +76 -66
  70. package/dist/invoke/LambderInvokeOutcome.d.ts +27 -26
  71. package/dist/invoke/LambderInvokeOutcome.js +9 -22
  72. package/dist/invoke/LambderLambdaEvent.d.ts +44 -10
  73. package/dist/invoke/LambderLambdaEvent.js +80 -37
  74. package/dist/invoke/lambderHandlerTransport.d.ts +12 -10
  75. package/dist/invoke/lambderHandlerTransport.js +16 -19
  76. package/dist/mock/LambderMockApp.d.ts +67 -83
  77. package/dist/mock/LambderMockApp.js +167 -153
  78. package/dist/mock/LambderMockBrowserCookies.d.ts +24 -28
  79. package/dist/mock/LambderMockBrowserCookies.js +24 -28
  80. package/dist/mock/LambderMockCallRecorder.d.ts +15 -22
  81. package/dist/mock/LambderMockCallRecorder.js +19 -28
  82. package/dist/mock/LambderMockCreateOptions.d.ts +42 -24
  83. package/dist/mock/LambderMockEntryRegistry.d.ts +11 -12
  84. package/dist/mock/LambderMockEntryRegistry.js +24 -29
  85. package/dist/mock/LambderMockFailureInjector.d.ts +3 -6
  86. package/dist/mock/LambderMockFailureInjector.js +3 -6
  87. package/dist/mock/LambderMockTypes.d.ts +78 -108
  88. package/dist/mock/lambderMockInvokeTransport.d.ts +11 -13
  89. package/dist/mock/lambderMockInvokeTransport.js +11 -10
  90. package/dist/mock/lambderMockMswHandler.d.ts +33 -29
  91. package/dist/mock/lambderMockMswHandler.js +50 -39
  92. package/dist/mock.d.ts +3 -1
  93. package/dist/mock.js +5 -3
  94. package/dist/session/LambderSessionController.d.ts +108 -89
  95. package/dist/session/LambderSessionController.js +187 -168
  96. package/dist/session/LambderSessionCrypto.d.ts +16 -7
  97. package/dist/session/LambderSessionCrypto.js +26 -12
  98. package/dist/session/LambderSessionManager.d.ts +136 -47
  99. package/dist/session/LambderSessionManager.js +280 -139
  100. package/dist/shared/LambderHtml.d.ts +42 -3
  101. package/dist/shared/LambderHtml.js +127 -7
  102. package/dist/shared/LambderHtmlPositions.d.ts +173 -0
  103. package/dist/shared/LambderHtmlPositions.js +652 -0
  104. package/dist/shared/LambderI18n.d.ts +10 -11
  105. package/dist/shared/LambderI18n.js +33 -21
  106. package/dist/shared/contracts/LambderCache.d.ts +66 -0
  107. package/dist/shared/contracts/LambderCache.js +11 -0
  108. package/dist/shared/contracts/LambderFileSource.d.ts +6 -6
  109. package/dist/shared/contracts/LambderFileSource.js +5 -8
  110. package/dist/shared/contracts/LambderIdempotencyStore.d.ts +51 -22
  111. package/dist/shared/contracts/LambderIdempotencyStore.js +4 -5
  112. package/dist/shared/contracts/LambderRateLimiter.d.ts +27 -15
  113. package/dist/shared/contracts/LambderRateLimiter.js +4 -5
  114. package/dist/shared/contracts/LambderSessionStore.d.ts +65 -26
  115. package/dist/shared/contracts/LambderSessionStore.js +5 -6
  116. package/dist/shared/transport/LambderApiTransport.d.ts +27 -27
  117. package/dist/shared/transport/LambderApiTransport.js +7 -7
  118. package/dist/shared/transport/LambderCookieJar.d.ts +28 -35
  119. package/dist/shared/transport/LambderCookieJar.js +54 -66
  120. package/dist/shared/transport/lambderCookieJarTransport.d.ts +11 -13
  121. package/dist/shared/transport/lambderCookieJarTransport.js +24 -23
  122. package/dist/shared/util/LambderCallAbort.d.ts +5 -5
  123. package/dist/shared/util/LambderCallAbort.js +5 -5
  124. package/dist/shared/util/LambderClientIp.d.ts +27 -11
  125. package/dist/shared/util/LambderClientIp.js +96 -13
  126. package/dist/shared/util/LambderExpiringMap.d.ts +35 -49
  127. package/dist/shared/util/LambderExpiringMap.js +41 -57
  128. package/dist/shared/util/LambderNodeModules.js +6 -7
  129. package/dist/shared/util/LambderOptionChecks.d.ts +4 -4
  130. package/dist/shared/util/LambderOptionChecks.js +4 -4
  131. package/dist/shared/util/LambderResponseBrand.d.ts +5 -5
  132. package/dist/shared/util/LambderResponseBrand.js +5 -5
  133. package/dist/shared/util/LambderTestingDoors.d.ts +29 -0
  134. package/dist/shared/util/LambderTestingDoors.js +29 -0
  135. package/dist/shared/util/LambderTypeUtilities.d.ts +7 -8
  136. package/dist/shared/util/LambderTypeUtilities.js +3 -3
  137. package/dist/shared/util/boundKeyField.d.ts +20 -0
  138. package/dist/shared/util/boundKeyField.js +34 -0
  139. package/dist/shared/util/canonicalJson.d.ts +11 -0
  140. package/dist/shared/util/canonicalJson.js +28 -0
  141. package/dist/shared/util/joinKeyFields.d.ts +20 -0
  142. package/dist/shared/util/joinKeyFields.js +22 -0
  143. package/dist/shared/wire/LambderAnswerHeaders.d.ts +12 -16
  144. package/dist/shared/wire/LambderAnswerHeaders.js +12 -16
  145. package/dist/shared/wire/LambderApiContract.d.ts +107 -32
  146. package/dist/shared/wire/LambderApiOutcome.d.ts +43 -31
  147. package/dist/shared/wire/LambderApiOutcome.js +48 -23
  148. package/dist/shared/wire/LambderApiRefusal.d.ts +39 -27
  149. package/dist/shared/wire/LambderApiRefusal.js +36 -7
  150. package/dist/shared/wire/LambderApiSignature.d.ts +18 -22
  151. package/dist/shared/wire/LambderApiSignature.js +16 -19
  152. package/dist/shared/wire/LambderCallOptions.d.ts +38 -47
  153. package/dist/shared/wire/LambderCallOptions.js +9 -11
  154. package/dist/shared/wire/LambderCompressionCodec.d.ts +29 -34
  155. package/dist/shared/wire/LambderCompressionCodec.js +31 -36
  156. package/dist/shared/wire/LambderCompressionOption.d.ts +9 -9
  157. package/dist/shared/wire/LambderCompressionOption.js +9 -9
  158. package/dist/shared/wire/LambderCrashDetail.d.ts +12 -15
  159. package/dist/shared/wire/LambderCrashDetail.js +12 -15
  160. package/dist/shared/wire/LambderDefaultApiPath.d.ts +6 -0
  161. package/dist/shared/wire/LambderDefaultApiPath.js +6 -0
  162. package/dist/shared/wire/LambderHttpStatus.d.ts +6 -7
  163. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +89 -0
  164. package/dist/shared/wire/LambderIdempotencyKeyScope.js +146 -0
  165. package/dist/shared/wire/LambderInvokeApiId.d.ts +27 -0
  166. package/dist/shared/wire/LambderInvokeApiId.js +27 -0
  167. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +79 -0
  168. package/dist/shared/wire/LambderOutcomeAssertions.js +112 -0
  169. package/dist/shared/wire/LambderRequestPayload.d.ts +18 -20
  170. package/dist/shared/wire/LambderRequestPayload.js +4 -6
  171. package/dist/stores/LambderCacheFiller.d.ts +48 -0
  172. package/dist/stores/LambderCacheFiller.js +119 -0
  173. package/dist/stores/LambderCacheKeys.d.ts +26 -0
  174. package/dist/stores/LambderCacheKeys.js +54 -0
  175. package/dist/stores/LambderCacheValues.d.ts +45 -0
  176. package/dist/stores/LambderCacheValues.js +74 -0
  177. package/dist/stores/LambderDdbCache.d.ts +121 -56
  178. package/dist/stores/LambderDdbCache.js +528 -225
  179. package/dist/stores/LambderDdbIdempotencyStore.d.ts +33 -22
  180. package/dist/stores/LambderDdbIdempotencyStore.js +75 -50
  181. package/dist/stores/LambderDdbRateLimiter.d.ts +76 -20
  182. package/dist/stores/LambderDdbRateLimiter.js +151 -39
  183. package/dist/stores/LambderDdbSdk.d.ts +43 -31
  184. package/dist/stores/LambderDdbSdk.js +79 -33
  185. package/dist/stores/LambderDdbSessionStore.d.ts +27 -14
  186. package/dist/stores/LambderDdbSessionStore.js +119 -47
  187. package/dist/stores/LambderHttpFileSource.d.ts +15 -6
  188. package/dist/stores/LambderHttpFileSource.js +15 -13
  189. package/dist/stores/LambderMemoryCache.d.ts +49 -0
  190. package/dist/stores/LambderMemoryCache.js +113 -0
  191. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +13 -12
  192. package/dist/stores/LambderMemoryIdempotencyStore.js +31 -30
  193. package/dist/stores/LambderMemoryRateLimiter.d.ts +8 -9
  194. package/dist/stores/LambderMemoryRateLimiter.js +14 -13
  195. package/dist/stores/LambderMemorySessionStore.d.ts +14 -11
  196. package/dist/stores/LambderMemorySessionStore.js +38 -19
  197. package/dist/stores/LambderS3FileSource.d.ts +21 -6
  198. package/dist/stores/LambderS3FileSource.js +12 -7
  199. package/dist/testing/LambderTestApp.d.ts +176 -0
  200. package/dist/testing/LambderTestApp.js +204 -0
  201. package/dist/testing/LambderTestVisitor.d.ts +153 -0
  202. package/dist/testing/LambderTestVisitor.js +154 -0
  203. package/dist/testing.d.ts +27 -0
  204. package/dist/testing.js +24 -0
  205. package/package.json +20 -3
  206. package/dist/api/LambderApiPolicyEngine.d.ts +0 -36
  207. package/dist/api/LambderApiPolicyEngine.js +0 -77
  208. package/dist/shared/util/LambderKeyFields.d.ts +0 -32
  209. package/dist/shared/util/LambderKeyFields.js +0 -34
@@ -2,56 +2,35 @@ import { z } from "zod";
2
2
  import { toGuardEntries } from "./LambderApiGuards.js";
3
3
  import { API_SIGNATURE_HEX_LENGTH, EXTENSIBLE_ENUM_META_KEY } from "../shared/wire/LambderApiSignature.js";
4
4
  import { sha256HexOf } from "../shared/util/LambderTextDigest.js";
5
+ import { canonicalJson } from "../shared/util/canonicalJson.js";
5
6
  /*
6
7
  * The digest of an endpoint's client-facing shape, computed once, by the
7
8
  * generator, through Lambder.apiSignatures(). Nothing digests at request
8
- * time: the server and the client both carry the generated map and the
9
- * pipeline compares entries, so the one computation has nothing to agree
10
- * with but itself. See LambderApiSignatureMap.
9
+ * time: server and client both carry the generated map and the pipeline
10
+ * compares entries, so there is no second computation to disagree with.
11
+ * See LambderApiSignatureMap.
11
12
  */
12
- /**
13
- * JSON with object keys sorted at every level, so two descriptions of the
14
- * same shape hash the same whatever order they were built in. Arrays keep
15
- * their order: a tuple's positions and an enum's values are part of the
16
- * shape. Undefined entries are dropped, as JSON.stringify would drop them.
17
- */
18
- const canonicalJson = (value) => JSON.stringify(sortKeys(value));
19
- const sortKeys = (value) => {
20
- if (Array.isArray(value))
21
- return value.map(sortKeys);
22
- if (value === null || typeof value !== "object")
23
- return value;
24
- const source = value;
25
- const sorted = {};
26
- for (const key of Object.keys(source).sort()) {
27
- if (source[key] !== undefined)
28
- sorted[key] = sortKeys(source[key]);
29
- }
30
- return sorted;
31
- };
32
13
  /**
33
14
  * Three edits to every node zod emits, before it is hashed.
34
15
  *
35
16
  * The `default` keyword goes. Its value is server behaviour, not shape: a
36
- * client never sends it, and its compiled types do not carry it. And for a
37
- * function default (`.default(() => new Date())`, `.prefault`, `.catch`)
38
- * zod writes whatever the function returned at conversion time, a clock
39
- * reading or a random value, which would give the endpoint a different
40
- * digest on every computation and a generated map that never matches the
41
- * server. Nothing distinguishes such a default from a constant one once zod
42
- * has evaluated it, so every default goes, and the one thing about a default
43
- * a client can see, that the field may be omitted, stays through `required`.
17
+ * client never sends it. For a function default (`.default(() => new
18
+ * Date())`, `.prefault`, `.catch`) zod writes whatever the function returned
19
+ * at conversion time, a clock reading or a random value, so the digest would
20
+ * differ on every computation and the generated map would never match the
21
+ * server. Once evaluated, such a default looks like a constant one, so every
22
+ * default goes; what a client can see of it, that the field may be omitted,
23
+ * stays through `required`.
44
24
  *
45
- * `required` is sorted. It is a set, and the order fields are declared in is
46
- * not shape either; left as emitted, reordering two fields forced a reload.
25
+ * `required` is sorted: it is a set, and declaration order is not shape.
26
+ * Left as emitted, reordering two fields would force a reload.
47
27
  *
48
- * An enum marked with extensibleEnum() loses its values in an output. Its
49
- * clients tolerate a value they do not know, so a response carrying one they
50
- * were not built with, or no longer carrying one they were, changes nothing
51
- * they can see; the node still says it holds a string. In an input the values
52
- * stay, since a value dropped from the list is a request an older client may
53
- * still send and the server now refuses. The mark itself goes in both, so
54
- * marking an enum changes no input's digest.
28
+ * An enum marked with extensibleEnum() loses its values in an output: its
29
+ * clients tolerate unknown values, so adding or removing one changes nothing
30
+ * they can see, and the node still says it holds a string. In an input the
31
+ * values stay, since a value dropped from the list is one an older client
32
+ * may still send and the server would refuse. The mark itself goes in both,
33
+ * so marking an enum changes no input's digest.
55
34
  */
56
35
  const keepShapeOnly = (node, io) => {
57
36
  delete node.default;
@@ -76,18 +55,16 @@ const ownGuard = (guards, name) => guards !== undefined && Object.prototype.hasO
76
55
  * input and output schemas as JSON Schema, every guard it declares with the
77
56
  * schema that guard validates (the guardInput the client sends separately,
78
57
  * or the apiInput slice of the payload), and whether it demands an
79
- * idempotency key. Anything else about the endpoint (its rate limits, a
80
- * guard's parameter, the handler) changes nothing for a client and is left
81
- * out, so changing it never forces a reload.
58
+ * idempotency key. Anything else (rate limits, a guard's parameter, the
59
+ * handler) changes nothing for a client and is left out, so changing it never
60
+ * forces a reload.
82
61
  *
83
- * The description is hashed as built, descriptions and titles included: a
84
- * schema is what the server says it is, and a client built against a
85
- * different one reloads once, with one exception the schema declares itself:
86
- * the values of an extensibleEnum() in an output (see keepShapeOnly). What
87
- * must hold for the digest to mean anything is that a schema is built from
88
- * static values: one that reads the clock, a random source or the environment
89
- * at construction digests differently in the generator's process and on the
90
- * server.
62
+ * Schemas are hashed as built, descriptions and titles included, so a client
63
+ * built against a different one reloads once. The exception is an output
64
+ * extensibleEnum()'s values (see keepShapeOnly). Schemas must be built from
65
+ * static values: one that reads the clock, a random source or the
66
+ * environment at construction digests differently in the generator's process
67
+ * and on the server.
91
68
  */
92
69
  export const apiSignatureOf = async (definition, guards) => {
93
70
  const guardShapes = toGuardEntries(definition.guards).map(({ name }) => {
@@ -5,13 +5,12 @@ import { LambderApiRefusal } from "../shared/wire/LambderApiRefusal.js";
5
5
  * response. The API's own schema and every preflight slice (a guard's input,
6
6
  * a rate-limit key's fields) throw this when a value fails to parse, and the
7
7
  * pipeline renders it in one place: through the app's input validation
8
- * handler when it set one, otherwise as the standard 422 body. The engines
9
- * therefore never build a response and never see a resolver, which is what
10
- * lets them run outside a Lambda.
8
+ * handler when set, otherwise as the standard 422 body. The engines never
9
+ * build a response or see a resolver, which lets them run outside a Lambda.
11
10
  *
12
- * A LambderApiRefusal, so every catch that maps refusals already handles it;
13
- * the brand tells the pipeline to route it through the validation handler
14
- * instead of the refusal envelope.
11
+ * It is a LambderApiRefusal, so every catch that maps refusals handles it;
12
+ * the brand routes it through the validation handler instead of the refusal
13
+ * envelope.
15
14
  */
16
15
  export declare class LambderApiValidationRefusal extends LambderApiRefusal {
17
16
  /** Brand for detection across duplicate lambder installs, like LambderApiRefusal's. */
@@ -26,7 +25,8 @@ export declare const isLambderApiValidationRefusal: (err: unknown) => err is Lam
26
25
  * a guardInput value from the raw guardInputs map). Runs before the API's
27
26
  * own validation; a failure throws the same LambderApiValidationRefusal the
28
27
  * API's schema throws, so the pipeline answers every rejected input alike.
29
- * Shared by the guards engine and the rate-limit engine, and living here
30
- * beside the error it throws rather than in one of the two.
28
+ * Shared by the guards and rate-limit engines, so it lives beside the error
29
+ * it throws. Parsed asynchronously, so a slice with an async refinement (a
30
+ * lookup, say) validates instead of making zod throw on every call.
31
31
  */
32
- export declare const parsePreflightSlice: (input: z.ZodType, value: unknown) => unknown;
32
+ export declare const parsePreflightSlice: (input: z.ZodType, value: unknown) => Promise<unknown>;
@@ -4,13 +4,12 @@ import { LambderApiRefusal, isLambderApiRefusal } from "../shared/wire/LambderAp
4
4
  * response. The API's own schema and every preflight slice (a guard's input,
5
5
  * a rate-limit key's fields) throw this when a value fails to parse, and the
6
6
  * pipeline renders it in one place: through the app's input validation
7
- * handler when it set one, otherwise as the standard 422 body. The engines
8
- * therefore never build a response and never see a resolver, which is what
9
- * lets them run outside a Lambda.
7
+ * handler when set, otherwise as the standard 422 body. The engines never
8
+ * build a response or see a resolver, which lets them run outside a Lambda.
10
9
  *
11
- * A LambderApiRefusal, so every catch that maps refusals already handles it;
12
- * the brand tells the pipeline to route it through the validation handler
13
- * instead of the refusal envelope.
10
+ * It is a LambderApiRefusal, so every catch that maps refusals handles it;
11
+ * the brand routes it through the validation handler instead of the refusal
12
+ * envelope.
14
13
  */
15
14
  export class LambderApiValidationRefusal extends LambderApiRefusal {
16
15
  /** Brand for detection across duplicate lambder installs, like LambderApiRefusal's. */
@@ -29,11 +28,12 @@ export const isLambderApiValidationRefusal = (err) => isLambderApiRefusal(err) &
29
28
  * a guardInput value from the raw guardInputs map). Runs before the API's
30
29
  * own validation; a failure throws the same LambderApiValidationRefusal the
31
30
  * API's schema throws, so the pipeline answers every rejected input alike.
32
- * Shared by the guards engine and the rate-limit engine, and living here
33
- * beside the error it throws rather than in one of the two.
31
+ * Shared by the guards and rate-limit engines, so it lives beside the error
32
+ * it throws. Parsed asynchronously, so a slice with an async refinement (a
33
+ * lookup, say) validates instead of making zod throw on every call.
34
34
  */
35
- export const parsePreflightSlice = (input, value) => {
36
- const parsed = input.safeParse(value);
35
+ export const parsePreflightSlice = async (input, value) => {
36
+ const parsed = await input.safeParseAsync(value);
37
37
  if (!parsed.success)
38
38
  throw new LambderApiValidationRefusal(parsed.error);
39
39
  return parsed.data;
@@ -0,0 +1,13 @@
1
+ /** What the parent asks for: which export of which module to digest, and the file to compare it with. */
2
+ export type FreshProcessRequest = {
3
+ moduleUrl: string;
4
+ exportName: string;
5
+ file: string;
6
+ };
7
+ /** The comparison, or why none could be made. */
8
+ export type FreshProcessVerdict = {
9
+ same: boolean;
10
+ lines: string[];
11
+ } | {
12
+ failure: string;
13
+ };
@@ -0,0 +1,19 @@
1
+ import { readFileSync } from "fs";
2
+ import { describeSignatureChanges, readSignatureMap, VERIFY_REPORT_PREFIX } from "./writeApiSignatures.js";
3
+ const request = JSON.parse(process.argv[2] ?? "null");
4
+ const namespace = await import(request.moduleUrl);
5
+ const source = namespace[request.exportName];
6
+ let verdict;
7
+ if (typeof source?.apiSignatureEntries !== "function") {
8
+ verdict = { failure: `${request.moduleUrl} has no export "${request.exportName}" that lists API signatures: name the export holding the instance in exportName` };
9
+ }
10
+ else {
11
+ const { movedLines, summary } = describeSignatureChanges(await source.apiSignatureEntries(), readSignatureMap(readFileSync(request.file, "utf8")));
12
+ verdict = { same: movedLines.length === 0, lines: [summary, ...movedLines] };
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${VERIFY_REPORT_PREFIX}${JSON.stringify(verdict)}\n`, () => resolveWrite()));
17
+ // The app's module may hold the process open (a client's sockets, a timer),
18
+ // and this process exists for the one comparison.
19
+ process.exit(0);
@@ -0,0 +1,109 @@
1
+ import type { LambderApiSignatureEntry } from "../api/LambderApiSignature.js";
2
+ /**
3
+ * How the fresh process reports its comparison: one line on stdout starting
4
+ * with this, then the verdict as JSON. The line, not the exit status, is the
5
+ * answer: the app's module prints what it likes while it loads, and a
6
+ * process that fails before the line never compared anything.
7
+ */
8
+ export declare const VERIFY_REPORT_PREFIX = "lambder-api-signatures-verified:";
9
+ /** This process's execArgv, less the flags only it may run with (see PARENT_ONLY_FLAG). */
10
+ export declare const freshProcessNodeFlags: (execArgv: readonly string[]) => string[];
11
+ /** What writeApiSignatures reads the signatures from: a Lambder instance, or anything else that lists them the same way. */
12
+ export type LambderApiSignatureSource = {
13
+ apiSignatureEntries(): Promise<LambderApiSignatureEntry[]>;
14
+ };
15
+ export type LambderApiSignatureFileOptions = {
16
+ /** The TypeScript module to write, exporting `apiSignatures`. Relative to the working directory. */
17
+ file: string;
18
+ /** Write nothing, and answer whether the file on disk is what the registrations produce now. Default: false. */
19
+ check?: boolean;
20
+ /** The comment the file opens with, one `//` line per line. It should name what generates the file. Default: a note naming writeApiSignatures(). */
21
+ header?: string;
22
+ /** Quotes in the generated module. Default: "double". */
23
+ quotes?: "single" | "double";
24
+ /** End the generated statements with semicolons. Default: true. */
25
+ semicolons?: boolean;
26
+ /**
27
+ * After writing, or after a check that found the file current, load the
28
+ * module that holds the instance in a fresh Node process and check the
29
+ * file against what it digests there. A schema built from the clock or a
30
+ * random source digests differently in every process; this catches it by
31
+ * endpoint name instead of letting signatures change on every build. Only
32
+ * that module is loaded, never the calling script, so nothing the script
33
+ * does runs twice; the module's own top-level code does run again. The
34
+ * fresh process gets this process's Node flags (`--import`, `--require`,
35
+ * `--loader`, `--conditions`) less the inspector, watch mode, the test
36
+ * runner and the eval flags (`-e`, `-p`, `--input-type`), so a TypeScript
37
+ * module loads there as it did here when its loader is on the command
38
+ * line or in NODE_OPTIONS. Default: not verified.
39
+ */
40
+ verifyInFreshProcess?: {
41
+ /**
42
+ * The module that exports the instance: a path relative to the
43
+ * working directory, or a file URL, as a URL such as
44
+ * `new URL("../backend/index.js", import.meta.url)` beside the
45
+ * generator's own import of it, or as the string
46
+ * `import.meta.resolve()` answers.
47
+ */
48
+ module: string | URL;
49
+ /** The export that holds the instance. Default: "default", the module's default export. */
50
+ exportName?: string;
51
+ };
52
+ };
53
+ export type LambderApiSignatureFileResult = {
54
+ /** False when a check found the file stale, or a fresh process digested different signatures or never compared them. */
55
+ ok: boolean;
56
+ /** The absolute path. */
57
+ file: string;
58
+ /** How many APIs the file holds. */
59
+ count: number;
60
+ /** True when the file was (re)written; a file that already holds these signatures is left untouched, however it is formatted. */
61
+ written: boolean;
62
+ /** Endpoints whose signature differs from the file that was on disk, by name. */
63
+ changed: string[];
64
+ added: string[];
65
+ /** Endpoints the file held and the registrations no longer have. Named by key only: a key is a one-way hash of a name that is gone. */
66
+ removedKeys: string[];
67
+ /** What happened, as lines to print: a summary, then one line per endpoint that moved. */
68
+ lines: string[];
69
+ };
70
+ /**
71
+ * The map a generated file holds, read back by its hex pairs. Either quote
72
+ * style parses, and so does a key without quotes: a formatter that quotes
73
+ * properties only as needed (Prettier's default, Biome's, ESLint's
74
+ * quote-props) unquotes every key that starts with a letter.
75
+ */
76
+ export declare const readSignatureMap: (contents: string) => Record<string, string>;
77
+ /** How the registrations differ from a map read off a file, by endpoint, as the lines both this process and a fresh one print. */
78
+ export declare const describeSignatureChanges: (entries: LambderApiSignatureEntry[], previousMap: Record<string, string>) => {
79
+ changed: string[];
80
+ added: string[];
81
+ removedKeys: string[];
82
+ movedLines: string[];
83
+ summary: string;
84
+ };
85
+ /**
86
+ * Writes the signature map both sides ship (see Lambder.apiSignatures()) to
87
+ * a TypeScript module, or checks the one on disk, and says which endpoints
88
+ * moved: every changed signature is a forced reload for the tabs calling
89
+ * that endpoint, so this is the line that says how wide a deploy's reload
90
+ * will be.
91
+ *
92
+ * Call it from a generator script that imports the app's instance:
93
+ *
94
+ * ```ts
95
+ * import { writeApiSignatures } from "lambder/build";
96
+ * import { lambder } from "../server/src/index.js";
97
+ *
98
+ * const result = await writeApiSignatures(lambder, { file: "shared/generated/apiSignatures.generated.ts", check: process.argv.includes("--check") });
99
+ * console.log(result.lines.join("\n"));
100
+ * process.exit(result.ok ? 0 : 1);
101
+ * ```
102
+ *
103
+ * Signatures are compared as the map the file holds, so a checkout that
104
+ * rewrote its line endings or a formatter that re-indented it or unquoted
105
+ * its keys is neither stale nor rewritten. `verifyInFreshProcess` also
106
+ * checks the file, written or found current, against the instance's module
107
+ * loaded in a fresh process.
108
+ */
109
+ export declare const writeApiSignatures: (source: LambderApiSignatureSource, options: LambderApiSignatureFileOptions) => Promise<LambderApiSignatureFileResult>;
@@ -0,0 +1,222 @@
1
+ import { spawnSync } from "child_process";
2
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "fs";
3
+ import { dirname, resolve } from "path";
4
+ import { fileURLToPath, pathToFileURL } from "url";
5
+ /*
6
+ * The signature file every app with apiSignatures needs, written and checked
7
+ * by the framework that defines it.
8
+ *
9
+ * The digests are computed once, by the generator, into this file, and both
10
+ * sides read the file: the frontend hands it to LambderCaller and the server
11
+ * hands it to create(), so the two can never disagree. What can still go wrong is the file itself (stale against the
12
+ * registrations, or different in every process because a schema reads the
13
+ * clock), and both are checked here.
14
+ */
15
+ /**
16
+ * How the fresh process reports its comparison: one line on stdout starting
17
+ * with this, then the verdict as JSON. The line, not the exit status, is the
18
+ * answer: the app's module prints what it likes while it loads, and a
19
+ * process that fails before the line never compared anything.
20
+ */
21
+ export const VERIFY_REPORT_PREFIX = "lambder-api-signatures-verified:";
22
+ /** How long a fresh-process verification may run before it counts as failed. */
23
+ const VERIFY_TIMEOUT_MS = 5 * 60 * 1000;
24
+ /** What a fresh process may print before its verdict line: a chatty module must not fill the pipe and fail the check. */
25
+ const VERIFY_OUTPUT_BYTES = 64 * 1024 * 1024;
26
+ /** The fresh process's entry, beside this module in the build. */
27
+ const FRESH_PROCESS_ENTRY = new URL("./freshProcessVerifier.js", import.meta.url);
28
+ /**
29
+ * This process's own Node flags that the fresh process must not inherit,
30
+ * because they make it something other than a plain run of the verifier: the
31
+ * inspector (`--inspect-brk` waits for a debugger forever, and a fixed port
32
+ * collides with this process's), watch mode (a watched process never exits),
33
+ * the test runner (`--test` runs the entry as a test file), and the eval
34
+ * flags: `-e`, `-p` and a cluster of them such as `-pe` (which run their code
35
+ * in place of the entry), and `--input-type` (which Node refuses beside an
36
+ * entry file). Everything else, `--import`, `--require`, `--loader`,
37
+ * `--conditions` and the rest, is how this process loads the app's modules,
38
+ * and the fresh one needs it to load the same way.
39
+ */
40
+ const PARENT_ONLY_FLAG = /^(--(inspect|debug-port|watch|test)([-=].*)?|--(eval|print|input-type)(=.*)?|-[a-z]*[ep][a-z]*)$/;
41
+ /** This process's execArgv, less the flags only it may run with (see PARENT_ONLY_FLAG). */
42
+ export const freshProcessNodeFlags = (execArgv) => {
43
+ const kept = [];
44
+ for (let index = 0; index < execArgv.length; index++) {
45
+ const flag = execArgv[index];
46
+ if (!PARENT_ONLY_FLAG.test(flag)) {
47
+ kept.push(flag);
48
+ continue;
49
+ }
50
+ // execArgv holds only Node's own options (the script and its
51
+ // arguments are argv), so an element that is not a flag is the value
52
+ // of the flag before it, and goes with it.
53
+ const next = execArgv[index + 1];
54
+ if (!flag.includes("=") && next !== undefined && !next.startsWith("-"))
55
+ index++;
56
+ }
57
+ return kept;
58
+ };
59
+ const DEFAULT_HEADER = [
60
+ "Generated by writeApiSignatures() from lambder/build. Do not edit.",
61
+ "",
62
+ "Every API the server registers, keyed by the hash of its name, with the",
63
+ "digest of its client-facing shape. The client sends the value with every",
64
+ "call and the server compares it with its own copy of this file, so a",
65
+ "deploy reloads only the tabs that call an endpoint whose shape changed.",
66
+ ].join("\n");
67
+ /**
68
+ * The map a generated file holds, read back by its hex pairs. Either quote
69
+ * style parses, and so does a key without quotes: a formatter that quotes
70
+ * properties only as needed (Prettier's default, Biome's, ESLint's
71
+ * quote-props) unquotes every key that starts with a letter.
72
+ */
73
+ export const readSignatureMap = (contents) => {
74
+ const map = {};
75
+ for (const match of contents.matchAll(/(?<![\w$])(['"]?)([0-9a-f]+)\1\s*:\s*['"]([0-9a-f]+)['"]/g))
76
+ map[match[2]] = match[3];
77
+ return map;
78
+ };
79
+ /** How the registrations differ from a map read off a file, by endpoint, as the lines both this process and a fresh one print. */
80
+ export const describeSignatureChanges = (entries, previousMap) => {
81
+ const byName = (a, b) => a.name.localeCompare(b.name);
82
+ const changed = entries.filter(({ key, signature }) => key in previousMap && previousMap[key] !== signature).sort(byName).map(({ name }) => name);
83
+ const added = entries.filter(({ key }) => !(key in previousMap)).sort(byName).map(({ name }) => name);
84
+ const keys = new Set(entries.map(({ key }) => key));
85
+ const removedKeys = Object.keys(previousMap).filter((key) => !keys.has(key));
86
+ const movedLines = [
87
+ ...changed.map((name) => ` ~ ${name}`),
88
+ ...added.map((name) => ` + ${name}`),
89
+ ...removedKeys.map((key) => ` - ${key} (removed: a key cannot be resolved back to its name)`),
90
+ ];
91
+ const summary = `${changed.length} changed, ${added.length} added, ${removedKeys.length} removed (${entries.length - changed.length - added.length} unchanged)`;
92
+ return { changed, added, removedKeys, movedLines, summary };
93
+ };
94
+ const renderSignatureFile = (entries, options) => {
95
+ const quote = options.quotes === "single" ? "'" : "\"";
96
+ const semicolon = options.semicolons === false ? "" : ";";
97
+ const header = (options.header ?? DEFAULT_HEADER).split("\n").map((line) => line ? `// ${line}` : "//");
98
+ // Keys and signatures are hex, so swapping the quote character touches nothing inside them.
99
+ const map = JSON.stringify(Object.fromEntries(entries.map(({ key, signature }) => [key, signature])), null, 4).replace(/"/g, quote);
100
+ return [
101
+ ...header,
102
+ `import type { LambderApiSignatureMap } from ${quote}lambder/client${quote}${semicolon}`,
103
+ "",
104
+ // Kept as written by a formatter that honours it, so a format pass
105
+ // after every generation has nothing to change. One that does not
106
+ // honour it changes the text only: the map reads back the same.
107
+ "// prettier-ignore",
108
+ `export const apiSignatures: LambderApiSignatureMap = ${map}${semicolon}`,
109
+ "",
110
+ ].join("\n");
111
+ };
112
+ /**
113
+ * Writes the signature map both sides ship (see Lambder.apiSignatures()) to
114
+ * a TypeScript module, or checks the one on disk, and says which endpoints
115
+ * moved: every changed signature is a forced reload for the tabs calling
116
+ * that endpoint, so this is the line that says how wide a deploy's reload
117
+ * will be.
118
+ *
119
+ * Call it from a generator script that imports the app's instance:
120
+ *
121
+ * ```ts
122
+ * import { writeApiSignatures } from "lambder/build";
123
+ * import { lambder } from "../server/src/index.js";
124
+ *
125
+ * const result = await writeApiSignatures(lambder, { file: "shared/generated/apiSignatures.generated.ts", check: process.argv.includes("--check") });
126
+ * console.log(result.lines.join("\n"));
127
+ * process.exit(result.ok ? 0 : 1);
128
+ * ```
129
+ *
130
+ * Signatures are compared as the map the file holds, so a checkout that
131
+ * rewrote its line endings or a formatter that re-indented it or unquoted
132
+ * its keys is neither stale nor rewritten. `verifyInFreshProcess` also
133
+ * checks the file, written or found current, against the instance's module
134
+ * loaded in a fresh process.
135
+ */
136
+ export const writeApiSignatures = async (source, options) => {
137
+ const file = resolve(options.file);
138
+ const entries = await source.apiSignatureEntries();
139
+ const previous = existsSync(file) ? readFileSync(file, "utf8") : null;
140
+ const { changed, added, removedKeys, movedLines, summary } = describeSignatureChanges(entries, previous === null ? {} : readSignatureMap(previous));
141
+ const unchanged = previous !== null && movedLines.length === 0;
142
+ const result = (ok, written, lines) => ({ ok, file, count: entries.length, written, changed, added, removedKeys, lines });
143
+ // A stale file is the answer by itself: a fresh process could only find
144
+ // it stale again. A current one goes on to the verification, as a write
145
+ // does, so a check that names verifyInFreshProcess verifies.
146
+ let written = false;
147
+ let lines;
148
+ if (options.check) {
149
+ if (!unchanged)
150
+ return result(false, false, [`✗ ${options.file} is stale: regenerate it`, ` ${summary}`, ...movedLines]);
151
+ lines = [`✓ ${options.file} matches the ${entries.length} registered APIs`];
152
+ }
153
+ else {
154
+ // A file that already holds this map is left as it is, however it is
155
+ // formatted, so a watcher or an incremental build sees no change
156
+ // where there is none, and a formatter's or a checkout's version of
157
+ // the file is not rewritten back on every run. A change of header,
158
+ // quotes or semicolons shows the next time the map changes. The write
159
+ // goes to a file beside the target and is renamed over it, so a build
160
+ // reading the file meanwhile sees the old map or the new one, never
161
+ // half of one. A symlink is followed to the file it names: renamed
162
+ // over, the link itself would become the new file and its target
163
+ // would keep the old map.
164
+ written = !unchanged;
165
+ if (written) {
166
+ const target = previous === null ? file : realpathSync(file);
167
+ mkdirSync(dirname(target), { recursive: true });
168
+ const partial = `${target}.${process.pid}.tmp`;
169
+ try {
170
+ writeFileSync(partial, renderSignatureFile(entries, options));
171
+ renameSync(partial, target);
172
+ }
173
+ catch (err) {
174
+ rmSync(partial, { force: true });
175
+ throw err;
176
+ }
177
+ }
178
+ lines = [
179
+ written ? `✓ Wrote ${options.file} (${entries.length} APIs)` : `✓ ${options.file} is up to date (${entries.length} APIs)`,
180
+ ...(movedLines.length ? [` ${summary}`, ...movedLines] : [" no signatures changed: this build forces no reloads"]),
181
+ ];
182
+ }
183
+ if (!options.verifyInFreshProcess)
184
+ return result(true, written, lines);
185
+ const { module: instanceModule, exportName = "default" } = options.verifyInFreshProcess;
186
+ const request = {
187
+ // A string that is already a file URL (what import.meta.resolve()
188
+ // answers) is taken as one: resolved as a path, it would name a
189
+ // directory called "file:" under the working directory.
190
+ moduleUrl: typeof instanceModule === "string" && !/^file:/i.test(instanceModule)
191
+ ? pathToFileURL(resolve(instanceModule)).href
192
+ : new URL(instanceModule).href,
193
+ exportName,
194
+ file,
195
+ };
196
+ const child = spawnSync(process.execPath, [...freshProcessNodeFlags(process.execArgv), fileURLToPath(FRESH_PROCESS_ENTRY), JSON.stringify(request)], {
197
+ encoding: "utf8",
198
+ timeout: VERIFY_TIMEOUT_MS,
199
+ maxBuffer: VERIFY_OUTPUT_BYTES,
200
+ });
201
+ const reportLine = (child.stdout ?? "").split("\n").findLast((line) => line.startsWith(VERIFY_REPORT_PREFIX));
202
+ if (reportLine === undefined) {
203
+ const stderrTail = (child.stderr ?? "").trim().split("\n").slice(-20).filter(Boolean);
204
+ return result(false, written, [
205
+ ...lines,
206
+ child.error
207
+ ? `✗ the fresh process that verifies ${options.file} could not finish: ${child.error.message}`
208
+ : `✗ the fresh process that verifies ${options.file} never compared it: loading ${request.moduleUrl} failed (exit status ${child.status ?? "none"})`,
209
+ ...stderrTail.map((line) => ` ${line}`),
210
+ ]);
211
+ }
212
+ const verdict = JSON.parse(reportLine.slice(VERIFY_REPORT_PREFIX.length));
213
+ if ("failure" in verdict)
214
+ return result(false, written, [...lines, `✗ the fresh process that verifies ${options.file} never compared it: ${verdict.failure}`]);
215
+ if (verdict.same)
216
+ return result(true, written, [...lines, "✓ a fresh process digests the same signatures"]);
217
+ return result(false, written, [
218
+ ...lines,
219
+ `✗ a fresh process digests different signatures for ${options.file}: a schema reads the clock or a random source`,
220
+ ...verdict.lines.map((line) => ` ${line}`),
221
+ ]);
222
+ };
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Build entry point (`import ... from "lambder/build"`).
3
+ *
4
+ * What a generator script runs at build time over the app's own instance to
5
+ * write the signature file both sides ship. Node-only and imported by nothing
6
+ * else in the package, so no deployment or bundle carries it.
7
+ */
8
+ export { writeApiSignatures } from "./build/writeApiSignatures.js";
9
+ export type { LambderApiSignatureSource, LambderApiSignatureFileOptions, LambderApiSignatureFileResult, } from "./build/writeApiSignatures.js";
package/dist/build.js ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Build entry point (`import ... from "lambder/build"`).
3
+ *
4
+ * What a generator script runs at build time over the app's own instance to
5
+ * write the signature file both sides ship. Node-only and imported by nothing
6
+ * else in the package, so no deployment or bundle carries it.
7
+ */
8
+ export { writeApiSignatures } from "./build/writeApiSignatures.js";