@brass-build/client 0.4.0 → 0.5.0
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/AGENTS.md +142 -41
- package/CHANGELOG.md +93 -4
- package/README.md +666 -179
- package/dist/acting-organization.d.ts.map +1 -1
- package/dist/acting-organization.js +6 -11
- package/dist/acting-organization.js.map +1 -1
- package/dist/api-fetch.d.ts +8 -0
- package/dist/api-fetch.d.ts.map +1 -1
- package/dist/api-fetch.js +35 -8
- package/dist/api-fetch.js.map +1 -1
- package/dist/batched-get.d.ts +11 -0
- package/dist/batched-get.d.ts.map +1 -0
- package/dist/batched-get.js +92 -0
- package/dist/batched-get.js.map +1 -0
- package/dist/call-service.d.ts +4 -0
- package/dist/call-service.d.ts.map +1 -1
- package/dist/call-service.js +66 -3
- package/dist/call-service.js.map +1 -1
- package/dist/client.d.ts +51 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +982 -124
- package/dist/client.js.map +1 -1
- package/dist/document-checkpoints.d.ts +25 -2
- package/dist/document-checkpoints.d.ts.map +1 -1
- package/dist/document-checkpoints.js +258 -61
- package/dist/document-checkpoints.js.map +1 -1
- package/dist/document-commands.d.ts.map +1 -1
- package/dist/document-commands.js +5 -3
- package/dist/document-commands.js.map +1 -1
- package/dist/document-contents.d.ts +10 -0
- package/dist/document-contents.d.ts.map +1 -1
- package/dist/document-contents.js +23 -1
- package/dist/document-contents.js.map +1 -1
- package/dist/document-set-subscription.d.ts +56 -0
- package/dist/document-set-subscription.d.ts.map +1 -0
- package/dist/document-set-subscription.js +240 -0
- package/dist/document-set-subscription.js.map +1 -0
- package/dist/document-signals.d.ts +6 -0
- package/dist/document-signals.d.ts.map +1 -1
- package/dist/document-signals.js +31 -1
- package/dist/document-signals.js.map +1 -1
- package/dist/document-streams.d.ts +36 -2
- package/dist/document-streams.d.ts.map +1 -1
- package/dist/document-streams.js +69 -18
- package/dist/document-streams.js.map +1 -1
- package/dist/document-thumbnail-capture.d.ts +12 -2
- package/dist/document-thumbnail-capture.d.ts.map +1 -1
- package/dist/document-thumbnail-capture.js +103 -27
- package/dist/document-thumbnail-capture.js.map +1 -1
- package/dist/documents-namespace.d.ts +92 -10
- package/dist/documents-namespace.d.ts.map +1 -1
- package/dist/documents-namespace.js +285 -39
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +173 -8
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +1778 -310
- package/dist/documents.js.map +1 -1
- package/dist/errors.d.ts +3 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/fetch-retry.d.ts +1 -0
- package/dist/fetch-retry.d.ts.map +1 -1
- package/dist/fetch-retry.js +2 -0
- package/dist/fetch-retry.js.map +1 -1
- package/dist/file-choice.d.ts +2 -0
- package/dist/file-choice.d.ts.map +1 -0
- package/dist/file-choice.js +27 -0
- package/dist/file-choice.js.map +1 -0
- package/dist/guest-link.d.ts +18 -1
- package/dist/guest-link.d.ts.map +1 -1
- package/dist/guest-link.js +329 -1
- package/dist/guest-link.js.map +1 -1
- package/dist/handle-types.d.ts +67 -5
- package/dist/handle-types.d.ts.map +1 -1
- package/dist/handles.d.ts +15 -19
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +231 -76
- package/dist/handles.js.map +1 -1
- package/dist/hosted-ui.d.ts +61 -0
- package/dist/hosted-ui.d.ts.map +1 -1
- package/dist/hosted-ui.js +94 -42
- package/dist/hosted-ui.js.map +1 -1
- package/dist/import-contract.d.ts +74 -0
- package/dist/import-contract.d.ts.map +1 -0
- package/dist/import-contract.js +163 -0
- package/dist/import-contract.js.map +1 -0
- package/dist/import-guest.d.ts +9 -0
- package/dist/import-guest.d.ts.map +1 -0
- package/dist/import-guest.js +155 -0
- package/dist/import-guest.js.map +1 -0
- package/dist/import-report.d.ts +17 -0
- package/dist/import-report.d.ts.map +1 -0
- package/dist/import-report.js +7 -0
- package/dist/import-report.js.map +1 -0
- package/dist/importer-wait.d.ts +2 -0
- package/dist/importer-wait.d.ts.map +1 -0
- package/dist/importer-wait.js +5 -0
- package/dist/importer-wait.js.map +1 -0
- package/dist/importer.d.ts +18 -4
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +322 -105
- package/dist/importer.js.map +1 -1
- package/dist/in-development.d.ts +2 -0
- package/dist/in-development.d.ts.map +1 -0
- package/dist/in-development.js +10 -0
- package/dist/in-development.js.map +1 -0
- package/dist/index.d.ts +16 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -8
- package/dist/index.js.map +1 -1
- package/dist/init-keys.d.ts +1 -1
- package/dist/init-keys.d.ts.map +1 -1
- package/dist/init-keys.js +6 -1
- package/dist/init-keys.js.map +1 -1
- package/dist/input-documents.d.ts +67 -0
- package/dist/input-documents.d.ts.map +1 -0
- package/dist/input-documents.js +693 -0
- package/dist/input-documents.js.map +1 -0
- package/dist/inputs-status.d.ts +80 -0
- package/dist/inputs-status.d.ts.map +1 -0
- package/dist/inputs-status.js +460 -0
- package/dist/inputs-status.js.map +1 -0
- package/dist/inputs.d.ts +2 -0
- package/dist/inputs.d.ts.map +1 -0
- package/dist/inputs.js +793 -0
- package/dist/inputs.js.map +1 -0
- package/dist/internal.d.ts +6 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +4 -1
- package/dist/internal.js.map +1 -1
- package/dist/join-content.d.ts +33 -0
- package/dist/join-content.d.ts.map +1 -0
- package/dist/join-content.js +114 -0
- package/dist/join-content.js.map +1 -0
- package/dist/join-stamp.d.ts +3 -0
- package/dist/join-stamp.d.ts.map +1 -0
- package/dist/join-stamp.js +12 -0
- package/dist/join-stamp.js.map +1 -0
- package/dist/manifest-drift.d.ts +9 -0
- package/dist/manifest-drift.d.ts.map +1 -0
- package/dist/manifest-drift.js +46 -0
- package/dist/manifest-drift.js.map +1 -0
- package/dist/open-document.d.ts +109 -2
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +1822 -811
- package/dist/open-document.js.map +1 -1
- package/dist/page-restore.d.ts +12 -0
- package/dist/page-restore.d.ts.map +1 -0
- package/dist/page-restore.js +23 -0
- package/dist/page-restore.js.map +1 -0
- package/dist/placement.d.ts +13 -0
- package/dist/placement.d.ts.map +1 -0
- package/dist/placement.js +34 -0
- package/dist/placement.js.map +1 -0
- package/dist/presence.d.ts +1 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js +16 -4
- package/dist/presence.js.map +1 -1
- package/dist/read-only-document.d.ts +87 -0
- package/dist/read-only-document.d.ts.map +1 -0
- package/dist/read-only-document.js +4 -0
- package/dist/read-only-document.js.map +1 -0
- package/dist/record-paging.d.ts +63 -6
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +122 -29
- package/dist/record-paging.js.map +1 -1
- package/dist/references.d.ts +40 -0
- package/dist/references.d.ts.map +1 -0
- package/dist/references.js +135 -0
- package/dist/references.js.map +1 -0
- package/dist/request-lane.d.ts +5 -0
- package/dist/request-lane.d.ts.map +1 -0
- package/dist/request-lane.js +48 -0
- package/dist/request-lane.js.map +1 -0
- package/dist/sealed-arrow.d.ts +170 -0
- package/dist/sealed-arrow.d.ts.map +1 -0
- package/dist/sealed-arrow.js +769 -0
- package/dist/sealed-arrow.js.map +1 -0
- package/dist/sealed-cache.d.ts +21 -0
- package/dist/sealed-cache.d.ts.map +1 -0
- package/dist/sealed-cache.js +247 -0
- package/dist/sealed-cache.js.map +1 -0
- package/dist/sealed-encode.d.ts +87 -0
- package/dist/sealed-encode.d.ts.map +1 -0
- package/dist/sealed-encode.js +876 -0
- package/dist/sealed-encode.js.map +1 -0
- package/dist/sealed-handles.d.ts +45 -0
- package/dist/sealed-handles.d.ts.map +1 -0
- package/dist/sealed-handles.js +408 -0
- package/dist/sealed-handles.js.map +1 -0
- package/dist/sealed-store.d.ts +242 -0
- package/dist/sealed-store.d.ts.map +1 -0
- package/dist/sealed-store.js +1155 -0
- package/dist/sealed-store.js.map +1 -0
- package/dist/serializer.d.ts +6 -15
- package/dist/serializer.d.ts.map +1 -1
- package/dist/session-toolbar.d.ts +20 -5
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +419 -67
- package/dist/session-toolbar.js.map +1 -1
- package/dist/session.d.ts +38 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +562 -79
- package/dist/session.js.map +1 -1
- package/dist/sha256.d.ts +3 -0
- package/dist/sha256.d.ts.map +1 -0
- package/dist/sha256.js +74 -0
- package/dist/sha256.js.map +1 -0
- package/dist/stable-handles.d.ts +19 -0
- package/dist/stable-handles.d.ts.map +1 -0
- package/dist/stable-handles.js +193 -0
- package/dist/stable-handles.js.map +1 -0
- package/dist/staged-seal.d.ts +2 -0
- package/dist/staged-seal.d.ts.map +1 -0
- package/dist/staged-seal.js +76 -0
- package/dist/staged-seal.js.map +1 -0
- package/dist/subscriber-delivery.d.ts +8 -0
- package/dist/subscriber-delivery.d.ts.map +1 -0
- package/dist/subscriber-delivery.js +31 -0
- package/dist/subscriber-delivery.js.map +1 -0
- package/dist/tail-op-import.d.ts +10 -0
- package/dist/tail-op-import.d.ts.map +1 -0
- package/dist/tail-op-import.js +33 -0
- package/dist/tail-op-import.js.map +1 -0
- package/dist/topic-lifecycle.d.ts +6 -0
- package/dist/topic-lifecycle.d.ts.map +1 -1
- package/dist/topic-lifecycle.js +32 -2
- package/dist/topic-lifecycle.js.map +1 -1
- package/dist/trial-replace-dialog.d.ts +10 -0
- package/dist/trial-replace-dialog.d.ts.map +1 -0
- package/dist/trial-replace-dialog.js +153 -0
- package/dist/trial-replace-dialog.js.map +1 -0
- package/dist/types.d.ts +8 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/undo.d.ts +60 -5
- package/dist/undo.d.ts.map +1 -1
- package/dist/undo.js +344 -75
- package/dist/undo.js.map +1 -1
- package/dist/version-bar.d.ts +1 -0
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +2 -1
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/ws-client.d.ts +21 -0
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +192 -8
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-frame-limit.d.ts +1 -0
- package/dist/ws-frame-limit.d.ts.map +1 -1
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/app-manifest.md +121 -17
- package/docs/custom-chrome.md +24 -16
- package/docs/hosted-ui-advanced.md +38 -6
- package/docs/imported-documents.md +104 -51
- package/docs/presence.md +16 -4
- package/docs/schemas.md +8 -5
- package/docs/services.md +6 -6
- package/docs/streams.md +139 -150
- package/docs/text-fields.md +26 -19
- package/package.json +5 -2
- package/src/acting-organization.ts +6 -12
- package/src/api-fetch.ts +53 -3
- package/src/auth-flow.ts +1 -1
- package/src/batched-get.ts +117 -0
- package/src/call-service.ts +78 -3
- package/src/client.ts +1092 -121
- package/src/document-checkpoints.ts +307 -65
- package/src/document-commands.ts +5 -5
- package/src/document-contents.ts +27 -1
- package/src/document-set-subscription.ts +274 -0
- package/src/document-signals.ts +47 -5
- package/src/document-streams.ts +85 -16
- package/src/document-thumbnail-capture.ts +118 -32
- package/src/documents-namespace.ts +539 -84
- package/src/documents.ts +2017 -348
- package/src/errors.ts +17 -6
- package/src/fetch-retry.ts +5 -0
- package/src/file-choice.ts +26 -0
- package/src/guest-link.ts +362 -1
- package/src/handle-types.ts +73 -47
- package/src/handles.ts +253 -83
- package/src/hosted-ui.ts +211 -50
- package/src/import-contract.ts +227 -0
- package/src/import-guest.ts +193 -0
- package/src/import-report.ts +24 -0
- package/src/importer-wait.ts +4 -0
- package/src/importer.ts +386 -131
- package/src/in-development.ts +10 -0
- package/src/index.ts +76 -16
- package/src/init-keys.ts +6 -1
- package/src/input-documents.ts +819 -0
- package/src/inputs-status.ts +598 -0
- package/src/inputs.ts +1090 -0
- package/src/internal.ts +7 -0
- package/src/join-content.ts +135 -0
- package/src/join-stamp.ts +12 -0
- package/src/manifest-drift.ts +54 -0
- package/src/open-document.ts +2346 -930
- package/src/page-restore.ts +38 -0
- package/src/placement.ts +54 -0
- package/src/presence.ts +21 -5
- package/src/read-only-document.ts +133 -0
- package/src/record-paging.ts +147 -25
- package/src/references.ts +207 -0
- package/src/request-lane.ts +59 -0
- package/src/sealed-arrow.ts +843 -0
- package/src/sealed-cache.ts +267 -0
- package/src/sealed-encode.ts +1015 -0
- package/src/sealed-handles.ts +486 -0
- package/src/sealed-store.ts +1330 -0
- package/src/serializer.ts +8 -20
- package/src/session-toolbar.ts +463 -72
- package/src/session.ts +740 -99
- package/src/sha256.ts +65 -0
- package/src/stable-handles.ts +235 -0
- package/src/staged-seal.ts +90 -0
- package/src/subscriber-delivery.ts +37 -0
- package/src/tail-op-import.ts +30 -0
- package/src/topic-lifecycle.ts +38 -2
- package/src/trial-replace-dialog.ts +163 -0
- package/src/types.ts +6 -6
- package/src/undo.ts +386 -73
- package/src/version-bar.ts +5 -1
- package/src/version.ts +1 -1
- package/src/ws-client.ts +208 -16
- package/src/ws-frame-limit.ts +1 -0
package/src/api-fetch.ts
CHANGED
|
@@ -13,6 +13,12 @@ export interface ApiRequest {
|
|
|
13
13
|
path: string;
|
|
14
14
|
// JSON-encoded when present; sets `content-type: application/json`.
|
|
15
15
|
body?: unknown;
|
|
16
|
+
// Attempts a GET gets when it is refused as throttled or fails in the
|
|
17
|
+
// network, including the first. Defaults to `fetchWithRetry`'s.
|
|
18
|
+
maxAttempts?: number;
|
|
19
|
+
// A POST that changes nothing (a read whose ids travel in the body), so it
|
|
20
|
+
// is retried as a GET is.
|
|
21
|
+
idempotent?: boolean;
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
interface ApiFetchOptions {
|
|
@@ -47,8 +53,11 @@ export function createApiFetch(opts: ApiFetchOptions): ApiFetch {
|
|
|
47
53
|
// rather than surfacing it as a hard failure; a write is never
|
|
48
54
|
// replayed here.
|
|
49
55
|
const response =
|
|
50
|
-
method === 'GET'
|
|
51
|
-
? await fetchWithRetry(
|
|
56
|
+
method === 'GET' || req.idempotent === true
|
|
57
|
+
? await fetchWithRetry(
|
|
58
|
+
() => fetch(url, init),
|
|
59
|
+
req.maxAttempts !== undefined ? { maxAttempts: req.maxAttempts } : {},
|
|
60
|
+
)
|
|
52
61
|
: await fetch(url, init);
|
|
53
62
|
if (response.status === 204) return undefined as T;
|
|
54
63
|
|
|
@@ -82,7 +91,38 @@ function isErrorBody(body: unknown): body is { error: string } {
|
|
|
82
91
|
// through here so the server's machine-readable denials, in particular
|
|
83
92
|
// `code: "app_not_installed"`, survive into the error the caller (and
|
|
84
93
|
// the SDK's own install-consent offer) can branch on.
|
|
94
|
+
// What a 409 to an import claim says about the client holding it: how long
|
|
95
|
+
// ago it last claimed or refreshed, and whether its output is being sealed.
|
|
96
|
+
export interface ClaimHolder {
|
|
97
|
+
refreshedMsAgo: number;
|
|
98
|
+
expiresInMs: number;
|
|
99
|
+
sealing: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const claimHolders = new WeakMap<BrassError, ClaimHolder>();
|
|
103
|
+
|
|
104
|
+
export function claimHolderOf(err: unknown): ClaimHolder | null {
|
|
105
|
+
return err instanceof BrassError ? (claimHolders.get(err) ?? null) : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function readClaimHolder(body: unknown): ClaimHolder | null {
|
|
109
|
+
if (typeof body !== 'object' || body === null) return null;
|
|
110
|
+
const b = body as Record<string, unknown>;
|
|
111
|
+
const ago = b['refreshed_ms_ago'];
|
|
112
|
+
const lapses = b['expires_in_ms'];
|
|
113
|
+
if (b['pending'] !== true || typeof ago !== 'number' || !Number.isFinite(ago)) return null;
|
|
114
|
+
if (typeof lapses !== 'number' || !Number.isFinite(lapses)) return null;
|
|
115
|
+
return { refreshedMsAgo: ago, expiresInMs: lapses, sealing: b['sealing'] === true };
|
|
116
|
+
}
|
|
117
|
+
|
|
85
118
|
export function brassErrorFromBody(status: number, parsed: unknown): BrassError {
|
|
119
|
+
const err = brassErrorOf(status, parsed);
|
|
120
|
+
const holder = status === 409 ? readClaimHolder(parsed) : null;
|
|
121
|
+
if (holder !== null) claimHolders.set(err, holder);
|
|
122
|
+
return err;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function brassErrorOf(status: number, parsed: unknown): BrassError {
|
|
86
126
|
const message = isErrorBody(parsed) ? parsed.error : 'Request failed';
|
|
87
127
|
if (isNotInstalledBody(parsed)) {
|
|
88
128
|
return new BrassError(message, {
|
|
@@ -99,7 +139,17 @@ export function brassErrorFromBody(status: number, parsed: unknown): BrassError
|
|
|
99
139
|
...(typeof title === 'string' ? { title } : {}),
|
|
100
140
|
});
|
|
101
141
|
}
|
|
102
|
-
|
|
142
|
+
const code = serverCode(parsed);
|
|
143
|
+
return new BrassError(message, { status, ...(code !== null ? { code } : {}) });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The code a refusal body carries, when it is one: a short snake_case token.
|
|
147
|
+
const SERVER_CODE = /^[a-z][a-z0-9_]{0,63}$/;
|
|
148
|
+
|
|
149
|
+
function serverCode(value: unknown): string | null {
|
|
150
|
+
if (typeof value !== 'object' || value === null) return null;
|
|
151
|
+
const code = (value as { code?: unknown }).code;
|
|
152
|
+
return typeof code === 'string' && SERVER_CODE.test(code) ? code : null;
|
|
103
153
|
}
|
|
104
154
|
|
|
105
155
|
// The platform refuses to join a document this app's declared schema cannot
|
package/src/auth-flow.ts
CHANGED
|
@@ -40,7 +40,7 @@ interface RefreshResponse {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
// The credential the SDK presents to POST /refresh.
|
|
43
|
-
// - `cookie`: the default; the browser attaches the `brass_auth` cookie
|
|
43
|
+
// - `cookie`: the default; the browser attaches the `__Host-brass_auth` cookie
|
|
44
44
|
// and nothing is sent in the body. Works wherever the third-party
|
|
45
45
|
// cookie is allowed.
|
|
46
46
|
// - `code`: a one-time `brass_code` from the sign-in redirect, exchanged
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Answers `documents.get` calls made in the same turn with one request per
|
|
2
|
+
// `BATCH_SIZE` ids. An app reading the record of each of several hundred
|
|
3
|
+
// documents calls `get` for each, usually all at once, and one request per
|
|
4
|
+
// document spends the API's request budget for the app's other calls and
|
|
5
|
+
// every other caller's. The server reads a batch's documents on one
|
|
6
|
+
// invocation, so a smaller batch spreads that work across invocations.
|
|
7
|
+
//
|
|
8
|
+
// A lone call is sent as its own read. Each id in a batch resolves or rejects
|
|
9
|
+
// exactly as its own read would, from the status and body the server answered
|
|
10
|
+
// for it. A platform that does not serve the batch route answers 404 for the
|
|
11
|
+
// whole request, and its ids are then read one at a time.
|
|
12
|
+
|
|
13
|
+
import type { ApiFetch } from './api-fetch.js';
|
|
14
|
+
import { brassErrorFromBody } from './api-fetch.js';
|
|
15
|
+
import { BrassError } from './errors.js';
|
|
16
|
+
import type { RequestLane } from './request-lane.js';
|
|
17
|
+
|
|
18
|
+
export const BATCH_SIZE = 20;
|
|
19
|
+
|
|
20
|
+
interface Pending<T> {
|
|
21
|
+
promise: Promise<T>;
|
|
22
|
+
resolve: (value: T) => void;
|
|
23
|
+
reject: (err: unknown) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface BatchResult {
|
|
27
|
+
doc_id: string;
|
|
28
|
+
status: number;
|
|
29
|
+
body: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface BatchedGetOptions {
|
|
33
|
+
apiFetch: ApiFetch;
|
|
34
|
+
lane: RequestLane;
|
|
35
|
+
// Reads one document on its own: the lone call, and the fallback.
|
|
36
|
+
single: (docId: string) => Promise<unknown>;
|
|
37
|
+
// Attempts the batch request makes against a throttled API.
|
|
38
|
+
maxAttempts: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createBatchedGet<T>(opts: BatchedGetOptions): (docId: string) => Promise<T> {
|
|
42
|
+
let waiting = new Map<string, Pending<T>>();
|
|
43
|
+
let scheduled = false;
|
|
44
|
+
|
|
45
|
+
const settle = (entry: Pending<T>, run: () => Promise<unknown>): void => {
|
|
46
|
+
run().then((value) => entry.resolve(value as T), entry.reject);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const sendBatch = async (ids: string[], entries: Map<string, Pending<T>>): Promise<void> => {
|
|
50
|
+
let results: BatchResult[];
|
|
51
|
+
try {
|
|
52
|
+
const answer = await opts.lane.run(`batch:${ids.join(',')}`, () =>
|
|
53
|
+
opts.apiFetch<{ results: BatchResult[] }>({
|
|
54
|
+
method: 'POST',
|
|
55
|
+
path: '/documents/details',
|
|
56
|
+
body: { doc_ids: ids },
|
|
57
|
+
idempotent: true,
|
|
58
|
+
maxAttempts: opts.maxAttempts,
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
results = answer.results;
|
|
62
|
+
} catch (err) {
|
|
63
|
+
if (err instanceof BrassError && err.status === 404) {
|
|
64
|
+
for (const id of ids) settle(entries.get(id)!, () => opts.single(id));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
for (const id of ids) entries.get(id)!.reject(err);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const byId = new Map(results.map((r) => [r.doc_id, r]));
|
|
71
|
+
for (const id of ids) {
|
|
72
|
+
const entry = entries.get(id)!;
|
|
73
|
+
const result = byId.get(id);
|
|
74
|
+
if (result === undefined) {
|
|
75
|
+
entry.reject(new BrassError('Request failed', { status: 500 }));
|
|
76
|
+
} else if (result.status >= 200 && result.status < 300) {
|
|
77
|
+
entry.resolve(result.body as T);
|
|
78
|
+
} else {
|
|
79
|
+
entry.reject(brassErrorFromBody(result.status, result.body));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const flush = (): void => {
|
|
85
|
+
scheduled = false;
|
|
86
|
+
const entries = waiting;
|
|
87
|
+
waiting = new Map();
|
|
88
|
+
const ids = [...entries.keys()];
|
|
89
|
+
if (ids.length === 1) {
|
|
90
|
+
settle(entries.get(ids[0]!)!, () => opts.single(ids[0]!));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
for (let i = 0; i < ids.length; i += BATCH_SIZE) {
|
|
94
|
+
void sendBatch(ids.slice(i, i + BATCH_SIZE), entries);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
return (docId: string): Promise<T> => {
|
|
99
|
+
const pending = waiting.get(docId);
|
|
100
|
+
if (pending !== undefined) return pending.promise;
|
|
101
|
+
let resolve!: (value: T) => void;
|
|
102
|
+
let reject!: (err: unknown) => void;
|
|
103
|
+
const promise = new Promise<T>((res, rej) => {
|
|
104
|
+
resolve = res;
|
|
105
|
+
reject = rej;
|
|
106
|
+
});
|
|
107
|
+
waiting.set(docId, { promise, resolve, reject });
|
|
108
|
+
if (!scheduled) {
|
|
109
|
+
scheduled = true;
|
|
110
|
+
// A task rather than a microtask, so a call made from a promise callback
|
|
111
|
+
// before the next task (a handler per input, a loop after an await)
|
|
112
|
+
// joins the same batch.
|
|
113
|
+
setTimeout(flush, 0);
|
|
114
|
+
}
|
|
115
|
+
return promise;
|
|
116
|
+
};
|
|
117
|
+
}
|
package/src/call-service.ts
CHANGED
|
@@ -149,6 +149,10 @@ export interface CallServiceDeps {
|
|
|
149
149
|
host: string,
|
|
150
150
|
) => Promise<boolean>;
|
|
151
151
|
fetchImpl?: typeof fetch;
|
|
152
|
+
/** Injected for tests; defaults to the real timer. */
|
|
153
|
+
sleep?: (ms: number) => Promise<void>;
|
|
154
|
+
/** Injected for tests; defaults to `Date.now`. */
|
|
155
|
+
now?: () => number;
|
|
152
156
|
}
|
|
153
157
|
|
|
154
158
|
export async function callService(
|
|
@@ -187,7 +191,7 @@ export async function callService(
|
|
|
187
191
|
});
|
|
188
192
|
};
|
|
189
193
|
|
|
190
|
-
const response = await send
|
|
194
|
+
const response = await sendThroughFrontDoor(send, deps);
|
|
191
195
|
// The service's own status is the answer, so nothing here throws on one.
|
|
192
196
|
// The exceptions are the refusals that are a question for the person rather
|
|
193
197
|
// than a result for the app: offer the hosted surface that asks it, and send
|
|
@@ -197,13 +201,84 @@ export async function callService(
|
|
|
197
201
|
const ask = response.headers.get(BRASS_ASK_HEADER);
|
|
198
202
|
if (ask === 'credential') {
|
|
199
203
|
return (await deps.offerServiceCredential(organizationId, host))
|
|
200
|
-
? send
|
|
204
|
+
? sendThroughFrontDoor(send, deps)
|
|
201
205
|
: response;
|
|
202
206
|
}
|
|
203
207
|
if (ask !== 'install') return response;
|
|
204
208
|
const denial = await notInstalledDenial(response);
|
|
205
209
|
if (!denial) return response;
|
|
206
|
-
return (await deps.offerInstallConsent(denial))
|
|
210
|
+
return (await deps.offerInstallConsent(denial))
|
|
211
|
+
? sendThroughFrontDoor(send, deps)
|
|
212
|
+
: response;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Every answer from Brass carries this header, and the broker drops any header
|
|
216
|
+
// under that prefix a service sends. Without it, in the gateway's own body, the
|
|
217
|
+
// answer came from the gateway in front of Brass: a `429` from its rate limit,
|
|
218
|
+
// or a quick `503` from a call it could not start because the brokered call's
|
|
219
|
+
// concurrency was all in use. Nothing behind the gateway ran for either, so
|
|
220
|
+
// sending it again is safe whatever the method. The gateway's `503` for a call
|
|
221
|
+
// that ran past its 30s limit has the same body, and that call may have reached
|
|
222
|
+
// the service, so a `503` counts only when it came back well inside that limit.
|
|
223
|
+
const ANSWERED_BY_BRASS_HEADER = 'x-brass-answered';
|
|
224
|
+
|
|
225
|
+
// A quick 503 lasts until a call already running ends, which is within the
|
|
226
|
+
// gateway's 30s limit, so the resend lasts that long. The backoff stays short
|
|
227
|
+
// so the call starts soon after room frees.
|
|
228
|
+
const FRONT_DOOR_WINDOW_MS = 30_000;
|
|
229
|
+
const FRONT_DOOR_BASE_DELAY_MS = 300;
|
|
230
|
+
const FRONT_DOOR_MAX_DELAY_MS = 2_000;
|
|
231
|
+
const FRONT_DOOR_MESSAGES: Record<number, string> = {
|
|
232
|
+
429: 'Too Many Requests',
|
|
233
|
+
503: 'Service Unavailable',
|
|
234
|
+
};
|
|
235
|
+
const FRONT_DOOR_503_WITHIN_MS = 10_000;
|
|
236
|
+
|
|
237
|
+
async function shedByFrontDoor(response: Response, elapsedMs: number): Promise<boolean> {
|
|
238
|
+
const message = FRONT_DOOR_MESSAGES[response.status];
|
|
239
|
+
if (message === undefined || response.headers.has(ANSWERED_BY_BRASS_HEADER)) return false;
|
|
240
|
+
if (response.status === 503 && elapsedMs >= FRONT_DOOR_503_WITHIN_MS) return false;
|
|
241
|
+
try {
|
|
242
|
+
const body = (await response.clone().json()) as unknown;
|
|
243
|
+
return (
|
|
244
|
+
typeof body === 'object' &&
|
|
245
|
+
body !== null &&
|
|
246
|
+
Object.keys(body).length === 1 &&
|
|
247
|
+
(body as { message?: unknown }).message === message
|
|
248
|
+
);
|
|
249
|
+
} catch {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function sendThroughFrontDoor(
|
|
255
|
+
send: () => Promise<Response>,
|
|
256
|
+
deps: Pick<CallServiceDeps, 'sleep' | 'now'>,
|
|
257
|
+
): Promise<Response> {
|
|
258
|
+
const now = deps.now ?? Date.now;
|
|
259
|
+
const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
260
|
+
const firstSentAt = now();
|
|
261
|
+
let response: Response;
|
|
262
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
263
|
+
const startedAt = now();
|
|
264
|
+
response = await send();
|
|
265
|
+
if (!(await shedByFrontDoor(response, now() - startedAt))) return response;
|
|
266
|
+
const delay =
|
|
267
|
+
Math.min(FRONT_DOOR_BASE_DELAY_MS * 2 ** attempt, FRONT_DOOR_MAX_DELAY_MS) +
|
|
268
|
+
Math.random() * FRONT_DOOR_BASE_DELAY_MS;
|
|
269
|
+
if (now() + delay - firstSentAt > FRONT_DOOR_WINDOW_MS) break;
|
|
270
|
+
await sleep(delay);
|
|
271
|
+
}
|
|
272
|
+
// Still shed after the window: answered with the code of Brass's own
|
|
273
|
+
// limit, so an app branches on one `code` for both, and with an `error`
|
|
274
|
+
// naming the gateway's status, so a report says which one turned it away.
|
|
275
|
+
return new Response(
|
|
276
|
+
JSON.stringify({
|
|
277
|
+
error: `The gateway in front of Brass turned the call away (${response.status})`,
|
|
278
|
+
code: 'rate_limited',
|
|
279
|
+
}),
|
|
280
|
+
{ status: 429, headers: { 'content-type': 'application/json' } },
|
|
281
|
+
);
|
|
207
282
|
}
|
|
208
283
|
|
|
209
284
|
// No organization resolved, for a caller that did not ask for the chooser.
|