@ultimat3/core 20.2.1 → 22.0.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/CLAUDE.md +189 -523
- package/README.md +62 -1
- package/package.json +6 -3
- package/src/actor.ts +14 -0
- package/src/address-class.ts +143 -0
- package/src/async-state.ts +19 -0
- package/src/canonical-json.ts +24 -1
- package/src/client-dispatch.ts +163 -0
- package/src/client-flight.ts +22 -3
- package/src/client-paths.ts +79 -0
- package/src/client-problem.ts +94 -0
- package/src/client-scope-error.ts +18 -0
- package/src/client-scope.ts +59 -0
- package/src/client-transport.ts +86 -0
- package/src/config-count.ts +19 -0
- package/src/config-fixes.ts +23 -0
- package/src/config-merge.ts +36 -0
- package/src/config.ts +122 -65
- package/src/conflict-policy.ts +48 -0
- package/src/context.ts +12 -1
- package/src/core-error-codes.ts +78 -0
- package/src/dev-secrets.ts +45 -0
- package/src/error-codes.ts +17 -66
- package/src/error-retry.ts +3 -0
- package/src/exports/error-contract.ts +2 -2
- package/src/exports/secrets.ts +1 -0
- package/src/generation-fence.ts +9 -2
- package/src/host-rules.ts +71 -0
- package/src/image/exif-orientation.ts +40 -0
- package/src/image/probe.ts +13 -1
- package/src/in-process-fetch.ts +39 -0
- package/src/index.ts +80 -30
- package/src/iso-date.ts +5 -0
- package/src/lifecycle-grace.ts +44 -0
- package/src/lifecycle-signals.ts +35 -0
- package/src/lifecycle.ts +44 -36
- package/src/logger.ts +22 -3
- package/src/measurement-actor.ts +52 -0
- package/src/metrics-text.ts +10 -2
- package/src/otlp-metric-exporter.ts +39 -12
- package/src/otlp-span-exporter.ts +38 -15
- package/src/outbound-headers.ts +16 -0
- package/src/outbox-drain.ts +14 -0
- package/src/page-meta.ts +41 -0
- package/src/page.ts +52 -0
- package/src/pending-records.ts +58 -0
- package/src/record-envelope-openapi.ts +41 -0
- package/src/record-envelope.ts +98 -0
- package/src/record-sink.ts +129 -0
- package/src/schema-error-codes.ts +1 -1
- package/src/secrets-errors.ts +1 -1
- package/src/secrets-store.ts +51 -5
- package/src/service.ts +9 -0
- package/src/source-mask.ts +30 -0
- package/src/telemetry.ts +3 -0
- package/src/type-pins.ts +9 -0
- package/src/write-digest.ts +29 -0
- package/src/write-origin.ts +31 -0
- package/src/result.ts +0 -78
package/README.md
CHANGED
|
@@ -8,7 +8,6 @@ Zero dependencies, zero `@ultimat3/*` imports.
|
|
|
8
8
|
| `UltimateError`, the 3-line rendering, `--json` shape | `errors.ts` |
|
|
9
9
|
| rendering an app's value into a `cause` / `fix` without throwing | `error-render.ts` |
|
|
10
10
|
| code → `{ title, docs }` registry, `registerErrorCodes()` | `error-codes.ts` |
|
|
11
|
-
| `Result<T, E>` for boundaries where throwing is wrong | `result.ts` |
|
|
12
11
|
| the one lazy `AsyncLocalStorage`, every ambient scope in the framework | `async-context.ts` |
|
|
13
12
|
| request context on that seam | `context.ts` |
|
|
14
13
|
| `Actor` (`user \| service \| agent \| anonymous`) | `actor.ts` |
|
|
@@ -22,14 +21,23 @@ Zero dependencies, zero `@ultimat3/*` imports.
|
|
|
22
21
|
| whether an answer still applies — `X_SUPERSEDED` | `generation-fence.ts` |
|
|
23
22
|
| the five composed into one typed-client call — dedup, fence, retry, deadline, ceiling | `client-flight.ts` |
|
|
24
23
|
| what a typed client puts on the wire and reads back off it | `client-wire.ts` |
|
|
24
|
+
| the ONE browser HTTP function — credentials, JSON, the error decode, records, the fence | `client-transport.ts` + `client-dispatch.ts` + `client-problem.ts` |
|
|
25
|
+
| the ONE URL rule for actions and queries | `client-paths.ts` |
|
|
26
|
+
| the records envelope an answer carries behind `x-ultimate-records: 1` | `record-envelope.ts` |
|
|
27
|
+
| the per-tab page handle (`globalThis[Symbol.for('ultimate.client')]`) records land in | `record-sink.ts` |
|
|
28
|
+
| which principal the page acts for, and the epoch that moves when it changes | `client-scope.ts` |
|
|
29
|
+
| which row survives a conflict — `server-wins`, `last-write-wins`, `custom` | `conflict-policy.ts` |
|
|
30
|
+
| the four shapes an async region can be in | `async-state.ts` |
|
|
25
31
|
| is this `unknown` a keyed record? | `json-object.ts` |
|
|
26
32
|
| typed env validated at boot | `env.ts` |
|
|
27
33
|
| `.env.example` rendered from that schema, and its drift check | `env-example.ts` |
|
|
28
34
|
| named environments + `ULTIMATE_ENV` resolution | `environment.ts` |
|
|
35
|
+
| the boot refusal of a shipped dev signing secret outside development/test — `X_CURSOR_SECRET_DEV` | `dev-secrets.ts` |
|
|
29
36
|
| a value that cannot be printed by accident | `secret.ts` |
|
|
30
37
|
| the committed encrypted secrets envelope, AES-256-GCM | `secrets.ts` |
|
|
31
38
|
| the two secrets files, and decrypted values → `defineEnv` | `secrets-store.ts` |
|
|
32
39
|
| `defineConfig()` for `app.config.ts` | `config.ts` |
|
|
40
|
+
| how overlays layer onto it — per section, key by key | `config-merge.ts` |
|
|
33
41
|
| the `pwa` block — what an install needs, and the boot refusal when it is not there | `config-pwa.ts` |
|
|
34
42
|
| the closed route vocabulary every renderer names | `route-vocabulary.ts` |
|
|
35
43
|
| runtime roles + `ROLE` resolution | `roles.ts` |
|
|
@@ -47,6 +55,9 @@ Zero dependencies, zero `@ultimat3/*` imports.
|
|
|
47
55
|
| the `/metrics` scrape body | `metrics-text.ts` |
|
|
48
56
|
| the series every process emits, incl. what the chart scales on | `runtime-metrics.ts` |
|
|
49
57
|
| graceful drain, `/healthz`, `/readyz` | `lifecycle.ts` |
|
|
58
|
+
| the readiness grace between `/readyz` → 503 and the listener closing (`drain.readinessGraceMs`) | `lifecycle-grace.ts` |
|
|
59
|
+
| SIGTERM/SIGINT → the one drain | `lifecycle-signals.ts` |
|
|
60
|
+
| which network an IP literal belongs to — `classifyAddress`, for SSRF screens | `address-class.ts` |
|
|
50
61
|
| the sockets this process opened, so a self-request is not egress | `listeners.ts` |
|
|
51
62
|
| `defineService('orgs', …)` → `ctx.orgs`, rebuilt per actor | `service.ts` |
|
|
52
63
|
| the registrar table one same-tier package reaches another through | `registrar.ts` |
|
|
@@ -157,6 +168,40 @@ Enforced, not documented: `x verify`'s `errors` step fails with `X_ERROR_RENDER_
|
|
|
157
168
|
parameter typed `unknown` reaches a `cause:` or `fix:` through `JSON.stringify`, `String()` or a
|
|
158
169
|
bare interpolation (`scripts/error-render.ts`).
|
|
159
170
|
|
|
171
|
+
### Error classes
|
|
172
|
+
|
|
173
|
+
Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
|
|
174
|
+
a job boundary the class is gone and the `code` is what survives — match on that.
|
|
175
|
+
|
|
176
|
+
| Class | Code | Declared in |
|
|
177
|
+
|---|---|---|
|
|
178
|
+
| `ConfigInvalidError` | `X_CONFIG_INVALID` | `src/errors.ts` |
|
|
179
|
+
| `CursorInvalidError` | `X_CURSOR_INVALID` | `src/cursor.ts` |
|
|
180
|
+
| `CursorSecretDevError` | `X_CURSOR_SECRET_DEV` | `src/dev-secrets.ts` |
|
|
181
|
+
| `EnvExampleDriftError` | `X_ENV_EXAMPLE_DRIFT` | `src/env-example.ts` |
|
|
182
|
+
| `EnvironmentInvalidError` | `X_ENVIRONMENT_INVALID` | `src/environment.ts` |
|
|
183
|
+
| `EnvMissingError` | `X_ENV_MISSING` | `src/errors.ts` |
|
|
184
|
+
| `ErrorReporterDsnInvalidError` | `X_ERROR_REPORTER_DSN_INVALID` | `src/error-reporter-sentry.ts` |
|
|
185
|
+
| `ImageDecodeFailedError` | `X_IMAGE_DECODE_FAILED` | `src/image/errors.ts` |
|
|
186
|
+
| `ImageTooLargeError` | `X_IMAGE_TOO_LARGE` | `src/image/errors.ts` |
|
|
187
|
+
| `ImageUnsupportedError` | `X_IMAGE_UNSUPPORTED` | `src/image/errors.ts` |
|
|
188
|
+
| `InternalError` | `X_INTERNAL` | `src/errors.ts` |
|
|
189
|
+
| `MetricCardinalityError` | `X_METRIC_CARDINALITY` | `src/metrics.ts` |
|
|
190
|
+
| `MetricNameInvalidError` | `X_METRIC_NAME_INVALID` | `src/metric-names.ts` |
|
|
191
|
+
| `MetricValueInvalidError` | `X_METRIC_VALUE_INVALID` | `src/metrics.ts` |
|
|
192
|
+
| `NotImplementedError` | `X_NOT_IMPLEMENTED` | `src/errors.ts` |
|
|
193
|
+
| `OtlpEndpointInvalidError` | `X_OTLP_ENDPOINT_INVALID` | `src/otlp.ts` |
|
|
194
|
+
| `OtlpHeadersInvalidError` | `X_OTLP_HEADERS_INVALID` | `src/otlp.ts` |
|
|
195
|
+
| `OtlpProtocolUnsupportedError` | `X_OTLP_PROTOCOL_UNSUPPORTED` | `src/otlp.ts` |
|
|
196
|
+
| `SecretsFileInvalidError` | `X_SECRETS_FILE_INVALID` | `src/secrets-errors.ts` |
|
|
197
|
+
| `SecretsFileMissingError` | `X_SECRETS_FILE_MISSING` | `src/secrets-errors.ts` |
|
|
198
|
+
| `SecretsKeyInvalidError` | `X_SECRETS_KEY_INVALID` | `src/secrets-errors.ts` |
|
|
199
|
+
| `SecretsKeyMismatchError` | `X_SECRETS_KEY_MISMATCH` | `src/secrets-errors.ts` |
|
|
200
|
+
| `SecretsKeyMissingError` | `X_SECRETS_KEY_MISSING` | `src/secrets-errors.ts` |
|
|
201
|
+
| `SecretsPlaintextInvalidError` | `X_SECRETS_PLAINTEXT_INVALID` | `src/secrets-errors.ts` |
|
|
202
|
+
| `SecretsTamperedError` | `X_SECRETS_TAMPERED` | `src/secrets-errors.ts` |
|
|
203
|
+
| `UltimateError` | any registered code — every class in the framework extends it | `src/errors.ts` |
|
|
204
|
+
|
|
160
205
|
## Context
|
|
161
206
|
|
|
162
207
|
```ts
|
|
@@ -562,6 +607,22 @@ cause and a runnable `fix:` with the fact that something was retried, which no r
|
|
|
562
607
|
`classifyThrown` and `statedDelayMs` live in `error-retry.ts`, one import away from the table they
|
|
563
608
|
read; `@ultimat3/jobs` re-exports both rather than keeping a second pair.
|
|
564
609
|
|
|
610
|
+
## One browser seam — transport, records, page handle, principal fence
|
|
611
|
+
|
|
612
|
+
| Export | The one answer | The question it settles |
|
|
613
|
+
|---|---|---|
|
|
614
|
+
| `clientTransport({ method, url, body?, rawBody?, headers?, signal?, idempotencyKey?, flight?, fresh?, retry?, onResponse?, decodeError?, onEnvelope?, fetchImpl? })` | every browser request | `credentials: 'same-origin'`, JSON in and out, the `idempotency-key` header, a non-2xx `problem+json` back into the server's code (`meta.origin: 'remote'`) unless `decodeError` answers first, a network `TypeError` into `X_CLIENT_TRANSPORT_FAILED`. A GET is abortable on `rescope()` and deduped only when a `flight` is passed (`fresh` refuses to join); any other method is never deduped and never aborted by the fence. `rawBody` goes out verbatim with no default header and resolves `undefined`. `onResponse` sees headers before the body is read. `onEnvelope` sees the decoded records envelope after adoption — the one way to learn the order of `records[type]`. `fetchImpl` defaults to `globalThis.fetch`, read at call time |
|
|
615
|
+
| `actionPath(name)`, `actionRoute(name)`, `queryPath(name)`, `QUERY_PATH_PREFIX`, `splitWords`, `pluralize` | the one URL rule | `publishPost` → `/api/posts/publish`, `liveFeed` → `/_x/query/live-feed`. Tier 0 so `action`, `query` and `realtime` derive one URL with no sideways import |
|
|
616
|
+
| `RECORDS_HEADER`, `encodeRecordEnvelope`, `decodeRecordEnvelope`, `RecordRows` | `{ data, records?: { [type]: { [key]: Row } }, removed?: { [type]: key[] } }`, only behind `x-ultimate-records: 1` | how an answer carries entity rows without changing the wire of an answer that has none. A malformed envelope is `X_CLIENT_RECORD_ENVELOPE_INVALID` |
|
|
617
|
+
| `pageClient()` → `{ store, socket, scope }`, `RecordSink` | one handle per TAB, not per module copy | every island bundle carries its own core; the handle lives on `globalThis` under one `Symbol.for` key so all of them resolve the same store. No store installed = records dropped |
|
|
618
|
+
| `rescope(principal)`, `onRescope(fn)`, `isSuperseded(error)` | the principal fence | a principal change bumps the epoch and notifies synchronously; reads in flight reject `X_CLIENT_SCOPE_CHANGED`, writes complete but their records are not adopted. `isSuperseded` answers true for it and for `X_SUPERSEDED` |
|
|
619
|
+
| `resolveConflict(policy, local, server, { clockField? })`, `ConflictPolicy`, `Row` | one conflict vocabulary, over ROWS | `last-write-wins` keeps the local row only when its numeric clock field (default `updatedAt`) is newer; no provable clock = the server's row |
|
|
620
|
+
| `AsyncState<T>` | `pending \| refreshing \| ready \| failed` | the type `realtime` produces and `ui` renders; tier 0 because neither may import the other |
|
|
621
|
+
|
|
622
|
+
`clientTransport` does not value-import `createClientFlight` or `traceHeaders()` — measured sizes
|
|
623
|
+
are in its file header. A server-side caller that propagates a trace passes `traceHeaders()` in
|
|
624
|
+
`headers` itself.
|
|
625
|
+
|
|
565
626
|
## One image pipeline, everywhere
|
|
566
627
|
|
|
567
628
|
```ts
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "22.0.0",
|
|
4
4
|
"description": "Ultimate's foundation: errors, context, env, config, clock, ids, logging, telemetry, lifecycle",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"sideEffects": [
|
|
8
8
|
"./src/context.ts",
|
|
9
|
+
"./src/core-error-codes.ts",
|
|
9
10
|
"./src/lifecycle-errors.ts",
|
|
10
11
|
"./src/schema-error-codes.ts",
|
|
11
12
|
"./src/secrets-errors.ts"
|
|
@@ -20,11 +21,13 @@
|
|
|
20
21
|
"provenance": true
|
|
21
22
|
},
|
|
22
23
|
"exports": {
|
|
23
|
-
".": "./src/index.ts"
|
|
24
|
+
".": "./src/index.ts",
|
|
25
|
+
"./page": "./src/page.ts"
|
|
24
26
|
},
|
|
25
27
|
"files": [
|
|
26
28
|
"src",
|
|
27
29
|
"!src/**/*.test.ts",
|
|
30
|
+
"!src/**/*-fixture.ts",
|
|
28
31
|
"CLAUDE.md",
|
|
29
32
|
"README.md",
|
|
30
33
|
"LICENSE"
|
|
@@ -37,6 +40,6 @@
|
|
|
37
40
|
"test": "bun test"
|
|
38
41
|
},
|
|
39
42
|
"dependencies": {
|
|
40
|
-
"@ultimat3/schema": "
|
|
43
|
+
"@ultimat3/schema": "22.0.0"
|
|
41
44
|
}
|
|
42
45
|
}
|
package/src/actor.ts
CHANGED
|
@@ -105,6 +105,14 @@ export interface Actor {
|
|
|
105
105
|
* one the customer issued. Every surface that renders an actor renders this with it.
|
|
106
106
|
*/
|
|
107
107
|
readonly onBehalfOf?: ActorOrigin | undefined;
|
|
108
|
+
/**
|
|
109
|
+
* The actor's SAVED language and IANA zone — the `user` rung of `@ultimat3/i18n`'s
|
|
110
|
+
* `resolveLocale` and `@ultimat3/time`'s `resolveTimeZone`. Whoever authenticates sets them from
|
|
111
|
+
* the user record; `@ultimat3/http` re-resolves `ctx.locale` / `ctx.tz` with them once the actor
|
|
112
|
+
* is known, so a cookie the reader chose still wins exactly where those owners say it does.
|
|
113
|
+
*/
|
|
114
|
+
readonly locale?: string | undefined;
|
|
115
|
+
readonly tz?: string | undefined;
|
|
108
116
|
}
|
|
109
117
|
|
|
110
118
|
export interface ActorInit {
|
|
@@ -117,6 +125,9 @@ export interface ActorInit {
|
|
|
117
125
|
readonly facts?: ActorFactMap | undefined;
|
|
118
126
|
/** For a session that already recorded an impersonation; `impersonate()` sets it otherwise. */
|
|
119
127
|
readonly onBehalfOf?: ActorOrigin | undefined;
|
|
128
|
+
/** Saved preferences — see `Actor.locale`. */
|
|
129
|
+
readonly locale?: string | undefined;
|
|
130
|
+
readonly tz?: string | undefined;
|
|
120
131
|
}
|
|
121
132
|
|
|
122
133
|
const NO_FACTS: ActorFactMap = Object.freeze({});
|
|
@@ -143,6 +154,9 @@ function build(kind: ActorKind, init: ActorInit): Actor {
|
|
|
143
154
|
permissions: Object.freeze([...(init.permissions ?? [])]),
|
|
144
155
|
facts: Object.freeze({ ...init.facts }),
|
|
145
156
|
onBehalfOf: init.onBehalfOf === undefined ? undefined : Object.freeze({ ...init.onBehalfOf }),
|
|
157
|
+
// Absent keys, not `undefined` ones: an actor is compared and serialised in tests and logs.
|
|
158
|
+
...(init.locale === undefined ? {} : { locale: init.locale }),
|
|
159
|
+
...(init.tz === undefined ? {} : { tz: init.tz }),
|
|
146
160
|
});
|
|
147
161
|
}
|
|
148
162
|
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Single responsibility: which kind of network an IP address literal belongs to. Tier 0 because the
|
|
2
|
+
// SSRF screens that need it sit in packages that may not import each other — `@ultimat3/jobs`'
|
|
3
|
+
// webhook delivery (tier 3) and `@ultimat3/scraping` (tier 5) — and a copy per screen is how one
|
|
4
|
+
// of them comes to miss `::ffff:127.0.0.1`.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `reserved` is every range that is neither a host network nor public: multicast, broadcast,
|
|
8
|
+
* `240.0.0.0/4`, the documentation and benchmarking nets. A screen allowing only `public` refuses
|
|
9
|
+
* all of them, which is the point of naming them rather than calling them public.
|
|
10
|
+
*/
|
|
11
|
+
export type AddressClass =
|
|
12
|
+
| 'loopback'
|
|
13
|
+
| 'private'
|
|
14
|
+
| 'link-local'
|
|
15
|
+
| 'ula'
|
|
16
|
+
| 'cgnat'
|
|
17
|
+
| 'unspecified'
|
|
18
|
+
| 'reserved'
|
|
19
|
+
| 'public';
|
|
20
|
+
|
|
21
|
+
/** `[network, prefixBits]` over the 32-bit value; ordered, first match wins. */
|
|
22
|
+
const V4_RULES: readonly (readonly [number, number, AddressClass])[] = [
|
|
23
|
+
[0x00000000, 8, 'unspecified'],
|
|
24
|
+
[0x7f000000, 8, 'loopback'],
|
|
25
|
+
[0x0a000000, 8, 'private'],
|
|
26
|
+
[0xac100000, 12, 'private'],
|
|
27
|
+
[0xc0a80000, 16, 'private'],
|
|
28
|
+
[0xa9fe0000, 16, 'link-local'],
|
|
29
|
+
[0x64400000, 10, 'cgnat'],
|
|
30
|
+
[0xc0000000, 24, 'reserved'], // 192.0.0.0/24, IETF protocol assignments
|
|
31
|
+
[0xc0000200, 24, 'reserved'], // 192.0.2.0/24, TEST-NET-1
|
|
32
|
+
[0xc6120000, 15, 'reserved'], // 198.18.0.0/15, benchmarking
|
|
33
|
+
[0xc6336400, 24, 'reserved'], // 198.51.100.0/24, TEST-NET-2
|
|
34
|
+
[0xcb007100, 24, 'reserved'], // 203.0.113.0/24, TEST-NET-3
|
|
35
|
+
[0xe0000000, 4, 'reserved'], // multicast
|
|
36
|
+
[0xf0000000, 4, 'reserved'], // 240.0.0.0/4 and the broadcast address inside it
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Strict dotted quad: four decimal octets, no leading zero. `010.0.0.1` is octal to some parsers
|
|
41
|
+
* and decimal to others, so it is not an address this module will vouch for — a screen that
|
|
42
|
+
* refuses what it cannot classify is the only safe reader of it.
|
|
43
|
+
*/
|
|
44
|
+
function parseV4(text: string): number | undefined {
|
|
45
|
+
const parts = text.split('.');
|
|
46
|
+
if (parts.length !== 4) return undefined;
|
|
47
|
+
let value = 0;
|
|
48
|
+
for (const part of parts) {
|
|
49
|
+
if (!/^(?:0|[1-9]\d{0,2})$/.test(part)) return undefined;
|
|
50
|
+
const octet = Number(part);
|
|
51
|
+
if (octet > 255) return undefined;
|
|
52
|
+
value = value * 256 + octet;
|
|
53
|
+
}
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function classifyV4(value: number): AddressClass {
|
|
58
|
+
for (const [network, bits, kind] of V4_RULES) {
|
|
59
|
+
const size = 2 ** (32 - bits);
|
|
60
|
+
if (value >= network && value < network + size) return kind;
|
|
61
|
+
}
|
|
62
|
+
return 'public';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Eight 16-bit groups, or `undefined`. A trailing dotted quad fills the last two. */
|
|
66
|
+
function parseV6(text: string): readonly number[] | undefined {
|
|
67
|
+
let body = text;
|
|
68
|
+
let tail: number[] = [];
|
|
69
|
+
const lastColon = body.lastIndexOf(':');
|
|
70
|
+
if (body.includes('.', lastColon)) {
|
|
71
|
+
const v4 = parseV4(body.slice(lastColon + 1));
|
|
72
|
+
if (v4 === undefined) return undefined;
|
|
73
|
+
tail = [Math.floor(v4 / 0x10000), v4 % 0x10000];
|
|
74
|
+
// `::ffff:1.2.3.4` keeps `::ffff`; `::1.2.3.4` keeps its `::`, the one colon that is syntax.
|
|
75
|
+
const head = body.slice(0, lastColon + 1);
|
|
76
|
+
body = head.endsWith('::') ? head : head.slice(0, -1);
|
|
77
|
+
}
|
|
78
|
+
const halves = body.split('::');
|
|
79
|
+
if (halves.length > 2) return undefined;
|
|
80
|
+
const groups = (half: string): number[] | undefined => {
|
|
81
|
+
if (half === '') return [];
|
|
82
|
+
const out: number[] = [];
|
|
83
|
+
for (const group of half.split(':')) {
|
|
84
|
+
if (!/^[0-9a-f]{1,4}$/i.test(group)) return undefined;
|
|
85
|
+
out.push(Number.parseInt(group, 16));
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
};
|
|
89
|
+
const head = groups(halves[0] ?? '');
|
|
90
|
+
const rest = halves.length === 2 ? groups(halves[1] ?? '') : [];
|
|
91
|
+
if (head === undefined || rest === undefined) return undefined;
|
|
92
|
+
const width = head.length + rest.length + tail.length;
|
|
93
|
+
if (halves.length === 1) return width === 8 ? [...head, ...tail] : undefined;
|
|
94
|
+
if (width > 7) return undefined;
|
|
95
|
+
return [...head, ...new Array<number>(8 - width).fill(0), ...rest, ...tail];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const embeddedV4 = (g: readonly number[]): number => (g[6] ?? 0) * 0x10000 + (g[7] ?? 0);
|
|
99
|
+
|
|
100
|
+
function classifyV6(g: readonly number[]): AddressClass {
|
|
101
|
+
const zeroUpTo = (n: number): boolean => g.slice(0, n).every((group) => group === 0);
|
|
102
|
+
if (zeroUpTo(8)) return 'unspecified';
|
|
103
|
+
if (zeroUpTo(7) && g[7] === 1) return 'loopback';
|
|
104
|
+
// IPv4-mapped `::ffff:a.b.c.d`, IPv4-compatible `::a.b.c.d` and NAT64 `64:ff9b::/96`: each is
|
|
105
|
+
// routed to the IPv4 address it carries, so that address is what gets classified.
|
|
106
|
+
if (zeroUpTo(5) && g[5] === 0xffff) return classifyV4(embeddedV4(g));
|
|
107
|
+
if (zeroUpTo(6)) return classifyV4(embeddedV4(g));
|
|
108
|
+
if (g[0] === 0x64 && g[1] === 0xff9b && g.slice(2, 6).every((group) => group === 0)) {
|
|
109
|
+
return classifyV4(embeddedV4(g));
|
|
110
|
+
}
|
|
111
|
+
const first = g[0] ?? 0;
|
|
112
|
+
if ((first & 0xffc0) === 0xfe80) return 'link-local';
|
|
113
|
+
if ((first & 0xffc0) === 0xfec0) return 'private'; // deprecated site-local
|
|
114
|
+
if ((first & 0xfe00) === 0xfc00) return 'ula';
|
|
115
|
+
if ((first & 0xff00) === 0xff00) return 'reserved'; // multicast
|
|
116
|
+
if (first === 0x2001 && g[1] === 0x0db8) return 'reserved'; // documentation
|
|
117
|
+
if (first === 0x0100 && g.slice(1, 4).every((group) => group === 0)) return 'reserved'; // 100::/64
|
|
118
|
+
|
|
119
|
+
return 'public';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The class of an IP address LITERAL — IPv4, IPv6, bracketed `[::1]`, a zone id `fe80::1%eth0`,
|
|
124
|
+
* and every IPv6 form carrying an IPv4 address. `undefined` means "not an address literal": a
|
|
125
|
+
* hostname must be resolved first and each resolved address classified, never this string.
|
|
126
|
+
*/
|
|
127
|
+
export function classifyAddress(address: string): AddressClass | undefined {
|
|
128
|
+
let text = address.trim();
|
|
129
|
+
if (text.startsWith('[') && text.endsWith(']')) text = text.slice(1, -1);
|
|
130
|
+
const zone = text.indexOf('%');
|
|
131
|
+
if (zone !== -1 && text.includes(':')) text = text.slice(0, zone);
|
|
132
|
+
if (text.includes(':')) {
|
|
133
|
+
const groups = parseV6(text);
|
|
134
|
+
return groups === undefined ? undefined : classifyV6(groups);
|
|
135
|
+
}
|
|
136
|
+
const v4 = parseV4(text);
|
|
137
|
+
return v4 === undefined ? undefined : classifyV4(v4);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Fails CLOSED: anything but a literal classified `public` — a hostname included — is `false`. */
|
|
141
|
+
export function isPublicAddress(address: string): boolean {
|
|
142
|
+
return classifyAddress(address) === 'public';
|
|
143
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four shapes an async region can be in — pending, refreshing, ready, failed. Tier 0 because
|
|
3
|
+
* every client layer produces or consumes one: `@ultimat3/realtime`'s `useQuery` returns it (tier 3)
|
|
4
|
+
* and `@ultimat3/ui`'s `asyncBranch` renders it (tier 4), and neither may import the other's way.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A STATE, never a query: a live-query accessor, a `createResource` and a plain signal all arrive
|
|
9
|
+
* at a region as the same four shapes.
|
|
10
|
+
*
|
|
11
|
+
* `refreshing` is the one that makes search feel fast — it CARRIES the previous data, so a refetch
|
|
12
|
+
* re-renders what is already on screen instead of tearing it down to a skeleton. `pending` holds
|
|
13
|
+
* no data, which is what makes "no results" before the first answer unconstructible.
|
|
14
|
+
*/
|
|
15
|
+
export type AsyncState<T> =
|
|
16
|
+
| { readonly status: 'pending' }
|
|
17
|
+
| { readonly status: 'refreshing'; readonly data: T }
|
|
18
|
+
| { readonly status: 'ready'; readonly data: T }
|
|
19
|
+
| { readonly status: 'failed'; readonly error: unknown };
|
package/src/canonical-json.ts
CHANGED
|
@@ -50,6 +50,27 @@ function hashCollection(value: Map<unknown, unknown> | Set<unknown>): string {
|
|
|
50
50
|
return `${value instanceof Map ? 'Map' : 'Set'}(${entries.sort().join(',')})`;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/** Lowercase hex, two digits a byte. Not `toBase64`: this module ships to browsers that lack it. */
|
|
54
|
+
function hexOf(bytes: Uint8Array): string {
|
|
55
|
+
let out = '';
|
|
56
|
+
for (const byte of bytes) out += byte.toString(16).padStart(2, '0');
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Tagged, because a typed array's indices ARE own enumerable keys: `Uint8Array([1])` rendered
|
|
62
|
+
* `{"0":1}`, one key with the plain object `{ 0: 1 }`. The view's OWN window is read, never the
|
|
63
|
+
* whole backing buffer, and any view but a `Uint8Array` carries its type name — a `Uint16Array([1])`
|
|
64
|
+
* and a `Uint8Array([1, 0])` hold the same bytes and are different payloads.
|
|
65
|
+
*/
|
|
66
|
+
function hashBytes(value: ArrayBuffer | ArrayBufferView): string {
|
|
67
|
+
if (value instanceof ArrayBuffer) return `ArrayBuffer(${hexOf(new Uint8Array(value))})`;
|
|
68
|
+
const bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
69
|
+
const tag =
|
|
70
|
+
value instanceof Uint8Array ? 'Bytes' : Object.prototype.toString.call(value).slice(8, -1);
|
|
71
|
+
return `${tag}(${hexOf(bytes)})`;
|
|
72
|
+
}
|
|
73
|
+
|
|
53
74
|
/**
|
|
54
75
|
* JSON with object keys sorted at every depth, and every value a JSON document would fold onto
|
|
55
76
|
* `null` or `{}` given a token of its own. No timestamps, no insertion-order leaks.
|
|
@@ -64,7 +85,8 @@ export function canonicalJson(value: unknown): string {
|
|
|
64
85
|
case 'boolean':
|
|
65
86
|
return String(value);
|
|
66
87
|
case 'bigint':
|
|
67
|
-
|
|
88
|
+
// A bare token like `Date(…)`: quoted as `"5n"`, it was one key with the STRING `'5n'`.
|
|
89
|
+
return `BigInt(${value})`;
|
|
68
90
|
case 'undefined':
|
|
69
91
|
case 'function':
|
|
70
92
|
case 'symbol':
|
|
@@ -77,6 +99,7 @@ export function canonicalJson(value: unknown): string {
|
|
|
77
99
|
// every set alike.
|
|
78
100
|
if (value instanceof Date) return hashDate(value);
|
|
79
101
|
if (value instanceof Map || value instanceof Set) return hashCollection(value);
|
|
102
|
+
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return hashBytes(value);
|
|
80
103
|
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
81
104
|
const record = value as Record<string, unknown>;
|
|
82
105
|
const keys = Object.keys(record)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One dispatch of a `clientTransport` request: the `RequestInit`, the fence's abort for a read,
|
|
3
|
+
* the network fault's code, and the ok/error split — nothing about how many times it happens
|
|
4
|
+
* (the flight's) or what the answer means (the transport's).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ClientFlight, ClientRetry } from './client-flight';
|
|
8
|
+
import { problemError, transportFailed } from './client-problem';
|
|
9
|
+
import { onRescope } from './client-scope';
|
|
10
|
+
import { scopeChanged } from './client-scope-error';
|
|
11
|
+
import type { UltimateError } from './errors';
|
|
12
|
+
import type { RecordEnvelope } from './record-envelope';
|
|
13
|
+
import { RECORDS_HEADER } from './record-envelope';
|
|
14
|
+
import { outboundSlot, pageClient } from './record-sink';
|
|
15
|
+
|
|
16
|
+
export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
|
|
17
|
+
|
|
18
|
+
/** The header `@ultimat3/action`'s server reads to replay rather than re-run a write. */
|
|
19
|
+
export const IDEMPOTENCY_HEADER = 'idempotency-key';
|
|
20
|
+
|
|
21
|
+
export interface TransportRequest {
|
|
22
|
+
readonly method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
23
|
+
readonly url: string;
|
|
24
|
+
/** JSON-encoded, with `content-type: application/json`. */
|
|
25
|
+
readonly body?: unknown;
|
|
26
|
+
/**
|
|
27
|
+
* Sent VERBATIM instead of `body` — bytes to a signed URL. No default header is added (a signed
|
|
28
|
+
* request may cover them), and the answer is not decoded: the call resolves `undefined`.
|
|
29
|
+
*/
|
|
30
|
+
readonly rawBody?: BodyInit | undefined;
|
|
31
|
+
/** Spread LAST, so an explicit value wins over every default. */
|
|
32
|
+
readonly headers?: Readonly<Record<string, string>> | undefined;
|
|
33
|
+
readonly signal?: AbortSignal | undefined;
|
|
34
|
+
readonly idempotencyKey?: string | undefined;
|
|
35
|
+
/** Dedup, retry, deadline and a concurrency ceiling — opt-in; a read dedupes only with one. */
|
|
36
|
+
readonly flight?: ClientFlight | undefined;
|
|
37
|
+
/** Refuse to join a read that left before this call did. Meaningful only with a `flight`. */
|
|
38
|
+
readonly fresh?: boolean | undefined;
|
|
39
|
+
/** This call's retry override, handed to the `flight`. */
|
|
40
|
+
readonly retry?: ClientRetry | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* The decoded records envelope, after its records were adopted — only when the answer carried
|
|
43
|
+
* `x-ultimate-records: 1` and belongs to the current principal. What a caller reads to know the
|
|
44
|
+
* ORDER of `records[type]`, which the store (keyed, unordered) cannot give it back.
|
|
45
|
+
*/
|
|
46
|
+
readonly onEnvelope?: ((envelope: RecordEnvelope) => void) | undefined;
|
|
47
|
+
/** Sees every response before its body is read — a header check may throw its own refusal. */
|
|
48
|
+
readonly onResponse?: ((response: Response) => void | Promise<void>) | undefined;
|
|
49
|
+
/** A non-2xx answer as the caller's own error; `undefined` falls back to the shared decode. */
|
|
50
|
+
readonly decodeError?: ((status: number, text: string) => UltimateError | undefined) | undefined;
|
|
51
|
+
/** Default `globalThis.fetch`, read at call time — never captured at module scope. */
|
|
52
|
+
readonly fetchImpl?: FetchLike | undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One dispatch's answer: immutable TEXT, so a deduped read's joiners each parse their own. */
|
|
56
|
+
export interface Answer {
|
|
57
|
+
readonly text: string;
|
|
58
|
+
readonly enveloped: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Called, never captured: a browser's `fetch` throws `Illegal invocation` when detached. */
|
|
62
|
+
const browserFetch: FetchLike = (input, init) => globalThis.fetch(input, init);
|
|
63
|
+
|
|
64
|
+
export async function dispatch(
|
|
65
|
+
req: TransportRequest,
|
|
66
|
+
read: boolean,
|
|
67
|
+
issued: number,
|
|
68
|
+
flightSignal: AbortSignal | undefined,
|
|
69
|
+
): Promise<Answer> {
|
|
70
|
+
// A read gets its own controller so the fence can abort it; a write never does, and only its
|
|
71
|
+
// caller's own signal reaches it (a flight never hands a write one).
|
|
72
|
+
const fence = read ? abortable([req.signal, flightSignal]) : undefined;
|
|
73
|
+
const signal = fence === undefined ? req.signal : fence.signal;
|
|
74
|
+
try {
|
|
75
|
+
const response = await onTheWire(req, read, () =>
|
|
76
|
+
(req.fetchImpl ?? browserFetch)(req.url, initOf(req, signal)),
|
|
77
|
+
);
|
|
78
|
+
await req.onResponse?.(response);
|
|
79
|
+
// Read as TEXT once: a body is a single-use stream, and the failure path wants it too.
|
|
80
|
+
const text = response.status === 204 ? '' : await onTheWire(req, read, () => response.text());
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
throw (
|
|
83
|
+
req.decodeError?.(response.status, text) ?? problemError(response.status, text, req.url)
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return { text, enveloped: response.headers.get(RECORDS_HEADER) === '1' };
|
|
87
|
+
} catch (error) {
|
|
88
|
+
const current = pageClient().scope.epoch;
|
|
89
|
+
if (read && current !== issued) throw scopeChanged(req.url, issued, current);
|
|
90
|
+
throw error;
|
|
91
|
+
} finally {
|
|
92
|
+
fence?.release();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The network's half of a dispatch, and only that half. `fetch` — and a body read cut mid-stream —
|
|
98
|
+
* rejects with a bare `TypeError` when no response arrived; that is `failure: 'network'`. A caller's
|
|
99
|
+
* `onResponse` or `decodeError` throwing a `TypeError` is a bug in the caller, and classifying it
|
|
100
|
+
* as the network made it retryable, so a flight re-sent the request for it. An abort is a decision
|
|
101
|
+
* and passes through untouched.
|
|
102
|
+
*/
|
|
103
|
+
async function onTheWire<T>(
|
|
104
|
+
req: TransportRequest,
|
|
105
|
+
read: boolean,
|
|
106
|
+
work: () => Promise<T>,
|
|
107
|
+
): Promise<T> {
|
|
108
|
+
try {
|
|
109
|
+
return await work();
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (!(error instanceof TypeError)) throw error;
|
|
112
|
+
throw transportFailed(
|
|
113
|
+
'network',
|
|
114
|
+
`${req.method} ${req.url} produced no response — the network refused or dropped it`,
|
|
115
|
+
read || req.idempotencyKey !== undefined ? 'retryable' : undefined,
|
|
116
|
+
{ url: req.url, method: req.method },
|
|
117
|
+
error,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function initOf(req: TransportRequest, signal: AbortSignal | undefined): RequestInit {
|
|
123
|
+
const raw = req.rawBody !== undefined;
|
|
124
|
+
const headers: Record<string, string> = raw ? {} : { accept: 'application/json' };
|
|
125
|
+
if (req.body !== undefined && !raw) headers['content-type'] = 'application/json';
|
|
126
|
+
if (req.idempotencyKey !== undefined) headers[IDEMPOTENCY_HEADER] = req.idempotencyKey;
|
|
127
|
+
const body = raw ? req.rawBody : req.body === undefined ? undefined : JSON.stringify(req.body);
|
|
128
|
+
// Server-side only: the slot is filled by `runWithContext`/`startSpan` (`outbound-headers.ts`),
|
|
129
|
+
// and a signed raw upload never carries it — an extra header can break the signature.
|
|
130
|
+
const outbound = raw ? undefined : outboundSlot().outboundHeaders?.();
|
|
131
|
+
return {
|
|
132
|
+
method: req.method,
|
|
133
|
+
credentials: 'same-origin',
|
|
134
|
+
headers: { ...headers, ...outbound, ...req.headers },
|
|
135
|
+
...(body === undefined ? {} : { body }),
|
|
136
|
+
...(signal === undefined ? {} : { signal }),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A read's controller: aborted by `rescope()`, by the caller's signal or by the flight's. Every
|
|
142
|
+
* listener comes off in `release`, so a long-lived caller signal does not collect one per request.
|
|
143
|
+
*/
|
|
144
|
+
function abortable(sources: readonly (AbortSignal | undefined)[]): {
|
|
145
|
+
readonly signal: AbortSignal;
|
|
146
|
+
release(): void;
|
|
147
|
+
} {
|
|
148
|
+
const controller = new AbortController();
|
|
149
|
+
const detach: (() => void)[] = [onRescope(() => controller.abort())];
|
|
150
|
+
for (const source of sources) {
|
|
151
|
+
if (source === undefined) continue;
|
|
152
|
+
if (source.aborted) controller.abort(source.reason);
|
|
153
|
+
const forward = (): void => controller.abort(source.reason);
|
|
154
|
+
source.addEventListener('abort', forward, { once: true });
|
|
155
|
+
detach.push(() => source.removeEventListener('abort', forward));
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
signal: controller.signal,
|
|
159
|
+
release: (): void => {
|
|
160
|
+
for (const off of detach) off();
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
package/src/client-flight.ts
CHANGED
|
@@ -61,6 +61,12 @@ export function isTransientFailure(error: unknown): boolean {
|
|
|
61
61
|
return isNetworkRejection(error);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/** Only a classification somebody declared — for work that already classified its own wire. */
|
|
65
|
+
function isDeclaredTransient(error: unknown): boolean {
|
|
66
|
+
const declared = classifyThrown(error);
|
|
67
|
+
return declared !== undefined && declared !== 'terminal';
|
|
68
|
+
}
|
|
69
|
+
|
|
64
70
|
/**
|
|
65
71
|
* A dispatch that never produced a response. `fetch` rejects with a plain `TypeError` when the
|
|
66
72
|
* network is down, DNS fails or the connection drops mid-body — the one unclassified throw a
|
|
@@ -89,6 +95,13 @@ export interface FlightPlan<T> {
|
|
|
89
95
|
run(signal: AbortSignal | undefined, attempt: number): Promise<T>;
|
|
90
96
|
/** Overrides the flight's policy for this one call. */
|
|
91
97
|
readonly retry?: ClientRetry | undefined;
|
|
98
|
+
/**
|
|
99
|
+
* `true` when `run` already turns every wire failure into a classified error, as
|
|
100
|
+
* `clientTransport` does. An UNclassified throw is then the caller's own — a hook's `TypeError`
|
|
101
|
+
* — and is never sent again, where the default would read it as a network rejection. A
|
|
102
|
+
* flight-wide `transient` still wins: that is the app's own answer.
|
|
103
|
+
*/
|
|
104
|
+
readonly classified?: boolean | undefined;
|
|
92
105
|
}
|
|
93
106
|
|
|
94
107
|
export interface ClientFlightOptions {
|
|
@@ -151,6 +164,8 @@ export function createClientFlight(options: ClientFlightOptions = {}): ClientFli
|
|
|
151
164
|
schedule(done, ms);
|
|
152
165
|
}));
|
|
153
166
|
const transient = options.transient ?? isTransientFailure;
|
|
167
|
+
const transientFor = (plan: FlightPlan<unknown>): ((error: unknown) => boolean) =>
|
|
168
|
+
plan.classified === true && options.transient === undefined ? isDeclaredTransient : transient;
|
|
154
169
|
const deadlineMs = options.deadlineMs;
|
|
155
170
|
const flights = createSingleFlight({ deadlineMs, schedule });
|
|
156
171
|
const gate: FlightGate | undefined =
|
|
@@ -169,12 +184,13 @@ export function createClientFlight(options: ClientFlightOptions = {}): ClientFli
|
|
|
169
184
|
// and a foreign `TypeError` again. The original value is rethrown below, unwrapped — wrapping
|
|
170
185
|
// it would replace a code, a cause and a runnable `fix:` with the fact that something retried.
|
|
171
186
|
let stopped: { readonly error: unknown } | undefined;
|
|
187
|
+
const sendAgain = transientFor(plan);
|
|
172
188
|
const answer = await retry<T | typeof STOPPED>(
|
|
173
189
|
async (count) => {
|
|
174
190
|
try {
|
|
175
191
|
return await plan.run(signal, count);
|
|
176
192
|
} catch (error) {
|
|
177
|
-
if (
|
|
193
|
+
if (sendAgain(error)) throw error;
|
|
178
194
|
stopped = { error };
|
|
179
195
|
return STOPPED;
|
|
180
196
|
}
|
|
@@ -259,8 +275,11 @@ export function createClientFlight(options: ClientFlightOptions = {}): ClientFli
|
|
|
259
275
|
const work = (): Promise<T> =>
|
|
260
276
|
gate === undefined ? dispatch(plan) : gate.run(() => dispatch(plan));
|
|
261
277
|
// The single flight sits OUTSIDE the gate: a joiner takes no slot, so dedup relieves the
|
|
262
|
-
// ceiling instead of queueing behind it.
|
|
263
|
-
|
|
278
|
+
// ceiling instead of queueing behind it. Keyed by GENERATION too: `bump()` aborts the old
|
|
279
|
+
// flights, but each holds its key until its rejection settles, and a same-key read issued
|
|
280
|
+
// in that window joined the aborted one and was answered with its AbortError.
|
|
281
|
+
const key = plan.key === undefined ? undefined : JSON.stringify([issued, plan.key]);
|
|
282
|
+
return settle(key === undefined ? work() : flights.run(key, work), issued);
|
|
264
283
|
},
|
|
265
284
|
|
|
266
285
|
get inflight(): number {
|