Skip to content

Commit 8f87546

Browse files
Fix websocket handling.
1 parent 75223f3 commit 8f87546

8 files changed

Lines changed: 775 additions & 73 deletions

File tree

Chimera/Application.js

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {app, BrowserWindow, dialog, ipcMain, protocol} from "electron";
1+
import {app, BrowserWindow, dialog, ipcMain} from "electron";
22
import path from "node:path";
33
import {fileURLToPath} from "node:url";
44

@@ -8,6 +8,7 @@ import {DarwinWindowController} from "./DarwinWindowController.js";
88
import {LinuxWindowController} from "./LinuxWindowController.js";
99
import {MenuController} from "./MenuController.js";
1010
import {RendererCommandDispatcher} from "./RendererCommandDispatcher.js";
11+
import {HTTYSurfaceServer} from "./HTTYSurfaceServer.js";
1112
import {trace} from "./Utilities.js";
1213
import {UpdateController} from "./UpdateController.js";
1314
import {WindowController} from "./WindowController.js";
@@ -37,7 +38,8 @@ export class ChimeraApplication {
3738
options: this.configuration.updateOptions(),
3839
trace: this.trace.bind(this),
3940
});
40-
41+
this.surfaceServer = new HTTYSurfaceServer();
42+
4143
if (this.environment.CHIMERA_E2E === "1") {
4244
globalThis.chimeraE2E = {
4345
evaluateSurface: (surfaceId, script) => this.evaluateSurfaceForTesting(surfaceId, script),
@@ -59,20 +61,6 @@ export class ChimeraApplication {
5961
return `surface-${this.surfaceCounter}`;
6062
}
6163

62-
registerPrivilegedSchemes() {
63-
protocol.registerSchemesAsPrivileged([
64-
{
65-
scheme: "htty",
66-
privileges: {
67-
standard: true,
68-
secure: true,
69-
supportFetchAPI: true,
70-
corsEnabled: true,
71-
},
72-
},
73-
]);
74-
}
75-
7664
windowControllerClass() {
7765
if (process.platform === "darwin") {
7866
return DarwinWindowController;
@@ -249,7 +237,11 @@ export class ChimeraApplication {
249237
}
250238

251239
async start() {
252-
this.registerPrivilegedSchemes();
240+
// Start the TCP bridge before Electron is ready so we can set the
241+
// host-resolver-rules switch that Chromium reads at startup.
242+
const port = await this.surfaceServer.start();
243+
app.commandLine.appendSwitch("host-resolver-rules", `MAP *${".htty"} 127.0.0.1:${port}`);
244+
253245
await app.whenReady();
254246
this.registerIpcHandlers();
255247
await this.createWindow();

Chimera/HTTYSurfaceServer.js

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import crypto from "node:crypto";
2+
import http from "node:http";
3+
4+
export const SURFACE_HOST_SUFFIX = ".htty";
5+
6+
// Hop headers must not be forwarded by any proxy (RFC 2616 §13.5.1).
7+
const HOP_HEADERS = new Set([
8+
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
9+
"te", "trailers", "transfer-encoding", "upgrade",
10+
]);
11+
12+
// A single HTTP/WSS server shared across all HTTY sessions.
13+
//
14+
// Chromium reaches it because host-resolver-rules maps *.htty to
15+
// 127.0.0.1:<this port>. The Host header identifies which session owns each
16+
// request. HTTP (not HTTPS) is used so no TLS certificate is needed; the
17+
// embedded pages use ws:// WebSocket URLs which match their http:// origin.
18+
//
19+
// HTTP requests → proxied to the session's HTTY HTTP/2 client.
20+
// WebSocket upgrades → HTTP/2 extended-CONNECT stream on the HTTY client;
21+
// raw WebSocket frames are piped bidirectionally. No frame
22+
// encoding/decoding is needed here: the browser and the HTTY server both
23+
// speak RFC 8441, so the server is a transparent byte tunnel.
24+
25+
export class HTTYSurfaceServer {
26+
#sessions = new Map(); // "session-1" → getClient fn
27+
#wellKnownHandlers = new Map(); // "session-1" → handler fn
28+
#server = http.createServer((req, res) => this.#handleHttp(req, res));
29+
port = null;
30+
31+
constructor() {
32+
this.#server.on("upgrade", (req, socket, head) => this.#handleUpgrade(req, socket, head));
33+
}
34+
35+
register(sessionId, getClient, wellKnownHandler) {
36+
this.#sessions.set(sessionId, getClient);
37+
this.#wellKnownHandlers.set(sessionId, wellKnownHandler);
38+
}
39+
40+
unregister(sessionId) {
41+
this.#sessions.delete(sessionId);
42+
this.#wellKnownHandlers.delete(sessionId);
43+
}
44+
45+
start() {
46+
return new Promise((resolve, reject) => {
47+
this.#server.listen(0, "127.0.0.1", () => {
48+
this.port = this.#server.address().port;
49+
resolve(this.port);
50+
});
51+
this.#server.once("error", reject);
52+
});
53+
}
54+
55+
close() {
56+
this.#server.close();
57+
}
58+
59+
#sessionId(req) {
60+
const host = req.headers.host ?? "";
61+
const hostname = host.split(":")[0];
62+
if (!hostname.endsWith(SURFACE_HOST_SUFFIX)) return null;
63+
return hostname.slice(0, -SURFACE_HOST_SUFFIX.length);
64+
}
65+
66+
async #handleHttp(req, res) {
67+
const sessionId = this.#sessionId(req);
68+
const client = sessionId ? this.#sessions.get(sessionId)?.() : null;
69+
if (!client) {
70+
res.writeHead(410);
71+
res.end("HTTY session not available");
72+
return;
73+
}
74+
75+
const requestPath = req.url ?? "/";
76+
77+
// Chimera-owned .well-known routes are handled locally.
78+
if (requestPath.startsWith("/.well-known/chimera/")) {
79+
const handled = this.#wellKnownHandlers.get(sessionId)?.(requestPath, req, res);
80+
if (handled) return;
81+
}
82+
83+
const method = (req.method ?? "GET").toUpperCase();
84+
const headers = {":scheme": "http", ":authority": req.headers.host ?? ""};
85+
for (const [key, value] of Object.entries(req.headers)) {
86+
if (key !== "host" && !HOP_HEADERS.has(key)) headers[key] = value;
87+
}
88+
89+
try {
90+
const response = await client.request({
91+
path: requestPath,
92+
method,
93+
headers,
94+
body: method !== "GET" && method !== "HEAD" ? req : undefined,
95+
});
96+
res.writeHead(response.status, response.headers);
97+
if (response.body) response.body.pipe(res); else res.end();
98+
} catch (err) {
99+
if (!res.headersSent) res.writeHead(500);
100+
res.end(String(err?.message ?? err));
101+
}
102+
}
103+
104+
#handleUpgrade(req, socket, head) {
105+
const sessionId = this.#sessionId(req);
106+
const client = sessionId ? this.#sessions.get(sessionId)?.() : null;
107+
if (!client) { socket.destroy(); return; }
108+
109+
const host = (req.headers.host ?? "").split(":")[0];
110+
const requestHeaders = {
111+
":method": "CONNECT",
112+
":protocol": "websocket",
113+
":scheme": "http",
114+
":authority": host,
115+
":path": req.url ?? "/",
116+
};
117+
if (req.headers["sec-websocket-protocol"]) {
118+
requestHeaders["sec-websocket-protocol"] = req.headers["sec-websocket-protocol"];
119+
}
120+
121+
let stream;
122+
try {
123+
stream = client.start().request(requestHeaders);
124+
} catch { socket.destroy(); return; }
125+
126+
stream.once("response", (responseHeaders) => {
127+
if (Number(responseHeaders[":status"]) !== 200) {
128+
socket.destroy(); stream.destroy(); return;
129+
}
130+
131+
const key = req.headers["sec-websocket-key"] ?? "";
132+
const accept = crypto.createHash("sha1")
133+
.update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")
134+
.digest("base64");
135+
const selectedProtocol = responseHeaders["sec-websocket-protocol"];
136+
137+
socket.write(
138+
"HTTP/1.1 101 Switching Protocols\r\n" +
139+
"Upgrade: websocket\r\n" +
140+
"Connection: Upgrade\r\n" +
141+
`Sec-WebSocket-Accept: ${accept}\r\n` +
142+
(selectedProtocol ? `Sec-WebSocket-Protocol: ${selectedProtocol}\r\n` : "") +
143+
"\r\n",
144+
);
145+
146+
// Pipe WebSocket frames transparently — no framing needed here.
147+
if (head.length > 0) stream.write(head);
148+
socket.pipe(stream);
149+
stream.pipe(socket);
150+
});
151+
152+
stream.on("error", () => socket.destroy());
153+
socket.on("error", () => stream.destroy());
154+
}
155+
}

