overmux 0.0.1 → 0.0.2
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/CHANGELOG.md +16 -0
- package/README.md +7 -0
- package/dist/auth/.vite/manifest.json +11 -0
- package/dist/auth/assets/index-Bx8JhwVi.css +1 -0
- package/dist/auth/assets/index-CHIE7qYj.js +72 -0
- package/dist/auth/index.html +14 -0
- package/dist/bin.js +4504 -7870
- package/dist/bin.js.map +1 -0
- package/dist/docs/000-index.md +21 -0
- package/dist/docs/100-introduction/100-what-is-overmux.md +7 -0
- package/dist/docs/100-introduction/200-how-overmux-works.md +7 -0
- package/dist/docs/100-introduction/300-why-overmux.md +22 -0
- package/dist/docs/200-getting-started/100-install-and-run-overmux.md +51 -0
- package/dist/docs/200-getting-started/200-install-overmux-desktop.md +41 -0
- package/dist/docs/200-getting-started/300-install-overmux-pwa.mdx +92 -0
- package/dist/docs/200-getting-started/400-secure-with-https/100-choose-an-https-setup.md +14 -0
- package/dist/docs/200-getting-started/400-secure-with-https/200-tailscale-serve.md +44 -0
- package/dist/docs/200-getting-started/400-secure-with-https/300-cloudflare-tunnel.md +59 -0
- package/dist/docs/200-getting-started/400-secure-with-https/400-self-hosted-reverse-proxy.md +32 -0
- package/dist/docs/300-fundamentals/100-project-structure.md +3 -0
- package/dist/docs/300-fundamentals/200-configuration.md +3 -0
- package/dist/docs/300-fundamentals/300-theming.md +3 -0
- package/dist/docs/300-fundamentals/400-server.md +3 -0
- package/dist/docs/300-fundamentals/500-client.md +3 -0
- package/dist/docs/300-fundamentals/600-operations.md +3 -0
- package/dist/docs/300-fundamentals/700-resources.md +3 -0
- package/dist/docs/300-fundamentals/800-streams.md +3 -0
- package/dist/docs/300-fundamentals/900-authentication-and-security.md +3 -0
- package/dist/docs/400-reference/100-configuration.md +3 -0
- package/dist/docs/400-reference/200-server-api.md +3 -0
- package/dist/docs/400-reference/300-client-api.md +3 -0
- package/dist/docs/400-reference/400-cli/100-serve.md +33 -0
- package/dist/docs/400-reference/400-cli/200-auth.md +52 -0
- package/dist/docs/400-reference/400-cli/300-call.md +28 -0
- package/dist/docs/400-reference/400-cli/400-check.md +20 -0
- package/dist/docs/400-reference/400-cli/500-ai-context.md +102 -0
- package/dist/docs/400-reference/400-cli/600-docs.md +23 -0
- package/dist/docs/400-reference/400-cli/700-desktop.md +35 -0
- package/dist/docs/500-hosted-pages.md +31 -0
- package/dist/exports/client.d.ts +237 -0
- package/dist/exports/client.d.ts.map +1 -0
- package/dist/exports/client.js +1643 -0
- package/dist/exports/client.js.map +1 -0
- package/dist/exports/index-D3_Dpy_T.d.ts +155 -0
- package/dist/exports/index-D3_Dpy_T.d.ts.map +1 -0
- package/dist/exports/index.d.ts +2 -0
- package/dist/exports/index.js +49 -0
- package/dist/exports/index.js.map +1 -0
- package/dist/exports/notifications-kDp16bU_.js +56 -0
- package/dist/exports/notifications-kDp16bU_.js.map +1 -0
- package/dist/exports/server.d.ts +63 -0
- package/dist/exports/server.d.ts.map +1 -0
- package/dist/exports/server.js +1120 -0
- package/dist/exports/server.js.map +1 -0
- package/dist/exports/style.css +368 -0
- package/dist/internal/server/coordinator/server-child.d.ts +1 -0
- package/dist/internal/server/coordinator/server-child.js +3299 -0
- package/dist/internal/server/coordinator/server-child.js.map +1 -0
- package/docs/000-index.md +21 -0
- package/docs/100-introduction/100-what-is-overmux.md +7 -0
- package/docs/100-introduction/200-how-overmux-works.md +7 -0
- package/docs/100-introduction/300-why-overmux.md +22 -0
- package/docs/200-getting-started/100-install-and-run-overmux.md +51 -0
- package/docs/200-getting-started/200-install-overmux-desktop.md +41 -0
- package/docs/200-getting-started/300-install-overmux-pwa.mdx +92 -0
- package/docs/200-getting-started/400-secure-with-https/100-choose-an-https-setup.md +14 -0
- package/docs/200-getting-started/400-secure-with-https/200-tailscale-serve.md +44 -0
- package/docs/200-getting-started/400-secure-with-https/300-cloudflare-tunnel.md +59 -0
- package/docs/200-getting-started/400-secure-with-https/400-self-hosted-reverse-proxy.md +32 -0
- package/docs/300-fundamentals/100-project-structure.md +3 -0
- package/docs/300-fundamentals/200-configuration.md +3 -0
- package/docs/300-fundamentals/300-theming.md +3 -0
- package/docs/300-fundamentals/400-server.md +3 -0
- package/docs/300-fundamentals/500-client.md +3 -0
- package/docs/300-fundamentals/600-operations.md +3 -0
- package/docs/300-fundamentals/700-resources.md +3 -0
- package/docs/300-fundamentals/800-streams.md +3 -0
- package/docs/300-fundamentals/900-authentication-and-security.md +3 -0
- package/docs/400-reference/100-configuration.md +3 -0
- package/docs/400-reference/200-server-api.md +3 -0
- package/docs/400-reference/300-client-api.md +3 -0
- package/docs/400-reference/400-cli/100-serve.md +33 -0
- package/docs/400-reference/400-cli/200-auth.md +52 -0
- package/docs/400-reference/400-cli/300-call.md +28 -0
- package/docs/400-reference/400-cli/400-check.md +20 -0
- package/docs/400-reference/400-cli/500-ai-context.md +102 -0
- package/docs/400-reference/400-cli/600-docs.md +23 -0
- package/docs/400-reference/400-cli/700-desktop.md +35 -0
- package/docs/500-hosted-pages.md +31 -0
- package/package.json +59 -18
- package/src/internal/cli/app.ts +72 -0
- package/src/internal/cli/bin.ts +3 -0
- package/src/internal/cli/commands/ai.ts +44 -0
- package/src/internal/cli/commands/auth.ts +195 -0
- package/src/internal/cli/commands/call.ts +128 -0
- package/src/internal/cli/commands/check.ts +48 -0
- package/src/internal/cli/commands/desktop-installer.ts +446 -0
- package/src/internal/cli/commands/desktop-release.ts +190 -0
- package/src/internal/cli/commands/desktop.ts +47 -0
- package/src/internal/cli/commands/docs.ts +34 -0
- package/src/internal/cli/commands/serve.ts +322 -0
- package/src/internal/cli/environment.ts +54 -0
- package/src/internal/cli/login.ts +46 -0
- package/src/internal/cli/managed-vite.ts +267 -0
- package/src/internal/cli/parse-port.ts +8 -0
- package/src/internal/client/auth/auth-api.ts +53 -0
- package/src/internal/client/auth/auth-shell.css +74 -0
- package/src/internal/client/auth/auth-shell.tsx +167 -0
- package/src/internal/client/auth/entry.tsx +26 -0
- package/src/internal/client/auth/index.html +13 -0
- package/src/internal/client/background-notifications.ts +194 -0
- package/src/internal/client/browser-api.ts +97 -0
- package/src/internal/client/client-definition.ts +184 -0
- package/src/internal/client/commands.tsx +260 -0
- package/src/internal/client/host/browser-log-forwarding.ts +149 -0
- package/src/internal/client/host/hosted-pages/browser-notification-settings.tsx +118 -0
- package/src/internal/client/host/hosted-pages/hosted-page.css +87 -0
- package/src/internal/client/host/hosted-pages/hosted-page.tsx +9 -0
- package/src/internal/client/host/hosted-pages/logout-page.tsx +75 -0
- package/src/internal/client/host/hosted-pages/settings-page.tsx +45 -0
- package/src/internal/client/host/notification-forwarding.ts +22 -0
- package/src/internal/client/host/overmux-host.tsx +200 -0
- package/src/internal/client/host/recovery-screen.css +75 -0
- package/src/internal/client/host/recovery-screen.tsx +17 -0
- package/src/internal/client/host/update-popover.css +82 -0
- package/src/internal/client/host/update-popover.tsx +166 -0
- package/src/internal/client/index.ts +45 -0
- package/src/internal/client/overmux-react.ts +283 -0
- package/src/internal/client/shortcuts.tsx +373 -0
- package/src/internal/client/theme-scope.css +114 -0
- package/src/internal/client/theme-scope.tsx +107 -0
- package/src/internal/client/transport.ts +69 -0
- package/src/internal/client/websocket.ts +441 -0
- package/src/internal/server/auth/auth-http.ts +414 -0
- package/src/internal/server/auth/auth-service.ts +627 -0
- package/src/internal/server/auth/auth-shell-http.ts +90 -0
- package/src/internal/server/auth/instance-control.ts +381 -0
- package/src/internal/server/check-overmux.ts +364 -0
- package/src/internal/server/coordinator/ipc-protocol.ts +44 -0
- package/src/internal/server/coordinator/server-child.ts +108 -0
- package/src/internal/server/coordinator/server-coordinator.ts +262 -0
- package/src/internal/server/coordinator/update-watcher.ts +54 -0
- package/src/internal/server/http/authenticated-http-app-test-helper.ts +45 -0
- package/src/internal/server/http/background-notification-handlers.ts +83 -0
- package/src/internal/server/http/control-handlers.ts +44 -0
- package/src/internal/server/http/create-http-app.ts +53 -0
- package/src/internal/server/http/development-web-backend.ts +83 -0
- package/src/internal/server/http/operation-handler.ts +93 -0
- package/src/internal/server/http/request-logging.ts +34 -0
- package/src/internal/server/http/routes.ts +241 -0
- package/src/internal/server/http/runtime-manifest-handler.ts +12 -0
- package/src/internal/server/index.ts +16 -0
- package/src/internal/server/notifications/background-notification-payload.ts +74 -0
- package/src/internal/server/notifications/background-notification-service.ts +139 -0
- package/src/internal/server/notifications/background-notification-store.ts +162 -0
- package/src/internal/server/notifications/notification-service.ts +70 -0
- package/src/internal/server/notifications/private-json-file.ts +84 -0
- package/src/internal/server/paths.ts +13 -0
- package/src/internal/server/rate-limiter.ts +58 -0
- package/src/internal/server/runtime/create-runtime.ts +87 -0
- package/src/internal/server/runtime/resource-dependency-graph.ts +128 -0
- package/src/internal/server/runtime/runtime-lifecycle.ts +95 -0
- package/src/internal/server/runtime/runtime-operations.ts +78 -0
- package/src/internal/server/runtime/runtime-resources.ts +201 -0
- package/src/internal/server/runtime/runtime-streams.ts +130 -0
- package/src/internal/server/server-logger.ts +110 -0
- package/src/internal/server/server-startup-options.ts +51 -0
- package/src/internal/server/start-application-server.ts +412 -0
- package/src/internal/server/start-overmux-server.ts +26 -0
- package/src/internal/server/test-port.ts +23 -0
- package/src/internal/server/vite-websocket-proxy.ts +201 -0
- package/src/internal/server/websocket-connection.ts +586 -0
- package/src/internal/server/websocket-limits.ts +2 -0
- package/src/internal/server/websocket-server.ts +311 -0
- package/src/internal/shared/background-notifications.ts +37 -0
- package/src/internal/shared/index.ts +2 -0
- package/src/internal/shared/protocol.ts +317 -0
- package/src/internal/shared/routes.ts +3 -0
- package/src/public/ai-context.ts +44 -0
- package/src/public/client.ts +53 -0
- package/src/public/config.ts +287 -0
- package/src/public/contracts.ts +55 -0
- package/src/public/index.ts +57 -0
- package/src/public/notifications.ts +10 -0
- package/src/public/server.ts +16 -0
- package/dist/public/core.ts +0 -119
- package/dist/public/protocol.ts +0 -51
- package/dist/public/terminal.ts +0 -23
- package/dist/web/assets/index-DC___IUw.css +0 -2
- package/dist/web/assets/index-DF9Cptse.js +0 -74
- package/dist/web/assets/terminal-component-BrP-ENHg.css +0 -1
- package/dist/web/assets/terminal-component-DyLEbMrx.js +0 -36
- package/dist/web/index.html +0 -14
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import type { ServerType as NodeServer } from "@hono/node-server";
|
|
2
|
+
import {
|
|
3
|
+
encodeProtocolFrame,
|
|
4
|
+
notificationEventSchema,
|
|
5
|
+
serverProtocolMessageSchema,
|
|
6
|
+
webSocketCloseCode,
|
|
7
|
+
type ServerProtocolMessage,
|
|
8
|
+
} from "../shared/index";
|
|
9
|
+
import type { IncomingMessage } from "node:http";
|
|
10
|
+
import type { Duplex } from "node:stream";
|
|
11
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
12
|
+
|
|
13
|
+
import { runtimeWebSocketPath, viteHmrPath } from "../shared/routes";
|
|
14
|
+
import { attachWebSocketConnection } from "./websocket-connection";
|
|
15
|
+
import type { AuthBoundary, RequestSession } from "./auth/auth-http";
|
|
16
|
+
import {
|
|
17
|
+
errorDetails as diagnosticErrorDetails,
|
|
18
|
+
type ServerLogger,
|
|
19
|
+
} from "./server-logger";
|
|
20
|
+
import type { Runtime } from "./runtime/create-runtime";
|
|
21
|
+
import { createViteWebSocketProxy } from "./vite-websocket-proxy";
|
|
22
|
+
import { maxWebSocketBufferedBytes } from "./websocket-limits";
|
|
23
|
+
|
|
24
|
+
const maxWebSocketPayloadBytes = 1_048_576;
|
|
25
|
+
|
|
26
|
+
// Upgrade requests bypass Hono routing, so rejected paths need a raw HTTP response.
|
|
27
|
+
const rejectUpgrade = (socket: Duplex, status: number, message: string) => {
|
|
28
|
+
socket.end(
|
|
29
|
+
`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`,
|
|
30
|
+
);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const requestPath = (request: IncomingMessage) => {
|
|
34
|
+
try {
|
|
35
|
+
return new URL(request.url ?? "", "http://localhost").pathname;
|
|
36
|
+
} catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const requestAuthenticationInput = (request: IncomingMessage) => {
|
|
42
|
+
const host = request.headers.host;
|
|
43
|
+
if (!host) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const headers = new Headers();
|
|
47
|
+
Object.entries(request.headers).forEach(([name, value]) => {
|
|
48
|
+
if (Array.isArray(value)) {
|
|
49
|
+
value.forEach((entry) => headers.append(name, entry));
|
|
50
|
+
} else if (value !== undefined) {
|
|
51
|
+
headers.set(name, value);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
const encrypted = Boolean(
|
|
55
|
+
(request.socket as typeof request.socket & { encrypted?: boolean })
|
|
56
|
+
.encrypted,
|
|
57
|
+
);
|
|
58
|
+
try {
|
|
59
|
+
return new Request(
|
|
60
|
+
`${encrypted ? "https" : "http"}://${host}${request.url ?? "/"}`,
|
|
61
|
+
{ headers },
|
|
62
|
+
);
|
|
63
|
+
} catch {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Tie a WebSocket to its session, periodically revalidating it and cleaning up
|
|
69
|
+
// the session mapping when the connection closes.
|
|
70
|
+
export const attachSessionLifecycle = ({
|
|
71
|
+
auth,
|
|
72
|
+
serverLogger,
|
|
73
|
+
session,
|
|
74
|
+
sessionSockets,
|
|
75
|
+
socket,
|
|
76
|
+
}: {
|
|
77
|
+
auth: AuthBoundary;
|
|
78
|
+
serverLogger?: ServerLogger;
|
|
79
|
+
session: RequestSession;
|
|
80
|
+
sessionSockets: Map<WebSocket, string>;
|
|
81
|
+
socket: WebSocket;
|
|
82
|
+
}) => {
|
|
83
|
+
sessionSockets.set(socket, session.id);
|
|
84
|
+
let revalidationPending = false;
|
|
85
|
+
const revalidationTimer = setInterval(() => {
|
|
86
|
+
if (revalidationPending) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
revalidationPending = true;
|
|
90
|
+
void auth.service
|
|
91
|
+
.authenticateToken(session.token, session.origin)
|
|
92
|
+
.then(
|
|
93
|
+
(authenticated) => {
|
|
94
|
+
if (!authenticated) {
|
|
95
|
+
socket.close(
|
|
96
|
+
webSocketCloseCode.authenticationRevoked,
|
|
97
|
+
"authentication revoked",
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
(cause: unknown) => {
|
|
102
|
+
serverLogger?.log({
|
|
103
|
+
details: diagnosticErrorDetails(cause),
|
|
104
|
+
event: "websocket-session-revalidation-failure",
|
|
105
|
+
level: "error",
|
|
106
|
+
});
|
|
107
|
+
socket.close(
|
|
108
|
+
webSocketCloseCode.authenticationUnavailable,
|
|
109
|
+
"authentication unavailable",
|
|
110
|
+
);
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
.finally(() => {
|
|
114
|
+
revalidationPending = false;
|
|
115
|
+
});
|
|
116
|
+
}, 1_000);
|
|
117
|
+
socket.once("close", () => {
|
|
118
|
+
clearInterval(revalidationTimer);
|
|
119
|
+
sessionSockets.delete(socket);
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// WebSocket upgrades bypass Hono but share its credential parser. Browser
|
|
124
|
+
// sessions additionally require the exact canonical Origin; local bearer clients
|
|
125
|
+
// are not browser-origin credentials. Each socket remains tied to its session so
|
|
126
|
+
// explicit revocation closes it immediately, while periodic revalidation covers
|
|
127
|
+
// expiration and changes made by another server process.
|
|
128
|
+
export const createWebSocketServer = ({
|
|
129
|
+
auth,
|
|
130
|
+
developmentWebTarget,
|
|
131
|
+
runtime,
|
|
132
|
+
server,
|
|
133
|
+
serverLogger,
|
|
134
|
+
}: {
|
|
135
|
+
auth: AuthBoundary;
|
|
136
|
+
developmentWebTarget?: string;
|
|
137
|
+
runtime: Runtime;
|
|
138
|
+
server: NodeServer;
|
|
139
|
+
serverLogger?: ServerLogger;
|
|
140
|
+
}) => {
|
|
141
|
+
const webSocketServer = new WebSocketServer({
|
|
142
|
+
maxPayload: maxWebSocketPayloadBytes,
|
|
143
|
+
noServer: true,
|
|
144
|
+
});
|
|
145
|
+
let updateAvailable = false;
|
|
146
|
+
const sessionSockets = new Map<WebSocket, string>();
|
|
147
|
+
const viteProxy = developmentWebTarget
|
|
148
|
+
? createViteWebSocketProxy({
|
|
149
|
+
auth,
|
|
150
|
+
serverLogger,
|
|
151
|
+
target: developmentWebTarget,
|
|
152
|
+
})
|
|
153
|
+
: undefined;
|
|
154
|
+
const removeRevocationListener = auth.service.onSessionsRevoked((ids) => {
|
|
155
|
+
sessionSockets.forEach((sessionId, socket) => {
|
|
156
|
+
if (ids.has(sessionId)) {
|
|
157
|
+
socket.close(
|
|
158
|
+
webSocketCloseCode.authenticationRevoked,
|
|
159
|
+
"authentication revoked",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
viteProxy?.revoke(ids);
|
|
164
|
+
});
|
|
165
|
+
const onUpgrade = async (
|
|
166
|
+
request: IncomingMessage,
|
|
167
|
+
socket: Duplex,
|
|
168
|
+
head: Buffer,
|
|
169
|
+
) => {
|
|
170
|
+
const correlationId = serverLogger?.id();
|
|
171
|
+
serverLogger?.log({ correlationId, event: "websocket-connect" });
|
|
172
|
+
const path = requestPath(request);
|
|
173
|
+
const isRuntimeSocket = path === runtimeWebSocketPath;
|
|
174
|
+
const isViteSocket =
|
|
175
|
+
path === viteHmrPath && developmentWebTarget !== undefined;
|
|
176
|
+
if (!isRuntimeSocket && !isViteSocket) {
|
|
177
|
+
serverLogger?.log({
|
|
178
|
+
correlationId,
|
|
179
|
+
details: { status: 404 },
|
|
180
|
+
event: "websocket-connect-rejected",
|
|
181
|
+
level: "warn",
|
|
182
|
+
});
|
|
183
|
+
rejectUpgrade(socket, 404, "Not Found");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const authenticationInput = requestAuthenticationInput(request);
|
|
187
|
+
if (!authenticationInput) {
|
|
188
|
+
rejectUpgrade(socket, 400, "Bad Request");
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const origin = auth.resolveOrigin({
|
|
192
|
+
remoteAddress: request.socket.remoteAddress,
|
|
193
|
+
request: authenticationInput,
|
|
194
|
+
});
|
|
195
|
+
let session: RequestSession | undefined;
|
|
196
|
+
try {
|
|
197
|
+
session = await auth.requests.authenticate(authenticationInput, origin);
|
|
198
|
+
} catch (cause) {
|
|
199
|
+
serverLogger?.log({
|
|
200
|
+
correlationId,
|
|
201
|
+
details: diagnosticErrorDetails(cause),
|
|
202
|
+
event: "websocket-authentication-failure",
|
|
203
|
+
level: "error",
|
|
204
|
+
});
|
|
205
|
+
rejectUpgrade(socket, 503, "Service Unavailable");
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (!session) {
|
|
209
|
+
rejectUpgrade(socket, 401, "Unauthorized");
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (
|
|
213
|
+
session.kind === "browser" &&
|
|
214
|
+
(!origin || request.headers.origin !== origin)
|
|
215
|
+
) {
|
|
216
|
+
rejectUpgrade(socket, 403, "Forbidden");
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (isViteSocket) {
|
|
220
|
+
if (
|
|
221
|
+
session.kind !== "browser" ||
|
|
222
|
+
session.credential !== "cookie" ||
|
|
223
|
+
!origin
|
|
224
|
+
) {
|
|
225
|
+
rejectUpgrade(socket, 401, "Unauthorized");
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (!viteProxy) {
|
|
229
|
+
rejectUpgrade(socket, 503, "Service Unavailable");
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
viteProxy.connect({
|
|
233
|
+
correlationId,
|
|
234
|
+
head,
|
|
235
|
+
origin,
|
|
236
|
+
request,
|
|
237
|
+
session,
|
|
238
|
+
socket,
|
|
239
|
+
});
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
webSocketServer.handleUpgrade(request, socket, head, (webSocket) => {
|
|
243
|
+
attachSessionLifecycle({
|
|
244
|
+
auth,
|
|
245
|
+
serverLogger,
|
|
246
|
+
session,
|
|
247
|
+
sessionSockets,
|
|
248
|
+
socket: webSocket,
|
|
249
|
+
});
|
|
250
|
+
webSocketServer.emit("connection", webSocket, request);
|
|
251
|
+
attachWebSocketConnection({ runtime, serverLogger, socket: webSocket });
|
|
252
|
+
if (updateAvailable) {
|
|
253
|
+
webSocket.send(encodeProtocolFrame({ type: "update-available" }));
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
};
|
|
257
|
+
server.on("upgrade", onUpgrade);
|
|
258
|
+
|
|
259
|
+
const broadcast = (message: ServerProtocolMessage) => {
|
|
260
|
+
const frame = encodeProtocolFrame(
|
|
261
|
+
serverProtocolMessageSchema.parse(message),
|
|
262
|
+
);
|
|
263
|
+
let delivered = 0;
|
|
264
|
+
webSocketServer.clients.forEach((socket) => {
|
|
265
|
+
if (socket.readyState !== WebSocket.OPEN) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
// Apply outbound backpressure by dropping slow clients before queued broadcasts consume unbounded memory.
|
|
269
|
+
if (socket.bufferedAmount > maxWebSocketBufferedBytes) {
|
|
270
|
+
socket.close(1013, "client could not keep up");
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
socket.send(frame);
|
|
275
|
+
delivered += 1;
|
|
276
|
+
} catch (cause) {
|
|
277
|
+
socket.terminate();
|
|
278
|
+
serverLogger?.log({
|
|
279
|
+
details: diagnosticErrorDetails(cause),
|
|
280
|
+
event: "websocket-broadcast-failure",
|
|
281
|
+
level: "error",
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
return delivered;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
announceRestarting: () => broadcast({ type: "restarting" }),
|
|
290
|
+
announceUpdateAvailable: () => {
|
|
291
|
+
updateAvailable = true;
|
|
292
|
+
return broadcast({ type: "update-available" });
|
|
293
|
+
},
|
|
294
|
+
broadcastNotification: (notification: unknown) =>
|
|
295
|
+
broadcast(
|
|
296
|
+
notificationEventSchema.parse({
|
|
297
|
+
notification,
|
|
298
|
+
type: "notification",
|
|
299
|
+
}),
|
|
300
|
+
),
|
|
301
|
+
close: async () => {
|
|
302
|
+
server.off("upgrade", onUpgrade);
|
|
303
|
+
removeRevocationListener();
|
|
304
|
+
viteProxy?.close();
|
|
305
|
+
webSocketServer.clients.forEach((socket) => socket.terminate());
|
|
306
|
+
await new Promise<void>((resolve) =>
|
|
307
|
+
webSocketServer.close(() => resolve()),
|
|
308
|
+
);
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Validates browser subscription data exchanged by background-notification delivery.
|
|
2
|
+
// Browser-standard key and endpoint details stay behind this internal boundary.
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
const deliveryKeySchema = z
|
|
6
|
+
.string()
|
|
7
|
+
.min(1)
|
|
8
|
+
.max(1_024)
|
|
9
|
+
.regex(/^[\w-]+$/u);
|
|
10
|
+
|
|
11
|
+
export const backgroundNotificationSubscriptionSchema = z
|
|
12
|
+
.object({
|
|
13
|
+
endpoint: z
|
|
14
|
+
.url()
|
|
15
|
+
.max(4_096)
|
|
16
|
+
.refine((endpoint) => {
|
|
17
|
+
const url = new URL(endpoint);
|
|
18
|
+
return url.protocol === "https:" && !url.username && !url.password;
|
|
19
|
+
}),
|
|
20
|
+
expirationTime: z.number().int().nonnegative().nullable(),
|
|
21
|
+
keys: z
|
|
22
|
+
.object({ auth: deliveryKeySchema, p256dh: deliveryKeySchema })
|
|
23
|
+
.strict(),
|
|
24
|
+
})
|
|
25
|
+
.strict();
|
|
26
|
+
|
|
27
|
+
export const backgroundNotificationPublicKeyResponseSchema = z
|
|
28
|
+
.object({ publicKey: deliveryKeySchema })
|
|
29
|
+
.strict();
|
|
30
|
+
|
|
31
|
+
export const backgroundNotificationDisableRequestSchema = z
|
|
32
|
+
.object({ endpoint: backgroundNotificationSubscriptionSchema.shape.endpoint })
|
|
33
|
+
.strict();
|
|
34
|
+
|
|
35
|
+
export type BackgroundNotificationSubscription = z.infer<
|
|
36
|
+
typeof backgroundNotificationSubscriptionSchema
|
|
37
|
+
>;
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import { notificationSchema } from "../../public/notifications";
|
|
4
|
+
|
|
5
|
+
// Compatibility gate, not negotiation: open tabs may still run older client assets.
|
|
6
|
+
// The exact version rejects incompatible runtime manifest and wire formats before later traffic.
|
|
7
|
+
export const protocolVersion = 9 as const;
|
|
8
|
+
|
|
9
|
+
// Application-defined WebSocket close codes shared by server and browser transports.
|
|
10
|
+
export const webSocketCloseCode = {
|
|
11
|
+
authenticationRevoked: 4001,
|
|
12
|
+
authenticationUnavailable: 4002,
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
export const runtimeManifestSchema = z.object({
|
|
16
|
+
debug: z.boolean(),
|
|
17
|
+
operations: z.array(z.string().min(1)),
|
|
18
|
+
protocolVersion: z.literal(protocolVersion),
|
|
19
|
+
resources: z.array(z.string().min(1)),
|
|
20
|
+
streams: z.array(z.string().min(1)),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const updateAvailableEventSchema = z.object({
|
|
24
|
+
type: z.literal("update-available"),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const restartingEventSchema = z.object({
|
|
28
|
+
type: z.literal("restarting"),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const serverLifecycleEventSchema = z.discriminatedUnion("type", [
|
|
32
|
+
updateAvailableEventSchema,
|
|
33
|
+
restartingEventSchema,
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
export const notificationEventSchema = z
|
|
37
|
+
.object({
|
|
38
|
+
notification: notificationSchema,
|
|
39
|
+
type: z.literal("notification"),
|
|
40
|
+
})
|
|
41
|
+
.strict();
|
|
42
|
+
|
|
43
|
+
const operationIdSchema = z.string().min(1).max(256);
|
|
44
|
+
const registrationNameSchema = z.string().min(1).max(256);
|
|
45
|
+
|
|
46
|
+
export const resourceReadMessageSchema = z.object({
|
|
47
|
+
input: z.unknown().optional(),
|
|
48
|
+
operationId: operationIdSchema,
|
|
49
|
+
resourceName: registrationNameSchema,
|
|
50
|
+
type: z.literal("resource-read"),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export const resourceSubscribeMessageSchema = z.object({
|
|
54
|
+
input: z.unknown().optional(),
|
|
55
|
+
operationId: operationIdSchema,
|
|
56
|
+
resourceName: registrationNameSchema,
|
|
57
|
+
subscriptionId: operationIdSchema,
|
|
58
|
+
type: z.literal("resource-subscribe"),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export const resourceUnsubscribeMessageSchema = z.object({
|
|
62
|
+
subscriptionId: operationIdSchema,
|
|
63
|
+
type: z.literal("resource-unsubscribe"),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
export const streamOpenMessageSchema = z.object({
|
|
67
|
+
input: z.unknown().optional(),
|
|
68
|
+
operationId: operationIdSchema,
|
|
69
|
+
streamId: operationIdSchema,
|
|
70
|
+
streamName: registrationNameSchema,
|
|
71
|
+
type: z.literal("stream-open"),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
export const streamMessageSchema = z.object({
|
|
75
|
+
message: z.unknown(),
|
|
76
|
+
streamId: operationIdSchema,
|
|
77
|
+
type: z.literal("stream-message"),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export const streamCloseMessageSchema = z.object({
|
|
81
|
+
streamId: operationIdSchema,
|
|
82
|
+
type: z.literal("stream-close"),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
export const clientDiagnosticEventSchema = z.object({
|
|
86
|
+
arguments: z.string().max(16_100),
|
|
87
|
+
level: z.enum(["log", "info", "warn", "error", "debug"]),
|
|
88
|
+
message: z.string().max(4_100),
|
|
89
|
+
stack: z.string().max(8_100).optional(),
|
|
90
|
+
timestamp: z.iso.datetime(),
|
|
91
|
+
type: z.literal("client-diagnostic"),
|
|
92
|
+
url: z.string().max(2_100).optional(),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
export const clientProtocolMessageSchema = z.discriminatedUnion("type", [
|
|
96
|
+
resourceReadMessageSchema,
|
|
97
|
+
resourceSubscribeMessageSchema,
|
|
98
|
+
resourceUnsubscribeMessageSchema,
|
|
99
|
+
streamOpenMessageSchema,
|
|
100
|
+
streamMessageSchema,
|
|
101
|
+
streamCloseMessageSchema,
|
|
102
|
+
clientDiagnosticEventSchema,
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
export const resourceResultMessageSchema = z.object({
|
|
106
|
+
operationId: operationIdSchema,
|
|
107
|
+
output: z.unknown(),
|
|
108
|
+
type: z.literal("resource-result"),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
export const resourceInvalidatedMessageSchema = z.object({
|
|
112
|
+
subscriptionId: operationIdSchema,
|
|
113
|
+
type: z.literal("resource-invalidated"),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
export const streamOpenedMessageSchema = z.object({
|
|
117
|
+
operationId: operationIdSchema,
|
|
118
|
+
streamId: operationIdSchema,
|
|
119
|
+
type: z.literal("stream-opened"),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
export const streamOutputMessageSchema = z.object({
|
|
123
|
+
message: z.unknown(),
|
|
124
|
+
streamId: operationIdSchema,
|
|
125
|
+
type: z.literal("stream-output"),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
export const streamClosedMessageSchema = z.object({
|
|
129
|
+
streamId: operationIdSchema,
|
|
130
|
+
type: z.literal("stream-closed"),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
export const protocolErrorMessageSchema = z.object({
|
|
134
|
+
code: z.enum(["bad-request", "conflict", "internal", "not-found"]),
|
|
135
|
+
message: z.string(),
|
|
136
|
+
operationId: operationIdSchema.optional(),
|
|
137
|
+
streamId: operationIdSchema.optional(),
|
|
138
|
+
subscriptionId: operationIdSchema.optional(),
|
|
139
|
+
type: z.literal("error"),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
export const serverProtocolMessageSchema = z.discriminatedUnion("type", [
|
|
143
|
+
resourceResultMessageSchema,
|
|
144
|
+
resourceInvalidatedMessageSchema,
|
|
145
|
+
streamOpenedMessageSchema,
|
|
146
|
+
streamOutputMessageSchema,
|
|
147
|
+
streamClosedMessageSchema,
|
|
148
|
+
protocolErrorMessageSchema,
|
|
149
|
+
updateAvailableEventSchema,
|
|
150
|
+
restartingEventSchema,
|
|
151
|
+
notificationEventSchema,
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
const byteMarker = "$overmuxBytes";
|
|
155
|
+
|
|
156
|
+
type EncodedBytes = { [byteMarker]: number[] };
|
|
157
|
+
|
|
158
|
+
const encodeValue = (value: unknown): unknown => {
|
|
159
|
+
if (value instanceof Uint8Array) {
|
|
160
|
+
return { [byteMarker]: Array.from(value) } satisfies EncodedBytes;
|
|
161
|
+
}
|
|
162
|
+
if (Array.isArray(value)) {
|
|
163
|
+
return value.map(encodeValue);
|
|
164
|
+
}
|
|
165
|
+
if (typeof value === "object" && value !== null) {
|
|
166
|
+
return Object.fromEntries(
|
|
167
|
+
Object.entries(value).map(([key, item]) => [key, encodeValue(item)]),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return value;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const decodeValue = (value: unknown): unknown => {
|
|
174
|
+
if (Array.isArray(value)) {
|
|
175
|
+
return value.map(decodeValue);
|
|
176
|
+
}
|
|
177
|
+
if (typeof value !== "object" || value === null) {
|
|
178
|
+
return value;
|
|
179
|
+
}
|
|
180
|
+
const record = value as Record<string, unknown>;
|
|
181
|
+
if (
|
|
182
|
+
Object.keys(record).length === 1 &&
|
|
183
|
+
Array.isArray(record[byteMarker]) &&
|
|
184
|
+
record[byteMarker].every(
|
|
185
|
+
(item) => Number.isInteger(item) && item >= 0 && item <= 255,
|
|
186
|
+
)
|
|
187
|
+
) {
|
|
188
|
+
return Uint8Array.from(record[byteMarker] as number[]);
|
|
189
|
+
}
|
|
190
|
+
return Object.fromEntries(
|
|
191
|
+
Object.entries(record).map(([key, item]) => [key, decodeValue(item)]),
|
|
192
|
+
);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const binaryMarker = "$overmuxBinary";
|
|
196
|
+
const binaryMagic = 0x4f4d5801;
|
|
197
|
+
|
|
198
|
+
type EncodedBinary = { [binaryMarker]: [number, number] };
|
|
199
|
+
|
|
200
|
+
const encodeBinaryValue = (
|
|
201
|
+
value: unknown,
|
|
202
|
+
payloads: Uint8Array[],
|
|
203
|
+
offset: { value: number },
|
|
204
|
+
): unknown => {
|
|
205
|
+
if (value instanceof Uint8Array) {
|
|
206
|
+
const marker = {
|
|
207
|
+
[binaryMarker]: [offset.value, value.byteLength],
|
|
208
|
+
} satisfies EncodedBinary;
|
|
209
|
+
payloads.push(value);
|
|
210
|
+
offset.value += value.byteLength;
|
|
211
|
+
return marker;
|
|
212
|
+
}
|
|
213
|
+
if (Array.isArray(value)) {
|
|
214
|
+
return value.map((item) => encodeBinaryValue(item, payloads, offset));
|
|
215
|
+
}
|
|
216
|
+
if (typeof value === "object" && value !== null) {
|
|
217
|
+
return Object.fromEntries(
|
|
218
|
+
Object.entries(value).map(([key, item]) => [
|
|
219
|
+
key,
|
|
220
|
+
encodeBinaryValue(item, payloads, offset),
|
|
221
|
+
]),
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
return value;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const decodeBinaryValue = (value: unknown, payload: Uint8Array): unknown => {
|
|
228
|
+
if (Array.isArray(value)) {
|
|
229
|
+
return value.map((item) => decodeBinaryValue(item, payload));
|
|
230
|
+
}
|
|
231
|
+
if (typeof value !== "object" || value === null) {
|
|
232
|
+
return value;
|
|
233
|
+
}
|
|
234
|
+
const record = value as Record<string, unknown>;
|
|
235
|
+
const marker = record[binaryMarker];
|
|
236
|
+
if (
|
|
237
|
+
Object.keys(record).length === 1 &&
|
|
238
|
+
Array.isArray(marker) &&
|
|
239
|
+
marker.length === 2 &&
|
|
240
|
+
marker.every((item) => Number.isInteger(item) && item >= 0)
|
|
241
|
+
) {
|
|
242
|
+
const [offset, length] = marker as [number, number];
|
|
243
|
+
if (offset + length > payload.byteLength) {
|
|
244
|
+
throw new Error("Invalid Overmux binary payload range");
|
|
245
|
+
}
|
|
246
|
+
return payload.slice(offset, offset + length);
|
|
247
|
+
}
|
|
248
|
+
return Object.fromEntries(
|
|
249
|
+
Object.entries(record).map(([key, item]) => [
|
|
250
|
+
key,
|
|
251
|
+
decodeBinaryValue(item, payload),
|
|
252
|
+
]),
|
|
253
|
+
);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
export const encodeProtocolMessage = (message: unknown): string =>
|
|
257
|
+
JSON.stringify(encodeValue(message));
|
|
258
|
+
|
|
259
|
+
export const decodeProtocolMessage = (message: string): unknown =>
|
|
260
|
+
decodeValue(JSON.parse(message));
|
|
261
|
+
|
|
262
|
+
export const encodeProtocolFrame = (message: unknown): string | Uint8Array => {
|
|
263
|
+
const payloads: Uint8Array[] = [];
|
|
264
|
+
const encoded = encodeBinaryValue(message, payloads, { value: 0 });
|
|
265
|
+
if (!payloads.length) {
|
|
266
|
+
return JSON.stringify(encoded);
|
|
267
|
+
}
|
|
268
|
+
const header = new TextEncoder().encode(JSON.stringify(encoded));
|
|
269
|
+
const payloadLength = payloads.reduce(
|
|
270
|
+
(total, payload) => total + payload.byteLength,
|
|
271
|
+
0,
|
|
272
|
+
);
|
|
273
|
+
const frame = new Uint8Array(8 + header.byteLength + payloadLength);
|
|
274
|
+
const view = new DataView(frame.buffer);
|
|
275
|
+
view.setUint32(0, binaryMagic);
|
|
276
|
+
view.setUint32(4, header.byteLength);
|
|
277
|
+
frame.set(header, 8);
|
|
278
|
+
let offset = 8 + header.byteLength;
|
|
279
|
+
payloads.forEach((payload) => {
|
|
280
|
+
frame.set(payload, offset);
|
|
281
|
+
offset += payload.byteLength;
|
|
282
|
+
});
|
|
283
|
+
return frame;
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
export const decodeProtocolFrame = (
|
|
287
|
+
frame: string | ArrayBuffer | Uint8Array,
|
|
288
|
+
): unknown => {
|
|
289
|
+
if (typeof frame === "string") {
|
|
290
|
+
return decodeProtocolMessage(frame);
|
|
291
|
+
}
|
|
292
|
+
const bytes =
|
|
293
|
+
frame instanceof Uint8Array
|
|
294
|
+
? frame
|
|
295
|
+
: new Uint8Array(frame, 0, frame.byteLength);
|
|
296
|
+
if (bytes.byteLength < 8) {
|
|
297
|
+
throw new Error("Invalid Overmux binary frame");
|
|
298
|
+
}
|
|
299
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
300
|
+
if (view.getUint32(0) !== binaryMagic) {
|
|
301
|
+
throw new Error("Invalid Overmux binary frame magic");
|
|
302
|
+
}
|
|
303
|
+
const headerLength = view.getUint32(4);
|
|
304
|
+
if (8 + headerLength > bytes.byteLength) {
|
|
305
|
+
throw new Error("Invalid Overmux binary frame header");
|
|
306
|
+
}
|
|
307
|
+
const header = JSON.parse(
|
|
308
|
+
new TextDecoder().decode(bytes.subarray(8, 8 + headerLength)),
|
|
309
|
+
) as unknown;
|
|
310
|
+
return decodeBinaryValue(header, bytes.subarray(8 + headerLength));
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
export type RuntimeManifest = z.infer<typeof runtimeManifestSchema>;
|
|
314
|
+
export type ClientDiagnosticEvent = z.infer<typeof clientDiagnosticEventSchema>;
|
|
315
|
+
export type ClientProtocolMessage = z.infer<typeof clientProtocolMessageSchema>;
|
|
316
|
+
export type ServerLifecycleEvent = z.infer<typeof serverLifecycleEventSchema>;
|
|
317
|
+
export type ServerProtocolMessage = z.infer<typeof serverProtocolMessageSchema>;
|