@ultimat3/http 29.4.0 → 30.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 +26 -51
- package/README.md +119 -6
- package/package.json +15 -5
- package/src/bearer-mount.ts +67 -12
- package/src/cache-policy.ts +8 -0
- package/src/compress-read.ts +115 -0
- package/src/compress.ts +173 -0
- package/src/config.ts +1 -7
- package/src/context.ts +50 -0
- package/src/cors.ts +43 -4
- package/src/csrf.ts +41 -8
- package/src/deadline.ts +2 -2
- package/src/error-facts.ts +26 -1
- package/src/error-map-http.ts +3 -0
- package/src/error-map-tier-0.ts +26 -0
- package/src/error-map-tier-1.ts +22 -0
- package/src/error-map-tier-2.ts +16 -0
- package/src/error-map-tier-3.ts +24 -0
- package/src/error-map-tier-4.ts +33 -1
- package/src/error-page.ts +6 -7
- package/src/error-status.ts +26 -17
- package/src/error-titles.ts +2 -0
- package/src/errors.ts +27 -12
- package/src/finalize.ts +1 -0
- package/src/index.ts +17 -2
- package/src/locale.ts +4 -1
- package/src/object-response.ts +134 -0
- package/src/overlay.ts +13 -2
- package/src/pipeline.ts +6 -1
- package/src/preferences.ts +4 -0
- package/src/problem-meta-framework.ts +21 -0
- package/src/problem-meta.ts +23 -15
- package/src/rate-limit-degrade.ts +10 -2
- package/src/rate-limit-postgres.ts +22 -9
- package/src/rate-limit-store-deadline.ts +60 -0
- package/src/response.ts +29 -5
- package/src/router.ts +9 -0
- package/src/server-websocket.ts +35 -0
- package/src/server.ts +8 -4
- package/src/stages.ts +18 -5
- package/src/stored-object-headers.ts +43 -0
- package/src/webhook-hmac.ts +73 -0
- package/src/webhook-verify.ts +26 -5
- package/src/websocket-mounts.ts +104 -0
package/src/webhook-verify.ts
CHANGED
|
@@ -3,11 +3,10 @@
|
|
|
3
3
|
// a pipeline stage because a receiver is an ordinary `api/` route — the secret is per sender, and
|
|
4
4
|
// only the route knows which one applies.
|
|
5
5
|
//
|
|
6
|
-
// THE FORMAT IS `@ultimat3/core`'s (`webhook-signature.ts`) and is not re-declared here
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// answers with.
|
|
6
|
+
// THE FORMAT IS `@ultimat3/core`'s (`webhook-signature.ts`) and is not re-declared here: this
|
|
7
|
+
// package (tier 2) may not reach `@ultimat3/jobs` (tier 3), which signs, so the format sits below
|
|
8
|
+
// both. What stays here is the POLICY — what counts as fresh, how large a body may be, and which
|
|
9
|
+
// refusal a receiver answers with.
|
|
11
10
|
|
|
12
11
|
import type { Clock } from '@ultimat3/core';
|
|
13
12
|
import {
|
|
@@ -22,6 +21,7 @@ import {
|
|
|
22
21
|
WEBHOOK_TOPIC_HEADER,
|
|
23
22
|
webhookMac,
|
|
24
23
|
} from '@ultimat3/core';
|
|
24
|
+
import type { WebhookVerifier } from './errors';
|
|
25
25
|
import {
|
|
26
26
|
bodyInvalid,
|
|
27
27
|
httpCountInvalid,
|
|
@@ -93,6 +93,27 @@ const assertWebhookSecret = (secret: unknown): string => {
|
|
|
93
93
|
);
|
|
94
94
|
};
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* The same screen over a rotation list (`verifyBodyHmac`): one entry or several, every one
|
|
98
|
+
* non-empty, and at least one. One empty entry refuses the list — `[current, process.env.OLD]`
|
|
99
|
+
* with `OLD` unset would otherwise verify anything under the empty key.
|
|
100
|
+
*/
|
|
101
|
+
export const assertWebhookSecrets = (
|
|
102
|
+
secret: unknown,
|
|
103
|
+
via: WebhookVerifier = 'verifyWebhookSignature',
|
|
104
|
+
): readonly string[] => {
|
|
105
|
+
const list: readonly unknown[] = Array.isArray(secret) ? secret : [secret];
|
|
106
|
+
if (list.length === 0) throw webhookSecretMissing('an empty list', via);
|
|
107
|
+
for (const entry of list) {
|
|
108
|
+
if (typeof entry === 'string' && entry.length > 0) continue;
|
|
109
|
+
throw webhookSecretMissing(
|
|
110
|
+
entry === '' ? 'an empty string' : `a value of type ${typeof entry}`,
|
|
111
|
+
via,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return list as readonly string[];
|
|
115
|
+
};
|
|
116
|
+
|
|
96
117
|
export interface WebhookVerifyOptions {
|
|
97
118
|
/** The shared secret for THIS sender. Never logged, never rendered into a refusal. */
|
|
98
119
|
readonly secret: string;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Several websocket mounts on ONE listener. `Bun.serve` takes one `websocket` handler set for every
|
|
2
|
+
// socket it opens, so the mounts are joined here: an upgrade is routed by path, and each socket's
|
|
3
|
+
// events go back to the mount that upgraded it — keyed by the `data` object that mount handed
|
|
4
|
+
// `server.upgrade`, which Bun returns as `ws.data`. No mount's socket is wrapped or proxied.
|
|
5
|
+
|
|
6
|
+
import { assert } from '@ultimat3/core';
|
|
7
|
+
import type { UpgradeTarget, WebSocketMount } from './server';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A mount's handler set with Bun's optional `drain`: the sync node answers it with its
|
|
11
|
+
* backpressure gap repair, and a join that dropped it left a refused frame's socket gapped.
|
|
12
|
+
*/
|
|
13
|
+
export type MountHandlers<TSocket> = WebSocketMount<TSocket>['websocket'] & {
|
|
14
|
+
drain?(ws: TSocket): void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** What `httpServer` dispatches: every path the mounts own, one fetch, one handler set. */
|
|
18
|
+
export interface JoinedWebSocketMounts {
|
|
19
|
+
readonly paths: ReadonlySet<string>;
|
|
20
|
+
fetch(
|
|
21
|
+
request: Request,
|
|
22
|
+
server: UpgradeTarget,
|
|
23
|
+
): Promise<Response | undefined> | Response | undefined;
|
|
24
|
+
readonly websocket: MountHandlers<{ readonly data: unknown }>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Bun's per-listener knobs, joined so no mount gets less than it declared. */
|
|
28
|
+
function joinedLimits(mounts: readonly WebSocketMount<never>[]) {
|
|
29
|
+
const max = (pick: (mount: WebSocketMount<never>) => number | undefined): number | undefined => {
|
|
30
|
+
const values = mounts.map(pick).filter((value): value is number => value !== undefined);
|
|
31
|
+
return values.length === 0 ? undefined : Math.max(...values);
|
|
32
|
+
};
|
|
33
|
+
const idleTimeout = max((mount) => mount.websocket.idleTimeout);
|
|
34
|
+
const backpressureLimit = max((mount) => mount.websocket.backpressureLimit);
|
|
35
|
+
const maxPayloadLength = max((mount) => mount.websocket.maxPayloadLength);
|
|
36
|
+
const sendPings = mounts.some((mount) => mount.websocket.sendPings === true);
|
|
37
|
+
return {
|
|
38
|
+
...(idleTimeout === undefined ? {} : { idleTimeout }),
|
|
39
|
+
...(backpressureLimit === undefined ? {} : { backpressureLimit }),
|
|
40
|
+
...(maxPayloadLength === undefined ? {} : { maxPayloadLength }),
|
|
41
|
+
...(sendPings ? { sendPings } : {}),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The mounts, joined. Two at one path is `X_INVARIANT` here — the same fact `httpServer` refuses
|
|
47
|
+
* between a mount and a route. The listener's limits are the widest any mount declared, so Bun
|
|
48
|
+
* enforces only those: a mount whose own bound is tighter (the sync node's `maxFrameBytes`, the
|
|
49
|
+
* device socket's frame cap) refuses past it in its own `message`, never through Bun alone.
|
|
50
|
+
*/
|
|
51
|
+
export function joinWebSocketMounts(
|
|
52
|
+
mounts: readonly WebSocketMount<never>[],
|
|
53
|
+
): JoinedWebSocketMounts {
|
|
54
|
+
const byPath = new Map<string, WebSocketMount<never>>();
|
|
55
|
+
for (const mount of mounts) {
|
|
56
|
+
assert(
|
|
57
|
+
!byPath.has(mount.path),
|
|
58
|
+
`two websocket mounts claim ${mount.path}, and a path is answered by one of them`,
|
|
59
|
+
'x routes --json # then move one mount to a path of its own',
|
|
60
|
+
);
|
|
61
|
+
byPath.set(mount.path, mount);
|
|
62
|
+
}
|
|
63
|
+
// A socket's owner, by the identity of the data its mount upgraded it with.
|
|
64
|
+
const owners = new WeakMap<object, WebSocketMount<never>>();
|
|
65
|
+
const ownerOf = (ws: { readonly data: unknown }): WebSocketMount<never> | undefined =>
|
|
66
|
+
typeof ws.data === 'object' && ws.data !== null ? owners.get(ws.data) : undefined;
|
|
67
|
+
// `never` is each mount's own socket type, unknown to this file: the socket handed back is the
|
|
68
|
+
// one that mount's upgrade produced, so the widening below is the only way to return it.
|
|
69
|
+
const hand = (ws: { readonly data: unknown }): never => ws as never;
|
|
70
|
+
// Read through the wider type: `drain` is optional, so every mount's handler set is one.
|
|
71
|
+
const handlersOf = (mount?: WebSocketMount<never>): MountHandlers<never> | undefined =>
|
|
72
|
+
mount?.websocket;
|
|
73
|
+
return {
|
|
74
|
+
paths: new Set(byPath.keys()),
|
|
75
|
+
fetch(request, server) {
|
|
76
|
+
const mount = byPath.get(new URL(request.url).pathname);
|
|
77
|
+
if (mount === undefined) return new Response('not found', { status: 404 });
|
|
78
|
+
const recording: UpgradeTarget = {
|
|
79
|
+
upgrade(upgraded, options) {
|
|
80
|
+
const { data } = options;
|
|
81
|
+
assert(
|
|
82
|
+
typeof data === 'object' && data !== null,
|
|
83
|
+
`the websocket mount at ${mount.path} upgraded with data that is not an object, so its socket cannot be routed back to it`,
|
|
84
|
+
'server.upgrade(request, { data: { socketId } }) # an object, in the mount at that path',
|
|
85
|
+
);
|
|
86
|
+
owners.set(data, mount);
|
|
87
|
+
return server.upgrade(upgraded, options);
|
|
88
|
+
},
|
|
89
|
+
requestIP: (asked) => server.requestIP(asked),
|
|
90
|
+
};
|
|
91
|
+
return mount.fetch(request, recording);
|
|
92
|
+
},
|
|
93
|
+
websocket: {
|
|
94
|
+
open: (ws) => ownerOf(ws)?.websocket.open(hand(ws)),
|
|
95
|
+
message: (ws, message) => ownerOf(ws)?.websocket.message(hand(ws), message),
|
|
96
|
+
close: (ws) => ownerOf(ws)?.websocket.close(hand(ws)),
|
|
97
|
+
// Present only when a mount answers it: Bun calls it once a socket's buffer can take frames.
|
|
98
|
+
...(mounts.some((mount) => handlersOf(mount)?.drain !== undefined)
|
|
99
|
+
? { drain: (ws) => handlersOf(ownerOf(ws))?.drain?.(hand(ws)) }
|
|
100
|
+
: {}),
|
|
101
|
+
...joinedLimits(mounts),
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|