cursedbelt-server 4.33.1 → 4.34.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.
|
@@ -405,6 +405,29 @@ export declare function binaryStoreTenant(appId: string, env?: Record<string, st
|
|
|
405
405
|
* apps and that is precisely why the difference goes unnoticed when it is not.
|
|
406
406
|
*/
|
|
407
407
|
export declare function resolvedMediaTenant(appId: string, env?: Record<string, string | undefined>): string | null;
|
|
408
|
+
/**
|
|
409
|
+
* binary-server's origin did not ANSWER a write — as opposed to answering "no".
|
|
410
|
+
*
|
|
411
|
+
* binary-server itself never answers 502, 503 or 504; those, and Cloudflare's 52x family (530 is a
|
|
412
|
+
* tunnel with no connector), are the edge speaking for an origin it could not reach — the owner's
|
|
413
|
+
* Mac off, asleep, or restarting. A thrown `fetch` is the same fact. Since 2026-09-26 the edge
|
|
414
|
+
* Worker takes an R2-backed tenant's whole upload itself in that case
|
|
415
|
+
* (`apps/binary-server/edge-worker/src/upload.ts`), so for desk and family this is what is left
|
|
416
|
+
* when BOTH halves failed; for a tenant whose bytes rest on the Mac it is simply "the Mac is off".
|
|
417
|
+
*/
|
|
418
|
+
export declare function storeUnreachableStatus(status: number): boolean;
|
|
419
|
+
/**
|
|
420
|
+
* The write could not reach the store. Carries `getResponse()`, so a Hono app that lets it escape
|
|
421
|
+
* a route answers a 503 that names the file store — Hono's default error handler returns
|
|
422
|
+
* `err.getResponse()` for any error that has one — instead of a bare `500 Internal Server Error`.
|
|
423
|
+
* The message keeps the `[binary-store] <op> <key>:` head every batch caller already prints.
|
|
424
|
+
*/
|
|
425
|
+
export declare class BinaryStoreUnreachableError extends Error {
|
|
426
|
+
readonly status = 503;
|
|
427
|
+
readonly code = "BYTE_STORE_UNREACHABLE";
|
|
428
|
+
constructor(op: string, key: string, detail: string);
|
|
429
|
+
getResponse(): Response;
|
|
430
|
+
}
|
|
408
431
|
/**
|
|
409
432
|
* The standard env surface every tenant's secrets file carries
|
|
410
433
|
* (`$FORGE_STATE/secrets/<app>.env`, written at bs registration):
|
|
@@ -430,7 +430,11 @@ export function createBinaryStore(cfg) {
|
|
|
430
430
|
method: 'PUT',
|
|
431
431
|
headers: mime ? { 'content-type': mime } : undefined,
|
|
432
432
|
body: bytes,
|
|
433
|
+
}).catch((cause) => {
|
|
434
|
+
throw new BinaryStoreUnreachableError('put', key, cause instanceof Error ? cause.message : String(cause));
|
|
433
435
|
});
|
|
436
|
+
if (storeUnreachableStatus(res.status))
|
|
437
|
+
throw new BinaryStoreUnreachableError('put', key, String(res.status));
|
|
434
438
|
if (!res.ok) {
|
|
435
439
|
// A 413 that reaches here despite the threshold above means the edge's
|
|
436
440
|
// real cap is lower than we think, or the caller disabled the
|
|
@@ -499,7 +503,12 @@ export function createBinaryStore(cfg) {
|
|
|
499
503
|
method: 'PUT',
|
|
500
504
|
headers: mime ? { 'content-type': mime } : undefined,
|
|
501
505
|
body: slice,
|
|
506
|
+
}).catch((cause) => {
|
|
507
|
+
throw new BinaryStoreUnreachableError('putLarge', key, cause instanceof Error ? cause.message : String(cause));
|
|
502
508
|
});
|
|
509
|
+
if (storeUnreachableStatus(res.status)) {
|
|
510
|
+
throw new BinaryStoreUnreachableError('putLarge', key, `chunk ${ci + 1}/${total}: ${res.status}`);
|
|
511
|
+
}
|
|
503
512
|
if (!res.ok) {
|
|
504
513
|
throw new Error(`[binary-store] putLarge ${key} chunk ${ci + 1}/${total}: ${res.status}`);
|
|
505
514
|
}
|
|
@@ -752,6 +761,40 @@ export function binaryStoreTenant(appId, env = process.env) {
|
|
|
752
761
|
export function resolvedMediaTenant(appId, env = process.env) {
|
|
753
762
|
return readBinaryStoreEnv(appId, env)?.appId ?? null;
|
|
754
763
|
}
|
|
764
|
+
/**
|
|
765
|
+
* binary-server's origin did not ANSWER a write — as opposed to answering "no".
|
|
766
|
+
*
|
|
767
|
+
* binary-server itself never answers 502, 503 or 504; those, and Cloudflare's 52x family (530 is a
|
|
768
|
+
* tunnel with no connector), are the edge speaking for an origin it could not reach — the owner's
|
|
769
|
+
* Mac off, asleep, or restarting. A thrown `fetch` is the same fact. Since 2026-09-26 the edge
|
|
770
|
+
* Worker takes an R2-backed tenant's whole upload itself in that case
|
|
771
|
+
* (`apps/binary-server/edge-worker/src/upload.ts`), so for desk and family this is what is left
|
|
772
|
+
* when BOTH halves failed; for a tenant whose bytes rest on the Mac it is simply "the Mac is off".
|
|
773
|
+
*/
|
|
774
|
+
export function storeUnreachableStatus(status) {
|
|
775
|
+
return status === 502 || status === 503 || status === 504 || (status >= 520 && status <= 530);
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* The write could not reach the store. Carries `getResponse()`, so a Hono app that lets it escape
|
|
779
|
+
* a route answers a 503 that names the file store — Hono's default error handler returns
|
|
780
|
+
* `err.getResponse()` for any error that has one — instead of a bare `500 Internal Server Error`.
|
|
781
|
+
* The message keeps the `[binary-store] <op> <key>:` head every batch caller already prints.
|
|
782
|
+
*/
|
|
783
|
+
export class BinaryStoreUnreachableError extends Error {
|
|
784
|
+
status = 503;
|
|
785
|
+
code = 'BYTE_STORE_UNREACHABLE';
|
|
786
|
+
constructor(op, key, detail) {
|
|
787
|
+
super(`[binary-store] ${op} ${key}: the file store did not answer (${detail}) — nothing was saved; ` +
|
|
788
|
+
'everything already stored still opens. Try again when the home server is on.');
|
|
789
|
+
this.name = 'BinaryStoreUnreachableError';
|
|
790
|
+
}
|
|
791
|
+
getResponse() {
|
|
792
|
+
return new Response(JSON.stringify({
|
|
793
|
+
error: this.code,
|
|
794
|
+
message: 'The file store did not answer, so nothing was saved. Everything already stored still opens — try again in a moment.',
|
|
795
|
+
}), { status: 503, headers: { 'content-type': 'application/json; charset=utf-8', 'retry-after': '30' } });
|
|
796
|
+
}
|
|
797
|
+
}
|
|
755
798
|
/**
|
|
756
799
|
* The standard env surface every tenant's secrets file carries
|
|
757
800
|
* (`$FORGE_STATE/secrets/<app>.env`, written at bs registration):
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.34.0",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The app-facing Bun/Hono server tier of the cursedbelt split — storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
BINARY_REPEAT_THRESHOLD,
|
|
35
35
|
BINARY_REPEAT_WINDOW_MS,
|
|
36
36
|
type BinaryStoreConfig,
|
|
37
|
+
BinaryStoreUnreachableError,
|
|
37
38
|
binaryStoreTenant,
|
|
38
39
|
createBinaryStore,
|
|
39
40
|
normalizePrivateKeyPem,
|
|
@@ -41,6 +42,7 @@ import {
|
|
|
41
42
|
repeatedBinaryKeys,
|
|
42
43
|
resetRepeatedBinaryKeys,
|
|
43
44
|
resolvedMediaTenant,
|
|
45
|
+
storeUnreachableStatus,
|
|
44
46
|
wantsFresh,
|
|
45
47
|
} from './binaryStore.js';
|
|
46
48
|
import { binaryStoreFakeDefaults } from './binaryStoreFake.js';
|
|
@@ -962,3 +964,58 @@ describe('binaryStoreFakeDefaults', () => {
|
|
|
962
964
|
expect(await fake.signToken({ k: 'c/a' }, 60)).toBe('fake-token');
|
|
963
965
|
});
|
|
964
966
|
});
|
|
967
|
+
|
|
968
|
+
describe('🔴 a write the store could not ANSWER is a 503 that names it (2026-09-26)', () => {
|
|
969
|
+
it('502-504 and 52x/530 are the origin unreachable; everything else is its own answer', () => {
|
|
970
|
+
for (const status of [502, 503, 504, 520, 522, 524, 530]) expect(storeUnreachableStatus(status)).toBe(true);
|
|
971
|
+
for (const status of [400, 403, 404, 413, 422, 500, 507]) expect(storeUnreachableStatus(status)).toBe(false);
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
it('put on a 530 throws the typed error, keeping the [binary-store] head batch callers print', async () => {
|
|
975
|
+
reply = () => new Response('tunnel gone', { status: 530 });
|
|
976
|
+
const error = await makeStore().put('art/a.png', new Uint8Array([1])).catch((e: unknown) => e);
|
|
977
|
+
expect(error).toBeInstanceOf(BinaryStoreUnreachableError);
|
|
978
|
+
expect(String((error as Error).message)).toStartWith('[binary-store] put art/a.png: the file store did not answer (530)');
|
|
979
|
+
expect((error as BinaryStoreUnreachableError).status).toBe(503);
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
it('a fetch that THROWS is the same fact', async () => {
|
|
983
|
+
const store = makeStore({ fetch: () => Promise.reject(new Error('connection refused')) });
|
|
984
|
+
const error = await store.put('k', new Uint8Array([1])).catch((e: unknown) => e);
|
|
985
|
+
expect(error).toBeInstanceOf(BinaryStoreUnreachableError);
|
|
986
|
+
expect(String((error as Error).message)).toContain('connection refused');
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
it('putLarge says which chunk found the store gone', async () => {
|
|
990
|
+
reply = () => new Response('', { status: 502 });
|
|
991
|
+
const error = await makeStore()
|
|
992
|
+
.putLarge('v.mp4', new Uint8Array(10), 'video/mp4', { chunkBytes: 4 })
|
|
993
|
+
.catch((e: unknown) => e);
|
|
994
|
+
expect(error).toBeInstanceOf(BinaryStoreUnreachableError);
|
|
995
|
+
expect(String((error as Error).message)).toContain('chunk 1/3: 502');
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
it('🔴 getResponse() is the 503 a Hono app answers with when the error escapes a route', async () => {
|
|
999
|
+
const res = new BinaryStoreUnreachableError('put', 'k', '530').getResponse();
|
|
1000
|
+
expect(res.status).toBe(503);
|
|
1001
|
+
expect(res.headers.get('retry-after')).toBe('30');
|
|
1002
|
+
expect(((await res.json()) as { error: string }).error).toBe('BYTE_STORE_UNREACHABLE');
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
it('…and a real Hono app DOES answer it, with no onError of its own', async () => {
|
|
1006
|
+
const { Hono } = await import('hono');
|
|
1007
|
+
const app = new Hono().post('/upload', () => {
|
|
1008
|
+
throw new BinaryStoreUnreachableError('put', 'k', '530');
|
|
1009
|
+
});
|
|
1010
|
+
const res = await app.request('/upload', { method: 'POST' });
|
|
1011
|
+
expect(res.status).toBe(503);
|
|
1012
|
+
expect(((await res.json()) as { error: string }).error).toBe('BYTE_STORE_UNREACHABLE');
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
it("the origin's own refusals are unchanged — a 403 is still the plain status error", async () => {
|
|
1016
|
+
reply = () => new Response('', { status: 403 });
|
|
1017
|
+
const error = await makeStore().put('k', new Uint8Array([1])).catch((e: unknown) => e);
|
|
1018
|
+
expect(error).not.toBeInstanceOf(BinaryStoreUnreachableError);
|
|
1019
|
+
expect(String((error as Error).message)).toBe('[binary-store] put k: 403');
|
|
1020
|
+
});
|
|
1021
|
+
});
|
|
@@ -725,7 +725,10 @@ export function createBinaryStore(cfg: BinaryStoreConfig): BinaryStore {
|
|
|
725
725
|
headers: mime ? { 'content-type': mime } : undefined,
|
|
726
726
|
body: bytes as unknown as BodyInit,
|
|
727
727
|
},
|
|
728
|
-
)
|
|
728
|
+
).catch((cause: unknown) => {
|
|
729
|
+
throw new BinaryStoreUnreachableError('put', key, cause instanceof Error ? cause.message : String(cause));
|
|
730
|
+
});
|
|
731
|
+
if (storeUnreachableStatus(res.status)) throw new BinaryStoreUnreachableError('put', key, String(res.status));
|
|
729
732
|
if (!res.ok) {
|
|
730
733
|
// A 413 that reaches here despite the threshold above means the edge's
|
|
731
734
|
// real cap is lower than we think, or the caller disabled the
|
|
@@ -805,7 +808,12 @@ export function createBinaryStore(cfg: BinaryStoreConfig): BinaryStore {
|
|
|
805
808
|
headers: mime ? { 'content-type': mime } : undefined,
|
|
806
809
|
body: slice,
|
|
807
810
|
},
|
|
808
|
-
)
|
|
811
|
+
).catch((cause: unknown) => {
|
|
812
|
+
throw new BinaryStoreUnreachableError('putLarge', key, cause instanceof Error ? cause.message : String(cause));
|
|
813
|
+
});
|
|
814
|
+
if (storeUnreachableStatus(res.status)) {
|
|
815
|
+
throw new BinaryStoreUnreachableError('putLarge', key, `chunk ${ci + 1}/${total}: ${res.status}`);
|
|
816
|
+
}
|
|
809
817
|
if (!res.ok) {
|
|
810
818
|
throw new Error(`[binary-store] putLarge ${key} chunk ${ci + 1}/${total}: ${res.status}`);
|
|
811
819
|
}
|
|
@@ -1071,6 +1079,48 @@ export function resolvedMediaTenant(
|
|
|
1071
1079
|
return readBinaryStoreEnv(appId, env)?.appId ?? null;
|
|
1072
1080
|
}
|
|
1073
1081
|
|
|
1082
|
+
/**
|
|
1083
|
+
* binary-server's origin did not ANSWER a write — as opposed to answering "no".
|
|
1084
|
+
*
|
|
1085
|
+
* binary-server itself never answers 502, 503 or 504; those, and Cloudflare's 52x family (530 is a
|
|
1086
|
+
* tunnel with no connector), are the edge speaking for an origin it could not reach — the owner's
|
|
1087
|
+
* Mac off, asleep, or restarting. A thrown `fetch` is the same fact. Since 2026-09-26 the edge
|
|
1088
|
+
* Worker takes an R2-backed tenant's whole upload itself in that case
|
|
1089
|
+
* (`apps/binary-server/edge-worker/src/upload.ts`), so for desk and family this is what is left
|
|
1090
|
+
* when BOTH halves failed; for a tenant whose bytes rest on the Mac it is simply "the Mac is off".
|
|
1091
|
+
*/
|
|
1092
|
+
export function storeUnreachableStatus(status: number): boolean {
|
|
1093
|
+
return status === 502 || status === 503 || status === 504 || (status >= 520 && status <= 530);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* The write could not reach the store. Carries `getResponse()`, so a Hono app that lets it escape
|
|
1098
|
+
* a route answers a 503 that names the file store — Hono's default error handler returns
|
|
1099
|
+
* `err.getResponse()` for any error that has one — instead of a bare `500 Internal Server Error`.
|
|
1100
|
+
* The message keeps the `[binary-store] <op> <key>:` head every batch caller already prints.
|
|
1101
|
+
*/
|
|
1102
|
+
export class BinaryStoreUnreachableError extends Error {
|
|
1103
|
+
readonly status = 503;
|
|
1104
|
+
readonly code = 'BYTE_STORE_UNREACHABLE';
|
|
1105
|
+
constructor(op: string, key: string, detail: string) {
|
|
1106
|
+
super(
|
|
1107
|
+
`[binary-store] ${op} ${key}: the file store did not answer (${detail}) — nothing was saved; ` +
|
|
1108
|
+
'everything already stored still opens. Try again when the home server is on.',
|
|
1109
|
+
);
|
|
1110
|
+
this.name = 'BinaryStoreUnreachableError';
|
|
1111
|
+
}
|
|
1112
|
+
getResponse(): Response {
|
|
1113
|
+
return new Response(
|
|
1114
|
+
JSON.stringify({
|
|
1115
|
+
error: this.code,
|
|
1116
|
+
message:
|
|
1117
|
+
'The file store did not answer, so nothing was saved. Everything already stored still opens — try again in a moment.',
|
|
1118
|
+
}),
|
|
1119
|
+
{ status: 503, headers: { 'content-type': 'application/json; charset=utf-8', 'retry-after': '30' } },
|
|
1120
|
+
);
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1074
1124
|
/**
|
|
1075
1125
|
* The standard env surface every tenant's secrets file carries
|
|
1076
1126
|
* (`$FORGE_STATE/secrets/<app>.env`, written at bs registration):
|