lambder 7.3.1 → 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 (207) hide show
  1. package/CHANGELOG.md +933 -3
  2. package/README.md +41 -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/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 +140 -75
  41. package/dist/core/Lambder.js +347 -227
  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 +21 -7
  51. package/dist/core/LambderFiles.js +62 -34
  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 +29 -9
  73. package/dist/invoke/LambderLambdaEvent.js +40 -22
  74. package/dist/invoke/lambderHandlerTransport.d.ts +9 -10
  75. package/dist/invoke/lambderHandlerTransport.js +15 -18
  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 +1 -1
  93. package/dist/mock.js +2 -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 +124 -46
  99. package/dist/session/LambderSessionManager.js +262 -137
  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/LambderTypeUtilities.d.ts +7 -8
  134. package/dist/shared/util/LambderTypeUtilities.js +3 -3
  135. package/dist/shared/util/boundKeyField.d.ts +20 -0
  136. package/dist/shared/util/boundKeyField.js +34 -0
  137. package/dist/shared/util/canonicalJson.d.ts +11 -0
  138. package/dist/shared/util/canonicalJson.js +28 -0
  139. package/dist/shared/util/joinKeyFields.d.ts +20 -0
  140. package/dist/shared/util/joinKeyFields.js +22 -0
  141. package/dist/shared/wire/LambderAnswerHeaders.d.ts +12 -16
  142. package/dist/shared/wire/LambderAnswerHeaders.js +12 -16
  143. package/dist/shared/wire/LambderApiContract.d.ts +107 -32
  144. package/dist/shared/wire/LambderApiOutcome.d.ts +43 -31
  145. package/dist/shared/wire/LambderApiOutcome.js +48 -23
  146. package/dist/shared/wire/LambderApiRefusal.d.ts +39 -27
  147. package/dist/shared/wire/LambderApiRefusal.js +36 -7
  148. package/dist/shared/wire/LambderApiSignature.d.ts +18 -22
  149. package/dist/shared/wire/LambderApiSignature.js +16 -19
  150. package/dist/shared/wire/LambderCallOptions.d.ts +38 -47
  151. package/dist/shared/wire/LambderCallOptions.js +9 -11
  152. package/dist/shared/wire/LambderCompressionCodec.d.ts +29 -34
  153. package/dist/shared/wire/LambderCompressionCodec.js +31 -36
  154. package/dist/shared/wire/LambderCompressionOption.d.ts +9 -9
  155. package/dist/shared/wire/LambderCompressionOption.js +9 -9
  156. package/dist/shared/wire/LambderCrashDetail.d.ts +12 -15
  157. package/dist/shared/wire/LambderCrashDetail.js +12 -15
  158. package/dist/shared/wire/LambderDefaultApiPath.d.ts +6 -0
  159. package/dist/shared/wire/LambderDefaultApiPath.js +6 -0
  160. package/dist/shared/wire/LambderHttpStatus.d.ts +6 -7
  161. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +89 -0
  162. package/dist/shared/wire/LambderIdempotencyKeyScope.js +146 -0
  163. package/dist/shared/wire/LambderInvokeApiId.d.ts +27 -0
  164. package/dist/shared/wire/LambderInvokeApiId.js +27 -0
  165. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +6 -7
  166. package/dist/shared/wire/LambderOutcomeAssertions.js +6 -7
  167. package/dist/shared/wire/LambderRequestPayload.d.ts +18 -20
  168. package/dist/shared/wire/LambderRequestPayload.js +4 -6
  169. package/dist/stores/LambderCacheFiller.d.ts +48 -0
  170. package/dist/stores/LambderCacheFiller.js +119 -0
  171. package/dist/stores/LambderCacheKeys.d.ts +26 -0
  172. package/dist/stores/LambderCacheKeys.js +54 -0
  173. package/dist/stores/LambderCacheValues.d.ts +45 -0
  174. package/dist/stores/LambderCacheValues.js +74 -0
  175. package/dist/stores/LambderDdbCache.d.ts +121 -56
  176. package/dist/stores/LambderDdbCache.js +528 -225
  177. package/dist/stores/LambderDdbIdempotencyStore.d.ts +33 -22
  178. package/dist/stores/LambderDdbIdempotencyStore.js +75 -50
  179. package/dist/stores/LambderDdbRateLimiter.d.ts +76 -20
  180. package/dist/stores/LambderDdbRateLimiter.js +151 -39
  181. package/dist/stores/LambderDdbSdk.d.ts +43 -31
  182. package/dist/stores/LambderDdbSdk.js +79 -33
  183. package/dist/stores/LambderDdbSessionStore.d.ts +27 -14
  184. package/dist/stores/LambderDdbSessionStore.js +119 -47
  185. package/dist/stores/LambderHttpFileSource.d.ts +15 -6
  186. package/dist/stores/LambderHttpFileSource.js +15 -13
  187. package/dist/stores/LambderMemoryCache.d.ts +49 -0
  188. package/dist/stores/LambderMemoryCache.js +113 -0
  189. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +13 -12
  190. package/dist/stores/LambderMemoryIdempotencyStore.js +31 -30
  191. package/dist/stores/LambderMemoryRateLimiter.d.ts +8 -9
  192. package/dist/stores/LambderMemoryRateLimiter.js +14 -13
  193. package/dist/stores/LambderMemorySessionStore.d.ts +14 -11
  194. package/dist/stores/LambderMemorySessionStore.js +38 -19
  195. package/dist/stores/LambderS3FileSource.d.ts +21 -6
  196. package/dist/stores/LambderS3FileSource.js +12 -7
  197. package/dist/testing/LambderTestApp.d.ts +21 -23
  198. package/dist/testing/LambderTestApp.js +22 -24
  199. package/dist/testing/LambderTestVisitor.d.ts +10 -12
  200. package/dist/testing/LambderTestVisitor.js +15 -15
  201. package/dist/testing.d.ts +1 -0
  202. package/dist/testing.js +1 -0
  203. package/package.json +12 -3
  204. package/dist/api/LambderApiPolicyEngine.d.ts +0 -47
  205. package/dist/api/LambderApiPolicyEngine.js +0 -85
  206. package/dist/shared/util/LambderKeyFields.d.ts +0 -32
  207. package/dist/shared/util/LambderKeyFields.js +0 -34