Chimera/SessionController.js

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,14 @@ export class SessionController {
106106

107107
this.#attachSessionListeners();
108108

109+
// Register this session with the shared surface server so Chromium can
110+
// reach it via HTTP and WebSocket at http://session-N.htty/.
111+
this.application.surfaceServer.register(
112+
this.id,
113+
() => this.client,
114+
(path, req, res) => this.#handleWellKnown(path, req, res),
115+
);
116+
109117
this.trace("createSession:done", {sessionId: this.id});
110118
}
111119

@@ -442,7 +450,23 @@ export class SessionController {
442450
this.delegate.sessionControllerDidRequestConfigurationRefresh?.(this, surface);
443451
}
444452

445-
surfaceControllerDidChange(surface) {
453+
// Handle Chimera-internal .well-known/chimera/ routes served by the Unix
454+
// socket server. Returns true if the route was handled.
455+
#handleWellKnown(routePath, _req, res) {
456+
if (routePath === "/.well-known/chimera/bookmarks/refresh") {
457+
this.delegate.sessionControllerDidRequestBookmarksRefresh?.(this, null);
458+
res.writeHead(204);
459+
res.end();
460+
return true;
461+
}
462+
if (routePath === "/.well-known/chimera/configuration/refresh") {
463+
this.delegate.sessionControllerDidRequestConfigurationRefresh?.(this, null);
464+
res.writeHead(204);
465+
res.end();
466+
return true;
467+
}
468+
return false;
469+
} surfaceControllerDidChange(surface) {
446470
this.delegate.sessionControllerDidUpdateSurface(this, surface);
447471
}
448472

