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
@@ -1,6 +1,8 @@
1
1
  import { createDynamoDocumentClientLoader, isConditionalCheckFailure } from "./LambderDdbSdk.js";
2
2
  import { compressText, restoreText } from "../shared/wire/LambderCompressionCodec.js";
3
3
  import { resolveCompressionOption, } from "../shared/wire/LambderCompressionOption.js";
4
+ /** The fields besides data an update may write, as the manager names them. */
5
+ const UPDATABLE_FIELDS = ["dataExpiresAt", "lastAccessedAt", "expiresAt"];
4
6
  /**
5
7
  * Session compression defaults: every record compressed (see
6
8
  * LambderCompressionOption for the option's shape and toggle semantics).
@@ -19,6 +21,10 @@ const SESSION_COMPRESSION_DEFAULTS = { minBytes: 0, quality: 5 };
19
21
  * Table shape: a string hash key (the salted sessionKey hash, every session
20
22
  * of one subject shares it) and a string range key (the bearer secret's
21
23
  * hash), plus a TTL on `expiresAt` to let DynamoDB sweep expired sessions.
24
+ *
25
+ * Every write is conditional: a create on the item not existing, an update
26
+ * on it existing, so no write already in flight can bring back a session a
27
+ * logout deleted.
22
28
  */