@@ -1,71 +1,115 @@
1
1
  /**
2
2
  * Stops a stale client from reloading forever.
3
3
  *
4
- * A versionExpired answer means "this client's signature for the endpoint is
5
- * not the one the server holds", and the ordinary response is to reload and
6
- * get the current bundle. When the bundle being served is itself the stale
7
- * one (a frontend deployed with a signature map the server does not match, a
8
- * cached bundle, a server deploy that failed behind a fresh frontend), the
9
- * reload brings back the same signature, the same call fails the same way,
10
- * and the page reloads again, indefinitely.
4
+ * A versionExpired answer means this client's signature for the endpoint is
5
+ * not the one the server holds (or its version is below the server's floor),
6
+ * and the ordinary response is to reload for the current bundle. When the
7
+ * served bundle is itself stale (a frontend deployed with a signature map the
8
+ * server does not match, a cached bundle, a server deploy that failed behind
9
+ * a fresh frontend), the reload brings back the same bundle, the calls fail
10
+ * the same way, and the page reloads again, indefinitely.
11
11
  *
12
- * The evidence of that loop is a versionExpired for the same endpoint with
13
- * the same signature shortly after the last one: a bundle that had actually
14
- * changed the endpoint would carry a different signature. The record lives
15
- * in sessionStorage, which is per tab and survives a reload, so the new page
16
- * instance sees what the previous one saw; without sessionStorage (a test, a
17
- * non-browser runtime) an in-memory record does the same within one page.
12
+ * The evidence is a versionExpired for a call an earlier load refused within
13
+ * the window: the same endpoint with the same signature and version. A bundle
14
+ * that had changed the endpoint would carry a different signature, and a
15
+ * rebuilt one a different version. Each call is kept with the time it was
16
+ * refused, and only one refused before this document loaded counts: a call
17
+ * this page refused itself (a retry, another caller's) has seen no reload
18
+ * since. Every call refused within the window is kept, not only the latest,
19
+ * because a stale bundle is usually stale for several endpoints and they
20
+ * answer in no fixed order: whichever of them answers first on the next load
21
+ * has to find itself in the record.
18
22
  *
19
- * Once a repeat is confirmed, every versionExpired within the window from
20
- * the first one counts as a repeat too, whichever endpoint it names: a stale
21
- * bundle is usually stale for several endpoints, and one reload per endpoint
22
- * is still a loop, only a slower one. After the window a reload is allowed
23
- * again, so a client stuck on a stale bundle retries a few times an hour and
24
- * recovers by itself once the deploy is fixed.
23
+ * A page asks one versionExpiredHandler at a time. While it runs, the rest of
24
+ * what the page hears (the other stale endpoints it boots with, a retry,
25
+ * another caller's) is recorded and answered quietly, since the page is being
26
+ * asked already. A handler that has returned while the page is still here may
27
+ * not have reloaded it (a per-call handler that does something else, a reload
28
+ * cancelled at a beforeunload prompt), so the next versionExpired asks again;
29
+ * where it did, asking again before the page unloads only repeats the reload
30
+ * under way. An instance is one page: LambderCaller keeps one at module
31
+ * scope, shared by every caller the page builds (in a runtime with no page,
32
+ * every caller of the process).
33
+ *
34
+ * Once a repeat is confirmed, every versionExpired within the window counts
35
+ * as one, whichever endpoint it names, so an endpoint the stale bundle calls
36
+ * only later does not earn a reload of its own. The window runs from the
37
+ * first versionExpired recorded, not from the latest. After it a reload is
38
+ * allowed again, so a client stuck on a stale bundle retries a few times an
39
+ * hour and recovers once the deploy is fixed.
40
+ *
41
+ * The record has to outlive the reload it watches for, so it lives in
42
+ * sessionStorage, per tab and per origin. Where that does not work (storage
43
+ * blocked, a runtime with no page) nothing outlives the page: it still asks
44
+ * one handler at a time, and a stale bundle there reloads as it would without
45
+ * this class.
25
46
  */
