@shipstatic/types 2.7.0-beta.1 → 2.7.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +19 -1
- package/dist/index.js +93 -33
- package/package.json +1 -1
- package/src/index.ts +96 -33
package/dist/index.d.ts
CHANGED
|
@@ -788,12 +788,30 @@ export declare class ShipError extends Error {
|
|
|
788
788
|
*
|
|
789
789
|
* Routing:
|
|
790
790
|
* - Already a `ShipError` → returned as-is (caller's intent preserved)
|
|
791
|
-
* - `AbortError` → `ShipError.cancelled(...)`
|
|
791
|
+
* - `AbortError` → `ShipError.cancelled(...)` — someone stopped it on purpose
|
|
792
|
+
* - `TimeoutError` → `ShipError.network(...)` — a deadline expired, so
|
|
793
|
+
* nothing was exchanged; the message names the timeout
|
|
792
794
|
* - A transport failure → `ShipError.network(...)` — see `isTransportFailure`
|
|
793
795
|
* for what each runtime offers as evidence
|
|
794
796
|
* - Any other `Error` → `ShipError(Api, ...)` (no HTTP status — fetch never reached the server)
|
|
795
797
|
* - Anything else (string, undefined, etc.) → `ShipError(Api, ...)`
|
|
796
798
|
*
|
|
799
|
+
* **Abort and timeout are read from `name` BEFORE any `instanceof Error`
|
|
800
|
+
* gate.** A `DOMException` satisfies that gate in every runtime measured
|
|
801
|
+
* (Node, Bun, Chromium, Firefox, WebKit, workerd — all six), but the
|
|
802
|
+
* inheritance is a comparatively recent spec change and this classification
|
|
803
|
+
* has no reason to depend on it: `name` is where the meaning lives, and
|
|
804
|
+
* reading it first costs nothing. The suite plants a non-`Error`
|
|
805
|
+
* `DOMException` shape to hold the arm, since no runtime on the table
|
|
806
|
+
* produces one.
|
|
807
|
+
*
|
|
808
|
+
* A caller's own `AbortSignal.timeout()` is the reachable source of
|
|
809
|
+
* `TimeoutError` — and the two are NOT interchangeable per runtime: WebKit
|
|
810
|
+
* reports a fired `AbortSignal.timeout()` as `AbortError`, so on Safari a
|
|
811
|
+
* deadline is indistinguishable from a cancellation and lands on
|
|
812
|
+
* `Cancelled`. Recorded rather than worked around; `Cancelled` is honest
|
|
813
|
+
* there, since the caller's signal is what stopped it.
|
|
814
|
+
*
|
|
797
815
|
* The optional `operationName` is composed into the message for context:
|
|
798
816
|
* `"Get account was cancelled"`, `"Get account failed: ..."`. Defaults to
|
|
799
817
|
* `"Request"` when omitted.
|
package/dist/index.js
CHANGED
|
@@ -340,38 +340,71 @@ const MAX_FOREIGN_MESSAGE_LENGTH = 200;
|
|
|
340
340
|
/**
|
|
341
341
|
* Did the runtime say the exchange never completed?
|
|
342
342
|
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
* |
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
* The
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
343
|
+
* Clients branch on the TYPE, never on message strings, so a misclassified
|
|
344
|
+
* transport failure is a lie every consumer inherits — and the one that costs
|
|
345
|
+
* most: `Api` claims a server answered when nothing was exchanged, and a
|
|
346
|
+
* retrying caller will not retry it.
|
|
347
|
+
*
|
|
348
|
+
* **Every row below is a transcript, not a belief.** Captured 2026-08-12
|
|
349
|
+
* against real runtimes — Node and Bun by direct run, the three engines by a
|
|
350
|
+
* one-off playwright probe, workerd through miniflare. The capture scripts are
|
|
351
|
+
* in `tests/errors.test.ts`, "runtime failure shapes".
|
|
352
|
+
*
|
|
353
|
+
* | runtime | connection refused / DNS failure | malformed URL |
|
|
354
|
+
* |------------------|------------------------------------------------------|--------------------------------------------------|
|
|
355
|
+
* | Node 22 / undici | `TypeError: fetch failed` | `TypeError: Failed to parse URL from …` |
|
|
356
|
+
* | Bun 1.3.14 | `Error` `code:'ConnectionRefused'` | `TypeError` `code:'ERR_INVALID_URL'` |
|
|
357
|
+
* | Chromium 151 | `TypeError: Failed to fetch` | `TypeError: …Failed to parse URL from …` |
|
|
358
|
+
* | Firefox 153 | `TypeError: NetworkError when attempting to fetch …` | `TypeError: … is not a valid URL.` |
|
|
359
|
+
* | WebKit 26.5 | `TypeError: Load failed` | `TypeError: URL is not valid or contains user …` |
|
|
360
|
+
* | workerd | `Error: Network connection lost.` (DNS: `internal error; reference = …`) | `TypeError: Invalid URL: …` |
|
|
361
|
+
*
|
|
362
|
+
* Reading that table gives the rule, and it is the INVERSE of the obvious one.
|
|
363
|
+
* The transport class is unbounded — every OS, TLS and DNS failure any engine
|
|
364
|
+
* will ever name — while the class fetch raises for its own ARGUMENTS is
|
|
365
|
+
* small, and every runtime names the URL when it complains about one. So the
|
|
366
|
+
* bounded side is the one worth testing, and the residual risk points the safe
|
|
367
|
+
* way: an unrecognised sentence lands on `Network`, which says only that
|
|
368
|
+
* nothing was exchanged.
|
|
369
|
+
*
|
|
370
|
+
* That inversion is what fixes **WebKit**, whose `Load failed` carries no code
|
|
371
|
+
* and no "fetch", and which every browser-SDK and `@shipstatic/drop` user on
|
|
372
|
+
* Safari was hitting as `Api`. It also makes the six runtimes AGREE about a
|
|
373
|
+
* malformed URL, which they did not before: the previous rule tested the
|
|
374
|
+
* message for "fetch", and Chromium's and Firefox's URL complaints both
|
|
375
|
+
* contain it, so the same mistake was `Network` on three engines and `Api` on
|
|
376
|
+
* three.
|
|
377
|
+
*
|
|
378
|
+
* **workerd is the recorded gap.** It rejects with a plain `Error`, no code
|
|
379
|
+
* and no shared sentence — and its two failure modes produce two unrelated
|
|
380
|
+
* ones — so nothing here can classify it and it lands on `Api`. Left alone
|
|
381
|
+
* rather than patched with a dialect string: the one consumer running ship in
|
|
382
|
+
* that runtime (`cloudflare/mcp`) reaches the API through a service BINDING,
|
|
383
|
+
* which is in-process and does not produce transport rejections at all.
|
|
384
|
+
*
|
|
385
|
+
* The accepted trade is unchanged: a caller's `TokenProvider` that throws a
|
|
386
|
+
* coded error (`ENOENT` from a keychain read) is typed `Network` rather than
|
|
387
|
+
* `Api`. Both are wrong for it; `Network` is the cheaper wrong.
|
|
367
388
|
*/
|
|
368
389
|
function isTransportFailure(cause) {
|
|
369
|
-
|
|
390
|
+
const code = cause.code;
|
|
391
|
+
// Bun is the one runtime that puts a CODE on an argument error, so it is
|
|
392
|
+
// excluded before the code arm can claim it.
|
|
393
|
+
if (code === 'ERR_INVALID_URL')
|
|
394
|
+
return false;
|
|
395
|
+
// A string `code` is a runtime naming a transport-level failure. An
|
|
396
|
+
// allowlist of codes was written first and rejected — the TLS row alone
|
|
397
|
+
// would mean enumerating BoringSSL's certificate table, and a code nobody
|
|
398
|
+
// guessed is precisely the bug this closes. A `DOMException`'s code is a
|
|
399
|
+
// NUMBER, so aborts and timeouts never reach here.
|
|
400
|
+
if (typeof code === 'string')
|
|
370
401
|
return true;
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
|
|
402
|
+
// WHATWG has fetch reject with a TypeError for BOTH halves — network error
|
|
403
|
+
// and argument error — so among TypeErrors the URL is the discriminator.
|
|
404
|
+
if (cause instanceof TypeError)
|
|
405
|
+
return !/\burl\b/i.test(cause.message);
|
|
406
|
+
// Anything else — an ordinary JS fault, or workerd — is not evidence.
|
|
407
|
+
return false;
|
|
375
408
|
}
|
|
376
409
|
/**
|
|
377
410
|
* Simple unified error class for both API and SDK
|
|
@@ -497,12 +530,30 @@ export class ShipError extends Error {
|
|
|
497
530
|
*
|
|
498
531
|
* Routing:
|
|
499
532
|
* - Already a `ShipError` → returned as-is (caller's intent preserved)
|
|
500
|
-
* - `AbortError` → `ShipError.cancelled(...)`
|
|
533
|
+
* - `AbortError` → `ShipError.cancelled(...)` — someone stopped it on purpose
|
|
534
|
+
* - `TimeoutError` → `ShipError.network(...)` — a deadline expired, so
|
|
535
|
+
* nothing was exchanged; the message names the timeout
|
|
501
536
|
* - A transport failure → `ShipError.network(...)` — see `isTransportFailure`
|
|
502
537
|
* for what each runtime offers as evidence
|
|
503
538
|
* - Any other `Error` → `ShipError(Api, ...)` (no HTTP status — fetch never reached the server)
|
|
504
539
|
* - Anything else (string, undefined, etc.) → `ShipError(Api, ...)`
|
|
505
540
|
*
|
|
541
|
+
* **Abort and timeout are read from `name` BEFORE any `instanceof Error`
|
|
542
|
+
* gate.** A `DOMException` satisfies that gate in every runtime measured
|
|
543
|
+
* (Node, Bun, Chromium, Firefox, WebKit, workerd — all six), but the
|
|
544
|
+
* inheritance is a comparatively recent spec change and this classification
|
|
545
|
+
* has no reason to depend on it: `name` is where the meaning lives, and
|
|
546
|
+
* reading it first costs nothing. The suite plants a non-`Error`
|
|
547
|
+
* `DOMException` shape to hold the arm, since no runtime on the table
|
|
548
|
+
* produces one.
|
|
549
|
+
*
|
|
550
|
+
* A caller's own `AbortSignal.timeout()` is the reachable source of
|
|
551
|
+
* `TimeoutError` — and the two are NOT interchangeable per runtime: WebKit
|
|
552
|
+
* reports a fired `AbortSignal.timeout()` as `AbortError`, so on Safari a
|
|
553
|
+
* deadline is indistinguishable from a cancellation and lands on
|
|
554
|
+
* `Cancelled`. Recorded rather than worked around; `Cancelled` is honest
|
|
555
|
+
* there, since the caller's signal is what stopped it.
|
|
556
|
+
*
|
|
506
557
|
* The optional `operationName` is composed into the message for context:
|
|
507
558
|
* `"Get account was cancelled"`, `"Get account failed: ..."`. Defaults to
|
|
508
559
|
* `"Request"` when omitted.
|
|
@@ -511,10 +562,19 @@ export class ShipError extends Error {
|
|
|
511
562
|
if (isShipError(cause))
|
|
512
563
|
return cause;
|
|
513
564
|
const op = operationName || 'Request';
|
|
565
|
+
// Read by NAME, ahead of the Error gate — see the note above.
|
|
566
|
+
const name = cause?.name;
|
|
567
|
+
if (name === 'AbortError') {
|
|
568
|
+
return ShipError.cancelled(`${op} was cancelled`);
|
|
569
|
+
}
|
|
570
|
+
if (name === 'TimeoutError') {
|
|
571
|
+
// A deadline, not a fault and not a cancellation: nothing was exchanged,
|
|
572
|
+
// which is exactly what `Network` claims. The runtime's own sentence is
|
|
573
|
+
// dropped here rather than relayed — "The operation was aborted due to
|
|
574
|
+
// timeout" is the mechanism, not the news.
|
|
575
|
+
return ShipError.network(`${op} timed out`, { cause });
|
|
576
|
+
}
|
|
514
577
|
if (cause instanceof Error) {
|
|
515
|
-
if (cause.name === 'AbortError') {
|
|
516
|
-
return ShipError.cancelled(`${op} was cancelled`);
|
|
517
|
-
}
|
|
518
578
|
if (isTransportFailure(cause)) {
|
|
519
579
|
return ShipError.network(`${op} failed: ${cause.message}`, { cause });
|
|
520
580
|
}
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -893,37 +893,72 @@ const MAX_FOREIGN_MESSAGE_LENGTH = 200;
|
|
|
893
893
|
/**
|
|
894
894
|
* Did the runtime say the exchange never completed?
|
|
895
895
|
*
|
|
896
|
-
*
|
|
897
|
-
*
|
|
898
|
-
*
|
|
899
|
-
*
|
|
900
|
-
*
|
|
901
|
-
*
|
|
902
|
-
*
|
|
903
|
-
*
|
|
904
|
-
*
|
|
905
|
-
*
|
|
906
|
-
* |
|
|
907
|
-
*
|
|
908
|
-
*
|
|
909
|
-
*
|
|
910
|
-
*
|
|
911
|
-
*
|
|
912
|
-
*
|
|
913
|
-
*
|
|
914
|
-
*
|
|
915
|
-
*
|
|
916
|
-
* The
|
|
917
|
-
*
|
|
918
|
-
*
|
|
919
|
-
*
|
|
896
|
+
* Clients branch on the TYPE, never on message strings, so a misclassified
|
|
897
|
+
* transport failure is a lie every consumer inherits — and the one that costs
|
|
898
|
+
* most: `Api` claims a server answered when nothing was exchanged, and a
|
|
899
|
+
* retrying caller will not retry it.
|
|
900
|
+
*
|
|
901
|
+
* **Every row below is a transcript, not a belief.** Captured 2026-08-12
|
|
902
|
+
* against real runtimes — Node and Bun by direct run, the three engines by a
|
|
903
|
+
* one-off playwright probe, workerd through miniflare. The capture scripts are
|
|
904
|
+
* in `tests/errors.test.ts`, "runtime failure shapes".
|
|
905
|
+
*
|
|
906
|
+
* | runtime | connection refused / DNS failure | malformed URL |
|
|
907
|
+
* |------------------|------------------------------------------------------|--------------------------------------------------|
|
|
908
|
+
* | Node 22 / undici | `TypeError: fetch failed` | `TypeError: Failed to parse URL from …` |
|
|
909
|
+
* | Bun 1.3.14 | `Error` `code:'ConnectionRefused'` | `TypeError` `code:'ERR_INVALID_URL'` |
|
|
910
|
+
* | Chromium 151 | `TypeError: Failed to fetch` | `TypeError: …Failed to parse URL from …` |
|
|
911
|
+
* | Firefox 153 | `TypeError: NetworkError when attempting to fetch …` | `TypeError: … is not a valid URL.` |
|
|
912
|
+
* | WebKit 26.5 | `TypeError: Load failed` | `TypeError: URL is not valid or contains user …` |
|
|
913
|
+
* | workerd | `Error: Network connection lost.` (DNS: `internal error; reference = …`) | `TypeError: Invalid URL: …` |
|
|
914
|
+
*
|
|
915
|
+
* Reading that table gives the rule, and it is the INVERSE of the obvious one.
|
|
916
|
+
* The transport class is unbounded — every OS, TLS and DNS failure any engine
|
|
917
|
+
* will ever name — while the class fetch raises for its own ARGUMENTS is
|
|
918
|
+
* small, and every runtime names the URL when it complains about one. So the
|
|
919
|
+
* bounded side is the one worth testing, and the residual risk points the safe
|
|
920
|
+
* way: an unrecognised sentence lands on `Network`, which says only that
|
|
921
|
+
* nothing was exchanged.
|
|
922
|
+
*
|
|
923
|
+
* That inversion is what fixes **WebKit**, whose `Load failed` carries no code
|
|
924
|
+
* and no "fetch", and which every browser-SDK and `@shipstatic/drop` user on
|
|
925
|
+
* Safari was hitting as `Api`. It also makes the six runtimes AGREE about a
|
|
926
|
+
* malformed URL, which they did not before: the previous rule tested the
|
|
927
|
+
* message for "fetch", and Chromium's and Firefox's URL complaints both
|
|
928
|
+
* contain it, so the same mistake was `Network` on three engines and `Api` on
|
|
929
|
+
* three.
|
|
930
|
+
*
|
|
931
|
+
* **workerd is the recorded gap.** It rejects with a plain `Error`, no code
|
|
932
|
+
* and no shared sentence — and its two failure modes produce two unrelated
|
|
933
|
+
* ones — so nothing here can classify it and it lands on `Api`. Left alone
|
|
934
|
+
* rather than patched with a dialect string: the one consumer running ship in
|
|
935
|
+
* that runtime (`cloudflare/mcp`) reaches the API through a service BINDING,
|
|
936
|
+
* which is in-process and does not produce transport rejections at all.
|
|
937
|
+
*
|
|
938
|
+
* The accepted trade is unchanged: a caller's `TokenProvider` that throws a
|
|
939
|
+
* coded error (`ENOENT` from a keychain read) is typed `Network` rather than
|
|
940
|
+
* `Api`. Both are wrong for it; `Network` is the cheaper wrong.
|
|
920
941
|
*/
|
|
921
942
|
function isTransportFailure(cause: Error): boolean {
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
//
|
|
925
|
-
//
|
|
926
|
-
|
|
943
|
+
const code = (cause as { code?: unknown }).code;
|
|
944
|
+
|
|
945
|
+
// Bun is the one runtime that puts a CODE on an argument error, so it is
|
|
946
|
+
// excluded before the code arm can claim it.
|
|
947
|
+
if (code === 'ERR_INVALID_URL') return false;
|
|
948
|
+
|
|
949
|
+
// A string `code` is a runtime naming a transport-level failure. An
|
|
950
|
+
// allowlist of codes was written first and rejected — the TLS row alone
|
|
951
|
+
// would mean enumerating BoringSSL's certificate table, and a code nobody
|
|
952
|
+
// guessed is precisely the bug this closes. A `DOMException`'s code is a
|
|
953
|
+
// NUMBER, so aborts and timeouts never reach here.
|
|
954
|
+
if (typeof code === 'string') return true;
|
|
955
|
+
|
|
956
|
+
// WHATWG has fetch reject with a TypeError for BOTH halves — network error
|
|
957
|
+
// and argument error — so among TypeErrors the URL is the discriminator.
|
|
958
|
+
if (cause instanceof TypeError) return !/\burl\b/i.test(cause.message);
|
|
959
|
+
|
|
960
|
+
// Anything else — an ordinary JS fault, or workerd — is not evidence.
|
|
961
|
+
return false;
|
|
927
962
|
}
|
|
928
963
|
|
|
929
964
|
/**
|
|
@@ -1071,12 +1106,30 @@ export class ShipError extends Error {
|
|
|
1071
1106
|
*
|
|
1072
1107
|
* Routing:
|
|
1073
1108
|
* - Already a `ShipError` → returned as-is (caller's intent preserved)
|
|
1074
|
-
* - `AbortError` → `ShipError.cancelled(...)`
|
|
1109
|
+
* - `AbortError` → `ShipError.cancelled(...)` — someone stopped it on purpose
|
|
1110
|
+
* - `TimeoutError` → `ShipError.network(...)` — a deadline expired, so
|
|
1111
|
+
* nothing was exchanged; the message names the timeout
|
|
1075
1112
|
* - A transport failure → `ShipError.network(...)` — see `isTransportFailure`
|
|
1076
1113
|
* for what each runtime offers as evidence
|
|
1077
1114
|
* - Any other `Error` → `ShipError(Api, ...)` (no HTTP status — fetch never reached the server)
|
|
1078
1115
|
* - Anything else (string, undefined, etc.) → `ShipError(Api, ...)`
|
|
1079
1116
|
*
|
|
1117
|
+
* **Abort and timeout are read from `name` BEFORE any `instanceof Error`
|
|
1118
|
+
* gate.** A `DOMException` satisfies that gate in every runtime measured
|
|
1119
|
+
* (Node, Bun, Chromium, Firefox, WebKit, workerd — all six), but the
|
|
1120
|
+
* inheritance is a comparatively recent spec change and this classification
|
|
1121
|
+
* has no reason to depend on it: `name` is where the meaning lives, and
|
|
1122
|
+
* reading it first costs nothing. The suite plants a non-`Error`
|
|
1123
|
+
* `DOMException` shape to hold the arm, since no runtime on the table
|
|
1124
|
+
* produces one.
|
|
1125
|
+
*
|
|
1126
|
+
* A caller's own `AbortSignal.timeout()` is the reachable source of
|
|
1127
|
+
* `TimeoutError` — and the two are NOT interchangeable per runtime: WebKit
|
|
1128
|
+
* reports a fired `AbortSignal.timeout()` as `AbortError`, so on Safari a
|
|
1129
|
+
* deadline is indistinguishable from a cancellation and lands on
|
|
1130
|
+
* `Cancelled`. Recorded rather than worked around; `Cancelled` is honest
|
|
1131
|
+
* there, since the caller's signal is what stopped it.
|
|
1132
|
+
*
|
|
1080
1133
|
* The optional `operationName` is composed into the message for context:
|
|
1081
1134
|
* `"Get account was cancelled"`, `"Get account failed: ..."`. Defaults to
|
|
1082
1135
|
* `"Request"` when omitted.
|
|
@@ -1086,10 +1139,20 @@ export class ShipError extends Error {
|
|
|
1086
1139
|
|
|
1087
1140
|
const op = operationName || 'Request';
|
|
1088
1141
|
|
|
1142
|
+
// Read by NAME, ahead of the Error gate — see the note above.
|
|
1143
|
+
const name = (cause as { name?: unknown } | null | undefined)?.name;
|
|
1144
|
+
if (name === 'AbortError') {
|
|
1145
|
+
return ShipError.cancelled(`${op} was cancelled`);
|
|
1146
|
+
}
|
|
1147
|
+
if (name === 'TimeoutError') {
|
|
1148
|
+
// A deadline, not a fault and not a cancellation: nothing was exchanged,
|
|
1149
|
+
// which is exactly what `Network` claims. The runtime's own sentence is
|
|
1150
|
+
// dropped here rather than relayed — "The operation was aborted due to
|
|
1151
|
+
// timeout" is the mechanism, not the news.
|
|
1152
|
+
return ShipError.network(`${op} timed out`, { cause });
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1089
1155
|
if (cause instanceof Error) {
|
|
1090
|
-
if (cause.name === 'AbortError') {
|
|
1091
|
-
return ShipError.cancelled(`${op} was cancelled`);
|
|
1092
|
-
}
|
|
1093
1156
|
if (isTransportFailure(cause)) {
|
|
1094
1157
|
return ShipError.network(`${op} failed: ${cause.message}`, { cause });
|
|
1095
1158
|
}
|