lambder 7.3.1 → 8.1.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 (237) hide show
  1. package/CHANGELOG.md +1047 -3
  2. package/README.md +46 -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 +68 -62
  13. package/dist/api/LambderApiIdempotency.js +214 -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 +47 -38
  17. package/dist/api/LambderApiPipeline.js +122 -63
  18. package/dist/api/LambderApiRateLimits.d.ts +201 -54
  19. package/dist/api/LambderApiRateLimits.js +185 -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/ContractTypePrinter.d.ts +85 -0
  27. package/dist/build/ContractTypePrinter.js +402 -0
  28. package/dist/build/freshProcessVerifier.d.ts +13 -0
  29. package/dist/build/freshProcessVerifier.js +19 -0
  30. package/dist/build/moduleLocation.d.ts +11 -0
  31. package/dist/build/moduleLocation.js +6 -0
  32. package/dist/build/writeApiContract.d.ts +78 -0
  33. package/dist/build/writeApiContract.js +302 -0
  34. package/dist/build/writeApiSignatures.d.ts +114 -0
  35. package/dist/build/writeApiSignatures.js +217 -0
  36. package/dist/build/writeFileAtomically.d.ts +8 -0
  37. package/dist/build/writeFileAtomically.js +22 -0
  38. package/dist/build.d.ts +14 -0
  39. package/dist/build.js +11 -0
  40. package/dist/client/LambderCaller.d.ts +13 -44
  41. package/dist/client/LambderCaller.js +77 -84
  42. package/dist/client/LambderReloadLoopBreaker.d.ts +56 -26
  43. package/dist/client/LambderReloadLoopBreaker.js +90 -46
  44. package/dist/client/LambderUploadRunner.d.ts +96 -0
  45. package/dist/client/LambderUploadRunner.js +234 -0
  46. package/dist/client/lambderFetchTransport.d.ts +4 -1
  47. package/dist/client/lambderFetchTransport.js +52 -28
  48. package/dist/client.d.ts +9 -3
  49. package/dist/client.js +6 -1
  50. package/dist/core/Lambder.d.ts +143 -79
  51. package/dist/core/Lambder.js +350 -231
  52. package/dist/core/LambderContext.d.ts +82 -15
  53. package/dist/core/LambderContext.js +107 -20
  54. package/dist/core/LambderCors.d.ts +21 -3
  55. package/dist/core/LambderCors.js +35 -16
  56. package/dist/core/LambderCrashHandling.d.ts +40 -0
  57. package/dist/core/LambderCrashHandling.js +97 -0
  58. package/dist/core/LambderCreateOptions.d.ts +151 -75
  59. package/dist/core/LambderCreateOptions.js +16 -23
  60. package/dist/core/LambderFiles.d.ts +21 -7
  61. package/dist/core/LambderFiles.js +62 -34
  62. package/dist/core/LambderIndexHtml.js +12 -11
  63. package/dist/core/LambderPolicyBuilders.d.ts +17 -5
  64. package/dist/core/LambderPolicyBuilders.js +17 -5
  65. package/dist/core/LambderPublicFiles.d.ts +11 -5
  66. package/dist/core/LambderPublicFiles.js +32 -4
  67. package/dist/core/LambderRequestPath.d.ts +43 -0
  68. package/dist/core/LambderRequestPath.js +63 -0
  69. package/dist/core/LambderResponse.d.ts +26 -5
  70. package/dist/core/LambderResponse.js +157 -70
  71. package/dist/core/LambderResponseBuilder.d.ts +49 -4
  72. package/dist/core/LambderResponseBuilder.js +64 -3
  73. package/dist/core/LambderRouting.d.ts +2 -3
  74. package/dist/core/LambderRouting.js +22 -7
  75. package/dist/core/LambderTemplatingEngine.js +211 -32
  76. package/dist/index.d.ts +25 -8
  77. package/dist/index.js +13 -4
  78. package/dist/invoke/LambderInvokeCaller.d.ts +37 -42
  79. package/dist/invoke/LambderInvokeCaller.js +76 -66
  80. package/dist/invoke/LambderInvokeOutcome.d.ts +27 -26
  81. package/dist/invoke/LambderInvokeOutcome.js +9 -22
  82. package/dist/invoke/LambderLambdaEvent.d.ts +29 -9
  83. package/dist/invoke/LambderLambdaEvent.js +40 -22
  84. package/dist/invoke/lambderHandlerTransport.d.ts +9 -10
  85. package/dist/invoke/lambderHandlerTransport.js +15 -18
  86. package/dist/mock/LambderMockApp.d.ts +67 -83
  87. package/dist/mock/LambderMockApp.js +167 -153
  88. package/dist/mock/LambderMockBrowserCookies.d.ts +24 -28
  89. package/dist/mock/LambderMockBrowserCookies.js +24 -28
  90. package/dist/mock/LambderMockCallRecorder.d.ts +15 -22
  91. package/dist/mock/LambderMockCallRecorder.js +19 -28
  92. package/dist/mock/LambderMockCreateOptions.d.ts +42 -24
  93. package/dist/mock/LambderMockEntryRegistry.d.ts +11 -12
  94. package/dist/mock/LambderMockEntryRegistry.js +24 -29
  95. package/dist/mock/LambderMockFailureInjector.d.ts +3 -6
  96. package/dist/mock/LambderMockFailureInjector.js +3 -6
  97. package/dist/mock/LambderMockTypes.d.ts +78 -108
  98. package/dist/mock/lambderMockInvokeTransport.d.ts +11 -13
  99. package/dist/mock/lambderMockInvokeTransport.js +11 -10
  100. package/dist/mock/lambderMockMswHandler.d.ts +43 -33
  101. package/dist/mock/lambderMockMswHandler.js +50 -39
  102. package/dist/mock/lambderMockUploadMswHandler.d.ts +26 -0
  103. package/dist/mock/lambderMockUploadMswHandler.js +28 -0
  104. package/dist/mock.d.ts +4 -1
  105. package/dist/mock.js +6 -3
  106. package/dist/session/LambderSessionController.d.ts +108 -89
  107. package/dist/session/LambderSessionController.js +187 -168
  108. package/dist/session/LambderSessionCrypto.d.ts +16 -7
  109. package/dist/session/LambderSessionCrypto.js +26 -12
  110. package/dist/session/LambderSessionManager.d.ts +124 -46
  111. package/dist/session/LambderSessionManager.js +262 -137
  112. package/dist/shared/LambderHtml.d.ts +42 -3
  113. package/dist/shared/LambderHtml.js +127 -7
  114. package/dist/shared/LambderHtmlPositions.d.ts +173 -0
  115. package/dist/shared/LambderHtmlPositions.js +652 -0
  116. package/dist/shared/LambderI18n.d.ts +10 -11
  117. package/dist/shared/LambderI18n.js +33 -21
  118. package/dist/shared/contracts/LambderCache.d.ts +66 -0
  119. package/dist/shared/contracts/LambderCache.js +11 -0
  120. package/dist/shared/contracts/LambderFileSource.d.ts +6 -6
  121. package/dist/shared/contracts/LambderFileSource.js +5 -8
  122. package/dist/shared/contracts/LambderIdempotencyStore.d.ts +51 -22
  123. package/dist/shared/contracts/LambderIdempotencyStore.js +4 -5
  124. package/dist/shared/contracts/LambderRateLimiter.d.ts +27 -15
  125. package/dist/shared/contracts/LambderRateLimiter.js +4 -5
  126. package/dist/shared/contracts/LambderSessionStore.d.ts +65 -26
  127. package/dist/shared/contracts/LambderSessionStore.js +5 -6
  128. package/dist/shared/contracts/LambderUploadBucket.d.ts +154 -0
  129. package/dist/shared/contracts/LambderUploadBucket.js +74 -0
  130. package/dist/shared/transport/LambderApiTransport.d.ts +27 -27
  131. package/dist/shared/transport/LambderApiTransport.js +7 -7
  132. package/dist/shared/transport/LambderCookieJar.d.ts +28 -35
  133. package/dist/shared/transport/LambderCookieJar.js +54 -66
  134. package/dist/shared/transport/lambderCookieJarTransport.d.ts +11 -13
  135. package/dist/shared/transport/lambderCookieJarTransport.js +24 -23
  136. package/dist/shared/util/LambderCallAbort.d.ts +5 -5
  137. package/dist/shared/util/LambderCallAbort.js +5 -5
  138. package/dist/shared/util/LambderClientIp.d.ts +27 -11
  139. package/dist/shared/util/LambderClientIp.js +96 -13
  140. package/dist/shared/util/LambderContentDisposition.d.ts +10 -0
  141. package/dist/shared/util/LambderContentDisposition.js +13 -0
  142. package/dist/shared/util/LambderExpiringMap.d.ts +35 -49
  143. package/dist/shared/util/LambderExpiringMap.js +41 -57
  144. package/dist/shared/util/LambderNodeModules.js +6 -7
  145. package/dist/shared/util/LambderOptionChecks.d.ts +4 -4
  146. package/dist/shared/util/LambderOptionChecks.js +4 -4
  147. package/dist/shared/util/LambderResponseBrand.d.ts +5 -5
  148. package/dist/shared/util/LambderResponseBrand.js +5 -5
  149. package/dist/shared/util/LambderTextDigest.d.ts +7 -5
  150. package/dist/shared/util/LambderTextDigest.js +11 -5
  151. package/dist/shared/util/LambderTypeUtilities.d.ts +7 -8
  152. package/dist/shared/util/LambderTypeUtilities.js +3 -3
  153. package/dist/shared/util/boundKeyField.d.ts +20 -0
  154. package/dist/shared/util/boundKeyField.js +34 -0
  155. package/dist/shared/util/canonicalJson.d.ts +11 -0
  156. package/dist/shared/util/canonicalJson.js +28 -0
  157. package/dist/shared/util/joinKeyFields.d.ts +20 -0
  158. package/dist/shared/util/joinKeyFields.js +22 -0
  159. package/dist/shared/wire/LambderAnswerHeaders.d.ts +12 -16
  160. package/dist/shared/wire/LambderAnswerHeaders.js +12 -16
  161. package/dist/shared/wire/LambderApiContract.d.ts +98 -53
  162. package/dist/shared/wire/LambderApiOutcome.d.ts +43 -31
  163. package/dist/shared/wire/LambderApiOutcome.js +48 -23
  164. package/dist/shared/wire/LambderApiRefusal.d.ts +45 -27
  165. package/dist/shared/wire/LambderApiRefusal.js +42 -7
  166. package/dist/shared/wire/LambderApiSignature.d.ts +18 -22
  167. package/dist/shared/wire/LambderApiSignature.js +16 -19
  168. package/dist/shared/wire/LambderCallOptions.d.ts +38 -47
  169. package/dist/shared/wire/LambderCallOptions.js +9 -11
  170. package/dist/shared/wire/LambderCompressionCodec.d.ts +29 -34
  171. package/dist/shared/wire/LambderCompressionCodec.js +31 -36
  172. package/dist/shared/wire/LambderCompressionOption.d.ts +9 -9
  173. package/dist/shared/wire/LambderCompressionOption.js +9 -9
  174. package/dist/shared/wire/LambderCrashDetail.d.ts +12 -15
  175. package/dist/shared/wire/LambderCrashDetail.js +12 -15
  176. package/dist/shared/wire/LambderDefaultApiPath.d.ts +6 -0
  177. package/dist/shared/wire/LambderDefaultApiPath.js +6 -0
  178. package/dist/shared/wire/LambderHttpStatus.d.ts +6 -7
  179. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +89 -0
  180. package/dist/shared/wire/LambderIdempotencyKeyScope.js +146 -0
  181. package/dist/shared/wire/LambderInvokeApiId.d.ts +27 -0
  182. package/dist/shared/wire/LambderInvokeApiId.js +27 -0
  183. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +6 -7
  184. package/dist/shared/wire/LambderOutcomeAssertions.js +6 -7
  185. package/dist/shared/wire/LambderRequestPayload.d.ts +18 -20
  186. package/dist/shared/wire/LambderRequestPayload.js +4 -6
  187. package/dist/shared/wire/LambderUploadObjectFields.d.ts +10 -0
  188. package/dist/shared/wire/LambderUploadObjectFields.js +24 -0
  189. package/dist/shared/wire/LambderUploadRefusal.d.ts +9 -0
  190. package/dist/shared/wire/LambderUploadRefusal.js +18 -0
  191. package/dist/shared/wire/LambderUploadSchemas.d.ts +12 -0
  192. package/dist/shared/wire/LambderUploadSchemas.js +30 -0
  193. package/dist/stores/LambderCacheFiller.d.ts +48 -0
  194. package/dist/stores/LambderCacheFiller.js +119 -0
  195. package/dist/stores/LambderCacheKeys.d.ts +26 -0
  196. package/dist/stores/LambderCacheKeys.js +54 -0
  197. package/dist/stores/LambderCacheValues.d.ts +45 -0
  198. package/dist/stores/LambderCacheValues.js +74 -0
  199. package/dist/stores/LambderDdbCache.d.ts +121 -56
  200. package/dist/stores/LambderDdbCache.js +528 -225
  201. package/dist/stores/LambderDdbIdempotencyStore.d.ts +33 -22
  202. package/dist/stores/LambderDdbIdempotencyStore.js +75 -50
  203. package/dist/stores/LambderDdbRateLimiter.d.ts +76 -20
  204. package/dist/stores/LambderDdbRateLimiter.js +151 -39
  205. package/dist/stores/LambderDdbSdk.d.ts +43 -31
  206. package/dist/stores/LambderDdbSdk.js +80 -38
  207. package/dist/stores/LambderDdbSessionStore.d.ts +27 -14
  208. package/dist/stores/LambderDdbSessionStore.js +119 -47
  209. package/dist/stores/LambderHttpFileSource.d.ts +15 -6
  210. package/dist/stores/LambderHttpFileSource.js +15 -13
  211. package/dist/stores/LambderMemoryCache.d.ts +49 -0
  212. package/dist/stores/LambderMemoryCache.js +113 -0
  213. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +13 -12
  214. package/dist/stores/LambderMemoryIdempotencyStore.js +31 -30
  215. package/dist/stores/LambderMemoryRateLimiter.d.ts +8 -9
  216. package/dist/stores/LambderMemoryRateLimiter.js +14 -13
  217. package/dist/stores/LambderMemorySessionStore.d.ts +14 -11
  218. package/dist/stores/LambderMemorySessionStore.js +38 -19
  219. package/dist/stores/LambderMemoryUploadBucket.d.ts +99 -0
  220. package/dist/stores/LambderMemoryUploadBucket.js +219 -0
  221. package/dist/stores/LambderS3FileSource.d.ts +21 -6
  222. package/dist/stores/LambderS3FileSource.js +12 -7
  223. package/dist/stores/LambderS3UploadBucket.d.ts +73 -0
  224. package/dist/stores/LambderS3UploadBucket.js +144 -0
  225. package/dist/stores/LambderSdkInstallHint.d.ts +11 -0
  226. package/dist/stores/LambderSdkInstallHint.js +14 -0
  227. package/dist/testing/LambderTestApp.d.ts +23 -25
  228. package/dist/testing/LambderTestApp.js +22 -24
  229. package/dist/testing/LambderTestVisitor.d.ts +10 -12
  230. package/dist/testing/LambderTestVisitor.js +15 -15
  231. package/dist/testing.d.ts +3 -0
  232. package/dist/testing.js +2 -0
  233. package/package.json +26 -3
  234. package/dist/api/LambderApiPolicyEngine.d.ts +0 -47
  235. package/dist/api/LambderApiPolicyEngine.js +0 -85
  236. package/dist/shared/util/LambderKeyFields.d.ts +0 -32
  237. package/dist/shared/util/LambderKeyFields.js +0 -34