26
47
  /** How long after the first versionExpired a repeat counts as the same loop. */
27
48
  export const RELOAD_LOOP_WINDOW_MS = 5 * 60 * 1000;
28
49
  const STORAGE_KEY = "lambder:version-expired";
29
- const isExpiredRecord = (value) => typeof value === "object" && value !== null
30
- && typeof value.apiName === "string"
31
- && typeof value.signature === "string"
50
+ const isRefusedCall = (value) => Array.isArray(value) && value.length === 4
51
+ && typeof value[0] === "string" && typeof value[1] === "string" && typeof value[2] === "string"
52
+ && typeof value[3] === "number";
53
+ const isVersionExpiredRecord = (value) => typeof value === "object" && value !== null
32
54
  && typeof value.at === "number"
33
- && typeof value.confirmed === "boolean";
55
+ && typeof value.confirmed === "boolean"
56
+ && Array.isArray(value.calls)
57
+ && value.calls.every(isRefusedCall);
58
+ /** A record from the future (a clock set back) is outside the window rather than inside it forever. */
59
+ const isWithinWindow = (at, now) => now >= at && now - at < RELOAD_LOOP_WINDOW_MS;
34
60
  export class LambderReloadLoopBreaker {
35
- /** The record when sessionStorage is unavailable; sessionStorage is read first wherever it exists. */
36
- memory = null;
37
- /**
38
- * Records this versionExpired and says whether it repeats a recent one,
39
- * in which case the caller must not invoke versionExpiredHandler again.
40
- */
41
- isRepeat(apiName, signature, now = Date.now()) {
42
- const last = this.read();
43
- if (last && now - last.at < RELOAD_LOOP_WINDOW_MS && (last.confirmed || (last.apiName === apiName && last.signature === signature))) {
44
- // `at` stays the first event's, so the window runs from the start
45
- // of the loop rather than being pushed forward by every repeat.
46
- this.write({ apiName, signature, at: last.at, confirmed: true });
47
- return true;
61
+ loadedAt;
62
+ /** Whether the handler this page asked is still running: what the page hears meanwhile stays quiet. */
63
+ reloadAskPending = false;
64
+ /** loadedAt: when this document loaded. A call refused before it was refused by an earlier load, with a reload in between. */
65
+ constructor(loadedAt = performance.timeOrigin) {
66
+ this.loadedAt = loadedAt;
67
+ }
68
+ /** Records this versionExpired and decides what the caller does about it. */
69
+ recordVersionExpired(apiName, signature, version, now = Date.now()) {
70
+ const stored = this.read();
71
+ const record = stored && isWithinWindow(stored.at, now) ? stored : { at: now, confirmed: false, calls: [] };
72
+ const earlier = record.calls.find(([name, sig, ver]) => name === apiName && sig === signature && ver === version);
73
+ if (!earlier)
74
+ record.calls.push([apiName, signature, version, now]);
75
+ let decision;
76
+ if (this.reloadAskPending)
77
+ decision = "alreadyAsked";
78
+ else if (record.confirmed || (earlier !== undefined && earlier[3] < this.loadedAt))
79
+ decision = "loopConfirmed";
80
+ else
81
+ decision = "askForReload";
82
+ if (decision === "loopConfirmed")
83
+ record.confirmed = true;
84
+ this.write(record);
85
+ return decision;
86
+ }
87
+ /** Runs the ask an askForReload decision calls for; until it settles, every versionExpired is alreadyAsked. */
88
+ async runReloadAsk(ask) {
89
+ this.reloadAskPending = true;
90
+ try {
91
+ await ask();
92
+ }
93
+ finally {
94
+ this.reloadAskPending = false;
48
95
  }
49
- this.write({ apiName, signature, at: now, confirmed: false });
50
- return false;
51
96
  }
52
97
  read() {
53
98
  try {
54
- const raw = globalThis.sessionStorage?.getItem(STORAGE_KEY);
55
- if (raw) {
56
- const parsed = JSON.parse(raw);
57
- if (isExpiredRecord(parsed))
99
+ const stored = globalThis.sessionStorage?.getItem(STORAGE_KEY);
100
+ if (stored) {
101
+ const parsed = JSON.parse(stored);
102
+ if (isVersionExpiredRecord(parsed))
58
103
  return parsed;
59
104
  }
60
105
  }
61
- catch { /* a private window or blocked storage: the in-memory record stands in */ }
62
- return this.memory;
106
+ catch { /* blocked storage, or a record that is not JSON: read as no record */ }
107
+ return null;
63
108
  }
64
109
  write(record) {
65
- this.memory = record;
66
110
  try {
67
111
  globalThis.sessionStorage?.setItem(STORAGE_KEY, JSON.stringify(record));
68
112
  }
69
- catch { /* same: the in-memory record stands in */ }
113
+ catch { /* blocked or full: nothing outlives this page */ }
70
114
  }
71
115
  }
@@ -2,7 +2,10 @@ import type { LambderApiTransport } from "../shared/transport/LambderApiTranspor
2
2
  /**
3
3
  * The production transport: one POST of the envelope to the API path over
4
4
  * fetch, with the cookie and CORS behaviour a browser call needs. The
5
- * caller's default, built from its isCorsEnabled option.
5
+ * caller's default, built from its isCorsEnabled option. `cors` defaults to
6
+ * whether the call's apiPath is on another origin than the page's: a browser
7
+ * refuses a same-origin mode request to another origin outright, and a
8
+ * credentialed cross-origin one is what a separate API host needs.
6
9
  */
7
10
  export declare const lambderFetchTransport: (options?: {
8
11
  cors?: boolean;
@@ -1,20 +1,19 @@
1
1
  import { buildTransportEnvelope, LambderTransportFailure } from "../shared/transport/LambderApiTransport.js";
2
2
  /**
3
- * fetch, with one failure worth a better sentence than the platform gives it.
4
- * A relative apiPath is resolved against the page; outside a page there is no
5
- * page to resolve it against, so fetch rejects with a URL parse error that
6
- * arrives at the caller looking like the network is down. The condition is
7
- * read from the actual failure rather than guessed beforehand, so a runtime
8
- * that resolves relative URLs some other way is left alone.
3
+ * fetch, with a clearer error for one failure. Outside a page a relative
4
+ * apiPath has nothing to resolve against, so fetch rejects with a URL parse
5
+ * error that reaches the caller looking like the network is down. The
6
+ * condition is read from the actual failure rather than guessed beforehand,
7
+ * so a runtime that resolves relative URLs some other way is left alone.
9
8
  */
10
9
  const fetchOrExplain = async (url, init) => {
11
10
  try {
12
11
  return await fetch(url, init);
13
12
  }
14
13
  catch (err) {
15
- // Narrow on purpose: only the URL failing to parse, which is what
16
- // this is. A transport that rejected for any other reason, a stubbed
17
- // one included, keeps its own meaning.
14
+ // Narrow on purpose: only a URL parse failure is rewritten. A fetch
15
+ // that rejected for any other reason, a stubbed one included, keeps
16
+ // its own error.
18
17
  const failedToParseUrl = err instanceof TypeError && /failed to parse url|invalid url/i.test(String(err.message));
19
18
  if (failedToParseUrl && url.startsWith("/") && typeof globalThis.location?.href !== "string") {
20
19
  throw new LambderTransportFailure("protocol", `lambderFetchTransport could not resolve the relative apiPath "${url}": there is no page to resolve it against outside a browser. `
@@ -26,10 +25,32 @@ const fetchOrExplain = async (url, init) => {
26
25
  /**
27
26
  * The production transport: one POST of the envelope to the API path over
28
27
  * fetch, with the cookie and CORS behaviour a browser call needs. The
29
- * caller's default, built from its isCorsEnabled option.
28
+ * caller's default, built from its isCorsEnabled option. `cors` defaults to
29
+ * whether the call's apiPath is on another origin than the page's: a browser
30
+ * refuses a same-origin mode request to another origin outright, and a
31
+ * credentialed cross-origin one is what a separate API host needs.
30
32
  */
31
33
  export const lambderFetchTransport = (options = {}) => async (request) => {
32
- const cors = options.cors ?? false;
34
+ let cors = options.cors;
35
+ if (cors === undefined) {
36
+ // Resolved against the page, so a protocol-relative `//api.example.com`
37
+ // counts as another origin too. Outside a page a relative path
38
+ // resolves against nothing and an absolute one has no page origin to
39
+ // match, and neither matters there: a fetch outside a browser does
40
+ // not enforce the mode.
41
+ try {
42
+ cors = new URL(request.apiPath, globalThis.location?.href).origin !== globalThis.location?.origin;
43
+ }
44
+ catch {
45
+ cors = false;
46
+ }
47
+ }
48
+ // The headers this transport sets itself leave the caller's set in any
49
+ // spelling: fetch merges header names case-insensitively, so a
50
+ // `content-type` beside the transport's `Content-Type` would join it
51
+ // ("text/plain, application/json") rather than give way to it.
52
+ const owned = new Set(['content-type', ...(request.cookies?.length ? ['cookie'] : [])]);
53
+ const callerHeaders = Object.fromEntries(Object.entries(request.headers ?? {}).filter(([name]) => !owned.has(name.toLowerCase())));
33
54
  const response = await fetchOrExplain(request.apiPath, {
34
55
  method: 'POST', cache: 'no-cache',
35
56
  // Cross-origin API hosts need CORS mode and included credentials.
@@ -37,33 +58,36 @@ export const lambderFetchTransport = (options = {}) => async (request) => {
37
58
  credentials: cors ? 'include' : 'same-origin',
38
59
  redirect: 'follow', referrerPolicy: 'origin',
39
60
  headers: {
40
- // The caller's own headers go on first, so the ones this transport
41
- // owns cannot be displaced by them, the way the synthesized invoke
42
- // event does it. With the spread last, a per-call `Cookie` header
43
- // (to carry one extra cookie, say) replaced the whole Cookie
44
- // header a jar had just built, and the session went missing with
45
- // nothing in the failure pointing at the cause.
46
- ...(request.headers ?? {}),
61
+ // The caller's own headers go on first so they cannot displace the
62
+ // ones this transport owns (the synthesized invoke event does the
63
+ // same). With the spread last, a per-call `Cookie` header would
64
+ // replace the whole Cookie header a jar had just built, and the
65
+ // session would go missing with nothing pointing at the cause.
66
+ ...callerHeaders,
47
67
  'Content-Type': 'application/json',
48
- // The cookies the request is meant to carry, which is how a cookie
49
- // jar works over fetch outside a browser: undici sends this
50
- // header, and a page cannot (Cookie is a forbidden header name, so
51
- // a browser drops it silently and uses its own cookie store, which
52
- // is the right answer there). Without it the jar collected every
53
- // Set-Cookie and sent none of them back, so a Node script against
54
- // a deployed app got its CSRF token filled in and sessionExpired
55
- // on every session call.
68
+ // The cookies the request carries: this is how a cookie jar works
69
+ // over fetch outside a browser, where undici sends the header. A
70
+ // page cannot (Cookie is a forbidden header name, so a browser
71
+ // drops it and uses its own cookie store, which is right there).
72
+ // Without it a jar would collect every Set-Cookie and send none
73
+ // back, and a Node script against a deployed app would get
74
+ // sessionExpired on every session call.
56
75
  ...(request.cookies?.length ? { Cookie: request.cookies.join('; ') } : {}),
57
76
  },
58
77
  body: JSON.stringify(buildTransportEnvelope(request)),
59
78
  ...(request.signal ? { signal: request.signal } : {}),
60
79
  });
80
+ // Read here, inside the call, so the caller's timeout and abort cover the
81
+ // whole answer: fetch resolves on the headers, and a body abandoned while
82
+ // it downloads would otherwise read as a malformed answer (a server
83
+ // error) rather than the timeout or abort it was.
84
+ const body = await response.text();
61
85
  return {
62
86
  status: response.status,
63
87
  statusText: response.statusText,
64
88
  header: (name) => response.headers?.get?.(name) ?? null,
65
- json: () => response.json(),
66
- text: () => response.text(),
89
+ json: async () => JSON.parse(body),
90
+ text: async () => body,
67
91
  // Set-Cookie is unreadable from a page's script; where the runtime
68
92
  // exposes it (Node's fetch), a cookie jar can still consume it.
69
93
  ...(typeof response.headers?.getSetCookie === "function" ? { setCookies: response.headers.getSetCookie() } : {}),
package/dist/client.d.ts CHANGED
@@ -20,10 +20,12 @@ export type { LambderApiSignatureMap } from "./shared/wire/LambderApiSignature.j
20
20
  export { RELOAD_LOOP_WINDOW_MS } from "./client/LambderReloadLoopBreaker.js";
21
21
  export { compareDottedVersions, isDottedVersion } from "./shared/wire/LambderVersionOrder.js";
22
22
  export type { LambderApiAnswerOutcome, LambderApiSuccessOutcome, LambderApiCallFailure, LambderApiValidationFailure, LambderApiEnvelopeFailure, LambderApiHttpAnswer, } from "./shared/wire/LambderApiOutcome.js";
23
- export type { LambderApiOutcome, LambderApiFailureReason, LambderValidationError, LambderCallOptions, LambderCallerOptions, LambderGuardInputsProvider, LambderProvidedGuardInputs, LambderIdempotencyKeyScope, LambderLogListHandler, } from "./client/LambderCaller.js";
24
- export { LambderApiRefusal, isLambderApiRefusal, refuse, LAMBDER_REFUSAL_CODES } from "./shared/wire/LambderApiRefusal.js";
23
+ export type { LambderApiOutcome, LambderApiFailureReason, LambderValidationError, LambderCallOptions, LambderCallerOptions, LambderGuardInputsProvider, LambderProvidedGuardInputs, LambderLogListHandler, } from "./client/LambderCaller.js";
24
+ export { createIdempotencyKey, createIdempotencyKeyScope } from "./shared/wire/LambderIdempotencyKeyScope.js";
25
+ export type { LambderIdempotencyKeyScope } from "./shared/wire/LambderIdempotencyKeyScope.js";
26
+ export { LambderApiRefusal, isLambderApiRefusal, refuse, refusalMessageOf, LAMBDER_REFUSAL_CODES } from "./shared/wire/LambderApiRefusal.js";
25
27
  export type { LambderApiRefusalOptions, LambderRefusalMessage, LambderAppRefusalMessage, LambderRefusalCode, LambderRefuseOptions } from "./shared/wire/LambderApiRefusal.js";
26
- export type { LambderApiContractShape, LambderApiMode, LambderApiEnvelopeBody, LambderApiResponseConfig, LambderGuardNamesIn, LambderContractMode, LambderContractKeysWithMode, LambderContractGuardsOf, LambderContractGuardNames, LambderContractGuardInputsOf, LambderContractGuardInput, LambderContractGuardInputNames, LambderContractRateLimitOf, LambderContractRateLimitNames, LambderContractIdempotencyOf, } from "./shared/wire/LambderApiContract.js";
28
+ export type { LambderApiContractShape, LambderApiMode, LambderApiEnvelopeBody, LambderApiResponseConfig, LambderGuardNamesIn, LambderContractMode, LambderContractKeysWithMode, LambderContractKeysWithGuard, LambderJsonOf, LambderJsonOutputOf, LambderContractGuardsOf, LambderContractGuardNames, LambderContractGuardInputsOf, LambderContractGuardInput, LambderContractGuardInputNames, LambderContractRateLimitOf, LambderContractRateLimitNames, LambderContractIdempotencyOf, } from "./shared/wire/LambderApiContract.js";
27
29
  export { describeCrash, errorFromCrashDetail } from "./shared/wire/LambderCrashDetail.js";
28
30
  export type { LambderCrashDetail, LambderCrashCause } from "./shared/wire/LambderCrashDetail.js";
29
31
  export { compressPayloadGzip, isRequestCompressionAvailable, COMPRESSED_PAYLOAD_GZ_FIELD, COMPRESSED_PAYLOAD_BR_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD, DEFAULT_REQUEST_COMPRESSION_SETTINGS, } from "./shared/wire/LambderRequestPayload.js";
package/dist/client.js CHANGED
@@ -19,9 +19,10 @@ export { resolveApiOutcome } from "./shared/wire/LambderApiOutcome.js";
19
19
  export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH, extensibleEnum } from "./shared/wire/LambderApiSignature.js";
20
20
  export { RELOAD_LOOP_WINDOW_MS } from "./client/LambderReloadLoopBreaker.js";
21
21
  export { compareDottedVersions, isDottedVersion } from "./shared/wire/LambderVersionOrder.js";
22
+ export { createIdempotencyKey, createIdempotencyKeyScope } from "./shared/wire/LambderIdempotencyKeyScope.js";
22
23
  // Typed API refusals (isomorphic: shared code may throw them from anywhere;
23
24
  // in the browser they are plain Errors).
24
- export { LambderApiRefusal, isLambderApiRefusal, refuse, LAMBDER_REFUSAL_CODES } from "./shared/wire/LambderApiRefusal.js";
25
+ export { LambderApiRefusal, isLambderApiRefusal, refuse, refusalMessageOf, LAMBDER_REFUSAL_CODES } from "./shared/wire/LambderApiRefusal.js";
25
26
  // A crash described for a caller allowed to see it (the envelope's `crash` field; pure, no Node built-ins).
26
27
  export { describeCrash, errorFromCrashDetail } from "./shared/wire/LambderCrashDetail.js";
27
28
  // Request payload compression (browser-safe: gzip via CompressionStream, no Node built-ins).