23
29
  export class LambderDdbSessionStore {
24
30
  /** DynamoDB keeps records after this process is gone. */
@@ -45,33 +51,38 @@ export class LambderDdbSessionStore {
45
51
  keyOf(sessionKeyHash, secretHash) {
46
52
  return { [this.partitionKey]: sessionKeyHash, [this.sortKey]: secretHash };
47
53
  }
54
+ /**
55
+ * session.data as the attributes that hold it: `dataBr` and `dataBytes`
56
+ * when compressed, a plain `data` otherwise. Either way it goes through
57
+ * its JSON first, so a plain record holds exactly what a compressed one
58
+ * restores to: an `undefined` inside the data is dropped rather than
59
+ * handed to the document client, which refuses one and would fail the
60
+ * write (a login answering 500) only when compression is off.
61
+ */
62
+ async dataAttributes(data) {
63
+ const json = JSON.stringify(data);
64
+ const raw = Buffer.from(json, "utf8");
65
+ if (this.compression && raw.byteLength >= this.compression.minBytes) {
66
+ return { dataBr: await compressText(raw, "br", this.compression.quality), dataBytes: raw.byteLength };
67
+ }
68
+ return { data: JSON.parse(json) };
69
+ }
48
70
  /** The item for a record: the two hashes under the table's key names, the data plain or compressed. */
49
71
  async toItem(record) {
50
72
  const { sessionKeyHash, secretHash, data, ...rest } = record;
51
- const item = { ...this.keyOf(sessionKeyHash, secretHash), ...rest };
52
- const raw = this.compression && Buffer.from(JSON.stringify(data), "utf8");
53
- if (this.compression && raw && raw.byteLength >= this.compression.minBytes) {
54
- item.dataBr = await compressText(raw, "br", this.compression.quality);
55
- item.dataBytes = raw.byteLength;
56
- }
57
- else {
58
- item.data = data;
59
- }
60
- return item;
73
+ return { ...this.keyOf(sessionKeyHash, secretHash), ...rest, ...await this.dataAttributes(data) };
61
74
  }
62
75
  /**
63
76
  * The record for an item. A compressed record decodes back into `data`;
64
- * one whose data cannot be decoded is a malformed record and reads as no
65
- * session, the same as a record missing its csrfTokenHash.
77
+ * one whose data cannot be decoded is malformed and reads as no session,
78
+ * like a record missing its csrfTokenHash.
66
79
  *
67
- * Read failures and malformed records have to stay apart, and this is the
68
- * seam where they separate. A read failure is infrastructure and must
69
- * surface as a 500, because signing somebody out over a transient
70
- * DynamoDB error is a worse answer than an error page. A record that will
71
- * not decode is not transient: it will not decode on the next request
72
- * either, so a 500 there is a session the visitor can neither use nor
73
- * clear, on every request, until the TTL retires it. Ending it lets them
74
- * log in again.
80
+ * This is where read failures and malformed records separate. A read
81
+ * failure is infrastructure and must surface as a 500: signing somebody
82
+ * out over a transient DynamoDB error is worse than an error page. A
83
+ * record that will not decode will not decode on the next request either,
84
+ * so a 500 there would be a session the visitor can neither use nor clear
85
+ * until the TTL retires it. Ending it lets them log in again.
75
86
  */
76
87
  async fromItem(item) {
77
88
  const { [this.partitionKey]: sessionKeyHash, [this.sortKey]: secretHash, dataBr, dataBytes, data, ...rest } = item;
@@ -89,15 +100,19 @@ export class LambderDdbSessionStore {
89
100
  // everything past this line trusts them: the manager compares the two
90
101
  // hashes in constant time (a non-string would throw there rather than
91
102
  // answer false) and reads expiresAt as a number to decide whether the
92
- // session is over. An item missing them is not this store's record,
93
- // whether it was written by hand, by an older schema, or by another
94
- // app sharing the table, and it reads as no session for the same
95
- // reason an undecodable one does: it will not become valid later.
103
+ // session is over. An item missing them is not this store's record
104
+ // (written by an earlier major, by hand, or by another app sharing the
105
+ // table), and it reads as no session for the same reason an
106
+ // undecodable one does: it will not become valid later. Not logged: a
107
+ // visitor whose cookie names such an item sends it on every request
108
+ // until the cookie expires, and the item itself goes with its TTL.
109
+ // dataVersion is load-bearing the same way: every conditioned write
110
+ // names the one read, and a record without it would fail that write
111
+ // rather than answer "stale".
96
112
  if (typeof sessionKeyHash !== "string" || typeof secretHash !== "string"
97
113
  || typeof rest.csrfTokenHash !== "string" || typeof rest.sessionKey !== "string"
98
114
  || typeof rest.createdAt !== "number" || typeof rest.expiresAt !== "number"
99
- || typeof rest.ttlInSeconds !== "number") {
100
- console.warn(`LambderDdbSessionStore: an item in "${this.tableName}" is missing the fields a session record has, so it reads as no session.`);
115
+ || typeof rest.ttlInSeconds !== "number" || typeof rest.dataVersion !== "number") {
101
116
  return null;
102
117
  }
103
118
  // The one cast: session.data is whatever the app put there, and JSON
@@ -112,14 +127,86 @@ export class LambderDdbSessionStore {
112
127
  return null;
113
128
  return await this.fromItem(response.Item);
114
129
  }
115
- async put(record) {
130
+ async create(record) {
116
131
  const item = await this.toItem(record);
117
132
  const { client, sdk } = await this.ready();
118
- await client.send(new sdk.PutCommand({ TableName: this.tableName, Item: item }));
133
+ await client.send(new sdk.PutCommand({
134
+ TableName: this.tableName,
135
+ Item: item,
136
+ ConditionExpression: "attribute_not_exists(#sk)",
137
+ ExpressionAttributeNames: { "#sk": this.sortKey },
138
+ }));
139
+ }
140
+ async update(sessionKeyHash, secretHash, changes, condition) {
141
+ const names = { "#sk": this.sortKey };
142
+ const values = {};
143
+ const set = [];
144
+ const remove = [];
145
+ const add = [];
146
+ if ("data" in changes) {
147
+ // The data is held in one of two forms, so writing one removes the other.
148
+ const attributes = await this.dataAttributes(changes.data);
149
+ for (const attribute of ["data", "dataBr", "dataBytes"]) {
150
+ names[`#${attribute}`] = attribute;
151
+ if (attribute in attributes) {
152
+ values[`:${attribute}`] = attributes[attribute];
153
+ set.push(`#${attribute} = :${attribute}`);
154
+ }
155
+ else {
156
+ remove.push(`#${attribute}`);
157
+ }
158
+ }
159
+ }
160
+ for (const field of UPDATABLE_FIELDS) {
161
+ if (changes[field] === undefined)
162
+ continue;
163
+ names[`#${field}`] = field;
164
+ values[`:${field}`] = changes[field];
165
+ set.push(`#${field} = :${field}`);
166
+ }
167
+ // A write of the data or its deadline moves the version in the same
168
+ // write, whatever value it writes (see LambderSessionStore.update).
169
+ if ("data" in changes || changes.dataExpiresAt !== undefined) {
170
+ names["#dataVersion"] = "dataVersion";
171
+ values[":dataVersionStep"] = 1;
172
+ add.push("#dataVersion :dataVersionStep");
173
+ }
174
+ let conditionExpression = "attribute_exists(#sk)";
175
+ if (condition) {
176
+ names["#dataVersion"] = "dataVersion";
177
+ values[":readDataVersion"] = condition.dataVersion;
178
+ conditionExpression += " AND #dataVersion = :readDataVersion";
179
+ }
180
+ try {
181
+ const { client, sdk } = await this.ready();
182
+ await client.send(new sdk.UpdateCommand({
183
+ TableName: this.tableName,
184
+ Key: this.keyOf(sessionKeyHash, secretHash),
185
+ UpdateExpression: [
186
+ set.length ? `SET ${set.join(", ")}` : "",
187
+ add.length ? `ADD ${add.join(", ")}` : "",
188
+ remove.length ? `REMOVE ${remove.join(", ")}` : "",
189
+ ].filter(Boolean).join(" "),
190
+ ConditionExpression: conditionExpression,
191
+ ExpressionAttributeNames: names,
192
+ ...(Object.keys(values).length ? { ExpressionAttributeValues: values } : {}),
193
+ // Which half of the condition failed is told by the item the
194
+ // refusal hands back, in the same call: none means the record
195
+ // is gone, one means its dataVersion moved.
196
+ ReturnValuesOnConditionCheckFailure: "ALL_OLD",
197
+ }));
198
+ return "updated";
199
+ }
200
+ catch (err) {
201
+ if (!isConditionalCheckFailure(err))
202
+ throw err;
203
+ return err.Item ? "stale" : "missing";
204
+ }
119
205
  }
120
206
  async delete(sessionKeyHash, secretHash) {
121
207
  const { client, sdk } = await this.ready();
122
- await client.send(new sdk.DeleteCommand({ TableName: this.tableName, Key: this.keyOf(sessionKeyHash, secretHash) }));
208
+ const response = await client.send(new sdk.DeleteCommand({ TableName: this.tableName, Key: this.keyOf(sessionKeyHash, secretHash), ReturnValues: "ALL_OLD" }));
209
+ return response.Attributes ? await this.fromItem(response.Attributes) : null;
123
210
  }
124
211
  async listSecretHashes(sessionKeyHash) {
125
212
  const params = {
@@ -128,6 +215,9 @@ export class LambderDdbSessionStore {
128
215
  ProjectionExpression: "#sk",
129
216
  ExpressionAttributeNames: { "#pk": this.partitionKey, "#sk": this.sortKey },
130
217
  ExpressionAttributeValues: { ":pv": sessionKeyHash },
218
+ // Consistent: "log out everywhere" has to find a session created
219
+ // a moment before it, and an eventually consistent read may not.
220
+ ConsistentRead: true,
131
221
  };
132
222
  const hashes = [];
133
223
  for (;;) {
@@ -140,22 +230,4 @@ export class LambderDdbSessionStore {
140
230
  params.ExclusiveStartKey = LastEvaluatedKey;
141
231
  }
142
232
  }
143
- async markDataExpired(sessionKeyHash, secretHash, at) {
144
- try {
145
- const { client, sdk } = await this.ready();
146
- await client.send(new sdk.UpdateCommand({
147
- TableName: this.tableName,
148
- Key: this.keyOf(sessionKeyHash, secretHash),
149
- UpdateExpression: "SET #dataExpiresAt = :at",
150
- ConditionExpression: "attribute_exists(#sk)",
151
- ExpressionAttributeNames: { "#dataExpiresAt": "dataExpiresAt", "#sk": this.sortKey },
152
- ExpressionAttributeValues: { ":at": at },
153
- }));
154
- }
155
- catch (err) {
156
- // Deleted between the query and the update: nothing left to expire.
157
- if (!isConditionalCheckFailure(err))
158
- throw err;
159
- }
160
- }
161
233
  }
@@ -9,6 +9,14 @@ export type LambderHttpFileSourceOptions = {
9
9
  headers?: Record<string, string>;
10
10
  /** How long one read may take before it fails. Default: 10000. */
11
11
  timeoutMs?: number;
12
+ /**
13
+ * The statuses that mean "no such file", read as null so the request
14
+ * falls through. Default: [403, 404, 410]. A private S3 bucket behind
15
+ * CloudFront answers a missing key 403, since its reader may not list the
16
+ * bucket; an origin whose 403 only ever means a refused credential passes
17
+ * [404, 410] so that failure surfaces as an error.
18
+ */
19
+ notFoundStatuses?: readonly number[];
12
20
  };
13
21
  /**
14
22
  * Files over HTTP(S) from any origin that serves them by path: a CDN, a
@@ -16,16 +24,17 @@ export type LambderHttpFileSourceOptions = {
16
24
  * endpoint) or another server. Reads with the runtime's fetch, so it needs
17
25
  * no SDK, and no credentials for a public origin; reads come out of the
18
26
  * origin's edge cache. Each path segment is percent-encoded, so a relative
19
- * path names the same object it would as an S3 key. A 404 or 410 reads as
20
- * null and the request falls through; any other failed status, a network
21
- * error or a timeout propagates as an error. The response's Content-Type is
22
- * used unless it is a generic octet-stream, in which case the extension
23
- * decides, as for local files.
27
+ * path names the same object it would as an S3 key. A missing file (see
28
+ * `notFoundStatuses`) reads as null and the request falls through; any other
29
+ * failed status, a network error or a timeout throws. The response's
30
+ * Content-Type is used unless it is a generic octet-stream, in which case
31
+ * the extension decides.
24
32
  */
25
33
  export declare class LambderHttpFileSource implements LambderFileSource {
26
34
  private readonly baseUrl;
27
35
  private readonly headers;
28
36
  private readonly timeoutMs;
29
- constructor({ baseUrl, headers, timeoutMs }: LambderHttpFileSourceOptions);
37
+ private readonly notFoundStatuses;
38
+ constructor({ baseUrl, headers, timeoutMs, notFoundStatuses }: LambderHttpFileSourceOptions);
30
39
  read(relativePath: string): Promise<LambderFile | null>;
31
40
  }
@@ -1,22 +1,24 @@
1
1
  import { remoteStoreFile } from "../shared/contracts/LambderFileSource.js";
2
2
  const DEFAULT_TIMEOUT_MS = 10_000;
3
+ const DEFAULT_NOT_FOUND_STATUSES = [403, 404, 410];
3
4
  /**
4
5
  * Files over HTTP(S) from any origin that serves them by path: a CDN, a
5
6
  * public bucket's own domain (a Cloudflare R2 custom domain, an S3 website
6
7
  * endpoint) or another server. Reads with the runtime's fetch, so it needs
7
8
  * no SDK, and no credentials for a public origin; reads come out of the
8
9
  * origin's edge cache. Each path segment is percent-encoded, so a relative
9
- * path names the same object it would as an S3 key. A 404 or 410 reads as
10
- * null and the request falls through; any other failed status, a network
11
- * error or a timeout propagates as an error. The response's Content-Type is
12
- * used unless it is a generic octet-stream, in which case the extension
13
- * decides, as for local files.
10
+ * path names the same object it would as an S3 key. A missing file (see
11
+ * `notFoundStatuses`) reads as null and the request falls through; any other
12
+ * failed status, a network error or a timeout throws. The response's
13
+ * Content-Type is used unless it is a generic octet-stream, in which case
14
+ * the extension decides.
14
15
  */
15
16
  export class LambderHttpFileSource {
16
17
  baseUrl;
17
18
  headers;
18
19
  timeoutMs;
19
- constructor({ baseUrl, headers = {}, timeoutMs = DEFAULT_TIMEOUT_MS }) {
20
+ notFoundStatuses;
21
+ constructor({ baseUrl, headers = {}, timeoutMs = DEFAULT_TIMEOUT_MS, notFoundStatuses = DEFAULT_NOT_FOUND_STATUSES }) {
20
22
  let url;
21
23
  try {
22
24
  url = new URL(baseUrl);
@@ -31,22 +33,22 @@ export class LambderHttpFileSource {
31
33
  this.baseUrl = url;
32
34
  this.headers = headers;
33
35
  this.timeoutMs = timeoutMs;
36
+ this.notFoundStatuses = notFoundStatuses;
34
37
  }
35
38
  async read(relativePath) {
36
39
  const url = new URL(relativePath.split("/").map(encodeURIComponent).join("/"), this.baseUrl);
37
- // The reader's path rule already refuses everything that could make
38
- // this reference leave the configured folder (a leading slash makes it
40
+ // The reader's path rule already refuses anything that could make this
41
+ // reference leave the configured folder (a leading slash makes it
39
42
  // root-relative, two make it protocol-relative and pick the host).
40
- // Checked again here rather than trusted, because the value being
41
- // resolved is the request path and what leaving costs is a
42
- // credentialed fetch of an attacker-named origin, served back from
43
- // this app's own domain.
43
+ // Re-checked rather than trusted: the value is the request path, and
44
+ // escaping would mean a credentialed fetch of an attacker-named
45
+ // origin, served back from this app's own domain.
44
46
  if (!url.href.startsWith(this.baseUrl.href))
45
47
  return null;
46
48
  const response = await fetch(url, { headers: this.headers, signal: AbortSignal.timeout(this.timeoutMs) });
47
49
  if (!response.ok) {
48
50
  await response.body?.cancel();
49
- if (response.status === 404 || response.status === 410)
51
+ if (this.notFoundStatuses.includes(response.status))
50
52
  return null;
51
53
  throw new Error(`LambderHttpFileSource: ${response.status} ${response.statusText} reading ${url}`);
52
54
  }
@@ -0,0 +1,49 @@
1
+ import type { LambderCache, LambderCacheKey, LambderCacheListOptions, LambderCacheSetOptions } from "../shared/contracts/LambderCache.js";
2
+ export interface LambderMemoryCacheOptions {
3
+ /** Default: one year, as LambderDdbCache's. */
4
+ defaultTtlSeconds?: number;
5
+ /** Largest value accepted, in UTF-8 bytes of its JSON. Default: 32 MiB, as LambderDdbCache's. */
6
+ maxValueBytes?: number;
7
+ /** Ceiling on entries held at once; past it the ones closest to expiring go first. Default: 100,000. */
8
+ maxEntries?: number;
9
+ /** The clock entries expire against, so a test can cross a TTL without waiting. */
10
+ now?: () => number;
11
+ }
12
+ /**
13
+ * LambderDdbCache's twin, held in memory: the same LambderCache interface and
14
+ * the same rules, so code written against the interface can be tested
15
+ * without a table. It refuses the keys and values the DynamoDB cache
16
+ * refuses, stores a value's JSON text and hands back a fresh parse of it,
17
+ * expires entries on the same TTL, and lists sort keys in the same order.
18
+ *
19
+ * getOrSet runs through the LambderCacheFiller both caches hold: concurrent
20
+ * calls for one key share a load, every call (the filling one included)
21
+ * answers the stored JSON parsed, a loader's undefined comes back uncached,
22
+ * and a set, delete or deletePartition of the key while the loader runs
23
+ * keeps the fill from storing over it, so a test that passes here passes
24
+ * against the table.
25
+ *
26
+ * It has none of the table's machinery: no compression, no chunks, no fill
27
+ * lease across containers. It is one process's cache, bounded by
28
+ * `maxEntries`, and a single-process server could use it as that.
29
+ */
30
+ export declare class LambderMemoryCache implements LambderCache {
31
+ private readonly entries;
32
+ /** getOrSet's single-flight and fail-open, shared with LambderDdbCache (see LambderCacheFiller). */
33
+ private readonly filler;
34
+ private readonly defaultTtlSeconds;
35
+ private readonly maxValueBytes;
36
+ private readonly now;
37
+ constructor(options?: LambderMemoryCacheOptions);
38
+ get<T>(key: LambderCacheKey): Promise<T | undefined>;
39
+ has(key: LambderCacheKey): Promise<boolean>;
40
+ set<T>(key: LambderCacheKey, value: T, options?: LambderCacheSetOptions): Promise<void>;
41
+ delete(key: LambderCacheKey): Promise<boolean>;
42
+ deletePartition(partition: string): Promise<number>;
43
+ listSortKeys(partition: string, options?: LambderCacheListOptions): Promise<string[]>;
44
+ getOrSet<T>(key: LambderCacheKey, loader: () => Promise<T>, options?: LambderCacheSetOptions): Promise<T>;
45
+ /** Forgets every entry; a fill in flight meanwhile stores nothing. */
46
+ reset(): void;
47
+ /** Stores the value and hands back what was stored, the parse of its JSON. */
48
+ private setByAddress;
49
+ }
@@ -0,0 +1,113 @@
1
+ import { LambderExpiringMap } from "../shared/util/LambderExpiringMap.js";
2
+ import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
3
+ import { cacheMemoryKeyOf, encodeCacheSortKey, normalizeCacheKey, normalizeCachePartition } from "./LambderCacheKeys.js";
4
+ import { DEFAULT_MAX_VALUE_BYTES, DEFAULT_TTL_SECONDS, resolveCacheTtlSeconds, resolveGetOrSetOptions, serializeCacheValue, } from "./LambderCacheValues.js";
5
+ import { LambderCacheFiller } from "./LambderCacheFiller.js";
6
+ /** DynamoDB orders string range keys by their UTF-8 bytes, which is not JavaScript's UTF-16 order for every character. */
7
+ const compareUtf8 = (first, second) => {
8
+ const a = new TextEncoder().encode(first);
9
+ const b = new TextEncoder().encode(second);
10
+ const length = Math.min(a.length, b.length);
11
+ for (let index = 0; index < length; index += 1) {
12
+ if (a[index] !== b[index])
13
+ return a[index] - b[index];
14
+ }
15
+ return a.length - b.length;
16
+ };
17
+ /**
18
+ * LambderDdbCache's twin, held in memory: the same LambderCache interface and
19
+ * the same rules, so code written against the interface can be tested
20
+ * without a table. It refuses the keys and values the DynamoDB cache
21
+ * refuses, stores a value's JSON text and hands back a fresh parse of it,
22
+ * expires entries on the same TTL, and lists sort keys in the same order.
23
+ *
24
+ * getOrSet runs through the LambderCacheFiller both caches hold: concurrent
25
+ * calls for one key share a load, every call (the filling one included)
26
+ * answers the stored JSON parsed, a loader's undefined comes back uncached,
27
+ * and a set, delete or deletePartition of the key while the loader runs
28
+ * keeps the fill from storing over it, so a test that passes here passes
29
+ * against the table.
30
+ *
31
+ * It has none of the table's machinery: no compression, no chunks, no fill
32
+ * lease across containers. It is one process's cache, bounded by
33
+ * `maxEntries`, and a single-process server could use it as that.
34
+ */
35
+ export class LambderMemoryCache {
36
+ entries;
37
+ /** getOrSet's single-flight and fail-open, shared with LambderDdbCache (see LambderCacheFiller). */
38
+ filler = new LambderCacheFiller("Memory cache failed open");
39
+ defaultTtlSeconds;
40
+ maxValueBytes;
41
+ now;
42
+ constructor(options = {}) {
43
+ this.now = options.now ?? (() => Date.now());
44
+ this.defaultTtlSeconds = assertPositiveInteger(options.defaultTtlSeconds ?? DEFAULT_TTL_SECONDS, "defaultTtlSeconds");
45
+ this.maxValueBytes = assertPositiveInteger(options.maxValueBytes ?? DEFAULT_MAX_VALUE_BYTES, "maxValueBytes");
46
+ this.entries = new LambderExpiringMap({ now: this.now, maxEntries: options.maxEntries });
47
+ }
48
+ async get(key) {
49
+ const entry = this.entries.get(normalizeCacheKey(key).memoryKey);
50
+ return entry ? JSON.parse(entry.json) : undefined;
51
+ }
52
+ async has(key) {
53
+ return this.entries.get(normalizeCacheKey(key).memoryKey) !== undefined;
54
+ }
55
+ async set(key, value, options = {}) {
56
+ const address = normalizeCacheKey(key);
57
+ const ttlSeconds = resolveCacheTtlSeconds(options.ttlSeconds, this.defaultTtlSeconds);
58
+ this.filler.supersedeFill(address.memoryKey);
59
+ this.setByAddress(address, value, ttlSeconds);
60
+ }
61
+ async delete(key) {
62
+ const address = normalizeCacheKey(key);
63
+ this.filler.supersedeFill(address.memoryKey);
64
+ const existed = this.entries.get(address.memoryKey) !== undefined;
65
+ this.entries.delete(address.memoryKey);
66
+ return existed;
67
+ }
68
+ async deletePartition(partition) {
69
+ const normalized = normalizeCachePartition(partition);
70
+ this.filler.supersedeFillsWithPrefix(cacheMemoryKeyOf(normalized, ""));
71
+ let removed = 0;
72
+ for (const { address } of this.entries.values()) {
73
+ if (address.partition !== normalized)
74
+ continue;
75
+ this.entries.delete(address.memoryKey);
76
+ removed += 1;
77
+ }
78
+ return removed;
79
+ }
80
+ async listSortKeys(partition, options = {}) {
81
+ const normalized = normalizeCachePartition(partition);
82
+ const prefix = options.prefix ?? "";
83
+ const limit = options.limit === undefined ? undefined : assertPositiveInteger(options.limit, "limit");
84
+ const sortKeys = this.entries.values()
85
+ .flatMap(({ address }) => address.partition === normalized && address.sortKey !== null && address.sortKey.startsWith(prefix) ? [address.sortKey] : [])
86
+ // Ordered as the table orders the items it lists: by the whole
87
+ // item sort key, which carries a "#" after the encoded key. So
88
+ // "New York City" sorts before "New York" there (" " is below
89
+ // "#"), and here too.
90
+ .sort((first, second) => compareUtf8(`${encodeCacheSortKey(first)}#`, `${encodeCacheSortKey(second)}#`));
91
+ return limit === undefined ? sortKeys : sortKeys.slice(0, limit);
92
+ }
93
+ async getOrSet(key, loader, options = {}) {
94
+ const address = normalizeCacheKey(key);
95
+ const { ttlSeconds } = resolveGetOrSetOptions(options, this.defaultTtlSeconds);
96
+ return this.filler.getOrSet(address.memoryKey, loader, async (load) => {
97
+ const existing = this.entries.get(address.memoryKey);
98
+ return existing ? JSON.parse(existing.json) : await load(async (value) => this.setByAddress(address, value, ttlSeconds));
99
+ });
100
+ }
101
+ /** Forgets every entry; a fill in flight meanwhile stores nothing. */
102
+ reset() {
103
+ this.filler.supersedeFillsWithPrefix("");
104
+ this.entries.clear();
105
+ }
106
+ /** Stores the value and hands back what was stored, the parse of its JSON. */
107
+ setByAddress(address, value, ttlSeconds) {
108
+ const { json } = serializeCacheValue(value, this.maxValueBytes);
109
+ const expiresAt = Math.floor(this.now() / 1000) + ttlSeconds;
110
+ this.entries.set(address.memoryKey, { json, address }, expiresAt);
111
+ return JSON.parse(json);
112
+ }
113
+ }
@@ -2,6 +2,7 @@ import type { LambderIdempotencyStore, LambderIdempotencyDoneRecord, LambderIdem
2
2
  type MemoryIdempotencyRecord = {
3
3
  state: "pending";
4
4
  ownerToken: string;
5
+ fingerprint: string;
5
6
  } | ({
6
7
  state: "done";
7
8
  ownerToken: string;
@@ -17,14 +18,12 @@ type MemoryIdempotencyRecord = {
17
18
  * path can be exercised; unbounded by default. `now` is injectable so a test
18
19
  * can expire a claim without waiting.
19
20
  *
20
- * `maxEntries` is the map's ceiling, 100,000 by default, and it is the one
21
- * way this store differs from a table: a process cannot hold records without
22
- * bound, so past the ceiling the SETTLED records are dropped, soonest expiry
23
- * first, and a retry whose record was dropped executes again instead of
24
- * replaying. Pending claims are never dropped for room, because losing one
25
- * lets two concurrent retries execute at once, which is the thing idempotency
26
- * exists to prevent; a claim that cannot be made room for is reported as
27
- * "pending" instead, so the duplicate is refused rather than run.
21
+ * `maxEntries` (100,000 by default) is the one way this store differs from a
22
+ * table: past the ceiling, SETTLED records are dropped, soonest expiry first,
23
+ * and a retry whose record was dropped executes again instead of replaying.
24
+ * Pending claims are never dropped, since losing one lets two concurrent
25
+ * retries both execute; a claim that finds no room is reported as "pending"
26
+ * instead, so the duplicate is refused rather than run.
28
27
  */
29
28
  export declare class LambderMemoryIdempotencyStore implements LambderIdempotencyStore {
30
29
  private readonly records;
@@ -42,17 +41,19 @@ export declare class LambderMemoryIdempotencyStore implements LambderIdempotency
42
41
  /** A settled record as the engine reads it: a copy, so a caller writing onto what it got back cannot rewrite the record. */
43
42
  private static answerOf;
44
43
  peek(scopeKey: string): Promise<LambderIdempotencyDoneRecord | null>;
45
- begin(scopeKey: string, { pendingTtlSeconds }: {
44
+ begin(scopeKey: string, { pendingTtlSeconds, fingerprint }: {
46
45
  pendingTtlSeconds: number;
46
+ fingerprint: string;
47
47
  }): Promise<LambderIdempotencyBeginResult>;
48
- complete(scopeKey: string, ownerToken: string, { statusCode, headers, body, ttlSeconds }: LambderIdempotencyDoneRecord & {
48
+ complete(scopeKey: string, ownerToken: string, { statusCode, headers, body, fingerprint, ttlSeconds }: LambderIdempotencyDoneRecord & {
49
49
  ttlSeconds: number;
50
50
  }): Promise<"stored" | "too-large" | "lost">;
51
+ /** Releases a pending claim for its owner; a settled record stays, as LambderIdempotencyStore requires. */
51
52
  abandon(scopeKey: string, ownerToken: string): Promise<void>;
52
53
  /**
53
54
  * The record under a scope, for assertions; null when absent or expired.
54
- * A copy, like every other read here, so an assertion that pokes at what
55
- * it got back cannot edit the stored record.
55
+ * A copy, like every read here, so an assertion cannot edit the stored
56
+ * record.
56
57
  */
57
58
  recordOf(scopeKey: string): MemoryIdempotencyRecord | null;
58
59
  /** Number of live records held. */