@powerhousedao/switchboard 6.2.3-dev.22 → 6.2.3-dev.23
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/.tsbuild/test/fatal-shutdown.test.d.ts +2 -0
- package/.tsbuild/test/fatal-shutdown.test.d.ts.map +1 -0
- package/.tsbuild/tsconfig.tsbuildinfo +1 -1
- package/CHANGELOG.md +13 -0
- package/dist/index.mjs +5 -4
- package/dist/index.mjs.map +1 -1
- package/dist/{server-C6VtW18k.mjs → server-DVotaGIp.mjs} +51 -7
- package/dist/server-DVotaGIp.mjs.map +1 -0
- package/dist/server.d.mts +8 -0
- package/dist/server.d.mts.map +1 -1
- package/dist/server.mjs +3 -3
- package/package.json +15 -15
- package/test/attachments/routes-integration.test.ts +40 -0
- package/test/attachments/routes.test.ts +126 -2
- package/test/fatal-shutdown.test.ts +102 -0
- package/dist/server-C6VtW18k.mjs.map +0 -1
|
@@ -6,13 +6,19 @@ import {
|
|
|
6
6
|
import { Kysely } from "kysely";
|
|
7
7
|
import { PGliteDialect } from "kysely-pglite-dialect";
|
|
8
8
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
9
|
-
import
|
|
9
|
+
import {
|
|
10
|
+
validateHeaderValue,
|
|
11
|
+
type IncomingMessage,
|
|
12
|
+
type ServerResponse,
|
|
13
|
+
} from "node:http";
|
|
10
14
|
import { tmpdir } from "node:os";
|
|
11
15
|
import { join } from "node:path";
|
|
12
16
|
import { Readable, Writable } from "node:stream";
|
|
13
17
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
14
18
|
import {
|
|
15
19
|
buildContentDisposition,
|
|
20
|
+
buildMetadataHeader,
|
|
21
|
+
buildPendingHeader,
|
|
16
22
|
makeDeleteReservationHandler,
|
|
17
23
|
makeDownloadHandler,
|
|
18
24
|
makeGetReservationHandler,
|
|
@@ -70,6 +76,9 @@ function makeRes(): CapturedRes {
|
|
|
70
76
|
statusCode: 200,
|
|
71
77
|
_headers: headers,
|
|
72
78
|
setHeader(name: string, value: string | number | readonly string[]) {
|
|
79
|
+
// Same check Node's ServerResponse applies, so a header value it would
|
|
80
|
+
// reject fails here instead of passing silently.
|
|
81
|
+
validateHeaderValue(name, String(value));
|
|
73
82
|
headers[name.toLowerCase()] = String(value);
|
|
74
83
|
},
|
|
75
84
|
getHeader(name: string) {
|
|
@@ -899,6 +908,7 @@ describe("attachment routes", () => {
|
|
|
899
908
|
describe("pending state: stat and download 202 responses", () => {
|
|
900
909
|
async function createPendingReservation(
|
|
901
910
|
content: string,
|
|
911
|
+
fileName = "pending.txt",
|
|
902
912
|
): Promise<{ hash: string }> {
|
|
903
913
|
const { createHash } = await import("node:crypto");
|
|
904
914
|
const hash = createHash("sha256").update(content, "utf8").digest("hex");
|
|
@@ -908,7 +918,7 @@ describe("attachment routes", () => {
|
|
|
908
918
|
method: "POST",
|
|
909
919
|
body: JSON.stringify({
|
|
910
920
|
mimeType: "text/plain",
|
|
911
|
-
fileName
|
|
921
|
+
fileName,
|
|
912
922
|
clientHash: hash,
|
|
913
923
|
sizeBytes: Buffer.byteLength(content, "utf8"),
|
|
914
924
|
}),
|
|
@@ -995,6 +1005,32 @@ describe("attachment routes", () => {
|
|
|
995
1005
|
expect(res.getHeader("attachment-metadata")).toBeFalsy();
|
|
996
1006
|
});
|
|
997
1007
|
|
|
1008
|
+
it.each(["opłata.pdf", "a – b’s.txt", "📎.png", "報告書.pdf"])(
|
|
1009
|
+
"HEAD and GET 202 carry a parseable Attachment-Pending for %s",
|
|
1010
|
+
async (fileName) => {
|
|
1011
|
+
const { hash } = await createPendingReservation(
|
|
1012
|
+
`pending ${fileName}`,
|
|
1013
|
+
fileName,
|
|
1014
|
+
);
|
|
1015
|
+
|
|
1016
|
+
for (const [method, handler] of [
|
|
1017
|
+
["HEAD", makeStatHandler(attachments)],
|
|
1018
|
+
["GET", makeDownloadHandler(attachments)],
|
|
1019
|
+
] as const) {
|
|
1020
|
+
const req = makeReq({ method, params: { hash } });
|
|
1021
|
+
const res = makeRes();
|
|
1022
|
+
await handler(req, res);
|
|
1023
|
+
await waitFor(res);
|
|
1024
|
+
|
|
1025
|
+
expect(res.statusCode).toBe(202);
|
|
1026
|
+
const parsed = JSON.parse(
|
|
1027
|
+
res.getHeader("attachment-pending") as string,
|
|
1028
|
+
) as { fileName: string };
|
|
1029
|
+
expect(parsed.fileName).toBe(fileName);
|
|
1030
|
+
}
|
|
1031
|
+
},
|
|
1032
|
+
);
|
|
1033
|
+
|
|
998
1034
|
it("GET download 202 body is empty (not a zero-byte attachment)", async () => {
|
|
999
1035
|
const { hash } = await createPendingReservation("empty body assertion");
|
|
1000
1036
|
|
|
@@ -1215,6 +1251,54 @@ describe("attachment routes", () => {
|
|
|
1215
1251
|
}
|
|
1216
1252
|
});
|
|
1217
1253
|
|
|
1254
|
+
const NON_LATIN1_NAMES = [
|
|
1255
|
+
"opłata.pdf",
|
|
1256
|
+
"a – b’s.txt",
|
|
1257
|
+
"📎 notes.png",
|
|
1258
|
+
"報告書.pdf",
|
|
1259
|
+
"résumé.pdf",
|
|
1260
|
+
'quote"back\\slash.txt',
|
|
1261
|
+
];
|
|
1262
|
+
|
|
1263
|
+
it.each(NON_LATIN1_NAMES)(
|
|
1264
|
+
"buildMetadataHeader output is a valid header that parses back to %s",
|
|
1265
|
+
(fileName) => {
|
|
1266
|
+
const value = buildMetadataHeader({
|
|
1267
|
+
mimeType: "application/pdf",
|
|
1268
|
+
fileName,
|
|
1269
|
+
sizeBytes: 1,
|
|
1270
|
+
extension: null,
|
|
1271
|
+
createdAtUtc: "2026-01-01T00:00:00.000Z",
|
|
1272
|
+
lastAccessedAtUtc: "2026-01-01T00:00:00.000Z",
|
|
1273
|
+
});
|
|
1274
|
+
expect(() =>
|
|
1275
|
+
validateHeaderValue("Attachment-Metadata", value),
|
|
1276
|
+
).not.toThrow();
|
|
1277
|
+
expect(value).toMatch(/^[\x20-\x7e]*$/);
|
|
1278
|
+
expect((JSON.parse(value) as { fileName: string }).fileName).toBe(
|
|
1279
|
+
fileName,
|
|
1280
|
+
);
|
|
1281
|
+
},
|
|
1282
|
+
);
|
|
1283
|
+
|
|
1284
|
+
it.each(NON_LATIN1_NAMES)(
|
|
1285
|
+
"buildPendingHeader output is a valid header that parses back to %s",
|
|
1286
|
+
(fileName) => {
|
|
1287
|
+
const value = buildPendingHeader({
|
|
1288
|
+
expiresAtUtc: "2026-01-01T00:00:00.000Z",
|
|
1289
|
+
mimeType: "application/pdf",
|
|
1290
|
+
fileName,
|
|
1291
|
+
sizeBytes: 1,
|
|
1292
|
+
});
|
|
1293
|
+
expect(() =>
|
|
1294
|
+
validateHeaderValue("Attachment-Pending", value),
|
|
1295
|
+
).not.toThrow();
|
|
1296
|
+
expect((JSON.parse(value) as { fileName: string }).fileName).toBe(
|
|
1297
|
+
fileName,
|
|
1298
|
+
);
|
|
1299
|
+
},
|
|
1300
|
+
);
|
|
1301
|
+
|
|
1218
1302
|
it("buildContentDisposition encodes RFC-5987-reserved chars in the encoded form", () => {
|
|
1219
1303
|
const value = buildContentDisposition("a'b(c)*!.txt");
|
|
1220
1304
|
expect(value).toContain("filename*=UTF-8''a%27b%28c%29%2A%21.txt");
|
|
@@ -1281,5 +1365,45 @@ describe("attachment routes", () => {
|
|
|
1281
1365
|
) as { fileName: string };
|
|
1282
1366
|
expect(meta.fileName).toBe("résumé.pdf");
|
|
1283
1367
|
});
|
|
1368
|
+
|
|
1369
|
+
it("HEAD and GET answer 200 with a parseable Attachment-Metadata for a name above U+00FF", async () => {
|
|
1370
|
+
const fileName = "opłata – 報告 📎.pdf";
|
|
1371
|
+
const reserveReq = makeReq({
|
|
1372
|
+
method: "POST",
|
|
1373
|
+
body: JSON.stringify({ mimeType: "application/pdf", fileName }),
|
|
1374
|
+
});
|
|
1375
|
+
const reserveRes = makeRes();
|
|
1376
|
+
await makeReserveHandler(attachments)(reserveReq, reserveRes);
|
|
1377
|
+
await waitFor(reserveRes);
|
|
1378
|
+
expect(reserveRes.statusCode).toBe(201);
|
|
1379
|
+
const { reservationId } = JSON.parse(
|
|
1380
|
+
reserveRes._body.toString("utf8"),
|
|
1381
|
+
) as { reservationId: string };
|
|
1382
|
+
|
|
1383
|
+
const uploadRes = makeRes();
|
|
1384
|
+
await makeUploadHandler(attachments)(
|
|
1385
|
+
makeReq({ method: "PUT", params: { reservationId }, body: "pdf" }),
|
|
1386
|
+
uploadRes,
|
|
1387
|
+
);
|
|
1388
|
+
await waitFor(uploadRes);
|
|
1389
|
+
expect(uploadRes.statusCode).toBe(200);
|
|
1390
|
+
const { hash } = JSON.parse(uploadRes._body.toString("utf8")) as {
|
|
1391
|
+
hash: string;
|
|
1392
|
+
};
|
|
1393
|
+
|
|
1394
|
+
for (const [method, handler] of [
|
|
1395
|
+
["HEAD", makeStatHandler(attachments)],
|
|
1396
|
+
["GET", makeDownloadHandler(attachments)],
|
|
1397
|
+
] as const) {
|
|
1398
|
+
const res = makeRes();
|
|
1399
|
+
await handler(makeReq({ method, params: { hash } }), res);
|
|
1400
|
+
await waitFor(res);
|
|
1401
|
+
expect(res.statusCode).toBe(200);
|
|
1402
|
+
const meta = JSON.parse(
|
|
1403
|
+
res.getHeader("attachment-metadata") as string,
|
|
1404
|
+
) as { fileName: string };
|
|
1405
|
+
expect(meta.fileName).toBe(fileName);
|
|
1406
|
+
}
|
|
1407
|
+
});
|
|
1284
1408
|
});
|
|
1285
1409
|
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { installFatalErrorShutdown } from "../src/fatal-shutdown.mjs";
|
|
4
|
+
|
|
5
|
+
function makeProc() {
|
|
6
|
+
const emitter = new EventEmitter();
|
|
7
|
+
const proc = Object.assign(emitter, {
|
|
8
|
+
pid: 1234,
|
|
9
|
+
exitCode: undefined as number | undefined,
|
|
10
|
+
kill: vi.fn((_pid: number, signal: string) => {
|
|
11
|
+
emitter.emit(signal);
|
|
12
|
+
return true;
|
|
13
|
+
}),
|
|
14
|
+
exit: vi.fn(),
|
|
15
|
+
});
|
|
16
|
+
return proc;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const logger = {
|
|
20
|
+
error: vi.fn(),
|
|
21
|
+
} as unknown as Parameters<typeof installFatalErrorShutdown>[0];
|
|
22
|
+
|
|
23
|
+
describe("installFatalErrorShutdown", () => {
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
vi.useRealTimers();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it.each(["uncaughtException", "unhandledRejection"])(
|
|
29
|
+
"routes %s through SIGTERM with exit code 1",
|
|
30
|
+
(event) => {
|
|
31
|
+
const proc = makeProc();
|
|
32
|
+
const onSigterm = vi.fn();
|
|
33
|
+
proc.on("SIGTERM", onSigterm);
|
|
34
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
35
|
+
|
|
36
|
+
proc.emit(event, new Error("boom"));
|
|
37
|
+
|
|
38
|
+
expect(proc.kill).toHaveBeenCalledWith(1234, "SIGTERM");
|
|
39
|
+
expect(onSigterm).toHaveBeenCalledOnce();
|
|
40
|
+
expect(proc.exitCode).toBe(1);
|
|
41
|
+
expect(proc.exit).not.toHaveBeenCalled();
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
it("leaves an unhandled rejection to another listener, such as Sentry's", () => {
|
|
46
|
+
const proc = makeProc();
|
|
47
|
+
const other = vi.fn();
|
|
48
|
+
proc.on("unhandledRejection", other);
|
|
49
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
50
|
+
|
|
51
|
+
proc.emit("unhandledRejection", new Error("boom"));
|
|
52
|
+
|
|
53
|
+
expect(other).toHaveBeenCalledOnce();
|
|
54
|
+
expect(proc.kill).not.toHaveBeenCalled();
|
|
55
|
+
expect(proc.exitCode).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("still shuts down on an uncaught exception when another listener exists", () => {
|
|
59
|
+
const proc = makeProc();
|
|
60
|
+
proc.on("uncaughtException", vi.fn());
|
|
61
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
62
|
+
|
|
63
|
+
proc.emit("uncaughtException", new Error("boom"));
|
|
64
|
+
|
|
65
|
+
expect(proc.kill).toHaveBeenCalledWith(1234, "SIGTERM");
|
|
66
|
+
expect(proc.exitCode).toBe(1);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("signals once when a second fatal error arrives during shutdown", () => {
|
|
70
|
+
const proc = makeProc();
|
|
71
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
72
|
+
|
|
73
|
+
proc.emit("unhandledRejection", new Error("first"));
|
|
74
|
+
proc.emit("uncaughtException", new Error("second"));
|
|
75
|
+
|
|
76
|
+
expect(proc.kill).toHaveBeenCalledOnce();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("forces exit 1 through the original exit when shutdown hangs", () => {
|
|
80
|
+
vi.useFakeTimers();
|
|
81
|
+
const proc = makeProc();
|
|
82
|
+
const originalExit = proc.exit;
|
|
83
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
84
|
+
// The builder swaps process.exit for a recording shim during shutdown.
|
|
85
|
+
proc.exit = vi.fn();
|
|
86
|
+
|
|
87
|
+
proc.emit("unhandledRejection", new Error("boom"));
|
|
88
|
+
vi.advanceTimersByTime(15_000);
|
|
89
|
+
|
|
90
|
+
expect(originalExit).toHaveBeenCalledWith(1);
|
|
91
|
+
expect(proc.exit).not.toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("installs its listeners once per process", () => {
|
|
95
|
+
const proc = makeProc();
|
|
96
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
97
|
+
installFatalErrorShutdown(logger, proc as never);
|
|
98
|
+
|
|
99
|
+
expect(proc.listenerCount("uncaughtException")).toBe(1);
|
|
100
|
+
expect(proc.listenerCount("unhandledRejection")).toBe(1);
|
|
101
|
+
});
|
|
102
|
+
});
|