@@ -510,5 +534,7 @@ export class SessionController {
510534

511535
this.browserDocumentRequests.clear();
512536
this.session?.close();
537+
538+
this.application.surfaceServer.unregister(this.id);
513539
}
514540
}

Chimera/SurfaceController.js

Lines changed: 0 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,6 @@
11
import {WebContentsView} from "electron";
22

33
import {normalizeRequestPath, parseSurfaceURL, toSurfaceURL} from "./Utilities.js";
4-
import {WellKnownController} from "./WellKnownController.js";
5-
6-
function surfaceErrorResponse(status, message) {
7-
return new Response(message, {
8-
status,
9-
headers: {
10-
"content-type": "text/plain; charset=utf-8",
11-
},
12-
});
13-
}
144

155
export class SurfaceController {
166
static async create(delegate, options) {
@@ -31,7 +21,6 @@ export class SurfaceController {
3121
this.focused = false;
3222
this.bounds = null;
3323
this.view = null;
34-
this.wellKnownController = new WellKnownController(this);
3524
}
3625

3726
async initialize() {
@@ -50,7 +39,6 @@ export class SurfaceController {
5039

5140
this.view.setVisible(false);
5241
this.view.webContents.setWindowOpenHandler(() => ({action: "deny"}));
53-
await this.view.webContents.session.protocol.handle("htty", (request) => this.handleSurfaceRequest(request));
5442
this.view.webContents.on("did-navigate", (_event, url) => {
5543
const {sessionId, requestPath} = parseSurfaceURL(url);
5644
if (sessionId === this.sessionId) {
@@ -85,39 +73,6 @@ export class SurfaceController {
8573
};
8674
}
8775

88-
async handleSurfaceRequest(request) {
89-
// Electron delivers every embedded WebContentsView request for the htty:// protocol here. The URL host selects the owning Chimera session; the path is either a Chimera-owned .well-known route or an application request forwarded over the session's HTTY client. SessionController handles the application response shape, including document navigation bookkeeping and subresource pass-through, then returns a Fetch Response for Electron to load in the isolated web view.
90-
const {sessionId, requestPath} = parseSurfaceURL(request.url);
91-
if (sessionId !== this.sessionId) {
92-
return surfaceErrorResponse(403, "Cross-session HTTY navigation is not supported.");
93-
}
94-
95-
if (!this.sessionController.client) {
96-
return surfaceErrorResponse(410, "HTTY session is no longer available.");
97-
}
98-
99-
try {
100-
// Chimera-owned .well-known routes are handled by the host before the request reaches the HTTY application.
101-
const wellKnownResponse = this.wellKnownController.handleRequest({
102-
path: requestPath,
103-
request,
104-
});
105-
if (wellKnownResponse) {
106-
return wellKnownResponse;
107-
}
108-
109-
// All other requests are application traffic and are forwarded through the session's HTTY client.
110-
return await this.sessionController.handleRequest({
111-
surface: this,
112-
path: requestPath,
113-
request,
114-
});
115-
} catch (error) {
116-
this.sessionController.handleSurfaceRequestError(error);
117-
return surfaceErrorResponse(500, error.message);
118-
}
119-
}
120-
12176
async load(requestPath = this.requestPath) {
12277
const nextPath = normalizeRequestPath(requestPath);
12378
const targetURL = toSurfaceURL(this.sessionId, nextPath);
@@ -161,14 +116,6 @@ export class SurfaceController {
161116
return true;
162117
}
163118

164-
wellKnownControllerDidRequestBookmarksRefresh() {
165-
this.delegate.surfaceControllerDidRequestBookmarksRefresh?.(this);
166-
}
167-
168-
wellKnownControllerDidRequestConfigurationRefresh() {
169-
this.delegate.surfaceControllerDidRequestConfigurationRefresh?.(this);
170-
}
171-
172119
close() {
173120
this.visible = false;
174121
this.focused = false;

Chimera/Utilities.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,20 @@ export function normalizeRequestPath(value = "/") {
3535
return `/${text}`;
3636
}
3737

38+
export const SURFACE_HOST_SUFFIX = ".htty";
39+
3840
export function toSurfaceURL(sessionId, requestPath = "/") {
39-
return `htty://${sessionId}${normalizeRequestPath(requestPath)}`;
41+
return `http://${sessionId}${SURFACE_HOST_SUFFIX}${normalizeRequestPath(requestPath)}`;
4042
}
4143

4244
export function parseSurfaceURL(urlString) {
4345
const url = new URL(urlString);
46+
const host = url.host;
47+
const sessionId = host.endsWith(SURFACE_HOST_SUFFIX)
48+
? host.slice(0, -SURFACE_HOST_SUFFIX.length)
49+
: host;
4450
return {
45-
sessionId: url.host,
51+
sessionId,
4652
requestPath: normalizeRequestPath(`${url.pathname || "/"}${url.search}`),
4753
};
4854
}

0 commit comments

Comments
 (0)