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.
- package/CHANGELOG.md +1047 -3
- package/README.md +46 -21
- package/dist/api/LambderApiAnswer.d.ts +18 -22
- package/dist/api/LambderApiAnswer.js +6 -7
- package/dist/api/LambderApiCallContext.d.ts +21 -8
- package/dist/api/LambderApiCallContext.js +22 -4
- package/dist/api/LambderApiDefinition.d.ts +4 -3
- package/dist/api/LambderApiEnvelope.d.ts +14 -9
- package/dist/api/LambderApiEnvelope.js +33 -34
- package/dist/api/LambderApiGuards.d.ts +78 -51
- package/dist/api/LambderApiGuards.js +34 -36
- package/dist/api/LambderApiIdempotency.d.ts +68 -62
- package/dist/api/LambderApiIdempotency.js +214 -151
- package/dist/api/LambderApiOutputValidationError.d.ts +32 -0
- package/dist/api/LambderApiOutputValidationError.js +50 -0
- package/dist/api/LambderApiPipeline.d.ts +47 -38
- package/dist/api/LambderApiPipeline.js +122 -63
- package/dist/api/LambderApiRateLimits.d.ts +201 -54
- package/dist/api/LambderApiRateLimits.js +185 -108
- package/dist/api/LambderApiRequest.d.ts +27 -21
- package/dist/api/LambderApiRequest.js +26 -19
- package/dist/api/LambderApiSignature.d.ts +12 -15
- package/dist/api/LambderApiSignature.js +28 -51
- package/dist/api/LambderApiValidationRefusal.d.ts +9 -9
- package/dist/api/LambderApiValidationRefusal.js +10 -10
- package/dist/build/ContractTypePrinter.d.ts +85 -0
- package/dist/build/ContractTypePrinter.js +402 -0
- package/dist/build/freshProcessVerifier.d.ts +13 -0
- package/dist/build/freshProcessVerifier.js +19 -0
- package/dist/build/moduleLocation.d.ts +11 -0
- package/dist/build/moduleLocation.js +6 -0
- package/dist/build/writeApiContract.d.ts +78 -0
- package/dist/build/writeApiContract.js +302 -0
- package/dist/build/writeApiSignatures.d.ts +114 -0
- package/dist/build/writeApiSignatures.js +217 -0
- package/dist/build/writeFileAtomically.d.ts +8 -0
- package/dist/build/writeFileAtomically.js +22 -0
- package/dist/build.d.ts +14 -0
- package/dist/build.js +11 -0
- package/dist/client/LambderCaller.d.ts +13 -44
- package/dist/client/LambderCaller.js +77 -84
- package/dist/client/LambderReloadLoopBreaker.d.ts +56 -26
- package/dist/client/LambderReloadLoopBreaker.js +90 -46
- package/dist/client/LambderUploadRunner.d.ts +96 -0
- package/dist/client/LambderUploadRunner.js +234 -0
- package/dist/client/lambderFetchTransport.d.ts +4 -1
- package/dist/client/lambderFetchTransport.js +52 -28
- package/dist/client.d.ts +9 -3
- package/dist/client.js +6 -1
- package/dist/core/Lambder.d.ts +143 -79
- package/dist/core/Lambder.js +350 -231
- package/dist/core/LambderContext.d.ts +82 -15
- package/dist/core/LambderContext.js +107 -20
- package/dist/core/LambderCors.d.ts +21 -3
- package/dist/core/LambderCors.js +35 -16
- package/dist/core/LambderCrashHandling.d.ts +40 -0
- package/dist/core/LambderCrashHandling.js +97 -0
- package/dist/core/LambderCreateOptions.d.ts +151 -75
- package/dist/core/LambderCreateOptions.js +16 -23
- package/dist/core/LambderFiles.d.ts +21 -7
- package/dist/core/LambderFiles.js +62 -34
- package/dist/core/LambderIndexHtml.js +12 -11
- package/dist/core/LambderPolicyBuilders.d.ts +17 -5
- package/dist/core/LambderPolicyBuilders.js +17 -5
- package/dist/core/LambderPublicFiles.d.ts +11 -5
- package/dist/core/LambderPublicFiles.js +32 -4
- package/dist/core/LambderRequestPath.d.ts +43 -0
- package/dist/core/LambderRequestPath.js +63 -0
- package/dist/core/LambderResponse.d.ts +26 -5
- package/dist/core/LambderResponse.js +157 -70
- package/dist/core/LambderResponseBuilder.d.ts +49 -4
- package/dist/core/LambderResponseBuilder.js +64 -3
- package/dist/core/LambderRouting.d.ts +2 -3
- package/dist/core/LambderRouting.js +22 -7
- package/dist/core/LambderTemplatingEngine.js +211 -32
- package/dist/index.d.ts +25 -8
- package/dist/index.js +13 -4
- package/dist/invoke/LambderInvokeCaller.d.ts +37 -42
- package/dist/invoke/LambderInvokeCaller.js +76 -66
- package/dist/invoke/LambderInvokeOutcome.d.ts +27 -26
- package/dist/invoke/LambderInvokeOutcome.js +9 -22
- package/dist/invoke/LambderLambdaEvent.d.ts +29 -9
- package/dist/invoke/LambderLambdaEvent.js +40 -22
- package/dist/invoke/lambderHandlerTransport.d.ts +9 -10
- package/dist/invoke/lambderHandlerTransport.js +15 -18
- package/dist/mock/LambderMockApp.d.ts +67 -83
- package/dist/mock/LambderMockApp.js +167 -153
- package/dist/mock/LambderMockBrowserCookies.d.ts +24 -28
- package/dist/mock/LambderMockBrowserCookies.js +24 -28
- package/dist/mock/LambderMockCallRecorder.d.ts +15 -22
- package/dist/mock/LambderMockCallRecorder.js +19 -28
- package/dist/mock/LambderMockCreateOptions.d.ts +42 -24
- package/dist/mock/LambderMockEntryRegistry.d.ts +11 -12
- package/dist/mock/LambderMockEntryRegistry.js +24 -29
- package/dist/mock/LambderMockFailureInjector.d.ts +3 -6
- package/dist/mock/LambderMockFailureInjector.js +3 -6
- package/dist/mock/LambderMockTypes.d.ts +78 -108
- package/dist/mock/lambderMockInvokeTransport.d.ts +11 -13
- package/dist/mock/lambderMockInvokeTransport.js +11 -10
- package/dist/mock/lambderMockMswHandler.d.ts +43 -33
- package/dist/mock/lambderMockMswHandler.js +50 -39
- package/dist/mock/lambderMockUploadMswHandler.d.ts +26 -0
- package/dist/mock/lambderMockUploadMswHandler.js +28 -0
- package/dist/mock.d.ts +4 -1
- package/dist/mock.js +6 -3
- package/dist/session/LambderSessionController.d.ts +108 -89
- package/dist/session/LambderSessionController.js +187 -168
- package/dist/session/LambderSessionCrypto.d.ts +16 -7
- package/dist/session/LambderSessionCrypto.js +26 -12
- package/dist/session/LambderSessionManager.d.ts +124 -46
- package/dist/session/LambderSessionManager.js +262 -137
- package/dist/shared/LambderHtml.d.ts +42 -3
- package/dist/shared/LambderHtml.js +127 -7
- package/dist/shared/LambderHtmlPositions.d.ts +173 -0
- package/dist/shared/LambderHtmlPositions.js +652 -0
- package/dist/shared/LambderI18n.d.ts +10 -11
- package/dist/shared/LambderI18n.js +33 -21
- package/dist/shared/contracts/LambderCache.d.ts +66 -0
- package/dist/shared/contracts/LambderCache.js +11 -0
- package/dist/shared/contracts/LambderFileSource.d.ts +6 -6
- package/dist/shared/contracts/LambderFileSource.js +5 -8
- package/dist/shared/contracts/LambderIdempotencyStore.d.ts +51 -22
- package/dist/shared/contracts/LambderIdempotencyStore.js +4 -5
- package/dist/shared/contracts/LambderRateLimiter.d.ts +27 -15
- package/dist/shared/contracts/LambderRateLimiter.js +4 -5
- package/dist/shared/contracts/LambderSessionStore.d.ts +65 -26
- package/dist/shared/contracts/LambderSessionStore.js +5 -6
- package/dist/shared/contracts/LambderUploadBucket.d.ts +154 -0
- package/dist/shared/contracts/LambderUploadBucket.js +74 -0
- package/dist/shared/transport/LambderApiTransport.d.ts +27 -27
- package/dist/shared/transport/LambderApiTransport.js +7 -7
- package/dist/shared/transport/LambderCookieJar.d.ts +28 -35
- package/dist/shared/transport/LambderCookieJar.js +54 -66
- package/dist/shared/transport/lambderCookieJarTransport.d.ts +11 -13
- package/dist/shared/transport/lambderCookieJarTransport.js +24 -23
- package/dist/shared/util/LambderCallAbort.d.ts +5 -5
- package/dist/shared/util/LambderCallAbort.js +5 -5
- package/dist/shared/util/LambderClientIp.d.ts +27 -11
- package/dist/shared/util/LambderClientIp.js +96 -13
- package/dist/shared/util/LambderContentDisposition.d.ts +10 -0
- package/dist/shared/util/LambderContentDisposition.js +13 -0
- package/dist/shared/util/LambderExpiringMap.d.ts +35 -49
- package/dist/shared/util/LambderExpiringMap.js +41 -57
- package/dist/shared/util/LambderNodeModules.js +6 -7
- package/dist/shared/util/LambderOptionChecks.d.ts +4 -4
- package/dist/shared/util/LambderOptionChecks.js +4 -4
- package/dist/shared/util/LambderResponseBrand.d.ts +5 -5
- package/dist/shared/util/LambderResponseBrand.js +5 -5
- package/dist/shared/util/LambderTextDigest.d.ts +7 -5
- package/dist/shared/util/LambderTextDigest.js +11 -5
- package/dist/shared/util/LambderTypeUtilities.d.ts +7 -8
- package/dist/shared/util/LambderTypeUtilities.js +3 -3
- package/dist/shared/util/boundKeyField.d.ts +20 -0
- package/dist/shared/util/boundKeyField.js +34 -0
- package/dist/shared/util/canonicalJson.d.ts +11 -0
- package/dist/shared/util/canonicalJson.js +28 -0
- package/dist/shared/util/joinKeyFields.d.ts +20 -0
- package/dist/shared/util/joinKeyFields.js +22 -0
- package/dist/shared/wire/LambderAnswerHeaders.d.ts +12 -16
- package/dist/shared/wire/LambderAnswerHeaders.js +12 -16
- package/dist/shared/wire/LambderApiContract.d.ts +98 -53
- package/dist/shared/wire/LambderApiOutcome.d.ts +43 -31
- package/dist/shared/wire/LambderApiOutcome.js +48 -23
- package/dist/shared/wire/LambderApiRefusal.d.ts +45 -27
- package/dist/shared/wire/LambderApiRefusal.js +42 -7
- package/dist/shared/wire/LambderApiSignature.d.ts +18 -22
- package/dist/shared/wire/LambderApiSignature.js +16 -19
- package/dist/shared/wire/LambderCallOptions.d.ts +38 -47
- package/dist/shared/wire/LambderCallOptions.js +9 -11
- package/dist/shared/wire/LambderCompressionCodec.d.ts +29 -34
- package/dist/shared/wire/LambderCompressionCodec.js +31 -36
- package/dist/shared/wire/LambderCompressionOption.d.ts +9 -9
- package/dist/shared/wire/LambderCompressionOption.js +9 -9
- package/dist/shared/wire/LambderCrashDetail.d.ts +12 -15
- package/dist/shared/wire/LambderCrashDetail.js +12 -15
- package/dist/shared/wire/LambderDefaultApiPath.d.ts +6 -0
- package/dist/shared/wire/LambderDefaultApiPath.js +6 -0
- package/dist/shared/wire/LambderHttpStatus.d.ts +6 -7
- package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +89 -0
- package/dist/shared/wire/LambderIdempotencyKeyScope.js +146 -0
- package/dist/shared/wire/LambderInvokeApiId.d.ts +27 -0
- package/dist/shared/wire/LambderInvokeApiId.js +27 -0
- package/dist/shared/wire/LambderOutcomeAssertions.d.ts +6 -7
- package/dist/shared/wire/LambderOutcomeAssertions.js +6 -7
- package/dist/shared/wire/LambderRequestPayload.d.ts +18 -20
- package/dist/shared/wire/LambderRequestPayload.js +4 -6
- package/dist/shared/wire/LambderUploadObjectFields.d.ts +10 -0
- package/dist/shared/wire/LambderUploadObjectFields.js +24 -0
- package/dist/shared/wire/LambderUploadRefusal.d.ts +9 -0
- package/dist/shared/wire/LambderUploadRefusal.js +18 -0
- package/dist/shared/wire/LambderUploadSchemas.d.ts +12 -0
- package/dist/shared/wire/LambderUploadSchemas.js +30 -0
- package/dist/stores/LambderCacheFiller.d.ts +48 -0
- package/dist/stores/LambderCacheFiller.js +119 -0
- package/dist/stores/LambderCacheKeys.d.ts +26 -0
- package/dist/stores/LambderCacheKeys.js +54 -0
- package/dist/stores/LambderCacheValues.d.ts +45 -0
- package/dist/stores/LambderCacheValues.js +74 -0
- package/dist/stores/LambderDdbCache.d.ts +121 -56
- package/dist/stores/LambderDdbCache.js +528 -225
- package/dist/stores/LambderDdbIdempotencyStore.d.ts +33 -22
- package/dist/stores/LambderDdbIdempotencyStore.js +75 -50
- package/dist/stores/LambderDdbRateLimiter.d.ts +76 -20
- package/dist/stores/LambderDdbRateLimiter.js +151 -39
- package/dist/stores/LambderDdbSdk.d.ts +43 -31
- package/dist/stores/LambderDdbSdk.js +80 -38
- package/dist/stores/LambderDdbSessionStore.d.ts +27 -14
- package/dist/stores/LambderDdbSessionStore.js +119 -47
- package/dist/stores/LambderHttpFileSource.d.ts +15 -6
- package/dist/stores/LambderHttpFileSource.js +15 -13
- package/dist/stores/LambderMemoryCache.d.ts +49 -0
- package/dist/stores/LambderMemoryCache.js +113 -0
- package/dist/stores/LambderMemoryIdempotencyStore.d.ts +13 -12
- package/dist/stores/LambderMemoryIdempotencyStore.js +31 -30
- package/dist/stores/LambderMemoryRateLimiter.d.ts +8 -9
- package/dist/stores/LambderMemoryRateLimiter.js +14 -13
- package/dist/stores/LambderMemorySessionStore.d.ts +14 -11
- package/dist/stores/LambderMemorySessionStore.js +38 -19
- package/dist/stores/LambderMemoryUploadBucket.d.ts +99 -0
- package/dist/stores/LambderMemoryUploadBucket.js +219 -0
- package/dist/stores/LambderS3FileSource.d.ts +21 -6
- package/dist/stores/LambderS3FileSource.js +12 -7
- package/dist/stores/LambderS3UploadBucket.d.ts +73 -0
- package/dist/stores/LambderS3UploadBucket.js +144 -0
- package/dist/stores/LambderSdkInstallHint.d.ts +11 -0
- package/dist/stores/LambderSdkInstallHint.js +14 -0
- package/dist/testing/LambderTestApp.d.ts +23 -25
- package/dist/testing/LambderTestApp.js +22 -24
- package/dist/testing/LambderTestVisitor.d.ts +10 -12
- package/dist/testing/LambderTestVisitor.js +15 -15
- package/dist/testing.d.ts +3 -0
- package/dist/testing.js +2 -0
- package/package.json +26 -3
- package/dist/api/LambderApiPolicyEngine.d.ts +0 -47
- package/dist/api/LambderApiPolicyEngine.js +0 -85
- package/dist/shared/util/LambderKeyFields.d.ts +0 -32
- 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
|
|
5
|
-
* not the one the server holds
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* cached bundle, a server deploy that failed behind
|
|
9
|
-
* reload brings back the same
|
|
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
|
|
13
|
-
* the same
|
|
14
|
-
* changed the endpoint would carry a different signature
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
*
|
|
20
|
-
* the
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
30
|
-
&& typeof value
|
|
31
|
-
&& typeof value
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
55
|
-
if (
|
|
56
|
-
const parsed = JSON.parse(
|
|
57
|
-
if (
|
|
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
|
|
62
|
-
return
|
|
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 { /*
|
|
113
|
+
catch { /* blocked or full: nothing outlives this page */ }
|
|
70
114
|
}
|
|
71
115
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { type LambderUploadFileFacts, type LambderUploadRule, type LambderUploadRuleVerdict, type LambderUploadTicket } from "../shared/contracts/LambderUploadBucket.js";
|
|
2
|
+
/** Where an upload is, in order. `sentBytes` only moves during `uploading`. */
|
|
3
|
+
export type LambderUploadPhase = "hashing" | "requesting" | "uploading" | "confirming";
|
|
4
|
+
export type LambderUploadProgress = {
|
|
5
|
+
phase: LambderUploadPhase;
|
|
6
|
+
sentBytes: number;
|
|
7
|
+
totalBytes: number;
|
|
8
|
+
};
|
|
9
|
+
export type LambderUploadFailureReason = LambderUploadRuleVerdict
|
|
10
|
+
/** The browser could not read the file (moved, deleted, or a cloud placeholder that never downloaded). */
|
|
11
|
+
| "fileUnreadable"
|
|
12
|
+
/** The app's ticket endpoint would not issue a ticket. */
|
|
13
|
+
| "ticketRefused"
|
|
14
|
+
/** Storage answered and said no, for a reason a retry cannot cure. */
|
|
15
|
+
| "storageRejected"
|
|
16
|
+
/** Storage could not be reached, or kept stalling, through every attempt. */
|
|
17
|
+
| "networkFailed"
|
|
18
|
+
/** The bytes are stored, and the app's confirm endpoint would not confirm them. */
|
|
19
|
+
| "confirmRefused" | "cancelled";
|
|
20
|
+
/** How an upload failed: `reason` for a screen to word, the underlying error as `cause`. */
|
|
21
|
+
export declare class LambderUploadError extends Error {
|
|
22
|
+
readonly reason: LambderUploadFailureReason;
|
|
23
|
+
constructor(reason: LambderUploadFailureReason, options?: {
|
|
24
|
+
cause?: unknown;
|
|
25
|
+
detail?: string;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
export type LambderUploadRunnerOptions<Reference, Receipt> = {
|
|
29
|
+
uploadRule: LambderUploadRule;
|
|
30
|
+
/**
|
|
31
|
+
* The app's ticket endpoint. `reference` is whatever its confirm endpoint
|
|
32
|
+
* needs to find this upload again (the id of the record it made), and
|
|
33
|
+
* means nothing to the runner. `signal` is the upload's own, for the call
|
|
34
|
+
* to pass on so a cancel stops it too.
|
|
35
|
+
*/
|
|
36
|
+
requestTicket: (fileFacts: LambderUploadFileFacts, call: {
|
|
37
|
+
signal: AbortSignal | undefined;
|
|
38
|
+
}) => Promise<{
|
|
39
|
+
ticket: LambderUploadTicket;
|
|
40
|
+
reference: Reference;
|
|
41
|
+
}>;
|
|
42
|
+
/** The app's confirm endpoint: the server checks the stored object and answers its record of it. */
|
|
43
|
+
confirmUpload: (reference: Reference, call: {
|
|
44
|
+
signal: AbortSignal | undefined;
|
|
45
|
+
}) => Promise<Receipt>;
|
|
46
|
+
/** The app's way of forgetting a confirmed upload the person removed again. Without one, discard() does nothing. */
|
|
47
|
+
discardUpload?: (receipt: Receipt) => Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* How storage is tried again when it cannot be reached, stalls, or answers
|
|
50
|
+
* a failure a retry can cure (a 5xx, RequestTimeout, SlowDown). Each wait
|
|
51
|
+
* is a random time between `baseDelayMs` and a ceiling that doubles with
|
|
52
|
+
* every failed attempt, never past `maxDelayMs`, so many browsers dropped
|
|
53
|
+
* together do not come back in step. Default: 4 attempts, waits from one
|
|
54
|
+
* second to 15.
|
|
55
|
+
*/
|
|
56
|
+
storageRetry?: {
|
|
57
|
+
attempts?: number;
|
|
58
|
+
baseDelayMs?: number;
|
|
59
|
+
maxDelayMs?: number;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* How long a post may be open and move nothing before it counts as
|
|
63
|
+
* dropped. Default: 60 seconds. Watched where XMLHttpRequest exists,
|
|
64
|
+
* which reports a body's progress; a runtime with only fetch posts
|
|
65
|
+
* unwatched.
|
|
66
|
+
*/
|
|
67
|
+
stallTimeoutMs?: number;
|
|
68
|
+
};
|
|
69
|
+
export declare class LambderUploadRunner<Reference, Receipt> {
|
|
70
|
+
private readonly options;
|
|
71
|
+
private readonly attempts;
|
|
72
|
+
private readonly baseDelayMs;
|
|
73
|
+
private readonly maxDelayMs;
|
|
74
|
+
private readonly stallTimeoutMs;
|
|
75
|
+
constructor(options: LambderUploadRunnerOptions<Reference, Receipt>);
|
|
76
|
+
/** For a file input's `accept`, so the picker only offers what the rule takes. */
|
|
77
|
+
get acceptedTypes(): string;
|
|
78
|
+
get maxBytes(): number;
|
|
79
|
+
/** The rule's verdict on a file, or null when it may be uploaded. Costs nothing, so a screen can ask on drop. */
|
|
80
|
+
checkFile(file: Blob): LambderUploadRuleVerdict | null;
|
|
81
|
+
/**
|
|
82
|
+
* Uploads one file and answers the app's receipt, or throws a
|
|
83
|
+
* LambderUploadError. Aborting `signal` stops it wherever it is: the
|
|
84
|
+
* runner's own steps at once, and the app's calls as far as they pass
|
|
85
|
+
* the signal on.
|
|
86
|
+
*/
|
|
87
|
+
upload(file: File, { onProgress, signal }?: {
|
|
88
|
+
onProgress?: (progress: LambderUploadProgress) => void;
|
|
89
|
+
signal?: AbortSignal;
|
|
90
|
+
}): Promise<Receipt>;
|
|
91
|
+
/** Forgets a confirmed upload through the app's endpoint, when it declared one. */
|
|
92
|
+
discard(receipt: Receipt): Promise<void>;
|
|
93
|
+
private waitBeforeRetry;
|
|
94
|
+
/** One post of the file to storage. Never throws: every ending is an outcome. */
|
|
95
|
+
private post;
|
|
96
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { checkUploadRule, } from "../shared/contracts/LambderUploadBucket.js";
|
|
2
|
+
import { sha256Base64Of } from "../shared/util/LambderTextDigest.js";
|
|
3
|
+
/** How an upload failed: `reason` for a screen to word, the underlying error as `cause`. */
|
|
4
|
+
export class LambderUploadError extends Error {
|
|
5
|
+
reason;
|
|
6
|
+
constructor(reason, options = {}) {
|
|
7
|
+
super(options.detail ? `${reason}: ${options.detail}` : reason, { cause: options.cause });
|
|
8
|
+
this.name = "LambderUploadError";
|
|
9
|
+
this.reason = reason;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* How many times one upload asks for a new ticket because storage called the
|
|
14
|
+
* last one expired. A new ticket is asked for at once and spends no attempt
|
|
15
|
+
* at storage; the bound is for a clock so far off that every ticket arrives
|
|
16
|
+
* expired.
|
|
17
|
+
*/
|
|
18
|
+
const TICKET_RENEWAL_LIMIT = 2;
|
|
19
|
+
/** S3's refusals that are the connection's or the service's fault rather than the file's, which its own SDK retries too. */
|
|
20
|
+
const TRANSIENT_STORAGE_CODES = new Set(["RequestTimeout", "SlowDown", "InternalError", "ServiceUnavailable"]);
|
|
21
|
+
export class LambderUploadRunner {
|
|
22
|
+
options;
|
|
23
|
+
attempts;
|
|
24
|
+
baseDelayMs;
|
|
25
|
+
maxDelayMs;
|
|
26
|
+
stallTimeoutMs;
|
|
27
|
+
constructor(options) {
|
|
28
|
+
this.options = options;
|
|
29
|
+
this.attempts = Math.max(1, options.storageRetry?.attempts ?? 4);
|
|
30
|
+
this.baseDelayMs = options.storageRetry?.baseDelayMs ?? 1_000;
|
|
31
|
+
this.maxDelayMs = options.storageRetry?.maxDelayMs ?? 15_000;
|
|
32
|
+
this.stallTimeoutMs = options.stallTimeoutMs ?? 60_000;
|
|
33
|
+
}
|
|
34
|
+
/** For a file input's `accept`, so the picker only offers what the rule takes. */
|
|
35
|
+
get acceptedTypes() {
|
|
36
|
+
return this.options.uploadRule.mimeTypes.join(",");
|
|
37
|
+
}
|
|
38
|
+
get maxBytes() {
|
|
39
|
+
return this.options.uploadRule.maxBytes;
|
|
40
|
+
}
|
|
41
|
+
/** The rule's verdict on a file, or null when it may be uploaded. Costs nothing, so a screen can ask on drop. */
|
|
42
|
+
checkFile(file) {
|
|
43
|
+
return checkUploadRule(this.options.uploadRule, { mimeType: file.type, byteSize: file.size });
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Uploads one file and answers the app's receipt, or throws a
|
|
47
|
+
* LambderUploadError. Aborting `signal` stops it wherever it is: the
|
|
48
|
+
* runner's own steps at once, and the app's calls as far as they pass
|
|
49
|
+
* the signal on.
|
|
50
|
+
*/
|
|
51
|
+
async upload(file, { onProgress, signal } = {}) {
|
|
52
|
+
const rejection = this.checkFile(file);
|
|
53
|
+
if (rejection)
|
|
54
|
+
throw new LambderUploadError(rejection);
|
|
55
|
+
const report = (phase, sentBytes = 0) => onProgress?.({ phase, sentBytes, totalBytes: file.size });
|
|
56
|
+
const stopIfCancelled = () => {
|
|
57
|
+
if (signal?.aborted)
|
|
58
|
+
throw new LambderUploadError("cancelled");
|
|
59
|
+
};
|
|
60
|
+
stopIfCancelled();
|
|
61
|
+
report("hashing");
|
|
62
|
+
let bytes;
|
|
63
|
+
try {
|
|
64
|
+
bytes = new Uint8Array(await file.arrayBuffer());
|
|
65
|
+
}
|
|
66
|
+
catch (cause) {
|
|
67
|
+
throw new LambderUploadError("fileUnreadable", { cause });
|
|
68
|
+
}
|
|
69
|
+
const fileFacts = {
|
|
70
|
+
fileName: file.name,
|
|
71
|
+
mimeType: file.type,
|
|
72
|
+
byteSize: file.size,
|
|
73
|
+
sha256Base64: await sha256Base64Of(bytes),
|
|
74
|
+
};
|
|
75
|
+
stopIfCancelled();
|
|
76
|
+
const requestTicket = async () => {
|
|
77
|
+
report("requesting");
|
|
78
|
+
try {
|
|
79
|
+
return await this.options.requestTicket(fileFacts, { signal });
|
|
80
|
+
}
|
|
81
|
+
catch (cause) {
|
|
82
|
+
throw new LambderUploadError(signal?.aborted ? "cancelled" : "ticketRefused", { cause });
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
let issued = await requestTicket();
|
|
86
|
+
let failedAttempts = 0;
|
|
87
|
+
let ticketRenewals = 0;
|
|
88
|
+
for (;;) {
|
|
89
|
+
stopIfCancelled();
|
|
90
|
+
report("uploading");
|
|
91
|
+
const outcome = await this.post(issued.ticket, file, signal, (sentBytes) => report("uploading", sentBytes));
|
|
92
|
+
if (outcome.kind === "stored")
|
|
93
|
+
break;
|
|
94
|
+
if (outcome.kind === "cancelled")
|
|
95
|
+
throw new LambderUploadError("cancelled");
|
|
96
|
+
if (outcome.kind === "rejected") {
|
|
97
|
+
// An expired ticket needs no wait and costs no attempt, only a
|
|
98
|
+
// new ticket; any other refusal is the file's, and final.
|
|
99
|
+
if (!outcome.ticketExpired || ++ticketRenewals > TICKET_RENEWAL_LIMIT)
|
|
100
|
+
throw new LambderUploadError("storageRejected", { detail: outcome.detail });
|
|
101
|
+
issued = await requestTicket();
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
// The ticket is kept through a network retry, so a flaky
|
|
105
|
+
// connection does not leave the app a record per attempt.
|
|
106
|
+
if (++failedAttempts >= this.attempts)
|
|
107
|
+
throw new LambderUploadError("networkFailed");
|
|
108
|
+
await this.waitBeforeRetry(failedAttempts, signal);
|
|
109
|
+
}
|
|
110
|
+
report("confirming", file.size);
|
|
111
|
+
try {
|
|
112
|
+
return await this.options.confirmUpload(issued.reference, { signal });
|
|
113
|
+
}
|
|
114
|
+
catch (cause) {
|
|
115
|
+
throw new LambderUploadError(signal?.aborted ? "cancelled" : "confirmRefused", { cause });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Forgets a confirmed upload through the app's endpoint, when it declared one. */
|
|
119
|
+
async discard(receipt) {
|
|
120
|
+
await this.options.discardUpload?.(receipt);
|
|
121
|
+
}
|
|
122
|
+
waitBeforeRetry(failedAttempts, signal) {
|
|
123
|
+
const ceiling = Math.max(this.baseDelayMs, Math.min(this.baseDelayMs * 2 ** (failedAttempts - 1), this.maxDelayMs));
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
if (signal?.aborted)
|
|
126
|
+
return reject(new LambderUploadError("cancelled"));
|
|
127
|
+
const cancel = () => {
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
reject(new LambderUploadError("cancelled"));
|
|
130
|
+
};
|
|
131
|
+
const timer = setTimeout(() => {
|
|
132
|
+
signal?.removeEventListener("abort", cancel);
|
|
133
|
+
resolve();
|
|
134
|
+
}, this.baseDelayMs + Math.random() * (ceiling - this.baseDelayMs));
|
|
135
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
/** One post of the file to storage. Never throws: every ending is an outcome. */
|
|
139
|
+
post(ticket, file, signal, onSent) {
|
|
140
|
+
if (signal?.aborted)
|
|
141
|
+
return Promise.resolve({ kind: "cancelled" });
|
|
142
|
+
const form = new FormData();
|
|
143
|
+
for (const [name, value] of Object.entries(ticket.formFields))
|
|
144
|
+
form.append(name, value);
|
|
145
|
+
// Storage ignores every field that comes after the file.
|
|
146
|
+
form.append("file", file);
|
|
147
|
+
return typeof XMLHttpRequest === "function"
|
|
148
|
+
? postWithXhr(ticket.uploadUrl, form, file.size, this.stallTimeoutMs, signal, onSent)
|
|
149
|
+
: postWithFetch(ticket.uploadUrl, form, file.size, signal, onSent);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** XMLHttpRequest rather than fetch where it exists: fetch cannot report how much of a request body has been sent. */
|
|
153
|
+
const postWithXhr = (url, form, fileBytes, stallTimeoutMs, signal, onSent) => new Promise((resolve) => {
|
|
154
|
+
const request = new XMLHttpRequest();
|
|
155
|
+
let stalled = false;
|
|
156
|
+
let stallTimer;
|
|
157
|
+
const watchForStall = () => {
|
|
158
|
+
clearTimeout(stallTimer);
|
|
159
|
+
stallTimer = setTimeout(() => {
|
|
160
|
+
stalled = true;
|
|
161
|
+
request.abort();
|
|
162
|
+
}, stallTimeoutMs);
|
|
163
|
+
};
|
|
164
|
+
const cancel = () => request.abort();
|
|
165
|
+
const settle = (outcome) => {
|
|
166
|
+
clearTimeout(stallTimer);
|
|
167
|
+
signal?.removeEventListener("abort", cancel);
|
|
168
|
+
resolve(outcome);
|
|
169
|
+
};
|
|
170
|
+
request.upload.onprogress = (event) => {
|
|
171
|
+
watchForStall();
|
|
172
|
+
// `loaded` counts the form's own framing too, a little over the file.
|
|
173
|
+
onSent(Math.min(event.loaded, fileBytes));
|
|
174
|
+
};
|
|
175
|
+
request.onload = () => {
|
|
176
|
+
if (request.status >= 200 && request.status < 300)
|
|
177
|
+
return settle({ kind: "stored" });
|
|
178
|
+
if (request.status >= 500)
|
|
179
|
+
return settle({ kind: "unreachable" });
|
|
180
|
+
settle(rejectedOutcome(request.status, request.responseText));
|
|
181
|
+
};
|
|
182
|
+
request.onerror = () => settle({ kind: "unreachable" });
|
|
183
|
+
request.onabort = () => settle(stalled ? { kind: "unreachable" } : { kind: "cancelled" });
|
|
184
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
185
|
+
watchForStall();
|
|
186
|
+
try {
|
|
187
|
+
request.open("POST", url);
|
|
188
|
+
request.send(form);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
// A URL the browser will not open, or a request it will not send, answers nothing.
|
|
192
|
+
settle({ kind: "unreachable" });
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
const postWithFetch = async (url, form, fileBytes, signal, onSent) => {
|
|
196
|
+
let response;
|
|
197
|
+
try {
|
|
198
|
+
response = await fetch(url, { method: "POST", body: form, signal });
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return signal?.aborted ? { kind: "cancelled" } : { kind: "unreachable" };
|
|
202
|
+
}
|
|
203
|
+
if (response.ok) {
|
|
204
|
+
onSent(fileBytes);
|
|
205
|
+
return { kind: "stored" };
|
|
206
|
+
}
|
|
207
|
+
if (response.status >= 500)
|
|
208
|
+
return { kind: "unreachable" };
|
|
209
|
+
return rejectedOutcome(response.status, await response.text().catch(() => ""));
|
|
210
|
+
};
|
|
211
|
+
const XML_ENTITIES = { amp: "&", lt: "<", gt: ">", quot: "\"", apos: "'" };
|
|
212
|
+
const xmlText = (text) => text.replace(/&(#x[0-9a-f]+|#\d+|\w+);/gi, (entity, name) => {
|
|
213
|
+
if (name[0] !== "#")
|
|
214
|
+
return XML_ENTITIES[name] ?? entity;
|
|
215
|
+
const codePoint = Number(name[1]?.toLowerCase() === "x" ? `0${name.slice(1)}` : name.slice(1));
|
|
216
|
+
return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : entity;
|
|
217
|
+
});
|
|
218
|
+
/**
|
|
219
|
+
* Storage explains a refusal in XML, `<Error><Code/><Message/></Error>`,
|
|
220
|
+
* read here without a DOM so the runner works wherever fetch does. A
|
|
221
|
+
* transient code is tried again like a dropped connection, and an expired
|
|
222
|
+
* ticket is the one refusal a new ticket cures.
|
|
223
|
+
*/
|
|
224
|
+
const rejectedOutcome = (status, body) => {
|
|
225
|
+
const code = xmlText(/<Code>([^<]*)<\/Code>/.exec(body)?.[1] ?? "") || `HTTP ${status}`;
|
|
226
|
+
const message = xmlText(/<Message>([^<]*)<\/Message>/.exec(body)?.[1] ?? "");
|
|
227
|
+
if (TRANSIENT_STORAGE_CODES.has(code))
|
|
228
|
+
return { kind: "unreachable" };
|
|
229
|
+
return {
|
|
230
|
+
kind: "rejected",
|
|
231
|
+
ticketExpired: code === "AccessDenied" && /expired/i.test(message),
|
|
232
|
+
detail: message ? `${code}: ${message}` : code,
|
|
233
|
+
};
|
|
234
|
+
};
|
|
@@ -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
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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
|
-
|
|
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
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
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
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
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: () =>
|
|
66
|
-
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() } : {}),
|