@@ -0,0 +1,219 @@
1
+ import { assertObjectOptions, assertPinnedObjectKey, assertSignatureLifetime, } from "../shared/contracts/LambderUploadBucket.js";
2
+ import { contentDispositionHeader } from "../shared/util/LambderContentDisposition.js";
3
+ import { uploadObjectFormFields } from "../shared/wire/LambderUploadObjectFields.js";
4
+ import { refuseUnacceptedUpload } from "../shared/wire/LambderUploadRefusal.js";
5
+ import { sha256Base64Of } from "../shared/util/LambderTextDigest.js";
6
+ /** The form field that names the ticket a post was signed with: the memory bucket's stand-in for S3's signed policy. */
7
+ const TICKET_FIELD = "x-lambder-upload-ticket";
8
+ /** The query parameter that names the link a download was issued with: the stand-in for S3's presigned query string. */
9
+ const LINK_PARAMETER = "x-lambder-download-link";
10
+ /**
11
+ * An upload bucket in memory (see LambderUploadBucket), for tests and the
12
+ * mock runtime.
13
+ *
14
+ * It holds a post to the rules S3 holds a presigned POST to: every field the
15
+ * ticket carries, each with the ticket's value, and no other, all ahead of
16
+ * the file (as S3 does, anything after the file is ignored); a ticket it
17
+ * issued and not yet expired; a body of exactly the size described; and bytes
18
+ * whose SHA-256 is the one described. It refuses otherwise with the status
19
+ * and the XML error S3 answers with (AccessDenied for a policy, and "Policy
20
+ * expired" for a late one, EntityTooSmall, EntityTooLarge, BadDigest), so any
21
+ * client, a LambderUploadRunner or another, takes the same path against it as
22
+ * against S3: an expired ticket is asked for again, a wrong file is refused.
23
+ * A download link reads the object until it expires.
24
+ *
25
+ * Storage requests reach it through handleStorageRequest(), which answers a
26
+ * fetch Request with a Response: lambderMockUploadMswHandler plugs that into
27
+ * MSW, and a test can route a stubbed fetch to it directly. Everything it
28
+ * uses is a web API (fetch's Request and Response, FormData, WebCrypto), so it
29
+ * runs in a browser, a service worker and Node alike.
30
+ */
31
+ export class LambderMemoryUploadBucket {
32
+ /** Where tickets and download links point, always ending in a slash. */
33
+ baseUrl;
34
+ ticketLifetimeSeconds;
35
+ downloadLifetimeSeconds;
36
+ now;
37
+ objects = new Map();
38
+ tickets = new Map();
39
+ links = new Map();
40
+ constructor({ baseUrl, ticketLifetimeSeconds = 600, downloadLifetimeSeconds = 300, now = Date.now } = {}) {
41
+ assertSignatureLifetime(ticketLifetimeSeconds, "ticketLifetimeSeconds");
42
+ assertSignatureLifetime(downloadLifetimeSeconds, "downloadLifetimeSeconds");
43
+ const url = new URL(baseUrl ?? `https://upload-bucket-${crypto.randomUUID()}.invalid/`);
44
+ url.search = "";
45
+ url.hash = "";
46
+ this.baseUrl = url.href.endsWith("/") ? url.href : `${url.href}/`;
47
+ this.ticketLifetimeSeconds = ticketLifetimeSeconds;
48
+ this.downloadLifetimeSeconds = downloadLifetimeSeconds;
49
+ this.now = now;
50
+ }
51
+ async issueUploadTicket({ objectKey, fileFacts, uploadRule, lifetimeSeconds = this.ticketLifetimeSeconds, object = {} }) {
52
+ assertPinnedObjectKey(objectKey);
53
+ assertSignatureLifetime(lifetimeSeconds, "lifetimeSeconds");
54
+ assertObjectOptions(object);
55
+ refuseUnacceptedUpload(uploadRule, fileFacts);
56
+ const ticketId = crypto.randomUUID();
57
+ const expiresAt = this.now() + lifetimeSeconds * 1000;
58
+ // The fields S3's ticket carries, so a client posts the same form to either.
59
+ const formFields = {
60
+ key: objectKey,
61
+ "Content-Type": fileFacts.mimeType,
62
+ "x-amz-checksum-algorithm": "SHA256",
63
+ "x-amz-checksum-sha256": fileFacts.sha256Base64,
64
+ ...uploadObjectFormFields(object),
65
+ [TICKET_FIELD]: ticketId,
66
+ };
67
+ this.tickets.set(ticketId, { objectKey, mimeType: fileFacts.mimeType, byteSize: fileFacts.byteSize, sha256Base64: fileFacts.sha256Base64, expiresAt, formFields, object });
68
+ return { uploadUrl: this.baseUrl, formFields, expiresAt };
69
+ }
70
+ async verifyUploadedObject({ objectKey, fileFacts }) {
71
+ const object = this.objects.get(objectKey);
72
+ if (!object)
73
+ return { verified: false, reason: "objectMissing" };
74
+ const matches = object.body.byteLength === fileFacts.byteSize && object.sha256Base64 === fileFacts.sha256Base64;
75
+ return matches ? { verified: true } : { verified: false, reason: "factsMismatch" };
76
+ }
77
+ async issueDownloadUrl({ objectKey, lifetimeSeconds = this.downloadLifetimeSeconds, contentDisposition }) {
78
+ assertSignatureLifetime(lifetimeSeconds, "lifetimeSeconds");
79
+ const linkId = crypto.randomUUID();
80
+ this.links.set(linkId, { objectKey, expiresAt: this.now() + lifetimeSeconds * 1000, contentDisposition });
81
+ // Appended rather than resolved against the base, so a key that starts
82
+ // with a slash stays under it.
83
+ const url = new URL(`${this.baseUrl}${encodeObjectKey(objectKey)}`);
84
+ url.searchParams.set(LINK_PARAMETER, linkId);
85
+ return url.href;
86
+ }
87
+ async readObject(objectKey) {
88
+ const object = this.objects.get(objectKey);
89
+ if (!object)
90
+ throw new Error(`LambderMemoryUploadBucket.readObject: nothing is stored under ${objectKey}`);
91
+ return object.body.slice();
92
+ }
93
+ async writeObject({ objectKey, body, mimeType, sha256Base64, object = {} }) {
94
+ assertObjectOptions(object);
95
+ const computed = await sha256Base64Of(body);
96
+ // S3 refuses a body whose checksum is not the one sent (BadDigest), and so does this.
97
+ if (sha256Base64 !== undefined && sha256Base64 !== computed) {
98
+ throw new Error(`LambderMemoryUploadBucket.writeObject: the SHA-256 given for ${objectKey} is not the body's`);
99
+ }
100
+ this.objects.set(objectKey, { body: body.slice(), mimeType, sha256Base64: computed, object });
101
+ }
102
+ async copyObject({ fromObjectKey, toObjectKey }) {
103
+ const object = this.objects.get(fromObjectKey);
104
+ if (!object)
105
+ throw new Error(`LambderMemoryUploadBucket.copyObject: nothing is stored under ${fromObjectKey}`);
106
+ this.objects.set(toObjectKey, { ...object, body: object.body.slice() });
107
+ }
108
+ async deleteObject(objectKey) {
109
+ this.objects.delete(objectKey);
110
+ }
111
+ /** The keys that hold an object, sorted, for a test to assert on. */
112
+ listObjectKeys() {
113
+ return [...this.objects.keys()].sort();
114
+ }
115
+ /** What is held under a key (its facts, tags, metadata and headers), for a test to assert on; null when nothing is. */
116
+ inspectObject(objectKey) {
117
+ const stored = this.objects.get(objectKey);
118
+ return stored ? { byteSize: stored.body.byteLength, mimeType: stored.mimeType, sha256Base64: stored.sha256Base64, ...stored.object } : null;
119
+ }
120
+ /** Forgets every object, ticket and link. */
121
+ reset() {
122
+ this.objects.clear();
123
+ this.tickets.clear();
124
+ this.links.clear();
125
+ }
126
+ /**
127
+ * Answers a request to storage the way S3 answers it: a post under a
128
+ * ticket stores its file, a GET or HEAD through a download link reads an
129
+ * object. A request outside baseUrl answers null, for the caller to hand
130
+ * on.
131
+ */
132
+ async handleStorageRequest(request) {
133
+ const url = new URL(request.url);
134
+ if (!`${url.origin}${url.pathname}`.startsWith(this.baseUrl))
135
+ return null;
136
+ let objectKey;
137
+ try {
138
+ objectKey = decodeObjectKey(url.pathname.slice(new URL(this.baseUrl).pathname.length));
139
+ }
140
+ catch {
141
+ return storageError(400, "InvalidURI", "Couldn't parse the specified URI.");
142
+ }
143
+ if (request.method === "POST" && objectKey === "")
144
+ return this.acceptUpload(request);
145
+ if (request.method === "GET" || request.method === "HEAD")
146
+ return this.serveDownload(objectKey, url.searchParams.get(LINK_PARAMETER), request.method === "HEAD");
147
+ return storageError(405, "MethodNotAllowed", "The specified method is not allowed against this resource.");
148
+ }
149
+ async acceptUpload(request) {
150
+ let form;
151
+ try {
152
+ form = await request.formData();
153
+ }
154
+ catch {
155
+ return storageError(400, "MalformedPOSTRequest", "The body of your POST request is not well-formed multipart/form-data.");
156
+ }
157
+ // S3 reads the form up to the file and ignores everything after it.
158
+ const fields = new Map();
159
+ let file;
160
+ for (const [name, value] of form.entries()) {
161
+ if (typeof value !== "string") {
162
+ if (name === "file") {
163
+ file = value;
164
+ break;
165
+ }
166
+ continue;
167
+ }
168
+ fields.set(name, value);
169
+ }
170
+ if (!file)
171
+ return storageError(400, "InvalidArgument", "POST requires exactly one file upload per request.");
172
+ const ticketId = fields.get(TICKET_FIELD);
173
+ const ticket = ticketId === undefined ? undefined : this.tickets.get(ticketId);
174
+ if (!ticket)
175
+ return storageError(403, "AccessDenied", "Invalid according to Policy: Policy Condition failed");
176
+ if (this.now() >= ticket.expiresAt)
177
+ return storageError(403, "AccessDenied", "Invalid according to Policy: Policy expired.");
178
+ const extra = [...fields.keys()].filter((name) => !(name in ticket.formFields));
179
+ if (extra.length)
180
+ return storageError(403, "AccessDenied", `Invalid according to Policy: Extra input fields: ${extra.join(", ")}`);
181
+ const pinned = Object.entries(ticket.formFields).every(([name, value]) => fields.get(name) === value);
182
+ if (!pinned)
183
+ return storageError(403, "AccessDenied", "Invalid according to Policy: Policy Condition failed");
184
+ const body = new Uint8Array(await file.arrayBuffer());
185
+ if (body.byteLength < ticket.byteSize)
186
+ return storageError(400, "EntityTooSmall", "Your proposed upload is smaller than the minimum allowed size");
187
+ if (body.byteLength > ticket.byteSize)
188
+ return storageError(400, "EntityTooLarge", "Your proposed upload exceeds the maximum allowed size");
189
+ const sha256Base64 = await sha256Base64Of(body);
190
+ if (sha256Base64 !== ticket.sha256Base64)
191
+ return storageError(400, "BadDigest", "The SHA256 you specified did not match the calculated checksum.");
192
+ this.objects.set(ticket.objectKey, { body, mimeType: ticket.mimeType, sha256Base64, object: ticket.object });
193
+ return new Response(null, { status: 204 });
194
+ }
195
+ serveDownload(objectKey, linkId, headOnly) {
196
+ const link = linkId === null ? undefined : this.links.get(linkId);
197
+ if (!link || link.objectKey !== objectKey)
198
+ return storageError(403, "AccessDenied", "Access Denied");
199
+ if (this.now() >= link.expiresAt)
200
+ return storageError(403, "AccessDenied", "Request has expired");
201
+ const stored = this.objects.get(objectKey);
202
+ if (!stored)
203
+ return storageError(404, "NoSuchKey", "The specified key does not exist.");
204
+ const headers = { "content-type": stored.mimeType, "content-length": String(stored.body.byteLength) };
205
+ if (stored.object.cacheControl !== undefined)
206
+ headers["cache-control"] = stored.object.cacheControl;
207
+ // The link's own disposition over the object's, as S3 answers a presigned read.
208
+ const disposition = link.contentDisposition ?? stored.object.contentDisposition;
209
+ if (disposition)
210
+ headers["content-disposition"] = contentDispositionHeader(disposition);
211
+ return new Response(headOnly ? null : new Blob([stored.body]), { status: 200, headers });
212
+ }
213
+ }
214
+ /** A key as a URL path: each segment escaped, the slashes kept, as S3 addresses an object. */
215
+ const encodeObjectKey = (objectKey) => objectKey.split("/").map(encodeURIComponent).join("/");
216
+ const decodeObjectKey = (path) => path.split("/").map(decodeURIComponent).join("/");
217
+ const escapeXml = (text) => text.replace(/[<>&'"]/g, (character) => `&#${character.charCodeAt(0)};`);
218
+ /** An error the way S3 writes one: the status, and `<Error><Code/><Message/></Error>` as XML. */
219
+ const storageError = (status, code, message) => new Response(`<?xml version="1.0" encoding="UTF-8"?>\n<Error><Code>${escapeXml(code)}</Code><Message>${escapeXml(message)}</Message></Error>`, { status, headers: { "content-type": "application/xml" } });
@@ -12,24 +12,39 @@ export type LambderS3FileSourceOptions = {
12
12
  * `{ region: "auto", endpoint, credentials }`.
13
13
  */
14
14
  clientConfig?: S3ClientConfig;
15
+ /**
16
+ * The S3 error names that mean "no such file", read as null so the
17
+ * request falls through. Default: NoSuchKey and NotFound (the key does
18
+ * not exist), AccessDenied (what S3 answers a reader without
19
+ * s3:ListBucket for a missing key, since it will not say whether the key
20
+ * exists), and the refusals of a key S3 will not look up at all:
21
+ * KeyTooLongError and InvalidURI from S3, InvalidObjectName from R2. A
22
+ * reader granted s3:ListBucket gets NoSuchKey for a missing key, so a
23
+ * list without AccessDenied makes a refused credential surface as the
24
+ * error it is.
25
+ */
26
+ notFoundErrorNames?: readonly string[];
15
27
  };
16
28
  /**
17
29
  * Files from an S3 bucket, or any S3-compatible store such as Cloudflare
18
30
  * R2 (pass its endpoint in clientConfig). Needs @aws-sdk/client-s3, an
19
31
  * optional peer dependency loaded on first read, so apps that serve from a
20
- * folder never load it. A missing object reads as null and the request
21
- * falls through; grant s3:ListBucket besides s3:GetObject, otherwise S3
22
- * answers a missing key with AccessDenied, which propagates as an error.
23
- * An object's Content-Type is used unless it is a generic octet-stream, in
24
- * which case the extension decides, as for local files.
32
+ * folder never load it. A missing object (see `notFoundErrorNames`) reads as
33
+ * null and the request falls through; any other failure throws. A request
34
+ * path names the key, so a key that is missing or that S3 refuses to look
35
+ * up is the visitor's doing, and reading it as an error would answer every
36
+ * such path with a 500 before an SPA's shell could be served. An object's
37
+ * Content-Type is used unless it is a generic octet-stream, in which case
38
+ * the extension decides, as for local files.
25
39
  */
26
40
  export declare class LambderS3FileSource implements LambderFileSource {
27
41
  private readonly bucket;
28
42
  private readonly prefix;
29
43
  private readonly clientConfig;
44
+ private readonly notFoundErrorNames;
30
45
  private client;
31
46
  private sdk;
32
- constructor({ bucket, prefix, client, clientConfig }: LambderS3FileSourceOptions);
47
+ constructor({ bucket, prefix, client, clientConfig, notFoundErrorNames }: LambderS3FileSourceOptions);
33
48
  private loadSdk;
34
49
  read(relativePath: string): Promise<LambderFile | null>;
35
50
  }
@@ -1,27 +1,32 @@
1
1
  import { remoteStoreFile } from "../shared/contracts/LambderFileSource.js";
2
+ const DEFAULT_NOT_FOUND_ERROR_NAMES = ["NoSuchKey", "NotFound", "AccessDenied", "KeyTooLongError", "InvalidURI", "InvalidObjectName"];
2
3
  /**
3
4
  * Files from an S3 bucket, or any S3-compatible store such as Cloudflare
4
5
  * R2 (pass its endpoint in clientConfig). Needs @aws-sdk/client-s3, an
5
6
  * optional peer dependency loaded on first read, so apps that serve from a
6
- * folder never load it. A missing object reads as null and the request
7
- * falls through; grant s3:ListBucket besides s3:GetObject, otherwise S3
8
- * answers a missing key with AccessDenied, which propagates as an error.
9
- * An object's Content-Type is used unless it is a generic octet-stream, in
10
- * which case the extension decides, as for local files.
7
+ * folder never load it. A missing object (see `notFoundErrorNames`) reads as
8
+ * null and the request falls through; any other failure throws. A request
9
+ * path names the key, so a key that is missing or that S3 refuses to look
10
+ * up is the visitor's doing, and reading it as an error would answer every
11
+ * such path with a 500 before an SPA's shell could be served. An object's
12
+ * Content-Type is used unless it is a generic octet-stream, in which case
13
+ * the extension decides, as for local files.
11
14
  */
12
15
  export class LambderS3FileSource {
13
16
  bucket;
14
17
  prefix;
15
18
  clientConfig;
19
+ notFoundErrorNames;
16
20
  client;
17
21
  sdk;
18
- constructor({ bucket, prefix = "", client, clientConfig }) {
22
+ constructor({ bucket, prefix = "", client, clientConfig, notFoundErrorNames = DEFAULT_NOT_FOUND_ERROR_NAMES }) {
19
23
  if (!bucket.trim())
20
24
  throw new Error("bucket is required");
21
25
  this.bucket = bucket;
22
26
  this.prefix = prefix;
23
27
  this.client = client;
24
28
  this.clientConfig = clientConfig;
29
+ this.notFoundErrorNames = notFoundErrorNames;
25
30
  }
26
31
  loadSdk() {
27
32
  if (!this.sdk) {
@@ -41,7 +46,7 @@ export class LambderS3FileSource {
41
46
  }
42
47
  catch (err) {
43
48
  const name = err.name;
44
- if (name === "NoSuchKey" || name === "NotFound")
49
+ if (typeof name === "string" && this.notFoundErrorNames.includes(name))
45
50
  return null;
46
51
  throw err;
47
52
  }
@@ -0,0 +1,73 @@
1
+ import type { S3Client, S3ClientConfig } from "@aws-sdk/client-s3";
2
+ import { type LambderUploadBucket, type LambderUploadContentDisposition, type LambderUploadFileFacts, type LambderUploadObjectOptions, type LambderUploadRule, type LambderUploadTicket, type LambderUploadVerdict } from "../shared/contracts/LambderUploadBucket.js";
3
+ export type LambderS3UploadBucketOptions = {
4
+ bucket: string;
5
+ /** A ready client, e.g. one shared with the rest of the app. */
6
+ client?: S3Client;
7
+ /** Otherwise the client is created from this on first use: `{ region }`. */
8
+ clientConfig?: S3ClientConfig;
9
+ /** How long a ticket stays usable, unless a ticket says otherwise. Default: 600 seconds, enough for a large file on a slow phone. */
10
+ ticketLifetimeSeconds?: number;
11
+ /** How long a download link reads the object, unless a link says otherwise. Default: 300 seconds. */
12
+ downloadLifetimeSeconds?: number;
13
+ };
14
+ /**
15
+ * An S3 bucket browsers upload to directly (see LambderUploadBucket).
16
+ *
17
+ * A ticket is an S3 presigned POST whose policy pins the key, the content
18
+ * type, the exact byte size and the SHA-256 checksum, so S3 itself refuses
19
+ * any other file. That needs S3's POST policies with checksum fields: S3, or
20
+ * a store that implements them; Cloudflare R2 does not take presigned POSTs.
21
+ *
22
+ * Signing a ticket or a download link is arithmetic over the function's
23
+ * credentials and reaches nothing; verifying, reading, writing, copying and
24
+ * deleting are calls to the bucket. A signature lives at most seven days,
25
+ * and never past the credentials that made it: a Lambda's role credentials
26
+ * last hours, so a link meant to outlive them needs long-lived keys. Needs @aws-sdk/client-s3,
27
+ * @aws-sdk/s3-presigned-post and @aws-sdk/s3-request-presigner, optional peer
28
+ * dependencies each loaded the first time a call needs it, so an app that
29
+ * never uploads never loads them.
30
+ */
31
+ export declare class LambderS3UploadBucket implements LambderUploadBucket {
32
+ private readonly bucket;
33
+ private readonly ticketLifetimeSeconds;
34
+ private readonly downloadLifetimeSeconds;
35
+ private readonly clientConfig;
36
+ private client;
37
+ private clientSdk;
38
+ private presignedPostSdk;
39
+ private requestPresignerSdk;
40
+ constructor({ bucket, client, clientConfig, ticketLifetimeSeconds, downloadLifetimeSeconds }: LambderS3UploadBucketOptions);
41
+ issueUploadTicket({ objectKey, fileFacts, uploadRule, lifetimeSeconds, object }: {
42
+ objectKey: string;
43
+ fileFacts: LambderUploadFileFacts;
44
+ uploadRule: LambderUploadRule;
45
+ lifetimeSeconds?: number;
46
+ object?: LambderUploadObjectOptions;
47
+ }): Promise<LambderUploadTicket>;
48
+ verifyUploadedObject({ objectKey, fileFacts }: {
49
+ objectKey: string;
50
+ fileFacts: Pick<LambderUploadFileFacts, "byteSize" | "sha256Base64">;
51
+ }): Promise<LambderUploadVerdict>;
52
+ issueDownloadUrl({ objectKey, lifetimeSeconds, contentDisposition }: {
53
+ objectKey: string;
54
+ lifetimeSeconds?: number;
55
+ contentDisposition?: LambderUploadContentDisposition;
56
+ }): Promise<string>;
57
+ readObject(objectKey: string): Promise<Uint8Array>;
58
+ writeObject({ objectKey, body, mimeType, sha256Base64, object }: {
59
+ objectKey: string;
60
+ body: Uint8Array;
61
+ mimeType: string;
62
+ sha256Base64?: string;
63
+ object?: LambderUploadObjectOptions;
64
+ }): Promise<void>;
65
+ copyObject({ fromObjectKey, toObjectKey }: {
66
+ fromObjectKey: string;
67
+ toObjectKey: string;
68
+ }): Promise<void>;
69
+ deleteObject(objectKey: string): Promise<void>;
70
+ private s3;
71
+ private loadPresignedPostSdk;
72
+ private loadRequestPresignerSdk;
73
+ }
@@ -0,0 +1,144 @@
1
+ import { assertObjectOptions, assertPinnedObjectKey, assertSignatureLifetime, } from "../shared/contracts/LambderUploadBucket.js";
2
+ import { contentDispositionHeader } from "../shared/util/LambderContentDisposition.js";
3
+ import { uploadObjectFormFields } from "../shared/wire/LambderUploadObjectFields.js";
4
+ import { refuseUnacceptedUpload } from "../shared/wire/LambderUploadRefusal.js";
5
+ import { withInstallHint } from "./LambderSdkInstallHint.js";
6
+ /** The S3 error names that mean nothing is stored under the key: HeadObject answers NotFound, the other calls NoSuchKey. */
7
+ const MISSING_OBJECT_ERROR_NAMES = ["NotFound", "NoSuchKey"];
8
+ /**
9
+ * An S3 bucket browsers upload to directly (see LambderUploadBucket).
10
+ *
11
+ * A ticket is an S3 presigned POST whose policy pins the key, the content
12
+ * type, the exact byte size and the SHA-256 checksum, so S3 itself refuses
13
+ * any other file. That needs S3's POST policies with checksum fields: S3, or
14
+ * a store that implements them; Cloudflare R2 does not take presigned POSTs.
15
+ *
16
+ * Signing a ticket or a download link is arithmetic over the function's
17
+ * credentials and reaches nothing; verifying, reading, writing, copying and
18
+ * deleting are calls to the bucket. A signature lives at most seven days,
19
+ * and never past the credentials that made it: a Lambda's role credentials
20
+ * last hours, so a link meant to outlive them needs long-lived keys. Needs @aws-sdk/client-s3,
21
+ * @aws-sdk/s3-presigned-post and @aws-sdk/s3-request-presigner, optional peer
22
+ * dependencies each loaded the first time a call needs it, so an app that
23
+ * never uploads never loads them.
24
+ */
25
+ export class LambderS3UploadBucket {
26
+ bucket;
27
+ ticketLifetimeSeconds;
28
+ downloadLifetimeSeconds;
29
+ clientConfig;
30
+ client;
31
+ clientSdk;
32
+ presignedPostSdk;
33
+ requestPresignerSdk;
34
+ constructor({ bucket, client, clientConfig, ticketLifetimeSeconds = 600, downloadLifetimeSeconds = 300 }) {
35
+ if (!bucket.trim())
36
+ throw new Error("bucket is required");
37
+ assertSignatureLifetime(ticketLifetimeSeconds, "ticketLifetimeSeconds");
38
+ assertSignatureLifetime(downloadLifetimeSeconds, "downloadLifetimeSeconds");
39
+ this.bucket = bucket;
40
+ this.client = client;
41
+ this.clientConfig = clientConfig;
42
+ this.ticketLifetimeSeconds = ticketLifetimeSeconds;
43
+ this.downloadLifetimeSeconds = downloadLifetimeSeconds;
44
+ }
45
+ async issueUploadTicket({ objectKey, fileFacts, uploadRule, lifetimeSeconds = this.ticketLifetimeSeconds, object }) {
46
+ assertPinnedObjectKey(objectKey);
47
+ assertSignatureLifetime(lifetimeSeconds, "lifetimeSeconds");
48
+ assertObjectOptions(object);
49
+ refuseUnacceptedUpload(uploadRule, fileFacts);
50
+ const [{ client }, { createPresignedPost }] = await Promise.all([this.s3(), this.loadPresignedPostSdk()]);
51
+ const post = await createPresignedPost(client, {
52
+ Bucket: this.bucket,
53
+ Key: objectKey,
54
+ Expires: lifetimeSeconds,
55
+ // Each field is also an exact-match condition of the signed policy.
56
+ Fields: {
57
+ "Content-Type": fileFacts.mimeType,
58
+ "x-amz-checksum-algorithm": "SHA256",
59
+ "x-amz-checksum-sha256": fileFacts.sha256Base64,
60
+ ...uploadObjectFormFields(object),
61
+ },
62
+ Conditions: [["content-length-range", fileFacts.byteSize, fileFacts.byteSize]],
63
+ });
64
+ return { uploadUrl: post.url, formFields: post.fields, expiresAt: Date.now() + lifetimeSeconds * 1000 };
65
+ }
66
+ async verifyUploadedObject({ objectKey, fileFacts }) {
67
+ const { sdk, client } = await this.s3();
68
+ let head;
69
+ try {
70
+ head = await client.send(new sdk.HeadObjectCommand({ Bucket: this.bucket, Key: objectKey, ChecksumMode: "ENABLED" }));
71
+ }
72
+ catch (err) {
73
+ if (MISSING_OBJECT_ERROR_NAMES.includes(err.name ?? ""))
74
+ return { verified: false, reason: "objectMissing" };
75
+ throw err;
76
+ }
77
+ const matches = head.ContentLength === fileFacts.byteSize && head.ChecksumSHA256 === fileFacts.sha256Base64;
78
+ return matches ? { verified: true } : { verified: false, reason: "factsMismatch" };
79
+ }
80
+ async issueDownloadUrl({ objectKey, lifetimeSeconds = this.downloadLifetimeSeconds, contentDisposition }) {
81
+ assertSignatureLifetime(lifetimeSeconds, "lifetimeSeconds");
82
+ const [{ sdk, client }, { getSignedUrl }] = await Promise.all([this.s3(), this.loadRequestPresignerSdk()]);
83
+ return getSignedUrl(client, new sdk.GetObjectCommand({
84
+ Bucket: this.bucket,
85
+ Key: objectKey,
86
+ // Signed into the link: S3 answers with this header for its reads alone.
87
+ ...(contentDisposition ? { ResponseContentDisposition: contentDispositionHeader(contentDisposition) } : {}),
88
+ }), { expiresIn: lifetimeSeconds });
89
+ }
90
+ async readObject(objectKey) {
91
+ const { sdk, client } = await this.s3();
92
+ const object = await client.send(new sdk.GetObjectCommand({ Bucket: this.bucket, Key: objectKey }));
93
+ if (!object.Body)
94
+ throw new Error(`LambderS3UploadBucket.readObject: no body under ${objectKey}`);
95
+ return object.Body.transformToByteArray();
96
+ }
97
+ async writeObject({ objectKey, body, mimeType, sha256Base64, object }) {
98
+ assertObjectOptions(object);
99
+ const { sdk, client } = await this.s3();
100
+ const tags = Object.entries(object?.tags ?? {});
101
+ const metadata = Object.entries(object?.metadata ?? {});
102
+ await client.send(new sdk.PutObjectCommand({
103
+ Bucket: this.bucket,
104
+ Key: objectKey,
105
+ Body: body,
106
+ ContentType: mimeType,
107
+ ...(tags.length ? { Tagging: new URLSearchParams(tags).toString() } : {}),
108
+ ...(metadata.length ? { Metadata: Object.fromEntries(metadata.map(([name, value]) => [name.toLowerCase(), value])) } : {}),
109
+ ...(object?.cacheControl !== undefined ? { CacheControl: object.cacheControl } : {}),
110
+ ...(object?.contentDisposition ? { ContentDisposition: contentDispositionHeader(object.contentDisposition) } : {}),
111
+ // A digest the caller already has is sent as it is; otherwise the
112
+ // SDK computes it. Either way S3 checks the body against it and
113
+ // stores it, which is what verifyUploadedObject reads back.
114
+ ...(sha256Base64 !== undefined ? { ChecksumSHA256: sha256Base64 } : { ChecksumAlgorithm: "SHA256" }),
115
+ }));
116
+ }
117
+ async copyObject({ fromObjectKey, toObjectKey }) {
118
+ const { sdk, client } = await this.s3();
119
+ await client.send(new sdk.CopyObjectCommand({
120
+ Bucket: this.bucket,
121
+ // The source is named as a URL path, so a key's own characters are escaped and its slashes kept.
122
+ CopySource: `${this.bucket}/${fromObjectKey.split("/").map(encodeURIComponent).join("/")}`,
123
+ Key: toObjectKey,
124
+ }));
125
+ }
126
+ async deleteObject(objectKey) {
127
+ const { sdk, client } = await this.s3();
128
+ await client.send(new sdk.DeleteObjectCommand({ Bucket: this.bucket, Key: objectKey }));
129
+ }
130
+ async s3() {
131
+ this.clientSdk ??= withInstallHint(import("@aws-sdk/client-s3"), "@aws-sdk/client-s3", "LambderS3UploadBucket", () => { this.clientSdk = undefined; });
132
+ const sdk = await this.clientSdk;
133
+ this.client ??= new sdk.S3Client(this.clientConfig ?? {});
134
+ return { sdk, client: this.client };
135
+ }
136
+ loadPresignedPostSdk() {
137
+ this.presignedPostSdk ??= withInstallHint(import("@aws-sdk/s3-presigned-post"), "@aws-sdk/s3-presigned-post", "LambderS3UploadBucket", () => { this.presignedPostSdk = undefined; });
138
+ return this.presignedPostSdk;
139
+ }
140
+ loadRequestPresignerSdk() {
141
+ this.requestPresignerSdk ??= withInstallHint(import("@aws-sdk/s3-request-presigner"), "@aws-sdk/s3-request-presigner", "LambderS3UploadBucket", () => { this.requestPresignerSdk = undefined; });
142
+ return this.requestPresignerSdk;
143
+ }
144
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * An optional peer package loaded on first use, failing with the install hint.
3
+ *
4
+ * The AWS SDK packages a store or bucket talks through are optional peer
5
+ * dependencies, so an app that never uses one neither installs it nor pays
6
+ * for loading it. A package that fails to load fails that first call with a
7
+ * message naming the class that needed it and the command that installs it.
8
+ * The failure is not remembered: the package may be installed later in the
9
+ * same process (tests do), and the next caller names itself.
10
+ */
11
+ export declare const withInstallHint: <T>(loading: Promise<T>, packageName: string, user: string, reset: () => void) => Promise<T>;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * An optional peer package loaded on first use, failing with the install hint.
3
+ *
4
+ * The AWS SDK packages a store or bucket talks through are optional peer
5
+ * dependencies, so an app that never uses one neither installs it nor pays
6
+ * for loading it. A package that fails to load fails that first call with a
7
+ * message naming the class that needed it and the command that installs it.
8
+ * The failure is not remembered: the package may be installed later in the
9
+ * same process (tests do), and the next caller names itself.
10
+ */
11
+ export const withInstallHint = (loading, packageName, user, reset) => loading.catch((cause) => {
12
+ reset();
13
+ throw new Error(`${user} requires ${packageName}: npm install ${packageName}`, { cause });
14
+ });
@@ -23,9 +23,9 @@ export type LambderTestAppOptions = {
23
23
  /**
24
24
  * The gateway shape the handler is called with: "v2" (an HTTP API, a
25
25
  * Function URL) or "v1" (a REST API). Default: "v2". A handler answers
26
- * both alike through its context, so this matters to code that reads the
27
- * raw `ctx.event`, and to anyone who wants the suite to run on exactly
28
- * what production delivers.
26
+ * both alike through its context, so this matters only to code that reads
27
+ * the raw `ctx.event`, or to a suite that should run on exactly what
28
+ * production delivers.
29
29
  */
30
30
  eventFormat?: LambderHttpEventFormat;
31
31
  /** Default: a fresh LambderMemorySessionStore. Pass your own to run the suite over another store (DynamoDB Local, say). */
@@ -46,10 +46,10 @@ export type LambderTestAppOptions = {
46
46
  /**
47
47
  * A Lambder instance as a test app takes it: any instance, read for its
48
48
  * session data type and, through the ApiContract property rather than the
49
- * class parameter, for its contract. The property is what lets a large app
50
- * name its flattened contract interface explicitly
51
- * (`lambderTestApp<SessionData, ApiContractType>(lambder)`) and keep the
52
- * cheap type check that interface exists for.
49
+ * class parameter, for its contract. The property lets a large app name the
50
+ * contract writeApiContract generated for its clients
51
+ * (`lambderTestApp<SessionData, ApiContractType>(lambder)`), so the tests type
52
+ * their calls against plain members rather than the chained intersection.
53
53
  */
54
54
  export type LambderTestedInstance<TSessionData, TContract> = Lambder<TSessionData, any, any, any, any, any, any, any> & {
55
55
  readonly ApiContract: TContract;
@@ -59,17 +59,17 @@ export type LambderTestedInstance<TSessionData, TContract> = Lambder<TSessionDat
59
59
  * stores put under it in place, and as many simulated browsers in front of it
60
60
  * as a test needs. No HTTP, no AWS, and nothing in the app restructured.
61
61
  *
62
- * The app's own declarations all run as written: its guards, its named
63
- * rate-limit policies, its idempotency settings, its session salt, cookie
64
- * options and dataRefresh, its hooks and error handlers. Only where things
65
- * rest is replaced, and from the moment this is created the stores the app
66
- * was configured with are out of the instance's reach, so a test cannot touch
67
- * a production table even by mistake. What the app reaches on its own (its
68
- * database, a mailer) is the app's to replace.
62
+ * The app's own declarations all run as written: guards, named rate-limit
63
+ * policies, idempotency settings, session salt, cookie options and
64
+ * dataRefresh, hooks and error handlers. Only where things rest is replaced:
65
+ * once this is created, the stores the app was configured with are out of the
66
+ * instance's reach, so a test cannot touch a production table by mistake.
67
+ * What the app reaches on its own (its database, a mailer) is the app's to
68
+ * replace.
69
69
  *
70
70
  * The sibling of LambderMockApp, which serves a contract from mock handlers:
71
71
  * the same verbs (`signIn`, `signOut`, `expireSessionData`, `reset`) over the
72
- * real handlers instead.
72
+ * real handlers.
73
73
  *
74
74
  * Time is not this class's: fake `Date` with the test runner
75
75
  * (`vi.useFakeTimers({ toFake: ["Date"] })`), which moves the framework, the
@@ -97,22 +97,20 @@ export declare class LambderTestApp<TContract extends LambderApiContractShape =
97
97
  private resetCount;
98
98
  private readonly crashList;
99
99
  /**
100
- * The call a crash happened under. The app answers a crash with a 500
101
- * that says nothing about it, so the error has to travel beside the
102
- * answer, and with calls running concurrently (a duplicate sent while
103
- * the original is in flight is an ordinary idempotency test) only the
104
- * async context says which call a crash belongs to.
100
+ * The call a crash happened under. The app's 500 says nothing about the
101
+ * crash, so the error travels beside the answer, and with concurrent
102
+ * calls (a duplicate sent while the original is in flight is an ordinary
103
+ * idempotency test) only the async context says which call it belongs to.
105
104
  */
106
105
  private readonly crashScope;
107
106
  constructor(lambder: LambderTestedInstance<TSessionData, TContract>, options?: LambderTestAppOptions);
108
107
  /**
109
108
  * Every error the app threw while answering a request since the last
110
109
  * reset, in order: what reached its global error handler, or the
111
- * framework's last-resort 500. The answers themselves say nothing about
112
- * what was thrown, so this is where a test reads it, and
113
- * `expect(app.crashes).toEqual([])` is how one says nothing crashed.
114
- * A refusal is not a crash, and neither is an error an `event()` rejects
115
- * with, which the test already holds.
110
+ * framework's last-resort 500. The answers say nothing about what was
111
+ * thrown, so a test reads it here; `expect(app.crashes).toEqual([])`
112
+ * says nothing crashed. A refusal is not a crash, and neither is an
113
+ * error an `event()` rejects with, which the test already holds.
116
114
  */
117
115
  get crashes(): readonly Error[];
118
116
  /** The session manager, for tests that inspect or manipulate sessions directly. Throws when the app has no sessions. */