@ultimat3/http 19.3.2 → 19.4.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/package.json +5 -5
- package/src/index.ts +7 -1
- package/src/server.ts +123 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/http",
|
|
3
|
-
"version": "19.
|
|
3
|
+
"version": "19.4.0",
|
|
4
4
|
"description": "Owned request lifecycle over Bun.serve: router, ordered pipeline, problem+json errors",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "19.
|
|
35
|
-
"@ultimat3/i18n": "19.
|
|
36
|
-
"@ultimat3/schema": "19.
|
|
37
|
-
"@ultimat3/time": "19.
|
|
34
|
+
"@ultimat3/core": "19.4.0",
|
|
35
|
+
"@ultimat3/i18n": "19.4.0",
|
|
36
|
+
"@ultimat3/schema": "19.4.0",
|
|
37
|
+
"@ultimat3/time": "19.4.0"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/index.ts
CHANGED
|
@@ -200,7 +200,13 @@ export {
|
|
|
200
200
|
} from './router';
|
|
201
201
|
export type { SecurityConfig } from './security-headers';
|
|
202
202
|
export { buildCsp, cspHashSource, DEFAULT_SECURITY, securityHeaders } from './security-headers';
|
|
203
|
-
export type {
|
|
203
|
+
export type {
|
|
204
|
+
LifecycleState,
|
|
205
|
+
ServerHandle,
|
|
206
|
+
ServerOptions,
|
|
207
|
+
UpgradeTarget,
|
|
208
|
+
WebSocketMount,
|
|
209
|
+
} from './server';
|
|
204
210
|
export { createServer } from './server';
|
|
205
211
|
// The stage vocabulary comes from its declaration site, beside the fourteen implementations it
|
|
206
212
|
// names; `PIPELINE_STAGES` — the ORDER — stays `pipeline.ts`'s.
|
package/src/server.ts
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
} from '@ultimat3/core';
|
|
18
18
|
import type { Server } from 'bun';
|
|
19
19
|
import { defineHttpConfig, type HttpConfig } from './config';
|
|
20
|
-
import { serverNotStarted } from './errors';
|
|
20
|
+
import { HttpError, serverNotStarted } from './errors';
|
|
21
21
|
import type { ServerHooks } from './hooks';
|
|
22
22
|
import type { Middleware } from './middleware';
|
|
23
23
|
import { createPipeline, type Pipeline } from './pipeline';
|
|
@@ -26,6 +26,22 @@ import { withRouteBuckets } from './rate-limit-buckets';
|
|
|
26
26
|
import { json } from './response';
|
|
27
27
|
import { createRouter, describeRoutes, type Route, type RouteDescription } from './router';
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Beside its one caller rather than in `errors.ts`, which is at the 500-line ceiling — the
|
|
31
|
+
* arrangement `dev-sync.ts` and `metrics-endpoint.ts` in `@ultimat3/cli` already take.
|
|
32
|
+
*
|
|
33
|
+
* A websocket mount and something already answering its path. Same code as two routes claiming
|
|
34
|
+
* one, because it is the same fact: one path, two declarations, and the framework picks — Bun's
|
|
35
|
+
* native route table is matched BEFORE `fetch`, so the route wins and the upgrade never reaches
|
|
36
|
+
* the mount. Refused at `createServer`, not discovered as a socket that will not open.
|
|
37
|
+
*/
|
|
38
|
+
const websocketPathTaken = (path: string, answered: string): HttpError =>
|
|
39
|
+
new HttpError({
|
|
40
|
+
code: 'X_ROUTE_CONFLICT',
|
|
41
|
+
cause: `the websocket mount claims ${path}, and ${answered} already answers it — Bun matches its native route table before \`fetch\`, so the upgrade would never reach the mount`,
|
|
42
|
+
fix: `x routes list --json # then move the mount's path, or the declaration at ${path}`,
|
|
43
|
+
});
|
|
44
|
+
|
|
29
45
|
/** Core owns the state machine; this alias exists so callers need one import. */
|
|
30
46
|
export type LifecycleState = HealthState;
|
|
31
47
|
|
|
@@ -48,6 +64,63 @@ export interface ServerOptions {
|
|
|
48
64
|
* passes a store that says the same, or `createServer` refuses here.
|
|
49
65
|
*/
|
|
50
66
|
readonly rateLimitStore?: RateLimitStore;
|
|
67
|
+
/**
|
|
68
|
+
* A websocket served on THIS socket, beside the pipeline. Omitted, the `web` role opens no
|
|
69
|
+
* websocket at all — which is what it did everywhere, and is why the sync node was only ever
|
|
70
|
+
* reachable on a port of its own.
|
|
71
|
+
*
|
|
72
|
+
* The problem that is: `PORT + 1` is a rule the app's own origin cannot express. A browser that
|
|
73
|
+
* reaches the app through anything that publishes ONE port — VS Code's remote port forwarding,
|
|
74
|
+
* a Codespace, an ingress, a tunnel — loads the page and then dials a neighbour nobody
|
|
75
|
+
* forwarded. Measured 2026-09-07 over a VSCodium Remote-SSH workspace: the page on the
|
|
76
|
+
* forwarded `localhost:3000` rendered, `ws://localhost:3001/_x/sync` failed on every attempt of
|
|
77
|
+
* the reconnect ladder, and the same upgrade answered `101` from the box itself. Nothing was
|
|
78
|
+
* broken on either end — there was no tunnel between them.
|
|
79
|
+
*
|
|
80
|
+
* So a host may mount the socket on the port it already publishes, and the two-port topology
|
|
81
|
+
* stays exactly as it was: `x dev` does BOTH, `docker/` keeps its own `sync` service, and a
|
|
82
|
+
* client picks whichever origin it can actually reach.
|
|
83
|
+
*/
|
|
84
|
+
readonly websocket?: WebSocketMount;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Structural view of `Bun.serve`'s server object, as much of one as an upgrade reads. Here rather
|
|
89
|
+
* than imported from `bun` so a mount can be written — and tested — without one, the same shape
|
|
90
|
+
* `@ultimat3/realtime`'s own `UpgradeTarget` already has: this option is the seam those two
|
|
91
|
+
* packages meet at, and neither may depend on the other.
|
|
92
|
+
*/
|
|
93
|
+
export interface UpgradeTarget {
|
|
94
|
+
upgrade(request: Request, options: { data: unknown }): boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* One path, taken off the pipeline and answered by a websocket host instead.
|
|
99
|
+
*
|
|
100
|
+
* `fetch` returning `undefined` means the upgrade TOOK and Bun owns the connection now — the
|
|
101
|
+
* convention `Bun.serve` itself uses, and the one `SyncNode.fetch` already speaks, so a node is a
|
|
102
|
+
* mount with no adapter in between. A `Response` is a refusal (`426`, `401`, a shed `503`) and is
|
|
103
|
+
* returned as it is.
|
|
104
|
+
*
|
|
105
|
+
* The handlers are structural for the reason above; `open`, `message` and `close` are declared as
|
|
106
|
+
* METHODS on purpose, so a host that types its socket precisely (`SyncWs`) still satisfies this.
|
|
107
|
+
*/
|
|
108
|
+
export interface WebSocketMount<TSocket = never> {
|
|
109
|
+
/** The one path this mount owns. Every other request goes to the pipeline, untouched. */
|
|
110
|
+
readonly path: string;
|
|
111
|
+
fetch(
|
|
112
|
+
request: Request,
|
|
113
|
+
server: UpgradeTarget,
|
|
114
|
+
): Promise<Response | undefined> | Response | undefined;
|
|
115
|
+
readonly websocket: {
|
|
116
|
+
open(ws: TSocket): void;
|
|
117
|
+
message(ws: TSocket, message: string | Uint8Array): void;
|
|
118
|
+
close(ws: TSocket): void;
|
|
119
|
+
readonly idleTimeout?: number;
|
|
120
|
+
readonly backpressureLimit?: number;
|
|
121
|
+
readonly maxPayloadLength?: number;
|
|
122
|
+
readonly sendPings?: boolean;
|
|
123
|
+
};
|
|
51
124
|
}
|
|
52
125
|
|
|
53
126
|
export interface ServerHandle {
|
|
@@ -100,6 +173,7 @@ export const createServer = (options: ServerOptions): ServerHandle => {
|
|
|
100
173
|
// "the app said 15 seconds" are different claims and `null` is what keeps them apart.
|
|
101
174
|
if (config.drainTimeoutMs !== null) configureLifecycle({ deadlineMs: config.drainTimeoutMs });
|
|
102
175
|
|
|
176
|
+
const mount = options.websocket;
|
|
103
177
|
let server: BunServer | undefined;
|
|
104
178
|
let unregister: (() => void) | undefined;
|
|
105
179
|
let unregisterClose: (() => void) | undefined;
|
|
@@ -130,6 +204,15 @@ export const createServer = (options: ServerOptions): ServerHandle => {
|
|
|
130
204
|
}
|
|
131
205
|
};
|
|
132
206
|
|
|
207
|
+
/**
|
|
208
|
+
* The path alone. `new URL` rather than a string scan: a mount's path has to match what the
|
|
209
|
+
* router would have matched, and `/_x/sync?build=abc` is that path with a query on it.
|
|
210
|
+
*/
|
|
211
|
+
const pathOf = (request: Request): string => new URL(request.url).pathname;
|
|
212
|
+
|
|
213
|
+
/** What Bun's native table is keyed by, and what a mount's path is compared against. */
|
|
214
|
+
const prefix = config.basePath === '/' ? '' : config.basePath.replace(/\/$/, '');
|
|
215
|
+
|
|
133
216
|
/**
|
|
134
217
|
* Static paths go into Bun's native route table so path dispatch happens in
|
|
135
218
|
* native code. Method resolution stays ours: Bun's automatic 405 would not carry
|
|
@@ -137,7 +220,6 @@ export const createServer = (options: ServerOptions): ServerHandle => {
|
|
|
137
220
|
*/
|
|
138
221
|
const nativeRoutes = (): Record<string, NativeHandler> => {
|
|
139
222
|
const out: Record<string, NativeHandler> = {};
|
|
140
|
-
const prefix = config.basePath === '/' ? '' : config.basePath.replace(/\/$/, '');
|
|
141
223
|
for (const description of describeRoutes(table)) {
|
|
142
224
|
if (description.params.length > 0) continue;
|
|
143
225
|
out[`${prefix}${description.path}`] = dispatch;
|
|
@@ -145,6 +227,27 @@ export const createServer = (options: ServerOptions): ServerHandle => {
|
|
|
145
227
|
return out;
|
|
146
228
|
};
|
|
147
229
|
|
|
230
|
+
/**
|
|
231
|
+
* A mount OWNS its path — and the table above is matched BEFORE `fetch`, so a static route (or a
|
|
232
|
+
* health endpoint) at the same path would take the upgrade request and answer it with a
|
|
233
|
+
* document: a websocket that never opens, and no error anywhere saying why. A param route cannot
|
|
234
|
+
* do that; it falls through to `fetch`, where the mount is asked first.
|
|
235
|
+
*
|
|
236
|
+
* Refused here rather than ranked, because either precedence is a surprise: a route silently
|
|
237
|
+
* shadowing the socket is the bug, and a mount silently shadowing a declared route would be the
|
|
238
|
+
* worse one. `X_ROUTE_CONFLICT` is the code two routes claiming one path already get.
|
|
239
|
+
*/
|
|
240
|
+
const assertMountPathFree = (path: string): void => {
|
|
241
|
+
if (path === '/healthz' || path === '/readyz')
|
|
242
|
+
throw websocketPathTaken(path, 'the health endpoint');
|
|
243
|
+
for (const description of describeRoutes(table)) {
|
|
244
|
+
if (description.params.length > 0) continue;
|
|
245
|
+
if (`${prefix}${description.path}` === path)
|
|
246
|
+
throw websocketPathTaken(path, `the route \`${description.name}\``);
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
if (mount !== undefined) assertMountPathFree(mount.path);
|
|
250
|
+
|
|
148
251
|
const handle: ServerHandle = {
|
|
149
252
|
role,
|
|
150
253
|
config,
|
|
@@ -168,7 +271,7 @@ export const createServer = (options: ServerOptions): ServerHandle => {
|
|
|
168
271
|
// is the metrics endpoint, which answers `METRICS_PATH` and nothing else.
|
|
169
272
|
markReady();
|
|
170
273
|
|
|
171
|
-
|
|
274
|
+
const listen = {
|
|
172
275
|
port: config.port,
|
|
173
276
|
hostname: config.hostname,
|
|
174
277
|
development: config.dev,
|
|
@@ -179,8 +282,23 @@ export const createServer = (options: ServerOptions): ServerHandle => {
|
|
|
179
282
|
'/healthz': () => healthResponse(healthzPayload()),
|
|
180
283
|
'/readyz': () => healthResponse(readyzPayload()),
|
|
181
284
|
},
|
|
182
|
-
|
|
183
|
-
|
|
285
|
+
};
|
|
286
|
+
// Two calls, not one options object with a spread: `Bun.serve` types `fetch` as returning a
|
|
287
|
+
// `Response` UNLESS `websocket` is present, and a conditionally-spread key leaves TypeScript
|
|
288
|
+
// on the first overload — where the `undefined` that means "upgraded" is an error. The
|
|
289
|
+
// mount's path is not in `routes` either: a native route answers before `fetch` runs, and
|
|
290
|
+
// an upgrade that never reaches `fetch` is a 404 with a websocket waiting behind it.
|
|
291
|
+
server =
|
|
292
|
+
mount === undefined
|
|
293
|
+
? Bun.serve({ ...listen, fetch: (request, socket) => dispatch(request, socket) })
|
|
294
|
+
: Bun.serve({
|
|
295
|
+
...listen,
|
|
296
|
+
fetch: async (request, socket) =>
|
|
297
|
+
pathOf(request) === mount.path
|
|
298
|
+
? await mount.fetch(request, socket as unknown as UpgradeTarget)
|
|
299
|
+
: await dispatch(request, socket),
|
|
300
|
+
websocket: mount.websocket,
|
|
301
|
+
});
|
|
184
302
|
|
|
185
303
|
// Tell core which socket we opened. A request to it is this process calling itself,
|
|
186
304
|
// so the test seal can let it through without an allowlist entry per random port.
|