@teamscala/service-boot 0.23.299 → 0.23.301
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 +17 -1
- package/src/mutable-app-ref.d.ts +6 -0
- package/src/mutable-app-ref.ts +8 -1
- package/src/resolve-service-port.d.ts +66 -0
- package/src/resolve-service-port.test.ts +98 -0
- package/src/resolve-service-port.ts +90 -0
- package/src/serve/serve.ts +28 -96
- package/src/serve/stamp-transport-identity.d.ts +32 -0
- package/src/serve/stamp-transport-identity.ts +96 -0
- package/src/ui-service.ts +30 -5
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"name": "@teamscala/service-boot",
|
|
8
8
|
"type": "module",
|
|
9
|
-
"version": "0.23.
|
|
9
|
+
"version": "0.23.301",
|
|
10
10
|
"sideEffects": false,
|
|
11
11
|
"files": [
|
|
12
12
|
"src"
|
|
@@ -882,6 +882,14 @@
|
|
|
882
882
|
"types": "./src/resolve-service-context.d.ts",
|
|
883
883
|
"default": "./src/resolve-service-context.ts"
|
|
884
884
|
},
|
|
885
|
+
"./resolve-service-port": {
|
|
886
|
+
"types": "./src/resolve-service-port.d.ts",
|
|
887
|
+
"default": "./src/resolve-service-port.ts"
|
|
888
|
+
},
|
|
889
|
+
"./resolve-service-port.ts": {
|
|
890
|
+
"types": "./src/resolve-service-port.d.ts",
|
|
891
|
+
"default": "./src/resolve-service-port.ts"
|
|
892
|
+
},
|
|
885
893
|
"./resolve-theme-mode": {
|
|
886
894
|
"types": "./src/resolve-theme-mode.d.ts",
|
|
887
895
|
"default": "./src/resolve-theme-mode.ts"
|
|
@@ -970,6 +978,14 @@
|
|
|
970
978
|
"types": "./src/serve/serve.d.ts",
|
|
971
979
|
"default": "./src/serve/serve.ts"
|
|
972
980
|
},
|
|
981
|
+
"./serve/stamp-transport-identity": {
|
|
982
|
+
"types": "./src/serve/stamp-transport-identity.d.ts",
|
|
983
|
+
"default": "./src/serve/stamp-transport-identity.ts"
|
|
984
|
+
},
|
|
985
|
+
"./serve/stamp-transport-identity.ts": {
|
|
986
|
+
"types": "./src/serve/stamp-transport-identity.d.ts",
|
|
987
|
+
"default": "./src/serve/stamp-transport-identity.ts"
|
|
988
|
+
},
|
|
973
989
|
"./server-error-page": {
|
|
974
990
|
"types": "./src/server-error-page.d.ts",
|
|
975
991
|
"default": "./src/server-error-page.ts"
|
package/src/mutable-app-ref.d.ts
CHANGED
|
@@ -34,5 +34,11 @@ export declare function swapApp(newApp: Hono, newLabel: string): string;
|
|
|
34
34
|
/**
|
|
35
35
|
* Fetch handler that routes through the mutable app reference.
|
|
36
36
|
* This replaces the direct app.fetch call in the serve layer.
|
|
37
|
+
*
|
|
38
|
+
* Stamps transport-derived identity (x-caller-remote-address/-port + the
|
|
39
|
+
* loopback local-caller comm) via the SAME stampTransportIdentity helper as
|
|
40
|
+
* direct mode, so two-phase-boot (mutable-ref) UI services carry identical
|
|
41
|
+
* identity on every request — closing the gap where the mutable-ref path
|
|
42
|
+
* previously served requests with no transport headers stamped at all.
|
|
37
43
|
*/
|
|
38
44
|
export declare function fetchWithMutableApp(req: Request, server: ReturnType<typeof Bun.serve>): Promise<Response>;
|
package/src/mutable-app-ref.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import type { Hono } from "hono";
|
|
17
|
+
import { stampTransportIdentity } from "./serve/stamp-transport-identity.ts";
|
|
17
18
|
|
|
18
19
|
let currentApp: Hono | null = null;
|
|
19
20
|
let appLabel = "initial";
|
|
@@ -58,11 +59,17 @@ export function swapApp(newApp: Hono, newLabel: string): string {
|
|
|
58
59
|
/**
|
|
59
60
|
* Fetch handler that routes through the mutable app reference.
|
|
60
61
|
* This replaces the direct app.fetch call in the serve layer.
|
|
62
|
+
*
|
|
63
|
+
* Stamps transport-derived identity (x-caller-remote-address/-port + the
|
|
64
|
+
* loopback local-caller comm) via the SAME stampTransportIdentity helper as
|
|
65
|
+
* direct mode, so two-phase-boot (mutable-ref) UI services carry identical
|
|
66
|
+
* identity on every request — closing the gap where the mutable-ref path
|
|
67
|
+
* previously served requests with no transport headers stamped at all.
|
|
61
68
|
*/
|
|
62
69
|
export function fetchWithMutableApp(
|
|
63
70
|
req: Request,
|
|
64
71
|
server: ReturnType<typeof Bun.serve>,
|
|
65
72
|
): Promise<Response> {
|
|
66
73
|
const app = getApp();
|
|
67
|
-
return Promise.resolve(app.fetch(req, { server }));
|
|
74
|
+
return Promise.resolve(app.fetch(stampTransportIdentity(req, server), { server }));
|
|
68
75
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-runtime
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Decides which port a service binds, from the two sources that can claim one:
|
|
7
|
+
* the PORT environment variable and the port the registry declares for that
|
|
8
|
+
* service (`service_routing.local_port` for a backend, `service_install.port`
|
|
9
|
+
* for a UI service).
|
|
10
|
+
*
|
|
11
|
+
* This is the resolver for BOTH kinds. It lives in service-boot rather than
|
|
12
|
+
* service-runtime because the UI branch boots from here and may not import
|
|
13
|
+
* upward; keeping one copy is the point, since the second copy is exactly what
|
|
14
|
+
* went wrong (below).
|
|
15
|
+
*
|
|
16
|
+
* The rule, and why it is not "env always wins":
|
|
17
|
+
*
|
|
18
|
+
* Railway injects PORT and healthchecks that exact port, reporting the service
|
|
19
|
+
* unavailable if it listens elsewhere — so where there is NO routing row (a
|
|
20
|
+
* Railway-only backend), the env is the only truth and must win.
|
|
21
|
+
*
|
|
22
|
+
* But a `service_routing` row means this service has a DECLARED port on this
|
|
23
|
+
* host. There, a PORT that disagrees is a polluted launcher environment, never
|
|
24
|
+
* an intention, and obeying it binds someone else's port. That is not a
|
|
25
|
+
* hypothetical: mcp-ai-chat-system booted onto 3021 — session-picker's port —
|
|
26
|
+
* while its own MCP_SERVER_URL still pointed at 8080. Both processes bound 3021,
|
|
27
|
+
* SO_REUSEPORT load-balanced between them, and every symptom appeared somewhere
|
|
28
|
+
* else: a chat loopback that resolved to nothing, tool-description curation
|
|
29
|
+
* failing for every tool and disabling itself via its circuit breaker, RPC calls
|
|
30
|
+
* landing in the wrong service, and 59 restarts. Nothing failed loudly because
|
|
31
|
+
* nothing ever compared the two numbers.
|
|
32
|
+
*
|
|
33
|
+
* So the registry wins wherever it has an opinion, and the disagreement is
|
|
34
|
+
* REPORTED rather than silently resolved — a wrong port that boots cleanly is
|
|
35
|
+
* the expensive shape, because the damage surfaces far from the cause.
|
|
36
|
+
*
|
|
37
|
+
* That reasoning was written for the backend branch and wired only there, so
|
|
38
|
+
* the UI branch kept taking `process.env.PORT` unconditionally — and on
|
|
39
|
+
* 2026-07-31 reproduced the identical incident in the other direction:
|
|
40
|
+
* scala-quiz (kind:ui, declared 5203) inherited a leaked PORT=3021 from the
|
|
41
|
+
* tmux server environment and bound session-picker's port. Half of every RPC
|
|
42
|
+
* to the session daemon was answered by a quiz service with no /rpc routes.
|
|
43
|
+
*
|
|
44
|
+
* A rule enforced on one of two branches is not enforced. Both branches now
|
|
45
|
+
* call this function, and `port-resolution-is-registry-authoritative` fails
|
|
46
|
+
* the push if either stops.
|
|
47
|
+
*/
|
|
48
|
+
export interface PortResolution {
|
|
49
|
+
/** The port to bind. */
|
|
50
|
+
port: number;
|
|
51
|
+
/** Which source decided it — for the boot log, so the choice is auditable. */
|
|
52
|
+
source: "env" | "service_routing";
|
|
53
|
+
/** Set when PORT disagreed with the row; the caller logs it. */
|
|
54
|
+
conflict?: {
|
|
55
|
+
envPort: number;
|
|
56
|
+
declaredPort: number;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* @param envPort parsed PORT env, or undefined when unset/unparseable
|
|
61
|
+
* @param declaredPort `service_routing.local_port`, or undefined when the
|
|
62
|
+
* service has no routing row (the Railway-only case)
|
|
63
|
+
*/
|
|
64
|
+
export declare function resolveServicePort(envPort: number | undefined, declaredPort: number | undefined): PortResolution | {
|
|
65
|
+
error: "no-port-source";
|
|
66
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The load-bearing case is the contradiction one: a PORT env that disagrees
|
|
7
|
+
* with the service's own declared port must NOT be obeyed. Obeying it put
|
|
8
|
+
* mcp-ai-chat-system on session-picker's port with a loopback pointing at a
|
|
9
|
+
* port nothing was listening on, and the failure surfaced as broken tool
|
|
10
|
+
* curation rather than as a port problem. It then did it again on 2026-07-31
|
|
11
|
+
* to scala-quiz — see the kind:ui block below.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, expect, test } from "bun:test";
|
|
15
|
+
import { resolveServicePort } from "./resolve-service-port.ts";
|
|
16
|
+
|
|
17
|
+
/** Narrow off the error arm so a resolution's port can be asserted directly. */
|
|
18
|
+
function resolvedPort(envPort: number | undefined, declaredPort: number | undefined): number {
|
|
19
|
+
const r = resolveServicePort(envPort, declaredPort);
|
|
20
|
+
if ("error" in r) throw new Error(`expected a resolution, got ${r.error}`);
|
|
21
|
+
return r.port;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("resolveServicePort", () => {
|
|
25
|
+
test("no routing row: Railway's injected PORT wins", () => {
|
|
26
|
+
// A Railway-only backend legitimately has no dev routing row, and Railway
|
|
27
|
+
// healthchecks the exact port it injected.
|
|
28
|
+
expect(resolveServicePort(8080, undefined)).toEqual({ port: 8080, source: "env" });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("no routing row and no usable PORT is an error, not a guess", () => {
|
|
32
|
+
expect(resolveServicePort(undefined, undefined)).toEqual({ error: "no-port-source" });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("routing row with no PORT env: the row wins", () => {
|
|
36
|
+
expect(resolveServicePort(undefined, 3021)).toEqual({ port: 3021, source: "service_routing" });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("routing row and an agreeing PORT: no conflict reported", () => {
|
|
40
|
+
const r = resolveServicePort(3021, 3021);
|
|
41
|
+
expect(r).toEqual({ port: 3021, source: "service_routing" });
|
|
42
|
+
expect("conflict" in r).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("a PORT contradicting the routing row is REFUSED and reported", () => {
|
|
46
|
+
// The exact incident: env said 3021 (session-picker's port), the row said
|
|
47
|
+
// 8080. Obeying the env bound another service's port.
|
|
48
|
+
expect(resolveServicePort(3021, 8080)).toEqual({
|
|
49
|
+
port: 8080,
|
|
50
|
+
source: "service_routing",
|
|
51
|
+
conflict: { envPort: 3021, declaredPort: 8080 },
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("an unparseable or out-of-range PORT never displaces a valid row", () => {
|
|
56
|
+
expect(resolvedPort(Number.NaN, 3021)).toBe(3021);
|
|
57
|
+
expect(resolvedPort(0, 3021)).toBe(3021);
|
|
58
|
+
expect(resolvedPort(70_000, 3021)).toBe(3021);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("an out-of-range PORT with no row is an error rather than a bad bind", () => {
|
|
62
|
+
expect(resolveServicePort(70_000, undefined)).toEqual({ error: "no-port-source" });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// The UI branch reaches this same function via `service_install.port`. These
|
|
66
|
+
// cases are the 2026-07-31 incident, which the backend-only wiring missed.
|
|
67
|
+
describe("kind:ui services (declared port from service_install)", () => {
|
|
68
|
+
test("the live bug: a leaked PORT cannot move a UI service off its declared port", () => {
|
|
69
|
+
// scala-quiz declares 5203; PORT=3021 leaked in from the tmux server
|
|
70
|
+
// env and it bound session-picker's port, splitting that daemon's
|
|
71
|
+
// traffic via SO_REUSEPORT.
|
|
72
|
+
expect(resolveServicePort(3021, 5203)).toEqual({
|
|
73
|
+
port: 5203,
|
|
74
|
+
source: "service_routing",
|
|
75
|
+
conflict: { envPort: 3021, declaredPort: 5203 },
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("the conflict is reported, not swallowed — the boot log must be able to say so", () => {
|
|
80
|
+
const r = resolveServicePort(3021, 5203);
|
|
81
|
+
expect("error" in r).toBe(false);
|
|
82
|
+
if ("error" in r) return;
|
|
83
|
+
expect(r.conflict).toBeDefined();
|
|
84
|
+
expect(r.conflict?.envPort).toBe(3021);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("Railway is unaffected: no declared port means the injected PORT still wins", () => {
|
|
88
|
+
expect(resolveServicePort(8080, undefined)).toEqual({ port: 8080, source: "env" });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("an agreeing PORT reports no conflict", () => {
|
|
92
|
+
expect(resolveServicePort(5203, 5203)).toEqual({
|
|
93
|
+
port: 5203,
|
|
94
|
+
source: "service_routing",
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-runtime
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Decides which port a service binds, from the two sources that can claim one:
|
|
7
|
+
* the PORT environment variable and the port the registry declares for that
|
|
8
|
+
* service (`service_routing.local_port` for a backend, `service_install.port`
|
|
9
|
+
* for a UI service).
|
|
10
|
+
*
|
|
11
|
+
* This is the resolver for BOTH kinds. It lives in service-boot rather than
|
|
12
|
+
* service-runtime because the UI branch boots from here and may not import
|
|
13
|
+
* upward; keeping one copy is the point, since the second copy is exactly what
|
|
14
|
+
* went wrong (below).
|
|
15
|
+
*
|
|
16
|
+
* The rule, and why it is not "env always wins":
|
|
17
|
+
*
|
|
18
|
+
* Railway injects PORT and healthchecks that exact port, reporting the service
|
|
19
|
+
* unavailable if it listens elsewhere — so where there is NO routing row (a
|
|
20
|
+
* Railway-only backend), the env is the only truth and must win.
|
|
21
|
+
*
|
|
22
|
+
* But a `service_routing` row means this service has a DECLARED port on this
|
|
23
|
+
* host. There, a PORT that disagrees is a polluted launcher environment, never
|
|
24
|
+
* an intention, and obeying it binds someone else's port. That is not a
|
|
25
|
+
* hypothetical: mcp-ai-chat-system booted onto 3021 — session-picker's port —
|
|
26
|
+
* while its own MCP_SERVER_URL still pointed at 8080. Both processes bound 3021,
|
|
27
|
+
* SO_REUSEPORT load-balanced between them, and every symptom appeared somewhere
|
|
28
|
+
* else: a chat loopback that resolved to nothing, tool-description curation
|
|
29
|
+
* failing for every tool and disabling itself via its circuit breaker, RPC calls
|
|
30
|
+
* landing in the wrong service, and 59 restarts. Nothing failed loudly because
|
|
31
|
+
* nothing ever compared the two numbers.
|
|
32
|
+
*
|
|
33
|
+
* So the registry wins wherever it has an opinion, and the disagreement is
|
|
34
|
+
* REPORTED rather than silently resolved — a wrong port that boots cleanly is
|
|
35
|
+
* the expensive shape, because the damage surfaces far from the cause.
|
|
36
|
+
*
|
|
37
|
+
* That reasoning was written for the backend branch and wired only there, so
|
|
38
|
+
* the UI branch kept taking `process.env.PORT` unconditionally — and on
|
|
39
|
+
* 2026-07-31 reproduced the identical incident in the other direction:
|
|
40
|
+
* scala-quiz (kind:ui, declared 5203) inherited a leaked PORT=3021 from the
|
|
41
|
+
* tmux server environment and bound session-picker's port. Half of every RPC
|
|
42
|
+
* to the session daemon was answered by a quiz service with no /rpc routes.
|
|
43
|
+
*
|
|
44
|
+
* A rule enforced on one of two branches is not enforced. Both branches now
|
|
45
|
+
* call this function, and `port-resolution-is-registry-authoritative` fails
|
|
46
|
+
* the push if either stops.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
export interface PortResolution {
|
|
50
|
+
/** The port to bind. */
|
|
51
|
+
port: number;
|
|
52
|
+
/** Which source decided it — for the boot log, so the choice is auditable. */
|
|
53
|
+
source: "env" | "service_routing";
|
|
54
|
+
/** Set when PORT disagreed with the row; the caller logs it. */
|
|
55
|
+
conflict?: { envPort: number; declaredPort: number };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isBindablePort(value: number | undefined): value is number {
|
|
59
|
+
return value !== undefined && Number.isFinite(value) && value > 0 && value <= 65535;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param envPort parsed PORT env, or undefined when unset/unparseable
|
|
64
|
+
* @param declaredPort `service_routing.local_port`, or undefined when the
|
|
65
|
+
* service has no routing row (the Railway-only case)
|
|
66
|
+
*/
|
|
67
|
+
export function resolveServicePort(
|
|
68
|
+
envPort: number | undefined,
|
|
69
|
+
declaredPort: number | undefined,
|
|
70
|
+
): PortResolution | { error: "no-port-source" } {
|
|
71
|
+
const envUsable = isBindablePort(envPort);
|
|
72
|
+
const declaredUsable = isBindablePort(declaredPort);
|
|
73
|
+
|
|
74
|
+
// No routing row: Railway's injected PORT is the only truth available.
|
|
75
|
+
if (!declaredUsable) {
|
|
76
|
+
return envUsable ? { port: envPort, source: "env" } : { error: "no-port-source" };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Routing row present and the env agrees (or is absent) — nothing to resolve.
|
|
80
|
+
if (!envUsable || envPort === declaredPort) {
|
|
81
|
+
return { port: declaredPort, source: "service_routing" };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Routing row present and the env contradicts it: the row wins, loudly.
|
|
85
|
+
return {
|
|
86
|
+
port: declaredPort,
|
|
87
|
+
source: "service_routing",
|
|
88
|
+
conflict: { envPort, declaredPort },
|
|
89
|
+
};
|
|
90
|
+
}
|
package/src/serve/serve.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import type { Hono } from "hono";
|
|
12
12
|
import { createBunWebSocket } from "hono/bun";
|
|
13
|
-
import {
|
|
13
|
+
import { stampTransportIdentity } from "./stamp-transport-identity.ts";
|
|
14
14
|
|
|
15
15
|
const _bunWs = createBunWebSocket();
|
|
16
16
|
|
|
@@ -38,105 +38,20 @@ export interface ServeHandle {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
|
-
* Create the fetch handler with
|
|
42
|
-
*
|
|
41
|
+
* Create the fetch handler with transport-identity stamping. Used in direct
|
|
42
|
+
* mode (mutable-ref mode uses fetchWithMutableApp, which also stamps via the
|
|
43
|
+
* same stampTransportIdentity helper — one stamp point for every request).
|
|
43
44
|
*/
|
|
44
|
-
function createFetchHandler(
|
|
45
|
-
app: Hono,
|
|
46
|
-
serviceSlug: string,
|
|
47
|
-
logger: { info(msg: string): void; warn(msg: string): void },
|
|
48
|
-
) {
|
|
49
|
-
let peerPortFailures = 0;
|
|
45
|
+
function createFetchHandler(app: Hono) {
|
|
50
46
|
return async (
|
|
51
47
|
req: Request,
|
|
52
48
|
server: ReturnType<typeof Bun.serve>,
|
|
53
49
|
) => {
|
|
54
50
|
// app.fetch's second arg becomes c.env. Hono's bun WebSocket upgrade
|
|
55
|
-
// adapter checks `"server" in c.env`
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
// WebSocket upgrade requests MUST NOT be cloned — Bun's server.upgrade()
|
|
60
|
-
// tracks pending upgrades by Request identity, so a cloned Request can
|
|
61
|
-
// never upgrade. For WS requests we skip the peer-port clone and pass
|
|
62
|
-
// the original Request through.
|
|
63
|
-
const isUpgrade = req.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
64
|
-
if (isUpgrade) {
|
|
65
|
-
return app.fetch(req, { server });
|
|
66
|
-
}
|
|
67
|
-
try {
|
|
68
|
-
const peer = (
|
|
69
|
-
server as unknown as {
|
|
70
|
-
requestIP(r: Request): { port?: number; address?: string } | null;
|
|
71
|
-
}
|
|
72
|
-
).requestIP?.(req);
|
|
73
|
-
const port = peer?.port;
|
|
74
|
-
// x-caller-remote-address: the unforgeable transport-origin trust
|
|
75
|
-
// signal (no-client-controlled-trust-signals). server.requestIP is the
|
|
76
|
-
// ONE reliable address source (Hono's getConnInfo does not expose
|
|
77
|
-
// address on Bun). ALWAYS sanitize (delete any client-supplied spoof)
|
|
78
|
-
// then set the verified socket peer — the @teamscala/mcp-tool-runtime
|
|
79
|
-
// /mcp discriminator reads this to gate the bearer on loopback-only.
|
|
80
|
-
const address = peer?.address ?? null;
|
|
81
|
-
const needsPort =
|
|
82
|
-
typeof port === "number" &&
|
|
83
|
-
port > 0 &&
|
|
84
|
-
!req.headers.has("x-caller-remote-port");
|
|
85
|
-
if (needsPort || address !== null) {
|
|
86
|
-
const headers = new Headers(req.headers);
|
|
87
|
-
if (needsPort) headers.set("x-caller-remote-port", String(port));
|
|
88
|
-
if (address) {
|
|
89
|
-
headers.delete("x-caller-remote-address");
|
|
90
|
-
headers.set("x-caller-remote-address", String(address));
|
|
91
|
-
}
|
|
92
|
-
// Local-caller attribution: a local `fetch` carries no forwarding
|
|
93
|
-
// header, so for a loopback peer (no CF-Connecting-IP / X-Forwarded-
|
|
94
|
-
// For) resolve the owning local PROCESS from the socket + stamp it for
|
|
95
|
-
// the access log. Names the daemon behind every local request —
|
|
96
|
-
// including an unidentified sweeper keeping scale-to-zero apps awake.
|
|
97
|
-
// Trust stays transport-derived (no-client-controlled-trust-signals);
|
|
98
|
-
// the comm is attribution only. resolveSocketPeer is TTL-cached + never
|
|
99
|
-
// throws (returns null on any miss); skipped for external callers.
|
|
100
|
-
if (
|
|
101
|
-
address &&
|
|
102
|
-
(address === "127.0.0.1" ||
|
|
103
|
-
address === "::1" ||
|
|
104
|
-
address === "::ffff:127.0.0.1") &&
|
|
105
|
-
!headers.has("cf-connecting-ip") &&
|
|
106
|
-
!headers.get("x-forwarded-for") &&
|
|
107
|
-
typeof port === "number" &&
|
|
108
|
-
port > 0
|
|
109
|
-
) {
|
|
110
|
-
const owner = resolveSocketPeer(port);
|
|
111
|
-
if (owner) {
|
|
112
|
-
headers.set("x-caller-local-comm", owner.comm);
|
|
113
|
-
const cmd = owner.cmdline.join(" ").trim();
|
|
114
|
-
if (cmd) headers.set("x-caller-local-cmd", cmd.slice(0, 160));
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
const cloned = new Request(req.url, {
|
|
118
|
-
method: req.method,
|
|
119
|
-
headers,
|
|
120
|
-
body: req.body,
|
|
121
|
-
signal: req.signal,
|
|
122
|
-
// @ts-expect-error duplex required by spec when body is a stream
|
|
123
|
-
duplex: "half",
|
|
124
|
-
});
|
|
125
|
-
return app.fetch(cloned, { server });
|
|
126
|
-
}
|
|
127
|
-
} catch (err) {
|
|
128
|
-
// Fail-open NOISY (protective-layers-fail-explicitly): the request
|
|
129
|
-
// must always be served, but a broken peer-port resolution degrades
|
|
130
|
-
// caller-identity to baked-stale headers. Aggregated: first +
|
|
131
|
-
// every 1000th, never per-request spam.
|
|
132
|
-
peerPortFailures++;
|
|
133
|
-
if (peerPortFailures === 1 || peerPortFailures % 1000 === 0) {
|
|
134
|
-
logger.warn(
|
|
135
|
-
`[boot-serve:${serviceSlug}] peer-port resolution failed ${peerPortFailures}x — caller-identity degrades to header fallback: ${err instanceof Error ? err.message : String(err)}`,
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
return app.fetch(req, { server });
|
|
51
|
+
// adapter checks `"server" in c.env` for the Bun server reference (WS
|
|
52
|
+
// upgrade throws TypeError without it). stampTransportIdentity handles
|
|
53
|
+
// the WS-upgrade no-clone rule + best-effort identity stamping internally.
|
|
54
|
+
return app.fetch(stampTransportIdentity(req, server), { server });
|
|
140
55
|
};
|
|
141
56
|
}
|
|
142
57
|
|
|
@@ -151,7 +66,24 @@ function serveStartInternal(
|
|
|
151
66
|
): ServeHandle {
|
|
152
67
|
const server = Bun.serve({
|
|
153
68
|
port: options.port,
|
|
154
|
-
reusePort
|
|
69
|
+
// reusePort is deliberately NOT set. SO_REUSEPORT lets a second process
|
|
70
|
+
// bind a port this service already owns; the kernel then load-balances
|
|
71
|
+
// between them and BOTH look healthy while each receives a fraction of
|
|
72
|
+
// the traffic. That is the worst available failure mode — it presents as
|
|
73
|
+
// intermittent, unattributable errors in whichever service is unlucky,
|
|
74
|
+
// far from the process that actually took the port.
|
|
75
|
+
//
|
|
76
|
+
// It has now cost two incidents. mcp-ai-chat-system bound session-picker's
|
|
77
|
+
// 3021 and broke tool curation; scala-quiz did the same on 2026-07-31 and
|
|
78
|
+
// answered ~half of every session-daemon RPC with a 404. Neither failed at
|
|
79
|
+
// bind, because reusePort made a collision legal.
|
|
80
|
+
//
|
|
81
|
+
// Nothing here needs it: PM2 fork mode stops before it starts, dev runs one
|
|
82
|
+
// process per service (`readiness-gated-boot` — the boot window is handled
|
|
83
|
+
// by the in-process readiness gate, explicitly not blue/green), and each
|
|
84
|
+
// Railway container is alone. So the only thing reusePort can express on
|
|
85
|
+
// this platform is a mistake, and without it that mistake is an immediate,
|
|
86
|
+
// legible EADDRINUSE naming the port.
|
|
155
87
|
// Bun's default idleTimeout (~10s, swept on a coarse timer wheel)
|
|
156
88
|
// FIN-closes every pending connection in one ~2ms sweep once their
|
|
157
89
|
// handlers exceed it — packet-captured 2026-06-06: concurrent SSR
|
|
@@ -211,6 +143,6 @@ export function serveStart(
|
|
|
211
143
|
}
|
|
212
144
|
|
|
213
145
|
// Direct mode: use the app directly
|
|
214
|
-
const fetchHandler = createFetchHandler(app
|
|
146
|
+
const fetchHandler = createFetchHandler(app);
|
|
215
147
|
return serveStartInternal(fetchHandler, options, log, "direct");
|
|
216
148
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Stamp transport-derived identity onto a request — the single shared stamp
|
|
7
|
+
* point used by BOTH serve paths (direct `createFetchHandler` + mutable-ref
|
|
8
|
+
* `fetchWithMutableApp`), so every request on every UI service carries the
|
|
9
|
+
* same identity regardless of boot mode.
|
|
10
|
+
*
|
|
11
|
+
* Two identity layers, both transport-derived (no-client-controlled-trust-
|
|
12
|
+
* signals — never from a client-controllable header):
|
|
13
|
+
* 1. `x-caller-remote-address` / `-port` — the unforgeable socket peer, from
|
|
14
|
+
* `Bun.serve.requestIP`. ALWAYS sanitised (any client-supplied spoof
|
|
15
|
+
* deleted) then set to the verified peer. The /mcp + fleet loopback gates
|
|
16
|
+
* read this.
|
|
17
|
+
* 2. `x-caller-local-comm` / `-cmd` — for a LOOPBACK peer with no forwarding
|
|
18
|
+
* header (a local `fetch` carries no CF-Connecting-IP), the owning local
|
|
19
|
+
* PROCESS resolved via /proc (@teamscala/proc-walker). Names the daemon
|
|
20
|
+
* behind every local request on the access log — including an unidentified
|
|
21
|
+
* sweeper. Attribution only (trust stays transport-derived).
|
|
22
|
+
*
|
|
23
|
+
* Best-effort + never throws: a resolution miss returns the original request
|
|
24
|
+
* unchanged (the request is always served; identity degrades to address-only).
|
|
25
|
+
* WebSocket upgrades are returned untouched (Bun tracks upgrades by Request
|
|
26
|
+
* identity — a clone can never upgrade).
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* Return a Request with transport-identity headers stamped, or the original
|
|
30
|
+
* `req` when no stamping is needed/possible. Never throws.
|
|
31
|
+
*/
|
|
32
|
+
export declare function stampTransportIdentity(req: Request, server: ReturnType<typeof Bun.serve>): Request;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Stamp transport-derived identity onto a request — the single shared stamp
|
|
7
|
+
* point used by BOTH serve paths (direct `createFetchHandler` + mutable-ref
|
|
8
|
+
* `fetchWithMutableApp`), so every request on every UI service carries the
|
|
9
|
+
* same identity regardless of boot mode.
|
|
10
|
+
*
|
|
11
|
+
* Two identity layers, both transport-derived (no-client-controlled-trust-
|
|
12
|
+
* signals — never from a client-controllable header):
|
|
13
|
+
* 1. `x-caller-remote-address` / `-port` — the unforgeable socket peer, from
|
|
14
|
+
* `Bun.serve.requestIP`. ALWAYS sanitised (any client-supplied spoof
|
|
15
|
+
* deleted) then set to the verified peer. The /mcp + fleet loopback gates
|
|
16
|
+
* read this.
|
|
17
|
+
* 2. `x-caller-local-comm` / `-cmd` — for a LOOPBACK peer with no forwarding
|
|
18
|
+
* header (a local `fetch` carries no CF-Connecting-IP), the owning local
|
|
19
|
+
* PROCESS resolved via /proc (@teamscala/proc-walker). Names the daemon
|
|
20
|
+
* behind every local request on the access log — including an unidentified
|
|
21
|
+
* sweeper. Attribution only (trust stays transport-derived).
|
|
22
|
+
*
|
|
23
|
+
* Best-effort + never throws: a resolution miss returns the original request
|
|
24
|
+
* unchanged (the request is always served; identity degrades to address-only).
|
|
25
|
+
* WebSocket upgrades are returned untouched (Bun tracks upgrades by Request
|
|
26
|
+
* identity — a clone can never upgrade).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { resolveSocketPeer } from "@teamscala/proc-walker/socket-peer";
|
|
30
|
+
|
|
31
|
+
const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Return a Request with transport-identity headers stamped, or the original
|
|
35
|
+
* `req` when no stamping is needed/possible. Never throws.
|
|
36
|
+
*/
|
|
37
|
+
export function stampTransportIdentity(
|
|
38
|
+
req: Request,
|
|
39
|
+
server: ReturnType<typeof Bun.serve>,
|
|
40
|
+
): Request {
|
|
41
|
+
// WebSocket upgrade requests MUST NOT be cloned — Bun's server.upgrade()
|
|
42
|
+
// tracks pending upgrades by Request identity, so a cloned Request can
|
|
43
|
+
// never upgrade.
|
|
44
|
+
if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
|
45
|
+
return req;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const peer = (
|
|
49
|
+
server as unknown as {
|
|
50
|
+
requestIP(r: Request): { port?: number; address?: string } | null;
|
|
51
|
+
}
|
|
52
|
+
).requestIP?.(req);
|
|
53
|
+
const port = peer?.port;
|
|
54
|
+
const address = peer?.address ?? null;
|
|
55
|
+
const needsPort =
|
|
56
|
+
typeof port === "number" && port > 0 && !req.headers.has("x-caller-remote-port");
|
|
57
|
+
// Nothing to stamp when there is no peer address and no port to add.
|
|
58
|
+
if (!needsPort && address === null) return req;
|
|
59
|
+
|
|
60
|
+
const headers = new Headers(req.headers);
|
|
61
|
+
if (needsPort) headers.set("x-caller-remote-port", String(port));
|
|
62
|
+
if (address) {
|
|
63
|
+
headers.delete("x-caller-remote-address");
|
|
64
|
+
headers.set("x-caller-remote-address", String(address));
|
|
65
|
+
}
|
|
66
|
+
// Local-caller attribution: loopback peer + no forwarding header => a
|
|
67
|
+
// direct local fetch whose owning process we can name via /proc.
|
|
68
|
+
if (
|
|
69
|
+
address &&
|
|
70
|
+
LOOPBACK.has(address) &&
|
|
71
|
+
!headers.has("cf-connecting-ip") &&
|
|
72
|
+
!headers.get("x-forwarded-for") &&
|
|
73
|
+
typeof port === "number" &&
|
|
74
|
+
port > 0
|
|
75
|
+
) {
|
|
76
|
+
const owner = resolveSocketPeer(port);
|
|
77
|
+
if (owner) {
|
|
78
|
+
headers.set("x-caller-local-comm", owner.comm);
|
|
79
|
+
const cmd = owner.cmdline.join(" ").trim();
|
|
80
|
+
if (cmd) headers.set("x-caller-local-cmd", cmd.slice(0, 160));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return new Request(req.url, {
|
|
84
|
+
method: req.method,
|
|
85
|
+
headers,
|
|
86
|
+
body: req.body,
|
|
87
|
+
signal: req.signal,
|
|
88
|
+
// @ts-expect-error duplex is required by spec when body is a stream
|
|
89
|
+
duplex: "half",
|
|
90
|
+
});
|
|
91
|
+
} catch {
|
|
92
|
+
// A broken peer resolution must never block serving — degrade to the
|
|
93
|
+
// original request (identity is best-effort, the request always served).
|
|
94
|
+
return req;
|
|
95
|
+
}
|
|
96
|
+
}
|
package/src/ui-service.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { BootFatalError } from "@teamscala/os/errors/boot-error";
|
|
|
14
14
|
import { Hono } from "hono";
|
|
15
15
|
import type { ServiceInstallConfig } from "./load-service-install-row.ts";
|
|
16
16
|
import { setReady } from "./readiness-gate.ts";
|
|
17
|
+
import { resolveServicePort } from "./resolve-service-port.ts";
|
|
17
18
|
import { serveStart } from "./serve/serve";
|
|
18
19
|
import { buildMinimalApp, buildFullApp, swapToFullApp } from "./two-phase-boot.ts";
|
|
19
20
|
|
|
@@ -66,12 +67,36 @@ export async function bootUiService(
|
|
|
66
67
|
// Phase 1: Build minimal tool-only app
|
|
67
68
|
const minimalAppResult = await buildMinimalApp(config, serviceSlug, databaseUrl);
|
|
68
69
|
|
|
69
|
-
//
|
|
70
|
+
// Resolve the listen port through the SHARED resolver (`resolveServicePort`),
|
|
71
|
+
// the same one the backend branch uses. The registry wins wherever it has an
|
|
72
|
+
// opinion and a contradicting PORT env is reported, never silently obeyed —
|
|
73
|
+
// full reasoning in that file.
|
|
74
|
+
//
|
|
75
|
+
// This branch previously took `process.env.PORT` unconditionally, which is
|
|
76
|
+
// how scala-quiz (declared 5203) booted onto session-picker's 3021 from a
|
|
77
|
+
// leaked env and silently split that daemon's traffic (2026-07-31). Railway
|
|
78
|
+
// is unaffected by construction: it injects PORT and a Railway-only service
|
|
79
|
+
// has no declared port, so the env still wins there.
|
|
70
80
|
const envPort = process.env.PORT ? Number(process.env.PORT) : undefined;
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
81
|
+
const resolution = resolveServicePort(envPort, config.port);
|
|
82
|
+
if ("error" in resolution) {
|
|
83
|
+
throw new BootFatalError(
|
|
84
|
+
"ui-service.port-resolution",
|
|
85
|
+
serviceSlug,
|
|
86
|
+
`No port source: service_install declares no port and no valid PORT env is set. ` +
|
|
87
|
+
`Every UI service requires a port in its service_install row (or a PORT env when Railway-deployed).`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
if (resolution.conflict) {
|
|
91
|
+
log.warn(
|
|
92
|
+
`[ui-service-boot:${serviceSlug}] PORT env (${resolution.conflict.envPort}) contradicts its ` +
|
|
93
|
+
`service_install row (${resolution.conflict.declaredPort}) — using ` +
|
|
94
|
+
`${resolution.conflict.declaredPort}. A stale PORT in this service's launcher ` +
|
|
95
|
+
`environment is a polluted launcher env, never an intention; binding the env port ` +
|
|
96
|
+
`would take another service's port.`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const port = requireValidPort(resolution.port, serviceSlug);
|
|
75
100
|
|
|
76
101
|
// Start serving with the minimal app (useMutableRef=true)
|
|
77
102
|
try {
|