@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/http",
3
- "version": "19.3.2",
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.3.2",
35
- "@ultimat3/i18n": "19.3.2",
36
- "@ultimat3/schema": "19.3.2",
37
- "@ultimat3/time": "19.3.2"
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 { LifecycleState, ServerHandle, ServerOptions } from './server';
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
- server = Bun.serve({
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
- fetch: (request, socket) => dispatch(request, socket),
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.