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,3299 @@
|
|
|
1
|
+
import { serve } from "@hono/node-server";
|
|
2
|
+
import { chmod, link, mkdir, open, readFile, realpath, rename, rm, stat } from "node:fs/promises";
|
|
3
|
+
import { createServer, isIP } from "node:net";
|
|
4
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { createJiti } from "jiti";
|
|
6
|
+
import { ZodError, z } from "zod";
|
|
7
|
+
import { homedir, tmpdir } from "node:os";
|
|
8
|
+
import { once } from "node:events";
|
|
9
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
10
|
+
import { generateCookie } from "hono/cookie";
|
|
11
|
+
import { bodyLimit } from "hono/body-limit";
|
|
12
|
+
import { parse } from "hono/utils/cookie";
|
|
13
|
+
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { Hono } from "hono";
|
|
16
|
+
import { serveStatic } from "@hono/node-server/serve-static";
|
|
17
|
+
import { proxyFetch, proxyUpgrade } from "httpxy";
|
|
18
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
19
|
+
import webPush from "web-push";
|
|
20
|
+
import { isDeepStrictEqual } from "node:util";
|
|
21
|
+
//#region ../shared/dist/node/index.js
|
|
22
|
+
const durationPattern$1 = /^([1-9]\d*)(ms|s|m|h|d)$/;
|
|
23
|
+
const millisecondsByUnit$1 = {
|
|
24
|
+
d: 1440 * 60 * 1e3,
|
|
25
|
+
h: 3600 * 1e3,
|
|
26
|
+
m: 60 * 1e3,
|
|
27
|
+
ms: 1,
|
|
28
|
+
s: 1e3
|
|
29
|
+
};
|
|
30
|
+
const durationToMilliseconds$1 = (duration) => {
|
|
31
|
+
const match = durationPattern$1.exec(duration);
|
|
32
|
+
if (!match) return;
|
|
33
|
+
const unit = match[2];
|
|
34
|
+
const milliseconds = Number(match[1]) * millisecondsByUnit$1[unit];
|
|
35
|
+
return Number.isSafeInteger(milliseconds) ? milliseconds : void 0;
|
|
36
|
+
};
|
|
37
|
+
const aiContextSnippets = ["package-source", "tech-stack-recommendations"];
|
|
38
|
+
const zodTypeSchema = z.custom((value) => value instanceof z.ZodType, "Expected a Zod schema");
|
|
39
|
+
const functionSchema = z.custom((value) => typeof value === "function", "Expected a function");
|
|
40
|
+
const contractRuntimeSchema = z.object({
|
|
41
|
+
input: zodTypeSchema,
|
|
42
|
+
output: zodTypeSchema
|
|
43
|
+
});
|
|
44
|
+
const operationRuntimeSchema = z.object({
|
|
45
|
+
handle: functionSchema,
|
|
46
|
+
input: zodTypeSchema,
|
|
47
|
+
output: zodTypeSchema
|
|
48
|
+
});
|
|
49
|
+
const streamHandlerRuntimeSchema = z.object({
|
|
50
|
+
contract: z.object({
|
|
51
|
+
clientMessage: zodTypeSchema,
|
|
52
|
+
input: zodTypeSchema,
|
|
53
|
+
serverMessage: zodTypeSchema
|
|
54
|
+
}),
|
|
55
|
+
open: functionSchema
|
|
56
|
+
});
|
|
57
|
+
const resourceDefinitionRuntimeSchema = z.discriminatedUnion("kind", [
|
|
58
|
+
z.object({
|
|
59
|
+
contract: contractRuntimeSchema,
|
|
60
|
+
kind: z.literal("query"),
|
|
61
|
+
read: functionSchema
|
|
62
|
+
}),
|
|
63
|
+
z.object({
|
|
64
|
+
contract: contractRuntimeSchema,
|
|
65
|
+
kind: z.literal("subscription"),
|
|
66
|
+
read: functionSchema,
|
|
67
|
+
subscribe: functionSchema
|
|
68
|
+
}),
|
|
69
|
+
z.object({
|
|
70
|
+
combine: functionSchema,
|
|
71
|
+
contract: contractRuntimeSchema,
|
|
72
|
+
dependencies: z.record(z.string(), z.string()),
|
|
73
|
+
kind: z.literal("derived")
|
|
74
|
+
})
|
|
75
|
+
]);
|
|
76
|
+
const authDurationSchema = z.string().regex(/^[1-9]\d*[mhd]$/, "Expected a positive duration such as 30d").refine((value) => durationToMilliseconds$1(value) !== void 0, "Duration is too large");
|
|
77
|
+
const isLoopbackHostname$1 = (hostname) => hostname === "localhost" || hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(hostname);
|
|
78
|
+
const exactOriginSchema = z.url().refine((value) => new URL(value).origin === value, "Expected an origin").refine((value) => {
|
|
79
|
+
const url = new URL(value);
|
|
80
|
+
return url.protocol === "https:" || url.protocol === "http:" && isLoopbackHostname$1(url.hostname);
|
|
81
|
+
}, "Non-loopback Overmux origins must use HTTPS");
|
|
82
|
+
const loopbackPeerSchema = z.string().refine((value) => isIP(value) !== 0 && (value === "::1" || value.startsWith("127.")), "Expected a loopback IP address");
|
|
83
|
+
const authConfigRuntimeSchema = z.object({
|
|
84
|
+
mode: z.literal("cli-login"),
|
|
85
|
+
origins: z.array(exactOriginSchema).min(1).refine((origins) => new Set(origins).size === origins.length, "Expected unique origins").optional(),
|
|
86
|
+
sessionLifetime: z.union([z.literal("never"), authDurationSchema]).default("never"),
|
|
87
|
+
trustedProxyPeer: loopbackPeerSchema.optional()
|
|
88
|
+
}).strict();
|
|
89
|
+
const serverConfigRuntimeSchema = z.object({
|
|
90
|
+
host: z.string().min(1).optional(),
|
|
91
|
+
logFile: z.string().min(1).optional(),
|
|
92
|
+
port: z.number().int().min(1).max(65535).optional(),
|
|
93
|
+
productionWebAssetsDir: z.string().min(1).optional(),
|
|
94
|
+
watch: z.boolean().optional()
|
|
95
|
+
});
|
|
96
|
+
const serverDefinitionRuntimeSchema = z.object({
|
|
97
|
+
operations: z.record(z.string(), operationRuntimeSchema).optional(),
|
|
98
|
+
resources: z.record(z.string(), resourceDefinitionRuntimeSchema),
|
|
99
|
+
streams: z.record(z.string(), streamHandlerRuntimeSchema).optional()
|
|
100
|
+
});
|
|
101
|
+
const configDefinitionRuntimeSchema = serverConfigRuntimeSchema.extend({
|
|
102
|
+
aiContextSnippets: z.array(z.enum(aiContextSnippets)).optional(),
|
|
103
|
+
auth: authConfigRuntimeSchema,
|
|
104
|
+
debug: z.boolean().optional(),
|
|
105
|
+
server: serverDefinitionRuntimeSchema,
|
|
106
|
+
vite: z.string().min(1).optional()
|
|
107
|
+
});
|
|
108
|
+
const resolveLogicalPath = async (path) => {
|
|
109
|
+
const logicalPath = resolve(path);
|
|
110
|
+
return {
|
|
111
|
+
logicalPath,
|
|
112
|
+
realPath: await realpath(logicalPath)
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
const resolveOvermuxConfigPaths = async (configPath) => {
|
|
116
|
+
const { logicalPath, realPath } = await resolveLogicalPath(configPath);
|
|
117
|
+
return {
|
|
118
|
+
logicalApplicationRoot: dirname(logicalPath),
|
|
119
|
+
logicalConfigPath: logicalPath,
|
|
120
|
+
realApplicationRoot: dirname(realPath),
|
|
121
|
+
realConfigPath: realPath
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
const unwrapDefaultExport = (module) => {
|
|
125
|
+
if (typeof module === "object" && module !== null && "default" in module) return module.default;
|
|
126
|
+
return module;
|
|
127
|
+
};
|
|
128
|
+
const loadOvermuxConfig = async ({ aliases = {}, configPath }) => {
|
|
129
|
+
const paths = await resolveOvermuxConfigPaths(configPath);
|
|
130
|
+
const module = await createJiti(paths.logicalConfigPath, {
|
|
131
|
+
alias: aliases,
|
|
132
|
+
fsCache: true,
|
|
133
|
+
jsx: {
|
|
134
|
+
importSource: "react",
|
|
135
|
+
runtime: "automatic"
|
|
136
|
+
},
|
|
137
|
+
moduleCache: false,
|
|
138
|
+
sourceMaps: true,
|
|
139
|
+
tsconfigPaths: true
|
|
140
|
+
}).import(paths.logicalConfigPath);
|
|
141
|
+
return {
|
|
142
|
+
config: configDefinitionRuntimeSchema.parse(unwrapDefaultExport(module)),
|
|
143
|
+
paths
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
const xdgDirectory = (configured, fallback) => configured && isAbsolute(configured) ? configured : fallback;
|
|
147
|
+
const overmuxDirectory = (root) => join(root, "overmux");
|
|
148
|
+
const getOvermuxPaths = ({ environment = process.env, homeDir = homedir(), xdgCacheHome = environment.XDG_CACHE_HOME, xdgConfigHome = environment.XDG_CONFIG_HOME, xdgDataHome = environment.XDG_DATA_HOME, xdgRuntimeDir = environment.XDG_RUNTIME_DIR, xdgStateHome = environment.XDG_STATE_HOME } = {}) => {
|
|
149
|
+
if (!isAbsolute(homeDir)) throw new TypeError("The home directory must be absolute.");
|
|
150
|
+
const runtimeDir = xdgRuntimeDir && isAbsolute(xdgRuntimeDir) ? overmuxDirectory(xdgRuntimeDir) : void 0;
|
|
151
|
+
return {
|
|
152
|
+
cacheDir: overmuxDirectory(xdgDirectory(xdgCacheHome, join(homeDir, ".cache"))),
|
|
153
|
+
configDir: overmuxDirectory(xdgDirectory(xdgConfigHome, join(homeDir, ".config"))),
|
|
154
|
+
dataDir: overmuxDirectory(xdgDirectory(xdgDataHome, join(homeDir, ".local", "share"))),
|
|
155
|
+
...runtimeDir ? { runtimeDir } : {},
|
|
156
|
+
stateDir: overmuxDirectory(xdgDirectory(xdgStateHome, join(homeDir, ".local", "state")))
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
//#endregion
|
|
160
|
+
//#region ../lib/dist/index.js
|
|
161
|
+
const durationPattern = /^([1-9]\d*)(ms|s|m|h|d)$/;
|
|
162
|
+
const millisecondsByUnit = {
|
|
163
|
+
d: 1440 * 60 * 1e3,
|
|
164
|
+
h: 3600 * 1e3,
|
|
165
|
+
m: 60 * 1e3,
|
|
166
|
+
ms: 1,
|
|
167
|
+
s: 1e3
|
|
168
|
+
};
|
|
169
|
+
const durationToMilliseconds = (duration) => {
|
|
170
|
+
const match = durationPattern.exec(duration);
|
|
171
|
+
if (!match) return;
|
|
172
|
+
const unit = match[2];
|
|
173
|
+
const milliseconds = Number(match[1]) * millisecondsByUnit[unit];
|
|
174
|
+
return Number.isSafeInteger(milliseconds) ? milliseconds : void 0;
|
|
175
|
+
};
|
|
176
|
+
//#endregion
|
|
177
|
+
//#region src/internal/server/notifications/private-json-file.ts
|
|
178
|
+
const missingFile = (cause) => cause.code === "ENOENT";
|
|
179
|
+
const preparePrivateDirectory = async (path) => {
|
|
180
|
+
await mkdir(path, {
|
|
181
|
+
mode: 448,
|
|
182
|
+
recursive: true
|
|
183
|
+
});
|
|
184
|
+
await chmod(path, 448);
|
|
185
|
+
};
|
|
186
|
+
const writeTemporaryPrivateJson = async (path, value) => {
|
|
187
|
+
await preparePrivateDirectory(dirname(path));
|
|
188
|
+
const temporaryPath = `${path}.${process.pid}.${randomUUID()}`;
|
|
189
|
+
try {
|
|
190
|
+
const handle = await open(temporaryPath, "wx", 384);
|
|
191
|
+
try {
|
|
192
|
+
await handle.writeFile(`${JSON.stringify(value)}\n`);
|
|
193
|
+
await handle.sync();
|
|
194
|
+
} finally {
|
|
195
|
+
await handle.close();
|
|
196
|
+
}
|
|
197
|
+
return temporaryPath;
|
|
198
|
+
} catch (cause) {
|
|
199
|
+
await rm(temporaryPath, { force: true });
|
|
200
|
+
throw cause;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
const readPrivateJson = async (path, schema) => {
|
|
204
|
+
try {
|
|
205
|
+
const contents = await readFile(path, "utf8");
|
|
206
|
+
await chmod(path, 384);
|
|
207
|
+
return schema.parse(JSON.parse(contents));
|
|
208
|
+
} catch (cause) {
|
|
209
|
+
if (missingFile(cause)) return;
|
|
210
|
+
throw cause;
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
const replacePrivateJsonAtomically = async (path, value) => {
|
|
214
|
+
const temporaryPath = await writeTemporaryPrivateJson(path, value);
|
|
215
|
+
try {
|
|
216
|
+
await rename(temporaryPath, path);
|
|
217
|
+
} catch (cause) {
|
|
218
|
+
await rm(temporaryPath, { force: true });
|
|
219
|
+
throw cause;
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
const createPrivateJsonExclusively = async (path, value) => {
|
|
223
|
+
const temporaryPath = await writeTemporaryPrivateJson(path, value);
|
|
224
|
+
try {
|
|
225
|
+
await link(temporaryPath, path);
|
|
226
|
+
} finally {
|
|
227
|
+
await rm(temporaryPath, { force: true });
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
//#endregion
|
|
231
|
+
//#region src/internal/server/auth/auth-service.ts
|
|
232
|
+
const loginGrantLifetimeMs = 10 * 6e4;
|
|
233
|
+
const localBearerLifetimeMs = 6e4;
|
|
234
|
+
const maxCodeAttempts = 8;
|
|
235
|
+
const lastSeenWriteIntervalMs = 6e4;
|
|
236
|
+
const codeAlphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
|
|
237
|
+
const loginGrantSchema$1 = z.object({
|
|
238
|
+
codeHash: z.string(),
|
|
239
|
+
codeLookupHash: z.string(),
|
|
240
|
+
consumedAt: z.string().optional(),
|
|
241
|
+
createdAt: z.string(),
|
|
242
|
+
expiresAt: z.string(),
|
|
243
|
+
failedCodeAttempts: z.number().int().nonnegative(),
|
|
244
|
+
id: z.string(),
|
|
245
|
+
linkTokenHash: z.string()
|
|
246
|
+
});
|
|
247
|
+
const authSessionSchema = z.object({
|
|
248
|
+
createdAt: z.string(),
|
|
249
|
+
expiresAt: z.string().optional(),
|
|
250
|
+
id: z.string(),
|
|
251
|
+
kind: z.enum(["browser", "local"]),
|
|
252
|
+
lastSeenAt: z.string(),
|
|
253
|
+
origin: z.string().optional(),
|
|
254
|
+
revokedAt: z.string().optional(),
|
|
255
|
+
tokenHash: z.string()
|
|
256
|
+
});
|
|
257
|
+
const authStoreSchema = z.object({
|
|
258
|
+
grants: z.array(loginGrantSchema$1),
|
|
259
|
+
installationId: z.string(),
|
|
260
|
+
sessions: z.array(authSessionSchema),
|
|
261
|
+
version: z.literal(1)
|
|
262
|
+
});
|
|
263
|
+
const tokenHash = (token) => createHash("sha256").update(token, "utf8").digest("hex");
|
|
264
|
+
const hashesEqual = (left, right) => {
|
|
265
|
+
const leftBytes = Buffer.from(left, "hex");
|
|
266
|
+
const rightBytes = Buffer.from(right, "hex");
|
|
267
|
+
return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes);
|
|
268
|
+
};
|
|
269
|
+
const randomToken = () => randomBytes(32).toString("base64url");
|
|
270
|
+
const randomCode = () => {
|
|
271
|
+
const bytes = randomBytes(8);
|
|
272
|
+
const characters = Array.from(bytes, (byte) => codeAlphabet[byte % 32]);
|
|
273
|
+
return `${characters.slice(0, 4).join("")}-${characters.slice(4).join("")}`;
|
|
274
|
+
};
|
|
275
|
+
const durationMilliseconds = (duration) => {
|
|
276
|
+
const milliseconds = durationToMilliseconds(authDurationSchema.parse(duration));
|
|
277
|
+
if (milliseconds === void 0) throw new Error("Expected a validated authentication duration");
|
|
278
|
+
return milliseconds;
|
|
279
|
+
};
|
|
280
|
+
const acquireStoreLock = async (path, deadline = Date.now() + 5e3) => {
|
|
281
|
+
await mkdir(dirname(path), {
|
|
282
|
+
mode: 448,
|
|
283
|
+
recursive: true
|
|
284
|
+
});
|
|
285
|
+
await chmod(dirname(path), 448);
|
|
286
|
+
try {
|
|
287
|
+
await (await open(path, "wx", 384)).close();
|
|
288
|
+
return () => rm(path, { force: true });
|
|
289
|
+
} catch (cause) {
|
|
290
|
+
if (cause.code !== "EEXIST") throw cause;
|
|
291
|
+
let lockAge;
|
|
292
|
+
try {
|
|
293
|
+
lockAge = Date.now() - (await stat(path)).mtimeMs;
|
|
294
|
+
} catch (statCause) {
|
|
295
|
+
if (statCause.code === "ENOENT") return acquireStoreLock(path, deadline);
|
|
296
|
+
throw statCause;
|
|
297
|
+
}
|
|
298
|
+
if (lockAge > 3e4) {
|
|
299
|
+
await rm(path, { force: true });
|
|
300
|
+
return acquireStoreLock(path, deadline);
|
|
301
|
+
}
|
|
302
|
+
if (Date.now() >= deadline) throw new Error("Timed out waiting for the authentication store lock");
|
|
303
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
304
|
+
return acquireStoreLock(path, deadline);
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
const dataDirectory = ({ environment, homeDirectory }) => join(getOvermuxPaths({
|
|
308
|
+
environment,
|
|
309
|
+
homeDir: homeDirectory
|
|
310
|
+
}).dataDir, "auth");
|
|
311
|
+
const publicSession = ({ kind: _kind, tokenHash: _tokenHash, ...session }) => session;
|
|
312
|
+
const activeSession = (session, now) => !session.revokedAt && (session.kind === "local" || Boolean(session.origin)) && (!session.expiresAt || Date.parse(session.expiresAt) > now);
|
|
313
|
+
const revokeExpired = (store, currentTime) => {
|
|
314
|
+
const expiredIds = [];
|
|
315
|
+
return {
|
|
316
|
+
expiredIds,
|
|
317
|
+
sessions: store.sessions.map((session) => {
|
|
318
|
+
if (!session.revokedAt && session.expiresAt && Date.parse(session.expiresAt) <= currentTime) {
|
|
319
|
+
expiredIds.push(session.id);
|
|
320
|
+
return {
|
|
321
|
+
...session,
|
|
322
|
+
revokedAt: new Date(currentTime).toISOString()
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
return session;
|
|
326
|
+
})
|
|
327
|
+
};
|
|
328
|
+
};
|
|
329
|
+
const authenticateStoredToken = ({ currentTime, store, origin, token }) => {
|
|
330
|
+
const { expiredIds, sessions } = revokeExpired(store, currentTime);
|
|
331
|
+
const expectedHash = tokenHash(token);
|
|
332
|
+
const session = sessions.find((candidate) => hashesEqual(candidate.tokenHash, expectedHash));
|
|
333
|
+
const authenticated = session && activeSession(session, currentTime) && (session.kind === "local" || session.origin === origin) ? {
|
|
334
|
+
...session.expiresAt ? { expiresAt: session.expiresAt } : {},
|
|
335
|
+
id: session.id,
|
|
336
|
+
kind: session.kind,
|
|
337
|
+
...session.origin ? { origin: session.origin } : {},
|
|
338
|
+
token
|
|
339
|
+
} : void 0;
|
|
340
|
+
const updateLastSeen = Boolean(session && authenticated && currentTime - Date.parse(session.lastSeenAt) >= lastSeenWriteIntervalMs);
|
|
341
|
+
return {
|
|
342
|
+
authenticated,
|
|
343
|
+
expiredIds,
|
|
344
|
+
nextSessions: updateLastSeen ? sessions.map((candidate) => candidate.id === session?.id ? {
|
|
345
|
+
...candidate,
|
|
346
|
+
lastSeenAt: new Date(currentTime).toISOString()
|
|
347
|
+
} : candidate) : sessions,
|
|
348
|
+
updateLastSeen
|
|
349
|
+
};
|
|
350
|
+
};
|
|
351
|
+
const createAuthService = ({ config, environment = process.env, homeDirectory = homedir(), now = Date.now }) => {
|
|
352
|
+
const directory = dataDirectory({
|
|
353
|
+
environment,
|
|
354
|
+
homeDirectory
|
|
355
|
+
});
|
|
356
|
+
const storePath = join(directory, "auth.json");
|
|
357
|
+
const lockPath = join(directory, "auth.lock");
|
|
358
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
359
|
+
let origins = config.origins;
|
|
360
|
+
let pendingWrite = Promise.resolve();
|
|
361
|
+
const readStore = async () => {
|
|
362
|
+
const existing = await readPrivateJson(storePath, authStoreSchema);
|
|
363
|
+
if (existing) return existing;
|
|
364
|
+
const initial = {
|
|
365
|
+
grants: [],
|
|
366
|
+
installationId: randomUUID(),
|
|
367
|
+
sessions: [],
|
|
368
|
+
version: 1
|
|
369
|
+
};
|
|
370
|
+
try {
|
|
371
|
+
await createPrivateJsonExclusively(storePath, initial);
|
|
372
|
+
return initial;
|
|
373
|
+
} catch (cause) {
|
|
374
|
+
if (cause.code !== "EEXIST") throw cause;
|
|
375
|
+
const concurrentlyCreated = await readPrivateJson(storePath, authStoreSchema);
|
|
376
|
+
if (!concurrentlyCreated) throw cause;
|
|
377
|
+
return concurrentlyCreated;
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
const updateStore = (update) => {
|
|
381
|
+
const operation = pendingWrite.then(async () => {
|
|
382
|
+
const release = await acquireStoreLock(lockPath);
|
|
383
|
+
try {
|
|
384
|
+
const updated = update(await readStore());
|
|
385
|
+
if (updated.store) await replacePrivateJsonAtomically(storePath, updated.store);
|
|
386
|
+
emitRevoked(updated.revokedIds ?? []);
|
|
387
|
+
return updated.result;
|
|
388
|
+
} finally {
|
|
389
|
+
await release();
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
pendingWrite = operation.then(() => void 0, () => void 0);
|
|
393
|
+
return operation;
|
|
394
|
+
};
|
|
395
|
+
const emitRevoked = (ids) => {
|
|
396
|
+
if (ids.length === 0) return;
|
|
397
|
+
const revoked = new Set(ids);
|
|
398
|
+
listeners.forEach((listener) => listener(revoked));
|
|
399
|
+
};
|
|
400
|
+
const createSession = ({ kind, lifetimeMs, origin }) => {
|
|
401
|
+
const token = randomToken();
|
|
402
|
+
const createdAt = new Date(now()).toISOString();
|
|
403
|
+
const configuredLifetime = config.sessionLifetime && config.sessionLifetime !== "never" ? durationMilliseconds(config.sessionLifetime) : void 0;
|
|
404
|
+
const expiresIn = lifetimeMs ?? configuredLifetime;
|
|
405
|
+
return {
|
|
406
|
+
stored: {
|
|
407
|
+
createdAt,
|
|
408
|
+
...expiresIn ? { expiresAt: new Date(now() + expiresIn).toISOString() } : {},
|
|
409
|
+
id: randomUUID(),
|
|
410
|
+
kind,
|
|
411
|
+
lastSeenAt: createdAt,
|
|
412
|
+
...origin ? { origin } : {},
|
|
413
|
+
tokenHash: tokenHash(token)
|
|
414
|
+
},
|
|
415
|
+
token
|
|
416
|
+
};
|
|
417
|
+
};
|
|
418
|
+
const authenticateToken = async (token, origin) => {
|
|
419
|
+
await pendingWrite;
|
|
420
|
+
const currentTime = now();
|
|
421
|
+
const inspected = authenticateStoredToken({
|
|
422
|
+
currentTime,
|
|
423
|
+
origin,
|
|
424
|
+
store: await readStore(),
|
|
425
|
+
token
|
|
426
|
+
});
|
|
427
|
+
if (inspected.expiredIds.length === 0 && !inspected.updateLastSeen) return inspected.authenticated;
|
|
428
|
+
return updateStore((store) => {
|
|
429
|
+
const current = authenticateStoredToken({
|
|
430
|
+
currentTime: now(),
|
|
431
|
+
origin,
|
|
432
|
+
store,
|
|
433
|
+
token
|
|
434
|
+
});
|
|
435
|
+
return {
|
|
436
|
+
result: current.authenticated,
|
|
437
|
+
revokedIds: current.expiredIds,
|
|
438
|
+
store: {
|
|
439
|
+
...store,
|
|
440
|
+
sessions: current.nextSessions
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
});
|
|
444
|
+
};
|
|
445
|
+
return {
|
|
446
|
+
authenticateToken,
|
|
447
|
+
consumeLoginGrant: (input) => updateStore((store) => {
|
|
448
|
+
const currentTime = now();
|
|
449
|
+
const presented = input.code?.trim().toUpperCase() ?? input.ticket;
|
|
450
|
+
if (!presented || Boolean(input.code) === Boolean(input.ticket)) return { result: { status: "invalid" } };
|
|
451
|
+
const expectedHash = tokenHash(presented);
|
|
452
|
+
const codeLookupHash = input.code ? tokenHash(presented.split("-", 1)[0] ?? "") : void 0;
|
|
453
|
+
const exactCodeIndex = store.grants.findIndex((grant) => hashesEqual(grant.codeHash, expectedHash));
|
|
454
|
+
const codeIndex = exactCodeIndex >= 0 ? exactCodeIndex : store.grants.findIndex((grant) => hashesEqual(grant.codeLookupHash, codeLookupHash ?? ""));
|
|
455
|
+
const index = input.code ? codeIndex : store.grants.findIndex((grant) => hashesEqual(grant.linkTokenHash, expectedHash));
|
|
456
|
+
if (index < 0) return { result: { status: "invalid" } };
|
|
457
|
+
const grant = store.grants[index];
|
|
458
|
+
if (grant.consumedAt) return { result: { status: "invalid" } };
|
|
459
|
+
if (Date.parse(grant.expiresAt) <= currentTime) return { result: { status: "expired" } };
|
|
460
|
+
if (input.code && grant.failedCodeAttempts >= maxCodeAttempts) return { result: { status: "rate-limited" } };
|
|
461
|
+
if (input.code && !hashesEqual(grant.codeHash, expectedHash)) {
|
|
462
|
+
const grants = store.grants.map((candidate, grantIndex) => grantIndex === index ? {
|
|
463
|
+
...candidate,
|
|
464
|
+
failedCodeAttempts: candidate.failedCodeAttempts + 1
|
|
465
|
+
} : candidate);
|
|
466
|
+
return {
|
|
467
|
+
result: { status: "invalid" },
|
|
468
|
+
store: {
|
|
469
|
+
...store,
|
|
470
|
+
grants
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
const { stored, token } = createSession({
|
|
475
|
+
kind: "browser",
|
|
476
|
+
origin: input.origin
|
|
477
|
+
});
|
|
478
|
+
const grants = store.grants.map((candidate, grantIndex) => grantIndex === index ? {
|
|
479
|
+
...candidate,
|
|
480
|
+
consumedAt: new Date(currentTime).toISOString()
|
|
481
|
+
} : candidate);
|
|
482
|
+
return {
|
|
483
|
+
result: {
|
|
484
|
+
session: publicSession(stored),
|
|
485
|
+
status: "authenticated",
|
|
486
|
+
token
|
|
487
|
+
},
|
|
488
|
+
store: {
|
|
489
|
+
...store,
|
|
490
|
+
grants,
|
|
491
|
+
sessions: [...store.sessions.filter((session) => activeSession(session, currentTime)), stored]
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
}),
|
|
495
|
+
createLoginGrant: () => updateStore((store) => {
|
|
496
|
+
if (!origins) throw new Error("The server has not established its browser origins");
|
|
497
|
+
const code = randomCode();
|
|
498
|
+
const ticket = randomToken();
|
|
499
|
+
const createdAt = new Date(now()).toISOString();
|
|
500
|
+
const expiresAt = new Date(now() + loginGrantLifetimeMs).toISOString();
|
|
501
|
+
const id = randomUUID();
|
|
502
|
+
const grant = {
|
|
503
|
+
codeHash: tokenHash(code),
|
|
504
|
+
codeLookupHash: tokenHash(code.split("-", 1)[0]),
|
|
505
|
+
createdAt,
|
|
506
|
+
expiresAt,
|
|
507
|
+
failedCodeAttempts: 0,
|
|
508
|
+
id,
|
|
509
|
+
linkTokenHash: tokenHash(ticket)
|
|
510
|
+
};
|
|
511
|
+
return {
|
|
512
|
+
result: {
|
|
513
|
+
code,
|
|
514
|
+
expiresAt,
|
|
515
|
+
id,
|
|
516
|
+
urls: origins.map((origin) => `${origin}/login#ticket=${encodeURIComponent(ticket)}`)
|
|
517
|
+
},
|
|
518
|
+
store: {
|
|
519
|
+
...store,
|
|
520
|
+
grants: [...store.grants.filter((candidate) => Date.parse(candidate.expiresAt) > now() && !candidate.consumedAt), grant]
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
}),
|
|
524
|
+
issueLocalBearer: () => updateStore((store) => {
|
|
525
|
+
const currentTime = now();
|
|
526
|
+
const { stored, token } = createSession({
|
|
527
|
+
kind: "local",
|
|
528
|
+
lifetimeMs: localBearerLifetimeMs
|
|
529
|
+
});
|
|
530
|
+
return {
|
|
531
|
+
result: {
|
|
532
|
+
expiresAt: stored.expiresAt,
|
|
533
|
+
token
|
|
534
|
+
},
|
|
535
|
+
store: {
|
|
536
|
+
...store,
|
|
537
|
+
sessions: [...store.sessions.filter((session) => activeSession(session, currentTime)), stored]
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
}),
|
|
541
|
+
isSessionActive: (id) => updateStore((store) => {
|
|
542
|
+
const currentTime = now();
|
|
543
|
+
const { expiredIds, sessions } = revokeExpired(store, currentTime);
|
|
544
|
+
return {
|
|
545
|
+
result: Boolean(sessions.find((session) => session.id === id && activeSession(session, currentTime))),
|
|
546
|
+
revokedIds: expiredIds,
|
|
547
|
+
...expiredIds.length > 0 ? { store: {
|
|
548
|
+
...store,
|
|
549
|
+
sessions
|
|
550
|
+
} } : {}
|
|
551
|
+
};
|
|
552
|
+
}),
|
|
553
|
+
listSessions: () => updateStore((store) => {
|
|
554
|
+
const currentTime = now();
|
|
555
|
+
const { expiredIds, sessions } = revokeExpired(store, currentTime);
|
|
556
|
+
return {
|
|
557
|
+
result: sessions.filter((session) => session.kind === "browser" && activeSession(session, currentTime)).map(publicSession),
|
|
558
|
+
revokedIds: expiredIds,
|
|
559
|
+
...expiredIds.length > 0 ? { store: {
|
|
560
|
+
...store,
|
|
561
|
+
sessions
|
|
562
|
+
} } : {}
|
|
563
|
+
};
|
|
564
|
+
}),
|
|
565
|
+
onSessionsRevoked: (listener) => {
|
|
566
|
+
listeners.add(listener);
|
|
567
|
+
return () => listeners.delete(listener);
|
|
568
|
+
},
|
|
569
|
+
revokeAllSessions: () => updateStore((store) => {
|
|
570
|
+
const currentTime = now();
|
|
571
|
+
const expired = revokeExpired(store, currentTime);
|
|
572
|
+
const ids = expired.sessions.filter((session) => session.kind === "browser" && activeSession(session, currentTime)).map((session) => session.id);
|
|
573
|
+
const revoked = new Set(ids);
|
|
574
|
+
const revokedAt = new Date(currentTime).toISOString();
|
|
575
|
+
const sessions = expired.sessions.map((session) => revoked.has(session.id) ? {
|
|
576
|
+
...session,
|
|
577
|
+
revokedAt
|
|
578
|
+
} : session);
|
|
579
|
+
const revokedIds = [...expired.expiredIds, ...ids];
|
|
580
|
+
return {
|
|
581
|
+
result: ids.length,
|
|
582
|
+
revokedIds,
|
|
583
|
+
...revokedIds.length > 0 ? { store: {
|
|
584
|
+
...store,
|
|
585
|
+
sessions
|
|
586
|
+
} } : {}
|
|
587
|
+
};
|
|
588
|
+
}),
|
|
589
|
+
revokeSession: (id) => updateStore((store) => {
|
|
590
|
+
if (!store.sessions.find((candidate) => candidate.id === id && !candidate.revokedAt)) return { result: false };
|
|
591
|
+
const revokedAt = new Date(now()).toISOString();
|
|
592
|
+
const sessions = store.sessions.map((candidate) => candidate.id === id ? {
|
|
593
|
+
...candidate,
|
|
594
|
+
revokedAt
|
|
595
|
+
} : candidate);
|
|
596
|
+
return {
|
|
597
|
+
result: true,
|
|
598
|
+
revokedIds: [id],
|
|
599
|
+
store: {
|
|
600
|
+
...store,
|
|
601
|
+
sessions
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
}),
|
|
605
|
+
setOrigins: (nextOrigins) => {
|
|
606
|
+
origins = nextOrigins.map((origin) => new URL(origin).origin);
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
};
|
|
610
|
+
//#endregion
|
|
611
|
+
//#region src/internal/server/rate-limiter.ts
|
|
612
|
+
const createRateLimiter = ({ maxAttemptsPerKey, maxGlobalAttempts, maxTrackedKeys, windowMs }) => ({
|
|
613
|
+
attemptsByKey: /* @__PURE__ */ new Map(),
|
|
614
|
+
globalAttempts: [],
|
|
615
|
+
maxAttemptsPerKey,
|
|
616
|
+
maxGlobalAttempts,
|
|
617
|
+
maxTrackedKeys,
|
|
618
|
+
windowMs
|
|
619
|
+
});
|
|
620
|
+
const hasRateLimitCapacity = ({ currentTime = Date.now(), key, limiter }) => {
|
|
621
|
+
const threshold = currentTime - limiter.windowMs;
|
|
622
|
+
const recent = (limiter.attemptsByKey.get(key) ?? []).filter((attempt) => attempt > threshold);
|
|
623
|
+
limiter.globalAttempts = limiter.globalAttempts.filter((attempt) => attempt > threshold);
|
|
624
|
+
if (recent.length >= limiter.maxAttemptsPerKey || limiter.globalAttempts.length >= limiter.maxGlobalAttempts) {
|
|
625
|
+
limiter.attemptsByKey.set(key, recent);
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
628
|
+
limiter.attemptsByKey.set(key, [...recent, currentTime]);
|
|
629
|
+
limiter.globalAttempts.push(currentTime);
|
|
630
|
+
if (limiter.attemptsByKey.size > limiter.maxTrackedKeys) limiter.attemptsByKey.clear();
|
|
631
|
+
return true;
|
|
632
|
+
};
|
|
633
|
+
//#endregion
|
|
634
|
+
//#region src/internal/server/auth/auth-shell-http.ts
|
|
635
|
+
const authEntry = "index.html";
|
|
636
|
+
const fingerprintedAssetPattern = /^assets\/[A-Za-z0-9_.-]+-[A-Za-z0-9_-]{8}\.(?:css|js)$/;
|
|
637
|
+
const defaultAuthAssetsDirectory = () => fileURLToPath(new URL("../auth/", import.meta.resolve("overmux")));
|
|
638
|
+
const loadAuthShell = (directory = defaultAuthAssetsDirectory()) => {
|
|
639
|
+
const manifest = JSON.parse(readFileSync(`${directory}/.vite/manifest.json`, "utf8"));
|
|
640
|
+
const entry = manifest[authEntry];
|
|
641
|
+
if (!entry?.file || !entry.css?.length) throw new Error("The packaged Overmux authentication assets are incomplete");
|
|
642
|
+
const paths = new Set(Object.values(manifest).flatMap(({ css = [], file }) => file ? [file, ...css] : css));
|
|
643
|
+
const invalidPath = [...paths].find((path) => !fingerprintedAssetPattern.test(path));
|
|
644
|
+
if (invalidPath) throw new Error(`Invalid Overmux authentication asset path: ${invalidPath}`);
|
|
645
|
+
return {
|
|
646
|
+
files: new Map([...paths].map((path) => [path, {
|
|
647
|
+
body: Uint8Array.from(readFileSync(`${directory}/${path}`)).buffer,
|
|
648
|
+
contentType: path.endsWith(".css") ? "text/css; charset=UTF-8" : "text/javascript; charset=UTF-8"
|
|
649
|
+
}])),
|
|
650
|
+
html: readFileSync(`${directory}/index.html`, "utf8")
|
|
651
|
+
};
|
|
652
|
+
};
|
|
653
|
+
const serveAuthShellAsset = (context, shell) => {
|
|
654
|
+
const asset = shell.files.get(context.req.path.slice(21));
|
|
655
|
+
if (!asset) return context.body(null, 404);
|
|
656
|
+
context.header("Cache-Control", "public, max-age=31536000, immutable");
|
|
657
|
+
context.header("Content-Type", asset.contentType);
|
|
658
|
+
context.header("Cross-Origin-Resource-Policy", "same-origin");
|
|
659
|
+
context.header("X-Content-Type-Options", "nosniff");
|
|
660
|
+
return context.body(asset.body);
|
|
661
|
+
};
|
|
662
|
+
const renderAuthShell = (context, shell, status = 200) => {
|
|
663
|
+
context.header("Cache-Control", "no-store");
|
|
664
|
+
context.header("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'");
|
|
665
|
+
context.header("Cross-Origin-Resource-Policy", "same-origin");
|
|
666
|
+
context.header("Referrer-Policy", "no-referrer");
|
|
667
|
+
context.header("X-Content-Type-Options", "nosniff");
|
|
668
|
+
return context.html(shell.html, status);
|
|
669
|
+
};
|
|
670
|
+
const isBrowserNavigation = (request) => request.headers.get("Sec-Fetch-Dest") === "document" || request.headers.get("Accept")?.includes("text/html");
|
|
671
|
+
//#endregion
|
|
672
|
+
//#region src/internal/server/auth/auth-http.ts
|
|
673
|
+
const maxLoginBodyBytes = 4096;
|
|
674
|
+
const loginWindowMs = 6e4;
|
|
675
|
+
const maxLoginRequestsPerWindow = 12;
|
|
676
|
+
const maxGlobalLoginRequestsPerWindow = 120;
|
|
677
|
+
const maxTrackedLoginAddresses = 1e4;
|
|
678
|
+
const neverCookieLifetimeSeconds = 400 * 24 * 60 * 60;
|
|
679
|
+
const sessionCookieName = "overmux_session";
|
|
680
|
+
const bearerToken = (header) => /^Bearer ([A-Za-z0-9_-]+)$/.exec(header ?? "")?.[1];
|
|
681
|
+
const isLoopbackHostname = (hostname) => hostname === "localhost" || hostname === "::1" || hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(hostname);
|
|
682
|
+
const validateAuthOrigin = (origin) => {
|
|
683
|
+
const url = new URL(origin);
|
|
684
|
+
if (url.origin !== origin) throw new Error("auth.origins must contain exact origins without paths");
|
|
685
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHostname(url.hostname))) throw new Error("Non-loopback Overmux origins must use HTTPS");
|
|
686
|
+
return origin;
|
|
687
|
+
};
|
|
688
|
+
const exactForwardedValue = (value) => value !== null && value === value.trim() && !value.includes(",") ? value : void 0;
|
|
689
|
+
const resolveRequestOrigin = ({ origins, remoteAddress, request, trustedProxyPeer }) => {
|
|
690
|
+
const requestUrl = new URL(request.url);
|
|
691
|
+
const forwardedHost = request.headers.get("X-Forwarded-Host");
|
|
692
|
+
const forwardedProto = request.headers.get("X-Forwarded-Proto");
|
|
693
|
+
if (trustedProxyPeer !== void 0 && remoteAddress === trustedProxyPeer && (forwardedHost !== null || forwardedProto !== null)) {
|
|
694
|
+
const host = exactForwardedValue(forwardedHost);
|
|
695
|
+
if (exactForwardedValue(forwardedProto) !== "https" || !host || requestUrl.protocol !== "http:" || requestUrl.host !== host) return;
|
|
696
|
+
const forwardedOrigin = new URL(`https://${host}`).origin;
|
|
697
|
+
return origins.includes(forwardedOrigin) ? forwardedOrigin : void 0;
|
|
698
|
+
}
|
|
699
|
+
return origins.includes(requestUrl.origin) ? requestUrl.origin : void 0;
|
|
700
|
+
};
|
|
701
|
+
const cookieMaxAge = (expiresAt) => Math.min(neverCookieLifetimeSeconds, expiresAt ? Math.max(0, Math.floor((Date.parse(expiresAt) - Date.now()) / 1e3)) : neverCookieLifetimeSeconds);
|
|
702
|
+
const generateSessionCookie = ({ origin, token, maxAge }) => generateCookie(sessionCookieName, token, {
|
|
703
|
+
httpOnly: true,
|
|
704
|
+
maxAge,
|
|
705
|
+
path: "/",
|
|
706
|
+
sameSite: "Strict",
|
|
707
|
+
...new URL(origin).protocol === "https:" ? { prefix: "host" } : {}
|
|
708
|
+
});
|
|
709
|
+
const createRequestAuthentication = ({ service }) => {
|
|
710
|
+
const cookieName = (origin) => new URL(origin).protocol === "https:" ? `__Host-${sessionCookieName}` : sessionCookieName;
|
|
711
|
+
const cookieHeader = (origin, token, expiresAt) => generateSessionCookie({
|
|
712
|
+
maxAge: cookieMaxAge(expiresAt),
|
|
713
|
+
origin,
|
|
714
|
+
token
|
|
715
|
+
});
|
|
716
|
+
return {
|
|
717
|
+
authenticate: async (request, origin) => {
|
|
718
|
+
const authorizationHeader = request.headers.get("Authorization");
|
|
719
|
+
if (authorizationHeader !== null) {
|
|
720
|
+
const token = bearerToken(authorizationHeader);
|
|
721
|
+
const authenticatedSession = token ? await service.authenticateToken(token) : void 0;
|
|
722
|
+
return authenticatedSession?.kind === "local" ? {
|
|
723
|
+
...authenticatedSession,
|
|
724
|
+
credential: "bearer"
|
|
725
|
+
} : void 0;
|
|
726
|
+
}
|
|
727
|
+
if (!origin) return;
|
|
728
|
+
const name = cookieName(origin);
|
|
729
|
+
const token = parse(request.headers.get("Cookie") ?? "", name)[name];
|
|
730
|
+
const authenticatedSession = token ? await service.authenticateToken(token, origin) : void 0;
|
|
731
|
+
return authenticatedSession?.kind === "browser" ? {
|
|
732
|
+
...authenticatedSession,
|
|
733
|
+
credential: "cookie"
|
|
734
|
+
} : void 0;
|
|
735
|
+
},
|
|
736
|
+
clearCookieHeader: (origin) => generateSessionCookie({
|
|
737
|
+
maxAge: 0,
|
|
738
|
+
origin,
|
|
739
|
+
token: ""
|
|
740
|
+
}),
|
|
741
|
+
cookieHeader
|
|
742
|
+
};
|
|
743
|
+
};
|
|
744
|
+
const createAuthBoundary = ({ origins, service, trustedProxyPeer }) => ({
|
|
745
|
+
requests: createRequestAuthentication({ service }),
|
|
746
|
+
resolveOrigin: (input) => resolveRequestOrigin({
|
|
747
|
+
...input,
|
|
748
|
+
origins: origins(),
|
|
749
|
+
trustedProxyPeer
|
|
750
|
+
}),
|
|
751
|
+
service
|
|
752
|
+
});
|
|
753
|
+
const loginInputSchema = z.union([z.object({ code: z.string() }).strict(), z.object({ ticket: z.string() }).strict()]);
|
|
754
|
+
const parseLoginBody = async (request) => {
|
|
755
|
+
if (request.headers.get("Content-Type")?.split(";", 1)[0]?.toLowerCase() !== "application/json") return;
|
|
756
|
+
try {
|
|
757
|
+
return loginInputSchema.safeParse(await request.json()).data;
|
|
758
|
+
} catch {
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
const hasTrustedBrowserHeaders = (request, origin) => request.headers.get("Origin") === origin && request.headers.get("Sec-Fetch-Site") === "same-origin";
|
|
763
|
+
const changesState = (request) => ![
|
|
764
|
+
"GET",
|
|
765
|
+
"HEAD",
|
|
766
|
+
"OPTIONS"
|
|
767
|
+
].includes(request.method);
|
|
768
|
+
const requestOrigin = (auth, context) => auth.resolveOrigin({
|
|
769
|
+
remoteAddress: context.env?.incoming?.socket.remoteAddress,
|
|
770
|
+
request: context.req.raw
|
|
771
|
+
});
|
|
772
|
+
const createHttpAuthenticationHandlers = ({ auth, authAssetsDirectory }) => {
|
|
773
|
+
const authShell = loadAuthShell(authAssetsDirectory);
|
|
774
|
+
const loginRateLimiter = createRateLimiter({
|
|
775
|
+
maxAttemptsPerKey: maxLoginRequestsPerWindow,
|
|
776
|
+
maxGlobalAttempts: maxGlobalLoginRequestsPerWindow,
|
|
777
|
+
maxTrackedKeys: maxTrackedLoginAddresses,
|
|
778
|
+
windowMs: loginWindowMs
|
|
779
|
+
});
|
|
780
|
+
return {
|
|
781
|
+
asset: (context) => serveAuthShellAsset(context, authShell),
|
|
782
|
+
state: async (context) => {
|
|
783
|
+
context.header("Cache-Control", "no-store");
|
|
784
|
+
const origin = requestOrigin(auth, context);
|
|
785
|
+
const session = await auth.requests.authenticate(context.req.raw, origin);
|
|
786
|
+
if (session?.credential === "cookie" && origin) context.header("Set-Cookie", auth.requests.cookieHeader(origin, session.token, session.expiresAt));
|
|
787
|
+
return context.json({ authenticated: Boolean(session) });
|
|
788
|
+
},
|
|
789
|
+
loginBodyLimitMiddleware: bodyLimit({
|
|
790
|
+
maxSize: maxLoginBodyBytes,
|
|
791
|
+
onError: (context) => context.json({ error: "Login request is too large" }, 413)
|
|
792
|
+
}),
|
|
793
|
+
login: async (context) => {
|
|
794
|
+
context.header("Cache-Control", "no-store");
|
|
795
|
+
const origin = requestOrigin(auth, context);
|
|
796
|
+
if (!origin || !hasTrustedBrowserHeaders(context.req.raw, origin)) return context.json({ error: "Login request origin was rejected" }, 403);
|
|
797
|
+
if (!hasRateLimitCapacity({
|
|
798
|
+
key: context.env?.incoming?.socket.remoteAddress ?? "unknown",
|
|
799
|
+
limiter: loginRateLimiter
|
|
800
|
+
})) return context.json({ status: "rate-limited" }, 429);
|
|
801
|
+
const input = await parseLoginBody(context.req.raw);
|
|
802
|
+
if (!input) return context.json({ error: "Invalid login request" }, 400);
|
|
803
|
+
const result = await auth.service.consumeLoginGrant({
|
|
804
|
+
...input,
|
|
805
|
+
origin
|
|
806
|
+
});
|
|
807
|
+
if (result.status !== "authenticated") return context.json({ status: result.status }, result.status === "rate-limited" ? 429 : 401);
|
|
808
|
+
context.header("Set-Cookie", auth.requests.cookieHeader(origin, result.token, result.session.expiresAt));
|
|
809
|
+
return context.json({ authenticated: true });
|
|
810
|
+
},
|
|
811
|
+
loginPage: async (context) => {
|
|
812
|
+
const origin = requestOrigin(auth, context);
|
|
813
|
+
if (await auth.requests.authenticate(context.req.raw, origin)) return context.redirect("/");
|
|
814
|
+
return renderAuthShell(context, authShell);
|
|
815
|
+
},
|
|
816
|
+
authenticate: async (context, next) => {
|
|
817
|
+
const origin = requestOrigin(auth, context);
|
|
818
|
+
let session;
|
|
819
|
+
try {
|
|
820
|
+
session = await auth.requests.authenticate(context.req.raw, origin);
|
|
821
|
+
} catch {
|
|
822
|
+
context.header("Cache-Control", "no-store");
|
|
823
|
+
return context.json({ error: "Authentication service unavailable" }, 503);
|
|
824
|
+
}
|
|
825
|
+
if (!session) {
|
|
826
|
+
if (!context.req.path.startsWith("/api/") && isBrowserNavigation(context.req.raw)) return renderAuthShell(context, authShell, 401);
|
|
827
|
+
context.header("Cache-Control", "no-store");
|
|
828
|
+
return context.json({ error: "Authentication required" }, 401);
|
|
829
|
+
}
|
|
830
|
+
context.set("session", session);
|
|
831
|
+
context.header("Cache-Control", "private, no-store");
|
|
832
|
+
if (session.credential === "cookie") {
|
|
833
|
+
if (!origin || changesState(context.req.raw) && !hasTrustedBrowserHeaders(context.req.raw, origin)) return context.json({ error: "Request origin was rejected" }, 403);
|
|
834
|
+
context.header("Set-Cookie", auth.requests.cookieHeader(origin, session.token, session.expiresAt));
|
|
835
|
+
}
|
|
836
|
+
await next();
|
|
837
|
+
},
|
|
838
|
+
logout: async (context) => {
|
|
839
|
+
const session = context.get("session");
|
|
840
|
+
await auth.service.revokeSession(session.id);
|
|
841
|
+
if (session.origin) context.header("Set-Cookie", auth.requests.clearCookieHeader(session.origin));
|
|
842
|
+
return context.json({ authenticated: false });
|
|
843
|
+
}
|
|
844
|
+
};
|
|
845
|
+
};
|
|
846
|
+
//#endregion
|
|
847
|
+
//#region src/internal/server/auth/instance-control.ts
|
|
848
|
+
const maxControlMessageBytes = 16384;
|
|
849
|
+
const controlRequestSchema = z.discriminatedUnion("type", [
|
|
850
|
+
z.object({ type: z.literal("ping") }).strict(),
|
|
851
|
+
z.object({ type: z.literal("create-login") }).strict(),
|
|
852
|
+
z.object({ type: z.literal("issue-bearer") }).strict(),
|
|
853
|
+
z.object({ type: z.literal("list-sessions") }).strict(),
|
|
854
|
+
z.object({
|
|
855
|
+
id: z.string(),
|
|
856
|
+
type: z.literal("revoke-session")
|
|
857
|
+
}).strict(),
|
|
858
|
+
z.object({ type: z.literal("revoke-all") }).strict()
|
|
859
|
+
]);
|
|
860
|
+
const loginGrantSchema = z.object({
|
|
861
|
+
code: z.string(),
|
|
862
|
+
expiresAt: z.string(),
|
|
863
|
+
id: z.string(),
|
|
864
|
+
urls: z.array(z.string())
|
|
865
|
+
}).strict();
|
|
866
|
+
const sessionSchema = z.object({
|
|
867
|
+
createdAt: z.string(),
|
|
868
|
+
expiresAt: z.string().optional(),
|
|
869
|
+
id: z.string(),
|
|
870
|
+
lastSeenAt: z.string(),
|
|
871
|
+
origin: z.string().optional(),
|
|
872
|
+
revokedAt: z.string().optional()
|
|
873
|
+
}).strict();
|
|
874
|
+
z.object({
|
|
875
|
+
login: loginGrantSchema,
|
|
876
|
+
ok: z.literal(true)
|
|
877
|
+
}).strict(), z.object({
|
|
878
|
+
bearer: z.object({
|
|
879
|
+
expiresAt: z.string(),
|
|
880
|
+
token: z.string()
|
|
881
|
+
}).strict(),
|
|
882
|
+
ok: z.literal(true)
|
|
883
|
+
}).strict(), z.object({
|
|
884
|
+
ok: z.literal(true),
|
|
885
|
+
sessions: z.array(sessionSchema)
|
|
886
|
+
}).strict(), z.object({ ok: z.literal(true) }).strict(), z.object({
|
|
887
|
+
count: z.number().int().nonnegative(),
|
|
888
|
+
ok: z.literal(true)
|
|
889
|
+
}).strict(), z.object({
|
|
890
|
+
ok: z.literal(true),
|
|
891
|
+
revoked: z.boolean()
|
|
892
|
+
}).strict();
|
|
893
|
+
z.object({
|
|
894
|
+
error: z.unknown().optional(),
|
|
895
|
+
ok: z.unknown()
|
|
896
|
+
}).passthrough();
|
|
897
|
+
z.object({
|
|
898
|
+
apiUrl: z.string().url(),
|
|
899
|
+
controlSocket: z.string(),
|
|
900
|
+
id: z.string(),
|
|
901
|
+
pid: z.number().int().positive(),
|
|
902
|
+
port: z.number().int().min(1).max(65535),
|
|
903
|
+
url: z.string().url()
|
|
904
|
+
});
|
|
905
|
+
const runtimeDirectory = () => {
|
|
906
|
+
const paths = getOvermuxPaths();
|
|
907
|
+
const preferred = paths.runtimeDir ?? join(paths.stateDir, "runtime");
|
|
908
|
+
if (Buffer.byteLength(preferred) <= 80) return preferred;
|
|
909
|
+
const suffix = createHash("sha256").update(preferred).digest("hex").slice(0, 12);
|
|
910
|
+
return join(tmpdir(), `overmux-${process.getuid?.() ?? "user"}-${suffix}`);
|
|
911
|
+
};
|
|
912
|
+
const readNewlineFrame = ({ maximumBytes, socket, timeout }) => new Promise((resolve, reject) => {
|
|
913
|
+
let buffer = Buffer.alloc(0);
|
|
914
|
+
let timer;
|
|
915
|
+
const cleanup = () => {
|
|
916
|
+
socket.off("data", onData);
|
|
917
|
+
socket.off("end", onEnd);
|
|
918
|
+
socket.off("error", onError);
|
|
919
|
+
if (timer) clearTimeout(timer);
|
|
920
|
+
};
|
|
921
|
+
const fail = (cause) => {
|
|
922
|
+
cleanup();
|
|
923
|
+
reject(cause);
|
|
924
|
+
};
|
|
925
|
+
const onData = (chunk) => {
|
|
926
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
927
|
+
if (buffer.length > maximumBytes) {
|
|
928
|
+
fail(/* @__PURE__ */ new Error("Control message is too large"));
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
const newline = buffer.indexOf(10);
|
|
932
|
+
if (newline < 0) return;
|
|
933
|
+
cleanup();
|
|
934
|
+
resolve(buffer.subarray(0, newline));
|
|
935
|
+
};
|
|
936
|
+
const onEnd = () => fail(/* @__PURE__ */ new Error("Control socket ended before a complete frame"));
|
|
937
|
+
const onError = (cause) => fail(cause);
|
|
938
|
+
if (socket.errored) fail(socket.errored);
|
|
939
|
+
else if (socket.readableEnded || socket.destroyed) fail(/* @__PURE__ */ new Error("Control socket is already closed"));
|
|
940
|
+
else {
|
|
941
|
+
socket.on("data", onData);
|
|
942
|
+
socket.once("end", onEnd);
|
|
943
|
+
socket.once("error", onError);
|
|
944
|
+
if (timeout !== void 0) timer = setTimeout(() => {
|
|
945
|
+
socket.destroy();
|
|
946
|
+
fail(/* @__PURE__ */ new Error("Control request timed out"));
|
|
947
|
+
}, timeout);
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
const writeResponse = (socket, response) => {
|
|
951
|
+
socket.end(`${JSON.stringify(response)}\n`);
|
|
952
|
+
};
|
|
953
|
+
const handleRequest = async (auth, request) => {
|
|
954
|
+
if (request.type === "ping") return { ok: true };
|
|
955
|
+
if (request.type === "create-login") return {
|
|
956
|
+
login: await auth.createLoginGrant(),
|
|
957
|
+
ok: true
|
|
958
|
+
};
|
|
959
|
+
if (request.type === "issue-bearer") return {
|
|
960
|
+
bearer: await auth.issueLocalBearer(),
|
|
961
|
+
ok: true
|
|
962
|
+
};
|
|
963
|
+
if (request.type === "list-sessions") return {
|
|
964
|
+
ok: true,
|
|
965
|
+
sessions: await auth.listSessions()
|
|
966
|
+
};
|
|
967
|
+
if (request.type === "revoke-session") return {
|
|
968
|
+
ok: true,
|
|
969
|
+
revoked: await auth.revokeSession(request.id)
|
|
970
|
+
};
|
|
971
|
+
return {
|
|
972
|
+
count: await auth.revokeAllSessions(),
|
|
973
|
+
ok: true
|
|
974
|
+
};
|
|
975
|
+
};
|
|
976
|
+
const handleConnection = async ({ auth, socket }) => {
|
|
977
|
+
try {
|
|
978
|
+
const frame = await readNewlineFrame({
|
|
979
|
+
maximumBytes: maxControlMessageBytes,
|
|
980
|
+
socket
|
|
981
|
+
});
|
|
982
|
+
const request = controlRequestSchema.parse(JSON.parse(frame.toString()));
|
|
983
|
+
writeResponse(socket, await handleRequest(auth, request));
|
|
984
|
+
} catch (cause) {
|
|
985
|
+
writeResponse(socket, {
|
|
986
|
+
error: cause instanceof Error ? cause.message : "Invalid control request",
|
|
987
|
+
ok: false
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
};
|
|
991
|
+
const startInstanceControl = async ({ auth, apiUrl, port, url }) => {
|
|
992
|
+
const directory = runtimeDirectory();
|
|
993
|
+
await mkdir(directory, {
|
|
994
|
+
mode: 448,
|
|
995
|
+
recursive: true
|
|
996
|
+
});
|
|
997
|
+
await chmod(directory, 448);
|
|
998
|
+
const id = randomBytes(8).toString("hex");
|
|
999
|
+
const socketPath = join(directory, `${id}.sock`);
|
|
1000
|
+
const registrationPath = join(directory, `${id}.json`);
|
|
1001
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
1002
|
+
const server = createServer((socket) => {
|
|
1003
|
+
sockets.add(socket);
|
|
1004
|
+
socket.once("close", () => sockets.delete(socket));
|
|
1005
|
+
handleConnection({
|
|
1006
|
+
auth,
|
|
1007
|
+
socket
|
|
1008
|
+
});
|
|
1009
|
+
});
|
|
1010
|
+
const close = async () => {
|
|
1011
|
+
sockets.forEach((socket) => socket.destroy());
|
|
1012
|
+
try {
|
|
1013
|
+
await new Promise((resolve, reject) => server.close((cause) => cause ? reject(cause) : resolve()));
|
|
1014
|
+
} finally {
|
|
1015
|
+
await Promise.all([rm(socketPath, { force: true }), rm(registrationPath, { force: true })]);
|
|
1016
|
+
}
|
|
1017
|
+
};
|
|
1018
|
+
await new Promise((resolve, reject) => {
|
|
1019
|
+
server.once("error", reject);
|
|
1020
|
+
server.listen(socketPath, () => {
|
|
1021
|
+
server.off("error", reject);
|
|
1022
|
+
resolve();
|
|
1023
|
+
});
|
|
1024
|
+
});
|
|
1025
|
+
try {
|
|
1026
|
+
await chmod(socketPath, 384);
|
|
1027
|
+
await replacePrivateJsonAtomically(registrationPath, {
|
|
1028
|
+
apiUrl,
|
|
1029
|
+
controlSocket: socketPath,
|
|
1030
|
+
id,
|
|
1031
|
+
pid: process.pid,
|
|
1032
|
+
port,
|
|
1033
|
+
url
|
|
1034
|
+
});
|
|
1035
|
+
} catch (cause) {
|
|
1036
|
+
await close().catch(() => void 0);
|
|
1037
|
+
throw cause;
|
|
1038
|
+
}
|
|
1039
|
+
return { close };
|
|
1040
|
+
};
|
|
1041
|
+
//#endregion
|
|
1042
|
+
//#region src/internal/shared/background-notifications.ts
|
|
1043
|
+
const deliveryKeySchema = z.string().min(1).max(1024).regex(/^[\w-]+$/u);
|
|
1044
|
+
const backgroundNotificationSubscriptionSchema = z.object({
|
|
1045
|
+
endpoint: z.url().max(4096).refine((endpoint) => {
|
|
1046
|
+
const url = new URL(endpoint);
|
|
1047
|
+
return url.protocol === "https:" && !url.username && !url.password;
|
|
1048
|
+
}),
|
|
1049
|
+
expirationTime: z.number().int().nonnegative().nullable(),
|
|
1050
|
+
keys: z.object({
|
|
1051
|
+
auth: deliveryKeySchema,
|
|
1052
|
+
p256dh: deliveryKeySchema
|
|
1053
|
+
}).strict()
|
|
1054
|
+
}).strict();
|
|
1055
|
+
z.object({ publicKey: deliveryKeySchema }).strict();
|
|
1056
|
+
const backgroundNotificationDisableRequestSchema = z.object({ endpoint: backgroundNotificationSubscriptionSchema.shape.endpoint }).strict();
|
|
1057
|
+
const overmuxSettingsPath = "/_overmux/settings";
|
|
1058
|
+
const overmuxLogoutPath = "/_overmux/logout";
|
|
1059
|
+
const safeRelativeNotificationLink = (link) => {
|
|
1060
|
+
let decoded;
|
|
1061
|
+
try {
|
|
1062
|
+
decoded = decodeURIComponent(link);
|
|
1063
|
+
} catch {
|
|
1064
|
+
return false;
|
|
1065
|
+
}
|
|
1066
|
+
return !decoded.startsWith("//") && !decoded.includes("\\") && ![...decoded].some((character) => {
|
|
1067
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
1068
|
+
return codePoint < 32 || codePoint === 127;
|
|
1069
|
+
});
|
|
1070
|
+
};
|
|
1071
|
+
const notificationLinkSchema = z.union([z.string().max(4096).startsWith("/").refine(safeRelativeNotificationLink), z.string().max(4096).url().refine((link) => {
|
|
1072
|
+
if (!URL.canParse(link)) return false;
|
|
1073
|
+
const url = new URL(link);
|
|
1074
|
+
return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password;
|
|
1075
|
+
})]);
|
|
1076
|
+
const notificationSchema = z.object({
|
|
1077
|
+
body: z.string().max(4096).optional(),
|
|
1078
|
+
open: z.object({ link: notificationLinkSchema }).strict().optional(),
|
|
1079
|
+
title: z.string().min(1).max(4096)
|
|
1080
|
+
}).strict();
|
|
1081
|
+
const webSocketCloseCode = {
|
|
1082
|
+
authenticationRevoked: 4001,
|
|
1083
|
+
authenticationUnavailable: 4002
|
|
1084
|
+
};
|
|
1085
|
+
z.object({
|
|
1086
|
+
debug: z.boolean(),
|
|
1087
|
+
operations: z.array(z.string().min(1)),
|
|
1088
|
+
protocolVersion: z.literal(9),
|
|
1089
|
+
resources: z.array(z.string().min(1)),
|
|
1090
|
+
streams: z.array(z.string().min(1))
|
|
1091
|
+
});
|
|
1092
|
+
const updateAvailableEventSchema = z.object({ type: z.literal("update-available") });
|
|
1093
|
+
const restartingEventSchema = z.object({ type: z.literal("restarting") });
|
|
1094
|
+
z.discriminatedUnion("type", [updateAvailableEventSchema, restartingEventSchema]);
|
|
1095
|
+
const notificationEventSchema = z.object({
|
|
1096
|
+
notification: notificationSchema,
|
|
1097
|
+
type: z.literal("notification")
|
|
1098
|
+
}).strict();
|
|
1099
|
+
const operationIdSchema = z.string().min(1).max(256);
|
|
1100
|
+
const registrationNameSchema = z.string().min(1).max(256);
|
|
1101
|
+
const resourceReadMessageSchema = z.object({
|
|
1102
|
+
input: z.unknown().optional(),
|
|
1103
|
+
operationId: operationIdSchema,
|
|
1104
|
+
resourceName: registrationNameSchema,
|
|
1105
|
+
type: z.literal("resource-read")
|
|
1106
|
+
});
|
|
1107
|
+
const resourceSubscribeMessageSchema = z.object({
|
|
1108
|
+
input: z.unknown().optional(),
|
|
1109
|
+
operationId: operationIdSchema,
|
|
1110
|
+
resourceName: registrationNameSchema,
|
|
1111
|
+
subscriptionId: operationIdSchema,
|
|
1112
|
+
type: z.literal("resource-subscribe")
|
|
1113
|
+
});
|
|
1114
|
+
const resourceUnsubscribeMessageSchema = z.object({
|
|
1115
|
+
subscriptionId: operationIdSchema,
|
|
1116
|
+
type: z.literal("resource-unsubscribe")
|
|
1117
|
+
});
|
|
1118
|
+
const streamOpenMessageSchema = z.object({
|
|
1119
|
+
input: z.unknown().optional(),
|
|
1120
|
+
operationId: operationIdSchema,
|
|
1121
|
+
streamId: operationIdSchema,
|
|
1122
|
+
streamName: registrationNameSchema,
|
|
1123
|
+
type: z.literal("stream-open")
|
|
1124
|
+
});
|
|
1125
|
+
const streamMessageSchema = z.object({
|
|
1126
|
+
message: z.unknown(),
|
|
1127
|
+
streamId: operationIdSchema,
|
|
1128
|
+
type: z.literal("stream-message")
|
|
1129
|
+
});
|
|
1130
|
+
const streamCloseMessageSchema = z.object({
|
|
1131
|
+
streamId: operationIdSchema,
|
|
1132
|
+
type: z.literal("stream-close")
|
|
1133
|
+
});
|
|
1134
|
+
const clientDiagnosticEventSchema = z.object({
|
|
1135
|
+
arguments: z.string().max(16100),
|
|
1136
|
+
level: z.enum([
|
|
1137
|
+
"log",
|
|
1138
|
+
"info",
|
|
1139
|
+
"warn",
|
|
1140
|
+
"error",
|
|
1141
|
+
"debug"
|
|
1142
|
+
]),
|
|
1143
|
+
message: z.string().max(4100),
|
|
1144
|
+
stack: z.string().max(8100).optional(),
|
|
1145
|
+
timestamp: z.iso.datetime(),
|
|
1146
|
+
type: z.literal("client-diagnostic"),
|
|
1147
|
+
url: z.string().max(2100).optional()
|
|
1148
|
+
});
|
|
1149
|
+
const clientProtocolMessageSchema = z.discriminatedUnion("type", [
|
|
1150
|
+
resourceReadMessageSchema,
|
|
1151
|
+
resourceSubscribeMessageSchema,
|
|
1152
|
+
resourceUnsubscribeMessageSchema,
|
|
1153
|
+
streamOpenMessageSchema,
|
|
1154
|
+
streamMessageSchema,
|
|
1155
|
+
streamCloseMessageSchema,
|
|
1156
|
+
clientDiagnosticEventSchema
|
|
1157
|
+
]);
|
|
1158
|
+
const resourceResultMessageSchema = z.object({
|
|
1159
|
+
operationId: operationIdSchema,
|
|
1160
|
+
output: z.unknown(),
|
|
1161
|
+
type: z.literal("resource-result")
|
|
1162
|
+
});
|
|
1163
|
+
const resourceInvalidatedMessageSchema = z.object({
|
|
1164
|
+
subscriptionId: operationIdSchema,
|
|
1165
|
+
type: z.literal("resource-invalidated")
|
|
1166
|
+
});
|
|
1167
|
+
const streamOpenedMessageSchema = z.object({
|
|
1168
|
+
operationId: operationIdSchema,
|
|
1169
|
+
streamId: operationIdSchema,
|
|
1170
|
+
type: z.literal("stream-opened")
|
|
1171
|
+
});
|
|
1172
|
+
const streamOutputMessageSchema = z.object({
|
|
1173
|
+
message: z.unknown(),
|
|
1174
|
+
streamId: operationIdSchema,
|
|
1175
|
+
type: z.literal("stream-output")
|
|
1176
|
+
});
|
|
1177
|
+
const streamClosedMessageSchema = z.object({
|
|
1178
|
+
streamId: operationIdSchema,
|
|
1179
|
+
type: z.literal("stream-closed")
|
|
1180
|
+
});
|
|
1181
|
+
const protocolErrorMessageSchema = z.object({
|
|
1182
|
+
code: z.enum([
|
|
1183
|
+
"bad-request",
|
|
1184
|
+
"conflict",
|
|
1185
|
+
"internal",
|
|
1186
|
+
"not-found"
|
|
1187
|
+
]),
|
|
1188
|
+
message: z.string(),
|
|
1189
|
+
operationId: operationIdSchema.optional(),
|
|
1190
|
+
streamId: operationIdSchema.optional(),
|
|
1191
|
+
subscriptionId: operationIdSchema.optional(),
|
|
1192
|
+
type: z.literal("error")
|
|
1193
|
+
});
|
|
1194
|
+
const serverProtocolMessageSchema = z.discriminatedUnion("type", [
|
|
1195
|
+
resourceResultMessageSchema,
|
|
1196
|
+
resourceInvalidatedMessageSchema,
|
|
1197
|
+
streamOpenedMessageSchema,
|
|
1198
|
+
streamOutputMessageSchema,
|
|
1199
|
+
streamClosedMessageSchema,
|
|
1200
|
+
protocolErrorMessageSchema,
|
|
1201
|
+
updateAvailableEventSchema,
|
|
1202
|
+
restartingEventSchema,
|
|
1203
|
+
notificationEventSchema
|
|
1204
|
+
]);
|
|
1205
|
+
const byteMarker = "$overmuxBytes";
|
|
1206
|
+
const decodeValue = (value) => {
|
|
1207
|
+
if (Array.isArray(value)) return value.map(decodeValue);
|
|
1208
|
+
if (typeof value !== "object" || value === null) return value;
|
|
1209
|
+
const record = value;
|
|
1210
|
+
if (Object.keys(record).length === 1 && Array.isArray(record[byteMarker]) && record[byteMarker].every((item) => Number.isInteger(item) && item >= 0 && item <= 255)) return Uint8Array.from(record[byteMarker]);
|
|
1211
|
+
return Object.fromEntries(Object.entries(record).map(([key, item]) => [key, decodeValue(item)]));
|
|
1212
|
+
};
|
|
1213
|
+
const binaryMarker = "$overmuxBinary";
|
|
1214
|
+
const binaryMagic = 1330468865;
|
|
1215
|
+
const encodeBinaryValue = (value, payloads, offset) => {
|
|
1216
|
+
if (value instanceof Uint8Array) {
|
|
1217
|
+
const marker = { [binaryMarker]: [offset.value, value.byteLength] };
|
|
1218
|
+
payloads.push(value);
|
|
1219
|
+
offset.value += value.byteLength;
|
|
1220
|
+
return marker;
|
|
1221
|
+
}
|
|
1222
|
+
if (Array.isArray(value)) return value.map((item) => encodeBinaryValue(item, payloads, offset));
|
|
1223
|
+
if (typeof value === "object" && value !== null) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, encodeBinaryValue(item, payloads, offset)]));
|
|
1224
|
+
return value;
|
|
1225
|
+
};
|
|
1226
|
+
const decodeBinaryValue = (value, payload) => {
|
|
1227
|
+
if (Array.isArray(value)) return value.map((item) => decodeBinaryValue(item, payload));
|
|
1228
|
+
if (typeof value !== "object" || value === null) return value;
|
|
1229
|
+
const record = value;
|
|
1230
|
+
const marker = record[binaryMarker];
|
|
1231
|
+
if (Object.keys(record).length === 1 && Array.isArray(marker) && marker.length === 2 && marker.every((item) => Number.isInteger(item) && item >= 0)) {
|
|
1232
|
+
const [offset, length] = marker;
|
|
1233
|
+
if (offset + length > payload.byteLength) throw new Error("Invalid Overmux binary payload range");
|
|
1234
|
+
return payload.slice(offset, offset + length);
|
|
1235
|
+
}
|
|
1236
|
+
return Object.fromEntries(Object.entries(record).map(([key, item]) => [key, decodeBinaryValue(item, payload)]));
|
|
1237
|
+
};
|
|
1238
|
+
const decodeProtocolMessage = (message) => decodeValue(JSON.parse(message));
|
|
1239
|
+
const encodeProtocolFrame = (message) => {
|
|
1240
|
+
const payloads = [];
|
|
1241
|
+
const encoded = encodeBinaryValue(message, payloads, { value: 0 });
|
|
1242
|
+
if (!payloads.length) return JSON.stringify(encoded);
|
|
1243
|
+
const header = new TextEncoder().encode(JSON.stringify(encoded));
|
|
1244
|
+
const payloadLength = payloads.reduce((total, payload) => total + payload.byteLength, 0);
|
|
1245
|
+
const frame = new Uint8Array(8 + header.byteLength + payloadLength);
|
|
1246
|
+
const view = new DataView(frame.buffer);
|
|
1247
|
+
view.setUint32(0, binaryMagic);
|
|
1248
|
+
view.setUint32(4, header.byteLength);
|
|
1249
|
+
frame.set(header, 8);
|
|
1250
|
+
let offset = 8 + header.byteLength;
|
|
1251
|
+
payloads.forEach((payload) => {
|
|
1252
|
+
frame.set(payload, offset);
|
|
1253
|
+
offset += payload.byteLength;
|
|
1254
|
+
});
|
|
1255
|
+
return frame;
|
|
1256
|
+
};
|
|
1257
|
+
const decodeProtocolFrame = (frame) => {
|
|
1258
|
+
if (typeof frame === "string") return decodeProtocolMessage(frame);
|
|
1259
|
+
const bytes = frame instanceof Uint8Array ? frame : new Uint8Array(frame, 0, frame.byteLength);
|
|
1260
|
+
if (bytes.byteLength < 8) throw new Error("Invalid Overmux binary frame");
|
|
1261
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
1262
|
+
if (view.getUint32(0) !== binaryMagic) throw new Error("Invalid Overmux binary frame magic");
|
|
1263
|
+
const headerLength = view.getUint32(4);
|
|
1264
|
+
if (8 + headerLength > bytes.byteLength) throw new Error("Invalid Overmux binary frame header");
|
|
1265
|
+
const header = JSON.parse(new TextDecoder().decode(bytes.subarray(8, 8 + headerLength)));
|
|
1266
|
+
return decodeBinaryValue(header, bytes.subarray(8 + headerLength));
|
|
1267
|
+
};
|
|
1268
|
+
//#endregion
|
|
1269
|
+
//#region src/internal/server/http/background-notification-handlers.ts
|
|
1270
|
+
const requestLimit = 16384;
|
|
1271
|
+
const parseJson = async (request, schema) => {
|
|
1272
|
+
if (request.headers.get("Content-Type")?.split(";", 1)[0]?.toLowerCase() !== "application/json") return;
|
|
1273
|
+
if (Number(request.headers.get("Content-Length") ?? 0) > requestLimit) return;
|
|
1274
|
+
try {
|
|
1275
|
+
const bytes = await request.arrayBuffer();
|
|
1276
|
+
if (bytes.byteLength > requestLimit) return;
|
|
1277
|
+
const parsed = schema.safeParse(JSON.parse(new TextDecoder().decode(bytes)));
|
|
1278
|
+
return parsed.success ? parsed.data : void 0;
|
|
1279
|
+
} catch {
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
};
|
|
1283
|
+
const createBackgroundNotificationHandlers = ({ backgroundNotifications }) => ({
|
|
1284
|
+
publicKey: async (context) => {
|
|
1285
|
+
context.header("Cache-Control", "no-store");
|
|
1286
|
+
return context.json({ publicKey: await backgroundNotifications.publicKey() });
|
|
1287
|
+
},
|
|
1288
|
+
enable: async (context) => {
|
|
1289
|
+
const subscription = await parseJson(context.req.raw, backgroundNotificationSubscriptionSchema);
|
|
1290
|
+
if (!subscription) return context.json({ error: "Invalid background subscription" }, 400);
|
|
1291
|
+
await backgroundNotifications.enable(context.get("session").id, subscription);
|
|
1292
|
+
return context.json({ ok: true });
|
|
1293
|
+
},
|
|
1294
|
+
disable: async (context) => {
|
|
1295
|
+
const input = await parseJson(context.req.raw, backgroundNotificationDisableRequestSchema);
|
|
1296
|
+
if (!input) return context.json({ error: "Invalid background subscription" }, 400);
|
|
1297
|
+
await backgroundNotifications.disable(input.endpoint);
|
|
1298
|
+
return context.json({ ok: true });
|
|
1299
|
+
}
|
|
1300
|
+
});
|
|
1301
|
+
//#endregion
|
|
1302
|
+
//#region src/internal/server/server-logger.ts
|
|
1303
|
+
const lifecycleEvents = /* @__PURE__ */ new Set([
|
|
1304
|
+
"server-start",
|
|
1305
|
+
"server-stop",
|
|
1306
|
+
"websocket-close",
|
|
1307
|
+
"websocket-close-event",
|
|
1308
|
+
"websocket-connect",
|
|
1309
|
+
"websocket-open"
|
|
1310
|
+
]);
|
|
1311
|
+
const getDefaultServerLogFile = ({ homeDir, xdgStateHome } = {}) => join(getOvermuxPaths({
|
|
1312
|
+
homeDir,
|
|
1313
|
+
xdgStateHome
|
|
1314
|
+
}).stateDir, "overmux.log");
|
|
1315
|
+
const safeJson = (value) => {
|
|
1316
|
+
try {
|
|
1317
|
+
return JSON.stringify(value);
|
|
1318
|
+
} catch {
|
|
1319
|
+
return JSON.stringify({
|
|
1320
|
+
event: "log-serialization-error",
|
|
1321
|
+
level: "error",
|
|
1322
|
+
source: "server",
|
|
1323
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
};
|
|
1327
|
+
const createServerLogger = ({ enabled, logFile = getDefaultServerLogFile() }) => {
|
|
1328
|
+
let activeLogFile = logFile;
|
|
1329
|
+
let debugEnabled = enabled;
|
|
1330
|
+
return {
|
|
1331
|
+
get enabled() {
|
|
1332
|
+
return debugEnabled;
|
|
1333
|
+
},
|
|
1334
|
+
id: randomUUID,
|
|
1335
|
+
log: (entry) => {
|
|
1336
|
+
if (!debugEnabled && entry.level !== "error" && !lifecycleEvents.has(entry.event)) return;
|
|
1337
|
+
const record = safeJson({
|
|
1338
|
+
timestamp: entry.timestamp ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
1339
|
+
level: entry.level ?? "info",
|
|
1340
|
+
source: entry.source ?? "server",
|
|
1341
|
+
...entry
|
|
1342
|
+
});
|
|
1343
|
+
mkdirSync(dirname(activeLogFile), { recursive: true });
|
|
1344
|
+
appendFileSync(activeLogFile, `${record}\n`, "utf8");
|
|
1345
|
+
process.stderr.write(`[overmux:debug] ${record}\n`);
|
|
1346
|
+
},
|
|
1347
|
+
setEnabled: (nextEnabled) => {
|
|
1348
|
+
debugEnabled = nextEnabled;
|
|
1349
|
+
},
|
|
1350
|
+
setLogFile: (nextLogFile) => {
|
|
1351
|
+
activeLogFile = nextLogFile;
|
|
1352
|
+
}
|
|
1353
|
+
};
|
|
1354
|
+
};
|
|
1355
|
+
const errorMessage = (cause) => cause instanceof Error ? cause.message : String(cause);
|
|
1356
|
+
const errorDetails$1 = (cause) => ({
|
|
1357
|
+
message: errorMessage(cause),
|
|
1358
|
+
...cause instanceof Error && cause.stack ? { stack: cause.stack } : {}
|
|
1359
|
+
});
|
|
1360
|
+
//#endregion
|
|
1361
|
+
//#region src/internal/server/http/development-web-backend.ts
|
|
1362
|
+
const createForwardingHeaders = ({ origin, remoteAddress, request }) => {
|
|
1363
|
+
const headers = new Headers(request.headers);
|
|
1364
|
+
[...headers.keys()].forEach((name) => {
|
|
1365
|
+
if (name === "authorization" || name === "cookie" || name === "forwarded" || name.startsWith("x-forwarded-")) headers.delete(name);
|
|
1366
|
+
});
|
|
1367
|
+
const publicUrl = new URL(origin);
|
|
1368
|
+
if (remoteAddress) headers.set("x-forwarded-for", remoteAddress);
|
|
1369
|
+
headers.set("x-forwarded-host", publicUrl.host);
|
|
1370
|
+
headers.set("x-forwarded-port", publicUrl.port || (publicUrl.protocol === "https:" ? "443" : "80"));
|
|
1371
|
+
headers.set("x-forwarded-proto", publicUrl.protocol.slice(0, -1));
|
|
1372
|
+
return headers;
|
|
1373
|
+
};
|
|
1374
|
+
const createDevelopmentWebHandler = ({ auth, serverLogger, target }) => async (context) => {
|
|
1375
|
+
const incoming = context.env?.incoming;
|
|
1376
|
+
const origin = auth.resolveOrigin({
|
|
1377
|
+
remoteAddress: incoming?.socket.remoteAddress,
|
|
1378
|
+
request: context.req.raw
|
|
1379
|
+
});
|
|
1380
|
+
if (!origin) return context.text("Request origin was rejected", 403);
|
|
1381
|
+
const headers = createForwardingHeaders({
|
|
1382
|
+
origin,
|
|
1383
|
+
remoteAddress: incoming?.socket.remoteAddress,
|
|
1384
|
+
request: context.req.raw
|
|
1385
|
+
});
|
|
1386
|
+
try {
|
|
1387
|
+
return await proxyFetch(target, new Request(context.req.raw, { headers }), void 0, {
|
|
1388
|
+
changeOrigin: true,
|
|
1389
|
+
xfwd: false
|
|
1390
|
+
});
|
|
1391
|
+
} catch (cause) {
|
|
1392
|
+
serverLogger?.log({
|
|
1393
|
+
details: errorDetails$1(cause),
|
|
1394
|
+
event: "vite-http-proxy-failure",
|
|
1395
|
+
level: "error"
|
|
1396
|
+
});
|
|
1397
|
+
return context.text("Overmux development server is unavailable", 503, { "Cache-Control": "no-store" });
|
|
1398
|
+
}
|
|
1399
|
+
};
|
|
1400
|
+
//#endregion
|
|
1401
|
+
//#region src/internal/server/http/control-handlers.ts
|
|
1402
|
+
const createHealthHandler = ({ shutdown }) => (context) => shutdown.requested ? context.json({ status: "stopping" }, 503) : context.json({ status: "ok" });
|
|
1403
|
+
const createRestartHandler = ({ onRestartRequested, shutdown }) => (context) => {
|
|
1404
|
+
const response = context.json({ status: "restarting" }, 202);
|
|
1405
|
+
if (!onRestartRequested) return response;
|
|
1406
|
+
const beginRestart = () => {
|
|
1407
|
+
shutdown.requested = true;
|
|
1408
|
+
onRestartRequested();
|
|
1409
|
+
};
|
|
1410
|
+
const nodeResponse = context.env?.outgoing;
|
|
1411
|
+
if (nodeResponse) nodeResponse.once("finish", beginRestart);
|
|
1412
|
+
else setImmediate(beginRestart);
|
|
1413
|
+
return response;
|
|
1414
|
+
};
|
|
1415
|
+
//#endregion
|
|
1416
|
+
//#region src/internal/server/runtime/runtime-operations.ts
|
|
1417
|
+
var OperationValidationError = class extends Error {
|
|
1418
|
+
phase;
|
|
1419
|
+
constructor(phase, cause) {
|
|
1420
|
+
super(cause.message, { cause });
|
|
1421
|
+
this.phase = phase;
|
|
1422
|
+
}
|
|
1423
|
+
};
|
|
1424
|
+
const createRuntimeOperations = ({ context, definitions, notifications }) => {
|
|
1425
|
+
const operations = /* @__PURE__ */ new Map();
|
|
1426
|
+
Object.entries(definitions).forEach(([name, definition]) => {
|
|
1427
|
+
if (!name) throw new Error("Operation names must not be empty");
|
|
1428
|
+
operations.set(name, {
|
|
1429
|
+
execute: async (rawInput, signal) => {
|
|
1430
|
+
const operationContext = context(signal);
|
|
1431
|
+
operationContext.signal.throwIfAborted();
|
|
1432
|
+
const input = definition.input.safeParse(rawInput);
|
|
1433
|
+
if (!input.success) throw new OperationValidationError("input", input.error);
|
|
1434
|
+
const rawOutput = await definition.handle(input.data, {
|
|
1435
|
+
...operationContext,
|
|
1436
|
+
notifications
|
|
1437
|
+
});
|
|
1438
|
+
operationContext.signal.throwIfAborted();
|
|
1439
|
+
const output = definition.output.safeParse(rawOutput);
|
|
1440
|
+
if (!output.success) throw new OperationValidationError("output", output.error);
|
|
1441
|
+
return output.data;
|
|
1442
|
+
},
|
|
1443
|
+
returnsVoid: definition.output instanceof z.ZodVoid
|
|
1444
|
+
});
|
|
1445
|
+
});
|
|
1446
|
+
return {
|
|
1447
|
+
get: (name) => operations.get(name),
|
|
1448
|
+
names: [...operations.keys()]
|
|
1449
|
+
};
|
|
1450
|
+
};
|
|
1451
|
+
//#endregion
|
|
1452
|
+
//#region src/internal/server/http/operation-handler.ts
|
|
1453
|
+
const maxOperationRequestBytes = 1048576;
|
|
1454
|
+
const parseOperationRequest = async (request) => {
|
|
1455
|
+
if ((request.headers.get("Content-Type")?.split(";", 1)[0])?.toLowerCase() !== "application/json") return {
|
|
1456
|
+
error: "Content-Type must be application/json",
|
|
1457
|
+
status: 415
|
|
1458
|
+
};
|
|
1459
|
+
if (Number(request.headers.get("Content-Length") ?? 0) > maxOperationRequestBytes) return {
|
|
1460
|
+
error: "Request body is too large",
|
|
1461
|
+
status: 413
|
|
1462
|
+
};
|
|
1463
|
+
try {
|
|
1464
|
+
const body = await request.arrayBuffer();
|
|
1465
|
+
if (body.byteLength > maxOperationRequestBytes) return {
|
|
1466
|
+
error: "Request body is too large",
|
|
1467
|
+
status: 413
|
|
1468
|
+
};
|
|
1469
|
+
const text = new TextDecoder().decode(body);
|
|
1470
|
+
return { input: text ? JSON.parse(text) : void 0 };
|
|
1471
|
+
} catch {
|
|
1472
|
+
return {
|
|
1473
|
+
error: "Invalid JSON request body",
|
|
1474
|
+
status: 400
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
};
|
|
1478
|
+
const createOperationHandler = ({ runtime, serverLogger }) => async (context) => {
|
|
1479
|
+
const operation = runtime.getOperation(context.req.param("name"));
|
|
1480
|
+
if (!operation) return context.json({ error: "Operation not found" }, 404);
|
|
1481
|
+
const parsed = await parseOperationRequest(context.req.raw);
|
|
1482
|
+
if ("error" in parsed) return context.json({ error: parsed.error }, parsed.status);
|
|
1483
|
+
const correlationId = context.res.headers.get("X-Overmux-Correlation-Id") ?? serverLogger?.id();
|
|
1484
|
+
const startedAt = Date.now();
|
|
1485
|
+
serverLogger?.log({
|
|
1486
|
+
correlationId,
|
|
1487
|
+
details: { registeredName: context.req.param("name") },
|
|
1488
|
+
event: "operation-start"
|
|
1489
|
+
});
|
|
1490
|
+
try {
|
|
1491
|
+
const output = await operation.execute(parsed.input, context.req.raw.signal);
|
|
1492
|
+
serverLogger?.log({
|
|
1493
|
+
correlationId,
|
|
1494
|
+
details: { registeredName: context.req.param("name") },
|
|
1495
|
+
durationMs: Date.now() - startedAt,
|
|
1496
|
+
event: "operation-complete"
|
|
1497
|
+
});
|
|
1498
|
+
return operation.returnsVoid ? new Response(void 0, { status: 204 }) : context.json(output);
|
|
1499
|
+
} catch (cause) {
|
|
1500
|
+
const message = cause instanceof Error ? cause.message : "Operation failed";
|
|
1501
|
+
serverLogger?.log({
|
|
1502
|
+
correlationId,
|
|
1503
|
+
details: { registeredName: context.req.param("name") },
|
|
1504
|
+
durationMs: Date.now() - startedAt,
|
|
1505
|
+
event: "operation-error",
|
|
1506
|
+
level: "error",
|
|
1507
|
+
message
|
|
1508
|
+
});
|
|
1509
|
+
return context.json({ error: message }, cause instanceof OperationValidationError && cause.phase === "input" ? 400 : 500);
|
|
1510
|
+
}
|
|
1511
|
+
};
|
|
1512
|
+
//#endregion
|
|
1513
|
+
//#region src/internal/server/http/request-logging.ts
|
|
1514
|
+
const createRequestLoggingMiddleware = ({ serverLogger }) => async (context, next) => {
|
|
1515
|
+
const correlationId = serverLogger?.id();
|
|
1516
|
+
const startedAt = Date.now();
|
|
1517
|
+
if (correlationId) context.header("X-Overmux-Correlation-Id", correlationId);
|
|
1518
|
+
await next();
|
|
1519
|
+
const failed = context.res.status >= 400 || Boolean(context.error);
|
|
1520
|
+
serverLogger?.log({
|
|
1521
|
+
correlationId,
|
|
1522
|
+
details: {
|
|
1523
|
+
method: context.req.method,
|
|
1524
|
+
path: context.req.path,
|
|
1525
|
+
status: context.res.status
|
|
1526
|
+
},
|
|
1527
|
+
durationMs: Date.now() - startedAt,
|
|
1528
|
+
event: failed ? "http-request-failure" : "http-request",
|
|
1529
|
+
level: failed ? "error" : void 0,
|
|
1530
|
+
message: context.error?.message
|
|
1531
|
+
});
|
|
1532
|
+
};
|
|
1533
|
+
//#endregion
|
|
1534
|
+
//#region src/internal/server/http/runtime-manifest-handler.ts
|
|
1535
|
+
const createRuntimeManifestHandler = ({ runtime }) => (context) => {
|
|
1536
|
+
context.header("Cache-Control", "no-store");
|
|
1537
|
+
return context.json(runtime.manifest);
|
|
1538
|
+
};
|
|
1539
|
+
//#endregion
|
|
1540
|
+
//#region src/internal/shared/routes.ts
|
|
1541
|
+
const runtimeWebSocketPath = "/api/socket";
|
|
1542
|
+
const viteInfrastructurePath = "/_overmux/vite/*";
|
|
1543
|
+
//#endregion
|
|
1544
|
+
//#region src/internal/server/http/routes.ts
|
|
1545
|
+
const applyDescriptor = (app, route) => {
|
|
1546
|
+
if (route.method === "USE") {
|
|
1547
|
+
app.use(route.path, route.handler);
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
if (route.middleware) {
|
|
1551
|
+
app.on(route.method, route.path, route.middleware, route.handler);
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
app.on(route.method, route.path, route.handler);
|
|
1555
|
+
};
|
|
1556
|
+
const registerHttpRoutes = ({ app, auth, authAssetsDirectory, backgroundNotifications, developmentWebTarget, onRestartRequested, runtime, serverLogger, shutdown, webAssetsDir }) => {
|
|
1557
|
+
const authentication = createHttpAuthenticationHandlers({
|
|
1558
|
+
auth,
|
|
1559
|
+
authAssetsDirectory
|
|
1560
|
+
});
|
|
1561
|
+
const notificationHandlers = backgroundNotifications ? createBackgroundNotificationHandlers({ backgroundNotifications }) : void 0;
|
|
1562
|
+
const developmentWebHandler = developmentWebTarget ? createDevelopmentWebHandler({
|
|
1563
|
+
auth,
|
|
1564
|
+
serverLogger,
|
|
1565
|
+
target: developmentWebTarget
|
|
1566
|
+
}) : void 0;
|
|
1567
|
+
const productionWebHandler = webAssetsDir ? serveStatic({ path: join(webAssetsDir, "index.html") }) : void 0;
|
|
1568
|
+
const hostedWebHandler = developmentWebHandler ?? productionWebHandler;
|
|
1569
|
+
[
|
|
1570
|
+
{
|
|
1571
|
+
method: "USE",
|
|
1572
|
+
path: "*",
|
|
1573
|
+
handler: createRequestLoggingMiddleware({ serverLogger })
|
|
1574
|
+
},
|
|
1575
|
+
{
|
|
1576
|
+
method: "GET",
|
|
1577
|
+
path: "/_overmux/auth-shell/*",
|
|
1578
|
+
handler: authentication.asset
|
|
1579
|
+
},
|
|
1580
|
+
{
|
|
1581
|
+
method: "GET",
|
|
1582
|
+
path: "/api/auth/state",
|
|
1583
|
+
handler: authentication.state
|
|
1584
|
+
},
|
|
1585
|
+
{
|
|
1586
|
+
method: "POST",
|
|
1587
|
+
path: "/api/auth/login",
|
|
1588
|
+
middleware: authentication.loginBodyLimitMiddleware,
|
|
1589
|
+
handler: authentication.login
|
|
1590
|
+
},
|
|
1591
|
+
{
|
|
1592
|
+
method: "GET",
|
|
1593
|
+
path: "/login",
|
|
1594
|
+
handler: authentication.loginPage
|
|
1595
|
+
},
|
|
1596
|
+
{
|
|
1597
|
+
method: "GET",
|
|
1598
|
+
path: "/api/health",
|
|
1599
|
+
handler: createHealthHandler({ shutdown })
|
|
1600
|
+
},
|
|
1601
|
+
{
|
|
1602
|
+
method: "USE",
|
|
1603
|
+
path: "*",
|
|
1604
|
+
handler: authentication.authenticate
|
|
1605
|
+
},
|
|
1606
|
+
{
|
|
1607
|
+
method: "POST",
|
|
1608
|
+
path: "/api/auth/logout",
|
|
1609
|
+
handler: authentication.logout
|
|
1610
|
+
},
|
|
1611
|
+
{
|
|
1612
|
+
method: "POST",
|
|
1613
|
+
path: "/api/operations/:name",
|
|
1614
|
+
handler: createOperationHandler({
|
|
1615
|
+
runtime,
|
|
1616
|
+
serverLogger
|
|
1617
|
+
})
|
|
1618
|
+
},
|
|
1619
|
+
{
|
|
1620
|
+
method: "POST",
|
|
1621
|
+
path: "/api/restart",
|
|
1622
|
+
handler: createRestartHandler({
|
|
1623
|
+
onRestartRequested,
|
|
1624
|
+
shutdown
|
|
1625
|
+
})
|
|
1626
|
+
},
|
|
1627
|
+
{
|
|
1628
|
+
method: "GET",
|
|
1629
|
+
path: "/api/runtime-manifest",
|
|
1630
|
+
handler: createRuntimeManifestHandler({ runtime })
|
|
1631
|
+
},
|
|
1632
|
+
...notificationHandlers ? [
|
|
1633
|
+
{
|
|
1634
|
+
method: "GET",
|
|
1635
|
+
path: "/api/background-notifications/public-key",
|
|
1636
|
+
handler: notificationHandlers.publicKey
|
|
1637
|
+
},
|
|
1638
|
+
{
|
|
1639
|
+
method: "PUT",
|
|
1640
|
+
path: "/api/background-notifications/subscription",
|
|
1641
|
+
handler: notificationHandlers.enable
|
|
1642
|
+
},
|
|
1643
|
+
{
|
|
1644
|
+
method: "DELETE",
|
|
1645
|
+
path: "/api/background-notifications/subscription",
|
|
1646
|
+
handler: notificationHandlers.disable
|
|
1647
|
+
}
|
|
1648
|
+
] : [],
|
|
1649
|
+
{
|
|
1650
|
+
method: "ALL",
|
|
1651
|
+
path: "/api/*",
|
|
1652
|
+
handler: (context) => context.notFound()
|
|
1653
|
+
},
|
|
1654
|
+
...developmentWebHandler ? [{
|
|
1655
|
+
method: "ALL",
|
|
1656
|
+
path: viteInfrastructurePath,
|
|
1657
|
+
handler: developmentWebHandler
|
|
1658
|
+
}] : [],
|
|
1659
|
+
...hostedWebHandler ? [{
|
|
1660
|
+
method: "GET",
|
|
1661
|
+
path: overmuxSettingsPath,
|
|
1662
|
+
handler: hostedWebHandler
|
|
1663
|
+
}, {
|
|
1664
|
+
method: "GET",
|
|
1665
|
+
path: overmuxLogoutPath,
|
|
1666
|
+
handler: hostedWebHandler
|
|
1667
|
+
}] : [],
|
|
1668
|
+
{
|
|
1669
|
+
method: "ALL",
|
|
1670
|
+
path: "/_overmux/*",
|
|
1671
|
+
handler: (context) => context.notFound()
|
|
1672
|
+
},
|
|
1673
|
+
...developmentWebHandler ? [{
|
|
1674
|
+
method: "ALL",
|
|
1675
|
+
path: "*",
|
|
1676
|
+
handler: developmentWebHandler
|
|
1677
|
+
}] : [],
|
|
1678
|
+
...webAssetsDir ? [{
|
|
1679
|
+
method: "USE",
|
|
1680
|
+
path: "*",
|
|
1681
|
+
handler: serveStatic({ root: webAssetsDir })
|
|
1682
|
+
}, {
|
|
1683
|
+
method: "GET",
|
|
1684
|
+
path: "*",
|
|
1685
|
+
handler: serveStatic({ path: join(webAssetsDir, "index.html") })
|
|
1686
|
+
}] : []
|
|
1687
|
+
].forEach((route) => applyDescriptor(app, route));
|
|
1688
|
+
};
|
|
1689
|
+
//#endregion
|
|
1690
|
+
//#region src/internal/server/http/create-http-app.ts
|
|
1691
|
+
const createHttpApp = ({ auth, authAssetsDirectory, backgroundNotifications, developmentWebTarget, onRestartRequested, serverLogger, runtime, webAssetsDir }) => {
|
|
1692
|
+
const app = new Hono();
|
|
1693
|
+
const shutdown = { requested: false };
|
|
1694
|
+
registerHttpRoutes({
|
|
1695
|
+
app,
|
|
1696
|
+
auth,
|
|
1697
|
+
authAssetsDirectory,
|
|
1698
|
+
backgroundNotifications,
|
|
1699
|
+
developmentWebTarget,
|
|
1700
|
+
onRestartRequested,
|
|
1701
|
+
runtime,
|
|
1702
|
+
serverLogger,
|
|
1703
|
+
shutdown,
|
|
1704
|
+
webAssetsDir
|
|
1705
|
+
});
|
|
1706
|
+
return Object.assign(app, { beginShutdown: () => {
|
|
1707
|
+
shutdown.requested = true;
|
|
1708
|
+
} });
|
|
1709
|
+
};
|
|
1710
|
+
//#endregion
|
|
1711
|
+
//#region src/internal/server/websocket-connection.ts
|
|
1712
|
+
const maxPendingStreamOutputBytes = 1048576;
|
|
1713
|
+
var OperationError = class extends Error {
|
|
1714
|
+
code;
|
|
1715
|
+
constructor(code, message) {
|
|
1716
|
+
super(message);
|
|
1717
|
+
this.code = code;
|
|
1718
|
+
}
|
|
1719
|
+
};
|
|
1720
|
+
const bytesOf = (data) => {
|
|
1721
|
+
if (Array.isArray(data)) return Buffer.concat(data);
|
|
1722
|
+
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
|
1723
|
+
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
1724
|
+
};
|
|
1725
|
+
const textOf = (data) => Buffer.from(bytesOf(data)).toString("utf8");
|
|
1726
|
+
const frameLength = (message) => {
|
|
1727
|
+
const frame = encodeProtocolFrame(message);
|
|
1728
|
+
return typeof frame === "string" ? Buffer.byteLength(frame, "utf8") : frame.byteLength;
|
|
1729
|
+
};
|
|
1730
|
+
const operationIdOf = (value) => {
|
|
1731
|
+
if (typeof value !== "object" || value === null) return;
|
|
1732
|
+
const operationId = value.operationId;
|
|
1733
|
+
return typeof operationId === "string" && operationId.length <= 256 ? operationId : void 0;
|
|
1734
|
+
};
|
|
1735
|
+
const errorDetails = (cause) => {
|
|
1736
|
+
if (cause instanceof OperationError) return {
|
|
1737
|
+
code: cause.code,
|
|
1738
|
+
message: cause.message
|
|
1739
|
+
};
|
|
1740
|
+
if (cause instanceof ZodError || cause instanceof SyntaxError) return {
|
|
1741
|
+
code: "bad-request",
|
|
1742
|
+
message: cause instanceof ZodError ? cause.issues.map((issue) => `${issue.path.join(".") || "value"}: ${issue.message}`).join("; ") : cause.message
|
|
1743
|
+
};
|
|
1744
|
+
return {
|
|
1745
|
+
code: "internal",
|
|
1746
|
+
message: cause instanceof Error ? cause.message : String(cause)
|
|
1747
|
+
};
|
|
1748
|
+
};
|
|
1749
|
+
const send$1 = (state, message) => {
|
|
1750
|
+
if (state.closed || state.socket.readyState !== WebSocket.OPEN) return false;
|
|
1751
|
+
if (state.socket.bufferedAmount > 1048576) {
|
|
1752
|
+
state.socket.close(1013, "client could not keep up");
|
|
1753
|
+
return false;
|
|
1754
|
+
}
|
|
1755
|
+
state.socket.send(encodeProtocolFrame(serverProtocolMessageSchema.parse(message)));
|
|
1756
|
+
return true;
|
|
1757
|
+
};
|
|
1758
|
+
const sendError = (state, cause, operationId, identity) => {
|
|
1759
|
+
const error = errorDetails(cause);
|
|
1760
|
+
state.serverLogger?.log({
|
|
1761
|
+
correlationId: identity?.operationId ?? operationId ?? state.connectionId,
|
|
1762
|
+
details: {
|
|
1763
|
+
code: error.code,
|
|
1764
|
+
...identity,
|
|
1765
|
+
...errorDetails$1(cause)
|
|
1766
|
+
},
|
|
1767
|
+
event: "protocol-handler-error",
|
|
1768
|
+
level: "error"
|
|
1769
|
+
});
|
|
1770
|
+
send$1(state, {
|
|
1771
|
+
...error,
|
|
1772
|
+
operationId,
|
|
1773
|
+
type: "error"
|
|
1774
|
+
});
|
|
1775
|
+
};
|
|
1776
|
+
const disposeSubscription = async (state, subscriptionId) => {
|
|
1777
|
+
const entry = state.subscriptions.get(subscriptionId);
|
|
1778
|
+
if (!entry) return;
|
|
1779
|
+
state.subscriptions.delete(subscriptionId);
|
|
1780
|
+
entry.controller.abort(/* @__PURE__ */ new Error("Resource subscription closed"));
|
|
1781
|
+
await entry.dispose?.();
|
|
1782
|
+
};
|
|
1783
|
+
const disposeStream = async (state, streamId, notify = true) => {
|
|
1784
|
+
const entry = state.streams.get(streamId);
|
|
1785
|
+
if (!entry) {
|
|
1786
|
+
if (notify) send$1(state, {
|
|
1787
|
+
streamId,
|
|
1788
|
+
type: "stream-closed"
|
|
1789
|
+
});
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
state.streams.delete(streamId);
|
|
1793
|
+
entry.controller.abort(/* @__PURE__ */ new Error("Stream closed"));
|
|
1794
|
+
await entry.session?.dispose();
|
|
1795
|
+
if (notify) send$1(state, {
|
|
1796
|
+
streamId,
|
|
1797
|
+
type: "stream-closed"
|
|
1798
|
+
});
|
|
1799
|
+
};
|
|
1800
|
+
const disposeConnectionOperations = async (state, notify) => {
|
|
1801
|
+
const operationControllers = [...state.operations.values()];
|
|
1802
|
+
state.operations.clear();
|
|
1803
|
+
operationControllers.forEach((controller) => controller.abort(/* @__PURE__ */ new Error("WebSocket connection closed")));
|
|
1804
|
+
const subscriptionIds = [...state.subscriptions.keys()];
|
|
1805
|
+
const streamIds = [...state.streams.keys()];
|
|
1806
|
+
await Promise.allSettled([...subscriptionIds.map((subscriptionId) => disposeSubscription(state, subscriptionId)), ...streamIds.map((streamId) => disposeStream(state, streamId, notify))]);
|
|
1807
|
+
};
|
|
1808
|
+
const runOperation = (state, identity, operation) => {
|
|
1809
|
+
if (state.operations.has(identity.operationId)) {
|
|
1810
|
+
sendError(state, new OperationError("conflict", "Operation ID is already active"), identity.operationId, identity);
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1813
|
+
const controller = new AbortController();
|
|
1814
|
+
const startedAt = Date.now();
|
|
1815
|
+
state.operations.set(identity.operationId, controller);
|
|
1816
|
+
state.serverLogger?.log({
|
|
1817
|
+
correlationId: identity.operationId,
|
|
1818
|
+
details: identity,
|
|
1819
|
+
event: "operation-start"
|
|
1820
|
+
});
|
|
1821
|
+
operation(controller.signal).then(() => {
|
|
1822
|
+
state.serverLogger?.log({
|
|
1823
|
+
correlationId: identity.operationId,
|
|
1824
|
+
details: identity,
|
|
1825
|
+
durationMs: Date.now() - startedAt,
|
|
1826
|
+
event: "operation-complete"
|
|
1827
|
+
});
|
|
1828
|
+
}).catch((cause) => {
|
|
1829
|
+
if (!controller.signal.aborted && !state.closed) {
|
|
1830
|
+
state.serverLogger?.log({
|
|
1831
|
+
correlationId: identity.operationId,
|
|
1832
|
+
details: {
|
|
1833
|
+
...identity,
|
|
1834
|
+
...errorDetails$1(cause)
|
|
1835
|
+
},
|
|
1836
|
+
durationMs: Date.now() - startedAt,
|
|
1837
|
+
event: "operation-error",
|
|
1838
|
+
level: "error"
|
|
1839
|
+
});
|
|
1840
|
+
sendError(state, cause, identity.operationId, identity);
|
|
1841
|
+
}
|
|
1842
|
+
}).finally(() => {
|
|
1843
|
+
if (state.operations.get(identity.operationId) === controller) state.operations.delete(identity.operationId);
|
|
1844
|
+
});
|
|
1845
|
+
};
|
|
1846
|
+
const readResource = (state, message) => {
|
|
1847
|
+
const resource = state.runtime.getResource(message.resourceName);
|
|
1848
|
+
const identity = {
|
|
1849
|
+
operationId: message.operationId,
|
|
1850
|
+
operationType: message.type,
|
|
1851
|
+
requestedName: message.resourceName
|
|
1852
|
+
};
|
|
1853
|
+
runOperation(state, identity, async (signal) => {
|
|
1854
|
+
if (!resource) throw new OperationError("not-found", "Resource not found");
|
|
1855
|
+
const output = await resource.read(message.input, signal);
|
|
1856
|
+
send$1(state, {
|
|
1857
|
+
operationId: message.operationId,
|
|
1858
|
+
output,
|
|
1859
|
+
type: "resource-result"
|
|
1860
|
+
});
|
|
1861
|
+
});
|
|
1862
|
+
};
|
|
1863
|
+
const subscribeResource = (state, message) => {
|
|
1864
|
+
const resource = state.runtime.getResource(message.resourceName);
|
|
1865
|
+
const identity = {
|
|
1866
|
+
operationId: message.operationId,
|
|
1867
|
+
operationType: message.type,
|
|
1868
|
+
requestedName: message.resourceName
|
|
1869
|
+
};
|
|
1870
|
+
if (state.subscriptions.has(message.subscriptionId)) {
|
|
1871
|
+
sendError(state, new OperationError("conflict", "Subscription ID is already active"), message.operationId, identity);
|
|
1872
|
+
return;
|
|
1873
|
+
}
|
|
1874
|
+
runOperation(state, identity, async (operationSignal) => {
|
|
1875
|
+
if (!resource) throw new OperationError("not-found", "Resource not found");
|
|
1876
|
+
const controller = new AbortController();
|
|
1877
|
+
const entry = { controller };
|
|
1878
|
+
state.subscriptions.set(message.subscriptionId, entry);
|
|
1879
|
+
const signal = AbortSignal.any([operationSignal, controller.signal]);
|
|
1880
|
+
try {
|
|
1881
|
+
entry.dispose = resource.subscribe(message.input, () => void send$1(state, {
|
|
1882
|
+
subscriptionId: message.subscriptionId,
|
|
1883
|
+
type: "resource-invalidated"
|
|
1884
|
+
}), signal);
|
|
1885
|
+
} catch (cause) {
|
|
1886
|
+
await disposeSubscription(state, message.subscriptionId);
|
|
1887
|
+
throw cause;
|
|
1888
|
+
}
|
|
1889
|
+
});
|
|
1890
|
+
};
|
|
1891
|
+
const openStream = (state, message) => {
|
|
1892
|
+
const stream = state.runtime.getStream(message.streamName);
|
|
1893
|
+
const identity = {
|
|
1894
|
+
operationId: message.operationId,
|
|
1895
|
+
operationType: message.type,
|
|
1896
|
+
requestedName: message.streamName
|
|
1897
|
+
};
|
|
1898
|
+
if (state.streams.has(message.streamId)) {
|
|
1899
|
+
sendError(state, new OperationError("conflict", "Stream ID is already active"), message.operationId, identity);
|
|
1900
|
+
return;
|
|
1901
|
+
}
|
|
1902
|
+
runOperation(state, identity, async (operationSignal) => {
|
|
1903
|
+
if (!stream) throw new OperationError("not-found", "Stream not found");
|
|
1904
|
+
const controller = new AbortController();
|
|
1905
|
+
const entry = {
|
|
1906
|
+
controller,
|
|
1907
|
+
identity
|
|
1908
|
+
};
|
|
1909
|
+
state.streams.set(message.streamId, entry);
|
|
1910
|
+
const pending = [];
|
|
1911
|
+
let pendingBytes = 0;
|
|
1912
|
+
let opened = false;
|
|
1913
|
+
try {
|
|
1914
|
+
entry.session = await stream.open(message.input, (output) => {
|
|
1915
|
+
if (opened) {
|
|
1916
|
+
if (!send$1(state, {
|
|
1917
|
+
message: output,
|
|
1918
|
+
streamId: message.streamId,
|
|
1919
|
+
type: "stream-output"
|
|
1920
|
+
})) throw new Error("Stream output could not be delivered");
|
|
1921
|
+
return;
|
|
1922
|
+
}
|
|
1923
|
+
pendingBytes += frameLength(output);
|
|
1924
|
+
if (pendingBytes > maxPendingStreamOutputBytes) throw new Error("Stream produced too much output while opening");
|
|
1925
|
+
pending.push(output);
|
|
1926
|
+
}, AbortSignal.any([operationSignal, controller.signal]), (cause) => {
|
|
1927
|
+
sendError(state, cause, message.streamId, identity);
|
|
1928
|
+
disposeStream(state, message.streamId).catch((disposeCause) => {
|
|
1929
|
+
sendError(state, disposeCause, message.streamId, identity);
|
|
1930
|
+
});
|
|
1931
|
+
});
|
|
1932
|
+
send$1(state, {
|
|
1933
|
+
operationId: message.operationId,
|
|
1934
|
+
streamId: message.streamId,
|
|
1935
|
+
type: "stream-opened"
|
|
1936
|
+
});
|
|
1937
|
+
opened = true;
|
|
1938
|
+
for (const output of pending) if (!send$1(state, {
|
|
1939
|
+
message: output,
|
|
1940
|
+
streamId: message.streamId,
|
|
1941
|
+
type: "stream-output"
|
|
1942
|
+
})) throw new Error("Stream output could not be delivered");
|
|
1943
|
+
} catch (cause) {
|
|
1944
|
+
await disposeStream(state, message.streamId, false);
|
|
1945
|
+
throw cause;
|
|
1946
|
+
}
|
|
1947
|
+
});
|
|
1948
|
+
};
|
|
1949
|
+
const sendStreamMessage = (state, message) => {
|
|
1950
|
+
const entry = state.streams.get(message.streamId);
|
|
1951
|
+
const identity = {
|
|
1952
|
+
...entry?.identity,
|
|
1953
|
+
operationId: message.streamId,
|
|
1954
|
+
operationType: message.type
|
|
1955
|
+
};
|
|
1956
|
+
if (!entry?.session) {
|
|
1957
|
+
sendError(state, new OperationError("not-found", "Stream not found"), message.streamId, identity);
|
|
1958
|
+
return;
|
|
1959
|
+
}
|
|
1960
|
+
entry.session.send(message.message).catch((cause) => {
|
|
1961
|
+
sendError(state, cause, message.streamId, identity);
|
|
1962
|
+
disposeStream(state, message.streamId).catch((disposeCause) => {
|
|
1963
|
+
sendError(state, disposeCause, message.streamId, identity);
|
|
1964
|
+
});
|
|
1965
|
+
});
|
|
1966
|
+
};
|
|
1967
|
+
const logClientDiagnostic = (state, message) => {
|
|
1968
|
+
if (!state.serverLogger?.enabled) return;
|
|
1969
|
+
state.serverLogger.log({
|
|
1970
|
+
details: {
|
|
1971
|
+
arguments: message.arguments,
|
|
1972
|
+
...message.stack ? { stack: message.stack } : {},
|
|
1973
|
+
...message.url ? { url: message.url } : {}
|
|
1974
|
+
},
|
|
1975
|
+
event: "browser-console",
|
|
1976
|
+
level: message.level === "log" ? "info" : message.level,
|
|
1977
|
+
message: message.message,
|
|
1978
|
+
source: "browser",
|
|
1979
|
+
timestamp: message.timestamp
|
|
1980
|
+
});
|
|
1981
|
+
};
|
|
1982
|
+
const handleMessage = (state, message) => {
|
|
1983
|
+
if (message.type === "client-diagnostic") {
|
|
1984
|
+
logClientDiagnostic(state, message);
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
if (message.type === "resource-read") {
|
|
1988
|
+
readResource(state, message);
|
|
1989
|
+
return;
|
|
1990
|
+
}
|
|
1991
|
+
if (message.type === "resource-subscribe") {
|
|
1992
|
+
subscribeResource(state, message);
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1995
|
+
if (message.type === "resource-unsubscribe") {
|
|
1996
|
+
disposeSubscription(state, message.subscriptionId).catch((cause) => {
|
|
1997
|
+
sendError(state, cause, message.subscriptionId);
|
|
1998
|
+
});
|
|
1999
|
+
return;
|
|
2000
|
+
}
|
|
2001
|
+
if (message.type === "stream-open") {
|
|
2002
|
+
openStream(state, message);
|
|
2003
|
+
return;
|
|
2004
|
+
}
|
|
2005
|
+
if (message.type === "stream-close") {
|
|
2006
|
+
disposeStream(state, message.streamId).catch((cause) => {
|
|
2007
|
+
sendError(state, cause, message.streamId);
|
|
2008
|
+
});
|
|
2009
|
+
return;
|
|
2010
|
+
}
|
|
2011
|
+
sendStreamMessage(state, message);
|
|
2012
|
+
};
|
|
2013
|
+
const handleFrame = (state, data, isBinary) => {
|
|
2014
|
+
let raw;
|
|
2015
|
+
try {
|
|
2016
|
+
raw = decodeProtocolFrame(isBinary ? bytesOf(data) : textOf(data));
|
|
2017
|
+
const result = clientProtocolMessageSchema.safeParse(raw);
|
|
2018
|
+
if (!result.success) {
|
|
2019
|
+
state.serverLogger?.log({
|
|
2020
|
+
correlationId: state.connectionId,
|
|
2021
|
+
details: errorDetails$1(result.error),
|
|
2022
|
+
event: "protocol-parse-error",
|
|
2023
|
+
level: "error"
|
|
2024
|
+
});
|
|
2025
|
+
sendError(state, result.error, operationIdOf(raw));
|
|
2026
|
+
return;
|
|
2027
|
+
}
|
|
2028
|
+
handleMessage(state, result.data);
|
|
2029
|
+
} catch (cause) {
|
|
2030
|
+
state.serverLogger?.log({
|
|
2031
|
+
correlationId: state.connectionId,
|
|
2032
|
+
details: errorDetails$1(cause),
|
|
2033
|
+
event: "protocol-parse-error",
|
|
2034
|
+
level: "error"
|
|
2035
|
+
});
|
|
2036
|
+
sendError(state, cause, operationIdOf(raw));
|
|
2037
|
+
}
|
|
2038
|
+
};
|
|
2039
|
+
const disposeConnection = async (state) => {
|
|
2040
|
+
if (state.closed) return;
|
|
2041
|
+
state.closed = true;
|
|
2042
|
+
state.serverLogger?.log({
|
|
2043
|
+
correlationId: state.connectionId,
|
|
2044
|
+
event: "websocket-close"
|
|
2045
|
+
});
|
|
2046
|
+
await disposeConnectionOperations(state, false);
|
|
2047
|
+
};
|
|
2048
|
+
const createConnectionState = ({ runtime, serverLogger, socket }) => ({
|
|
2049
|
+
closed: false,
|
|
2050
|
+
connectionId: serverLogger?.id(),
|
|
2051
|
+
operations: /* @__PURE__ */ new Map(),
|
|
2052
|
+
runtime,
|
|
2053
|
+
serverLogger,
|
|
2054
|
+
socket,
|
|
2055
|
+
streams: /* @__PURE__ */ new Map(),
|
|
2056
|
+
subscriptions: /* @__PURE__ */ new Map()
|
|
2057
|
+
});
|
|
2058
|
+
const attachWebSocketConnection = ({ runtime, serverLogger, socket }) => {
|
|
2059
|
+
const state = createConnectionState({
|
|
2060
|
+
runtime,
|
|
2061
|
+
serverLogger,
|
|
2062
|
+
socket
|
|
2063
|
+
});
|
|
2064
|
+
serverLogger?.log({
|
|
2065
|
+
correlationId: state.connectionId,
|
|
2066
|
+
event: "websocket-open"
|
|
2067
|
+
});
|
|
2068
|
+
socket.on("message", (data, isBinary) => handleFrame(state, data, isBinary));
|
|
2069
|
+
socket.once("close", (code, reason) => {
|
|
2070
|
+
serverLogger?.log({
|
|
2071
|
+
correlationId: state.connectionId,
|
|
2072
|
+
details: {
|
|
2073
|
+
code,
|
|
2074
|
+
reason: reason?.toString() ?? ""
|
|
2075
|
+
},
|
|
2076
|
+
event: "websocket-close-event"
|
|
2077
|
+
});
|
|
2078
|
+
disposeConnection(state);
|
|
2079
|
+
});
|
|
2080
|
+
socket.once("error", (cause) => {
|
|
2081
|
+
serverLogger?.log({
|
|
2082
|
+
correlationId: state.connectionId,
|
|
2083
|
+
details: errorDetails$1(cause),
|
|
2084
|
+
event: "websocket-error",
|
|
2085
|
+
level: "error"
|
|
2086
|
+
});
|
|
2087
|
+
disposeConnection(state);
|
|
2088
|
+
});
|
|
2089
|
+
return () => disposeConnection(state);
|
|
2090
|
+
};
|
|
2091
|
+
//#endregion
|
|
2092
|
+
//#region src/internal/server/vite-websocket-proxy.ts
|
|
2093
|
+
const setTrustedForwardingHeaders = (request, origin) => {
|
|
2094
|
+
Object.keys(request.headers).forEach((name) => {
|
|
2095
|
+
if (name === "authorization" || name === "cookie" || name === "forwarded" || name.startsWith("x-forwarded-")) Reflect.deleteProperty(request.headers, name);
|
|
2096
|
+
});
|
|
2097
|
+
const publicUrl = new URL(origin);
|
|
2098
|
+
request.headers["x-forwarded-for"] = request.socket.remoteAddress;
|
|
2099
|
+
request.headers["x-forwarded-host"] = publicUrl.host;
|
|
2100
|
+
request.headers["x-forwarded-port"] = publicUrl.port || (publicUrl.protocol === "https:" ? "443" : "80");
|
|
2101
|
+
request.headers["x-forwarded-proto"] = publicUrl.protocol.slice(0, -1);
|
|
2102
|
+
};
|
|
2103
|
+
const closeFrame = (code, reason) => {
|
|
2104
|
+
const payload = Buffer.allocUnsafe(2 + Buffer.byteLength(reason));
|
|
2105
|
+
payload.writeUInt16BE(code);
|
|
2106
|
+
payload.write(reason, 2);
|
|
2107
|
+
return Buffer.concat([Buffer.from([136, payload.length]), payload]);
|
|
2108
|
+
};
|
|
2109
|
+
const closeSocket = (socket, code, reason) => {
|
|
2110
|
+
if (socket.closed) return;
|
|
2111
|
+
socket.closed = true;
|
|
2112
|
+
clearInterval(socket.timer);
|
|
2113
|
+
if (!socket.client.destroyed) socket.client.end(closeFrame(code, reason));
|
|
2114
|
+
socket.upstream?.destroy();
|
|
2115
|
+
};
|
|
2116
|
+
const revalidateSocket = async ({ auth, serverLogger, origin, socket, token }) => {
|
|
2117
|
+
try {
|
|
2118
|
+
if (!await auth.service.authenticateToken(token, origin)) closeSocket(socket, webSocketCloseCode.authenticationRevoked, "authentication revoked");
|
|
2119
|
+
} catch (cause) {
|
|
2120
|
+
serverLogger?.log({
|
|
2121
|
+
details: errorDetails$1(cause),
|
|
2122
|
+
event: "vite-websocket-session-revalidation-failure",
|
|
2123
|
+
level: "error"
|
|
2124
|
+
});
|
|
2125
|
+
closeSocket(socket, webSocketCloseCode.authenticationUnavailable, "authentication unavailable");
|
|
2126
|
+
}
|
|
2127
|
+
};
|
|
2128
|
+
const createViteWebSocketProxy = ({ auth, serverLogger, target }) => {
|
|
2129
|
+
const sockets = /* @__PURE__ */ new Map();
|
|
2130
|
+
const connect = ({ correlationId, head, origin, request, session, socket }) => {
|
|
2131
|
+
let revalidationPending = false;
|
|
2132
|
+
const proxied = {
|
|
2133
|
+
client: socket,
|
|
2134
|
+
closed: false,
|
|
2135
|
+
sessionId: session.id,
|
|
2136
|
+
timer: setInterval(() => {
|
|
2137
|
+
if (revalidationPending) return;
|
|
2138
|
+
revalidationPending = true;
|
|
2139
|
+
revalidateSocket({
|
|
2140
|
+
auth,
|
|
2141
|
+
origin,
|
|
2142
|
+
serverLogger,
|
|
2143
|
+
socket: proxied,
|
|
2144
|
+
token: session.token
|
|
2145
|
+
}).finally(() => {
|
|
2146
|
+
revalidationPending = false;
|
|
2147
|
+
});
|
|
2148
|
+
}, 1e3)
|
|
2149
|
+
};
|
|
2150
|
+
sockets.set(socket, proxied);
|
|
2151
|
+
socket.once("close", () => {
|
|
2152
|
+
clearInterval(proxied.timer);
|
|
2153
|
+
sockets.delete(socket);
|
|
2154
|
+
});
|
|
2155
|
+
setTrustedForwardingHeaders(request, origin);
|
|
2156
|
+
proxyUpgrade(target, request, socket, head, {
|
|
2157
|
+
changeOrigin: true,
|
|
2158
|
+
xfwd: false
|
|
2159
|
+
}).then((upstream) => {
|
|
2160
|
+
if (proxied.closed) {
|
|
2161
|
+
upstream.destroy();
|
|
2162
|
+
return;
|
|
2163
|
+
}
|
|
2164
|
+
proxied.upstream = upstream;
|
|
2165
|
+
}, (cause) => {
|
|
2166
|
+
clearInterval(proxied.timer);
|
|
2167
|
+
sockets.delete(socket);
|
|
2168
|
+
if (proxied.closed) return;
|
|
2169
|
+
proxied.closed = true;
|
|
2170
|
+
serverLogger?.log({
|
|
2171
|
+
correlationId,
|
|
2172
|
+
details: errorDetails$1(cause),
|
|
2173
|
+
event: "vite-websocket-proxy-failure",
|
|
2174
|
+
level: "error"
|
|
2175
|
+
});
|
|
2176
|
+
});
|
|
2177
|
+
};
|
|
2178
|
+
return {
|
|
2179
|
+
close: () => {
|
|
2180
|
+
sockets.forEach((socket) => closeSocket(socket, 1001, "server shutting down"));
|
|
2181
|
+
sockets.clear();
|
|
2182
|
+
},
|
|
2183
|
+
connect,
|
|
2184
|
+
revoke: (ids) => {
|
|
2185
|
+
sockets.forEach((socket) => {
|
|
2186
|
+
if (ids.has(socket.sessionId)) closeSocket(socket, webSocketCloseCode.authenticationRevoked, "authentication revoked");
|
|
2187
|
+
});
|
|
2188
|
+
}
|
|
2189
|
+
};
|
|
2190
|
+
};
|
|
2191
|
+
//#endregion
|
|
2192
|
+
//#region src/internal/server/websocket-server.ts
|
|
2193
|
+
const maxWebSocketPayloadBytes = 1048576;
|
|
2194
|
+
const rejectUpgrade = (socket, status, message) => {
|
|
2195
|
+
socket.end(`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
|
|
2196
|
+
};
|
|
2197
|
+
const requestPath = (request) => {
|
|
2198
|
+
try {
|
|
2199
|
+
return new URL(request.url ?? "", "http://localhost").pathname;
|
|
2200
|
+
} catch {
|
|
2201
|
+
return;
|
|
2202
|
+
}
|
|
2203
|
+
};
|
|
2204
|
+
const requestAuthenticationInput = (request) => {
|
|
2205
|
+
const host = request.headers.host;
|
|
2206
|
+
if (!host) return;
|
|
2207
|
+
const headers = new Headers();
|
|
2208
|
+
Object.entries(request.headers).forEach(([name, value]) => {
|
|
2209
|
+
if (Array.isArray(value)) value.forEach((entry) => headers.append(name, entry));
|
|
2210
|
+
else if (value !== void 0) headers.set(name, value);
|
|
2211
|
+
});
|
|
2212
|
+
const encrypted = Boolean(request.socket.encrypted);
|
|
2213
|
+
try {
|
|
2214
|
+
return new Request(`${encrypted ? "https" : "http"}://${host}${request.url ?? "/"}`, { headers });
|
|
2215
|
+
} catch {
|
|
2216
|
+
return;
|
|
2217
|
+
}
|
|
2218
|
+
};
|
|
2219
|
+
const attachSessionLifecycle = ({ auth, serverLogger, session, sessionSockets, socket }) => {
|
|
2220
|
+
sessionSockets.set(socket, session.id);
|
|
2221
|
+
let revalidationPending = false;
|
|
2222
|
+
const revalidationTimer = setInterval(() => {
|
|
2223
|
+
if (revalidationPending) return;
|
|
2224
|
+
revalidationPending = true;
|
|
2225
|
+
auth.service.authenticateToken(session.token, session.origin).then((authenticated) => {
|
|
2226
|
+
if (!authenticated) socket.close(webSocketCloseCode.authenticationRevoked, "authentication revoked");
|
|
2227
|
+
}, (cause) => {
|
|
2228
|
+
serverLogger?.log({
|
|
2229
|
+
details: errorDetails$1(cause),
|
|
2230
|
+
event: "websocket-session-revalidation-failure",
|
|
2231
|
+
level: "error"
|
|
2232
|
+
});
|
|
2233
|
+
socket.close(webSocketCloseCode.authenticationUnavailable, "authentication unavailable");
|
|
2234
|
+
}).finally(() => {
|
|
2235
|
+
revalidationPending = false;
|
|
2236
|
+
});
|
|
2237
|
+
}, 1e3);
|
|
2238
|
+
socket.once("close", () => {
|
|
2239
|
+
clearInterval(revalidationTimer);
|
|
2240
|
+
sessionSockets.delete(socket);
|
|
2241
|
+
});
|
|
2242
|
+
};
|
|
2243
|
+
const createWebSocketServer = ({ auth, developmentWebTarget, runtime, server, serverLogger }) => {
|
|
2244
|
+
const webSocketServer = new WebSocketServer({
|
|
2245
|
+
maxPayload: maxWebSocketPayloadBytes,
|
|
2246
|
+
noServer: true
|
|
2247
|
+
});
|
|
2248
|
+
let updateAvailable = false;
|
|
2249
|
+
const sessionSockets = /* @__PURE__ */ new Map();
|
|
2250
|
+
const viteProxy = developmentWebTarget ? createViteWebSocketProxy({
|
|
2251
|
+
auth,
|
|
2252
|
+
serverLogger,
|
|
2253
|
+
target: developmentWebTarget
|
|
2254
|
+
}) : void 0;
|
|
2255
|
+
const removeRevocationListener = auth.service.onSessionsRevoked((ids) => {
|
|
2256
|
+
sessionSockets.forEach((sessionId, socket) => {
|
|
2257
|
+
if (ids.has(sessionId)) socket.close(webSocketCloseCode.authenticationRevoked, "authentication revoked");
|
|
2258
|
+
});
|
|
2259
|
+
viteProxy?.revoke(ids);
|
|
2260
|
+
});
|
|
2261
|
+
const onUpgrade = async (request, socket, head) => {
|
|
2262
|
+
const correlationId = serverLogger?.id();
|
|
2263
|
+
serverLogger?.log({
|
|
2264
|
+
correlationId,
|
|
2265
|
+
event: "websocket-connect"
|
|
2266
|
+
});
|
|
2267
|
+
const path = requestPath(request);
|
|
2268
|
+
const isRuntimeSocket = path === runtimeWebSocketPath;
|
|
2269
|
+
const isViteSocket = path === "/_overmux/vite/hmr" && developmentWebTarget !== void 0;
|
|
2270
|
+
if (!isRuntimeSocket && !isViteSocket) {
|
|
2271
|
+
serverLogger?.log({
|
|
2272
|
+
correlationId,
|
|
2273
|
+
details: { status: 404 },
|
|
2274
|
+
event: "websocket-connect-rejected",
|
|
2275
|
+
level: "warn"
|
|
2276
|
+
});
|
|
2277
|
+
rejectUpgrade(socket, 404, "Not Found");
|
|
2278
|
+
return;
|
|
2279
|
+
}
|
|
2280
|
+
const authenticationInput = requestAuthenticationInput(request);
|
|
2281
|
+
if (!authenticationInput) {
|
|
2282
|
+
rejectUpgrade(socket, 400, "Bad Request");
|
|
2283
|
+
return;
|
|
2284
|
+
}
|
|
2285
|
+
const origin = auth.resolveOrigin({
|
|
2286
|
+
remoteAddress: request.socket.remoteAddress,
|
|
2287
|
+
request: authenticationInput
|
|
2288
|
+
});
|
|
2289
|
+
let session;
|
|
2290
|
+
try {
|
|
2291
|
+
session = await auth.requests.authenticate(authenticationInput, origin);
|
|
2292
|
+
} catch (cause) {
|
|
2293
|
+
serverLogger?.log({
|
|
2294
|
+
correlationId,
|
|
2295
|
+
details: errorDetails$1(cause),
|
|
2296
|
+
event: "websocket-authentication-failure",
|
|
2297
|
+
level: "error"
|
|
2298
|
+
});
|
|
2299
|
+
rejectUpgrade(socket, 503, "Service Unavailable");
|
|
2300
|
+
return;
|
|
2301
|
+
}
|
|
2302
|
+
if (!session) {
|
|
2303
|
+
rejectUpgrade(socket, 401, "Unauthorized");
|
|
2304
|
+
return;
|
|
2305
|
+
}
|
|
2306
|
+
if (session.kind === "browser" && (!origin || request.headers.origin !== origin)) {
|
|
2307
|
+
rejectUpgrade(socket, 403, "Forbidden");
|
|
2308
|
+
return;
|
|
2309
|
+
}
|
|
2310
|
+
if (isViteSocket) {
|
|
2311
|
+
if (session.kind !== "browser" || session.credential !== "cookie" || !origin) {
|
|
2312
|
+
rejectUpgrade(socket, 401, "Unauthorized");
|
|
2313
|
+
return;
|
|
2314
|
+
}
|
|
2315
|
+
if (!viteProxy) {
|
|
2316
|
+
rejectUpgrade(socket, 503, "Service Unavailable");
|
|
2317
|
+
return;
|
|
2318
|
+
}
|
|
2319
|
+
viteProxy.connect({
|
|
2320
|
+
correlationId,
|
|
2321
|
+
head,
|
|
2322
|
+
origin,
|
|
2323
|
+
request,
|
|
2324
|
+
session,
|
|
2325
|
+
socket
|
|
2326
|
+
});
|
|
2327
|
+
return;
|
|
2328
|
+
}
|
|
2329
|
+
webSocketServer.handleUpgrade(request, socket, head, (webSocket) => {
|
|
2330
|
+
attachSessionLifecycle({
|
|
2331
|
+
auth,
|
|
2332
|
+
serverLogger,
|
|
2333
|
+
session,
|
|
2334
|
+
sessionSockets,
|
|
2335
|
+
socket: webSocket
|
|
2336
|
+
});
|
|
2337
|
+
webSocketServer.emit("connection", webSocket, request);
|
|
2338
|
+
attachWebSocketConnection({
|
|
2339
|
+
runtime,
|
|
2340
|
+
serverLogger,
|
|
2341
|
+
socket: webSocket
|
|
2342
|
+
});
|
|
2343
|
+
if (updateAvailable) webSocket.send(encodeProtocolFrame({ type: "update-available" }));
|
|
2344
|
+
});
|
|
2345
|
+
};
|
|
2346
|
+
server.on("upgrade", onUpgrade);
|
|
2347
|
+
const broadcast = (message) => {
|
|
2348
|
+
const frame = encodeProtocolFrame(serverProtocolMessageSchema.parse(message));
|
|
2349
|
+
let delivered = 0;
|
|
2350
|
+
webSocketServer.clients.forEach((socket) => {
|
|
2351
|
+
if (socket.readyState !== WebSocket.OPEN) return;
|
|
2352
|
+
if (socket.bufferedAmount > 1048576) {
|
|
2353
|
+
socket.close(1013, "client could not keep up");
|
|
2354
|
+
return;
|
|
2355
|
+
}
|
|
2356
|
+
try {
|
|
2357
|
+
socket.send(frame);
|
|
2358
|
+
delivered += 1;
|
|
2359
|
+
} catch (cause) {
|
|
2360
|
+
socket.terminate();
|
|
2361
|
+
serverLogger?.log({
|
|
2362
|
+
details: errorDetails$1(cause),
|
|
2363
|
+
event: "websocket-broadcast-failure",
|
|
2364
|
+
level: "error"
|
|
2365
|
+
});
|
|
2366
|
+
}
|
|
2367
|
+
});
|
|
2368
|
+
return delivered;
|
|
2369
|
+
};
|
|
2370
|
+
return {
|
|
2371
|
+
announceRestarting: () => broadcast({ type: "restarting" }),
|
|
2372
|
+
announceUpdateAvailable: () => {
|
|
2373
|
+
updateAvailable = true;
|
|
2374
|
+
return broadcast({ type: "update-available" });
|
|
2375
|
+
},
|
|
2376
|
+
broadcastNotification: (notification) => broadcast(notificationEventSchema.parse({
|
|
2377
|
+
notification,
|
|
2378
|
+
type: "notification"
|
|
2379
|
+
})),
|
|
2380
|
+
close: async () => {
|
|
2381
|
+
server.off("upgrade", onUpgrade);
|
|
2382
|
+
removeRevocationListener();
|
|
2383
|
+
viteProxy?.close();
|
|
2384
|
+
webSocketServer.clients.forEach((socket) => socket.terminate());
|
|
2385
|
+
await new Promise((resolve) => webSocketServer.close(() => resolve()));
|
|
2386
|
+
}
|
|
2387
|
+
};
|
|
2388
|
+
};
|
|
2389
|
+
//#endregion
|
|
2390
|
+
//#region src/internal/server/notifications/background-notification-payload.ts
|
|
2391
|
+
const maxPayloadBytes = 3800;
|
|
2392
|
+
const payloadBytes = (payload) => new TextEncoder().encode(JSON.stringify(payload)).byteLength;
|
|
2393
|
+
const largestFittingPrefix = (characters, accepts, low = 0, high = characters.length) => {
|
|
2394
|
+
if (low >= high) return characters.slice(0, low).join("");
|
|
2395
|
+
const middle = Math.ceil((low + high) / 2);
|
|
2396
|
+
return accepts(characters.slice(0, middle).join("")) ? largestFittingPrefix(characters, accepts, middle, high) : largestFittingPrefix(characters, accepts, low, middle - 1);
|
|
2397
|
+
};
|
|
2398
|
+
const notificationPayload = (notification) => ({
|
|
2399
|
+
notification,
|
|
2400
|
+
type: "notification"
|
|
2401
|
+
});
|
|
2402
|
+
const createBackgroundNotificationPayload = (notification) => {
|
|
2403
|
+
const original = notificationPayload(notification);
|
|
2404
|
+
if (payloadBytes(original) <= maxPayloadBytes) return JSON.stringify(original);
|
|
2405
|
+
const withoutBody = {
|
|
2406
|
+
...notification,
|
|
2407
|
+
body: void 0
|
|
2408
|
+
};
|
|
2409
|
+
const body = largestFittingPrefix([...notification.body ?? ""], (prefix) => payloadBytes(notificationPayload({
|
|
2410
|
+
...withoutBody,
|
|
2411
|
+
...prefix ? { body: prefix } : {}
|
|
2412
|
+
})) <= maxPayloadBytes);
|
|
2413
|
+
const bodyTruncated = notificationPayload({
|
|
2414
|
+
...withoutBody,
|
|
2415
|
+
...body ? { body } : {}
|
|
2416
|
+
});
|
|
2417
|
+
if (payloadBytes(bodyTruncated) <= maxPayloadBytes) return JSON.stringify(bodyTruncated);
|
|
2418
|
+
const title = largestFittingPrefix([...notification.title], (prefix) => Boolean(prefix) && payloadBytes(notificationPayload({
|
|
2419
|
+
...withoutBody,
|
|
2420
|
+
title: prefix
|
|
2421
|
+
})) <= maxPayloadBytes);
|
|
2422
|
+
if (title) return JSON.stringify(notificationPayload({
|
|
2423
|
+
...withoutBody,
|
|
2424
|
+
title
|
|
2425
|
+
}));
|
|
2426
|
+
const safeTitle = largestFittingPrefix([...notification.title], (prefix) => Boolean(prefix) && payloadBytes(notificationPayload({ title: prefix })) <= maxPayloadBytes);
|
|
2427
|
+
return JSON.stringify(notificationPayload({ title: safeTitle || "Overmux" }));
|
|
2428
|
+
};
|
|
2429
|
+
//#endregion
|
|
2430
|
+
//#region src/internal/server/notifications/background-notification-service.ts
|
|
2431
|
+
const vapidSubject = "https://github.com/richardgill/overmux";
|
|
2432
|
+
const webPushStatus = (cause) => {
|
|
2433
|
+
if (typeof cause === "object" && cause !== null && "statusCode" in cause && typeof cause.statusCode === "number") return cause.statusCode;
|
|
2434
|
+
};
|
|
2435
|
+
const deliverToSubscription = async ({ keys, logger, payload, sendWebPush, store, subscription }) => {
|
|
2436
|
+
try {
|
|
2437
|
+
await sendWebPush(subscription, payload, {
|
|
2438
|
+
TTL: 60,
|
|
2439
|
+
vapidDetails: {
|
|
2440
|
+
privateKey: keys.privateKey,
|
|
2441
|
+
publicKey: keys.publicKey,
|
|
2442
|
+
subject: vapidSubject
|
|
2443
|
+
}
|
|
2444
|
+
});
|
|
2445
|
+
} catch (cause) {
|
|
2446
|
+
const status = webPushStatus(cause);
|
|
2447
|
+
if (status === 404 || status === 410) await store.removeSubscription(subscription.endpoint);
|
|
2448
|
+
logger?.log({
|
|
2449
|
+
details: {
|
|
2450
|
+
...errorDetails$1(cause),
|
|
2451
|
+
...status ? { status } : {}
|
|
2452
|
+
},
|
|
2453
|
+
event: "background-notification-delivery-failure",
|
|
2454
|
+
level: "warn"
|
|
2455
|
+
});
|
|
2456
|
+
}
|
|
2457
|
+
};
|
|
2458
|
+
const createBackgroundNotificationService = ({ isSessionActive = async () => true, logger, sendWebPush = webPush.sendNotification, store }) => {
|
|
2459
|
+
let keysPromise;
|
|
2460
|
+
const getKeys = () => {
|
|
2461
|
+
if (!keysPromise) keysPromise = store.getOrCreateKeys(webPush.generateVAPIDKeys).catch((cause) => {
|
|
2462
|
+
keysPromise = void 0;
|
|
2463
|
+
throw cause;
|
|
2464
|
+
});
|
|
2465
|
+
return keysPromise;
|
|
2466
|
+
};
|
|
2467
|
+
return {
|
|
2468
|
+
disable: (endpoint) => store.removeSubscription(endpoint),
|
|
2469
|
+
disableSessions: (sessionIds) => store.removeSessionSubscriptions(sessionIds),
|
|
2470
|
+
enable: (sessionId, subscription) => store.upsertSubscription(sessionId, subscription),
|
|
2471
|
+
publicKey: async () => (await getKeys()).publicKey,
|
|
2472
|
+
send: async (notification) => {
|
|
2473
|
+
const storedSubscriptions = await store.listSubscriptions();
|
|
2474
|
+
const subscriptions = (await Promise.all(storedSubscriptions.map(async (entry) => ({
|
|
2475
|
+
active: await isSessionActive(entry.sessionId),
|
|
2476
|
+
entry
|
|
2477
|
+
})))).flatMap(({ active: sessionActive, entry }) => sessionActive ? [entry] : []);
|
|
2478
|
+
if (!subscriptions.length) return;
|
|
2479
|
+
const keys = await getKeys();
|
|
2480
|
+
const payload = createBackgroundNotificationPayload(notification);
|
|
2481
|
+
await Promise.all(subscriptions.map((entry) => deliverToSubscription({
|
|
2482
|
+
keys,
|
|
2483
|
+
logger,
|
|
2484
|
+
payload,
|
|
2485
|
+
sendWebPush,
|
|
2486
|
+
store,
|
|
2487
|
+
subscription: entry.subscription
|
|
2488
|
+
})));
|
|
2489
|
+
}
|
|
2490
|
+
};
|
|
2491
|
+
};
|
|
2492
|
+
//#endregion
|
|
2493
|
+
//#region src/internal/server/notifications/background-notification-store.ts
|
|
2494
|
+
const vapidKeysSchema = z.object({
|
|
2495
|
+
privateKey: z.string().min(1),
|
|
2496
|
+
publicKey: z.string().min(1)
|
|
2497
|
+
}).strict();
|
|
2498
|
+
const storedSubscriptionSchema = z.object({
|
|
2499
|
+
sessionId: z.string(),
|
|
2500
|
+
subscription: backgroundNotificationSubscriptionSchema
|
|
2501
|
+
});
|
|
2502
|
+
const subscriptionsSchema = z.array(storedSubscriptionSchema);
|
|
2503
|
+
const backgroundNotificationDataDirectory = ({ environment, homeDirectory }) => join(getOvermuxPaths({
|
|
2504
|
+
environment,
|
|
2505
|
+
homeDir: homeDirectory
|
|
2506
|
+
}).dataDir, "background-notifications");
|
|
2507
|
+
const createFileBackgroundNotificationStore = ({ environment = process.env, homeDirectory = homedir() } = {}) => {
|
|
2508
|
+
const directory = backgroundNotificationDataDirectory({
|
|
2509
|
+
environment,
|
|
2510
|
+
homeDirectory
|
|
2511
|
+
});
|
|
2512
|
+
const keysPath = join(directory, "vapid.json");
|
|
2513
|
+
const subscriptionsPath = join(directory, "subscriptions.json");
|
|
2514
|
+
let pendingSubscriptionWrite = Promise.resolve();
|
|
2515
|
+
const readSubscriptions = async () => await readPrivateJson(subscriptionsPath, subscriptionsSchema) ?? [];
|
|
2516
|
+
const updateSubscriptions = (update) => {
|
|
2517
|
+
const operation = pendingSubscriptionWrite.then(async () => {
|
|
2518
|
+
const updated = update(await readSubscriptions());
|
|
2519
|
+
if (updated) await replacePrivateJsonAtomically(subscriptionsPath, updated);
|
|
2520
|
+
});
|
|
2521
|
+
pendingSubscriptionWrite = operation.catch(() => void 0);
|
|
2522
|
+
return operation;
|
|
2523
|
+
};
|
|
2524
|
+
return {
|
|
2525
|
+
getOrCreateKeys: async (generate) => {
|
|
2526
|
+
const existing = await readPrivateJson(keysPath, vapidKeysSchema);
|
|
2527
|
+
if (existing) return existing;
|
|
2528
|
+
const generated = vapidKeysSchema.parse(generate());
|
|
2529
|
+
try {
|
|
2530
|
+
await createPrivateJsonExclusively(keysPath, generated);
|
|
2531
|
+
return generated;
|
|
2532
|
+
} catch (cause) {
|
|
2533
|
+
if (cause.code !== "EEXIST") throw cause;
|
|
2534
|
+
const concurrentlyCreated = await readPrivateJson(keysPath, vapidKeysSchema);
|
|
2535
|
+
if (!concurrentlyCreated) throw cause;
|
|
2536
|
+
return concurrentlyCreated;
|
|
2537
|
+
}
|
|
2538
|
+
},
|
|
2539
|
+
listSubscriptions: async () => {
|
|
2540
|
+
await pendingSubscriptionWrite;
|
|
2541
|
+
return readSubscriptions();
|
|
2542
|
+
},
|
|
2543
|
+
removeSubscription: (endpoint) => updateSubscriptions((subscriptions) => {
|
|
2544
|
+
if (!subscriptions.some((entry) => entry.subscription.endpoint === endpoint)) return;
|
|
2545
|
+
return subscriptions.filter((entry) => entry.subscription.endpoint !== endpoint);
|
|
2546
|
+
}),
|
|
2547
|
+
removeSessionSubscriptions: (sessionIds) => updateSubscriptions((subscriptions) => {
|
|
2548
|
+
const updated = subscriptions.filter((entry) => !sessionIds.has(entry.sessionId));
|
|
2549
|
+
return updated.length === subscriptions.length ? void 0 : updated;
|
|
2550
|
+
}),
|
|
2551
|
+
upsertSubscription: (sessionId, subscription) => {
|
|
2552
|
+
const parsed = backgroundNotificationSubscriptionSchema.parse(subscription);
|
|
2553
|
+
return updateSubscriptions((subscriptions) => {
|
|
2554
|
+
const entry = {
|
|
2555
|
+
sessionId,
|
|
2556
|
+
subscription: parsed
|
|
2557
|
+
};
|
|
2558
|
+
if (isDeepStrictEqual(subscriptions.find((candidate) => candidate.subscription.endpoint === parsed.endpoint), entry)) return;
|
|
2559
|
+
return [...subscriptions.filter((candidate) => candidate.subscription.endpoint !== parsed.endpoint), entry];
|
|
2560
|
+
});
|
|
2561
|
+
}
|
|
2562
|
+
};
|
|
2563
|
+
};
|
|
2564
|
+
z.void();
|
|
2565
|
+
const jsonValueSchema = z.lazy(() => z.union([
|
|
2566
|
+
z.string(),
|
|
2567
|
+
z.number().finite(),
|
|
2568
|
+
z.boolean(),
|
|
2569
|
+
z.null(),
|
|
2570
|
+
z.array(jsonValueSchema),
|
|
2571
|
+
z.record(z.string(), jsonValueSchema)
|
|
2572
|
+
]));
|
|
2573
|
+
//#endregion
|
|
2574
|
+
//#region src/internal/server/notifications/notification-service.ts
|
|
2575
|
+
const deliverSafely = async ({ deliver, event, logger }) => {
|
|
2576
|
+
try {
|
|
2577
|
+
await deliver();
|
|
2578
|
+
} catch (cause) {
|
|
2579
|
+
logger?.log({
|
|
2580
|
+
details: errorDetails$1(cause),
|
|
2581
|
+
event,
|
|
2582
|
+
level: "error"
|
|
2583
|
+
});
|
|
2584
|
+
}
|
|
2585
|
+
};
|
|
2586
|
+
const createNotificationService = ({ backgroundNotifications, logger }) => {
|
|
2587
|
+
let liveDeliveryAttached = false;
|
|
2588
|
+
let deliverLive = () => void 0;
|
|
2589
|
+
return {
|
|
2590
|
+
send: async (input) => {
|
|
2591
|
+
const notification = notificationSchema.parse(input);
|
|
2592
|
+
await Promise.all([deliverSafely({
|
|
2593
|
+
deliver: () => deliverLive(notification),
|
|
2594
|
+
event: "live-notification-dispatch-failure",
|
|
2595
|
+
logger
|
|
2596
|
+
}), deliverSafely({
|
|
2597
|
+
deliver: () => backgroundNotifications.send(notification),
|
|
2598
|
+
event: "background-notification-dispatch-failure",
|
|
2599
|
+
logger
|
|
2600
|
+
})]);
|
|
2601
|
+
},
|
|
2602
|
+
attachLiveDelivery: (deliver) => {
|
|
2603
|
+
if (liveDeliveryAttached) throw new Error("Live delivery is already attached");
|
|
2604
|
+
liveDeliveryAttached = true;
|
|
2605
|
+
deliverLive = deliver;
|
|
2606
|
+
}
|
|
2607
|
+
};
|
|
2608
|
+
};
|
|
2609
|
+
//#endregion
|
|
2610
|
+
//#region src/internal/server/runtime/runtime-lifecycle.ts
|
|
2611
|
+
const asError = (cause) => cause instanceof Error ? cause : new Error(String(cause));
|
|
2612
|
+
const disposeAll = async (disposers) => {
|
|
2613
|
+
const errors = (await Promise.allSettled([...disposers].map(async (dispose) => dispose()))).flatMap((result) => result.status === "rejected" ? [asError(result.reason)] : []);
|
|
2614
|
+
if (errors.length) throw new AggregateError(errors, "Runtime disposal failed");
|
|
2615
|
+
};
|
|
2616
|
+
const createRuntimeLifecycle = (preparationSignal) => {
|
|
2617
|
+
const controller = new AbortController();
|
|
2618
|
+
const activeDisposers = /* @__PURE__ */ new Set();
|
|
2619
|
+
let disposal;
|
|
2620
|
+
const abortPreparation = () => controller.abort(preparationSignal?.reason);
|
|
2621
|
+
preparationSignal?.addEventListener("abort", abortPreparation, { once: true });
|
|
2622
|
+
if (preparationSignal?.aborted) abortPreparation();
|
|
2623
|
+
const completePreparation = () => {
|
|
2624
|
+
preparationSignal?.removeEventListener("abort", abortPreparation);
|
|
2625
|
+
};
|
|
2626
|
+
const requestSignal = (signal) => signal ? AbortSignal.any([controller.signal, signal]) : controller.signal;
|
|
2627
|
+
const track = (dispose, signal) => {
|
|
2628
|
+
let pending;
|
|
2629
|
+
const disposeOnce = () => {
|
|
2630
|
+
if (pending) return pending;
|
|
2631
|
+
controller.signal.removeEventListener("abort", onAbort);
|
|
2632
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2633
|
+
activeDisposers.delete(disposeOnce);
|
|
2634
|
+
pending = Promise.resolve().then(dispose);
|
|
2635
|
+
return pending;
|
|
2636
|
+
};
|
|
2637
|
+
const onAbort = () => {
|
|
2638
|
+
disposeOnce().catch(() => void 0);
|
|
2639
|
+
};
|
|
2640
|
+
activeDisposers.add(disposeOnce);
|
|
2641
|
+
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
2642
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2643
|
+
if (controller.signal.aborted || signal?.aborted) onAbort();
|
|
2644
|
+
return disposeOnce;
|
|
2645
|
+
};
|
|
2646
|
+
const dispose = (reason = /* @__PURE__ */ new Error("Runtime disposed")) => {
|
|
2647
|
+
if (disposal) return disposal;
|
|
2648
|
+
completePreparation();
|
|
2649
|
+
const pending = [...activeDisposers];
|
|
2650
|
+
activeDisposers.clear();
|
|
2651
|
+
controller.abort(reason);
|
|
2652
|
+
disposal = disposeAll(pending);
|
|
2653
|
+
return disposal;
|
|
2654
|
+
};
|
|
2655
|
+
return {
|
|
2656
|
+
completePreparation,
|
|
2657
|
+
dispose,
|
|
2658
|
+
disposeAll,
|
|
2659
|
+
requestSignal,
|
|
2660
|
+
signal: controller.signal,
|
|
2661
|
+
track
|
|
2662
|
+
};
|
|
2663
|
+
};
|
|
2664
|
+
//#endregion
|
|
2665
|
+
//#region src/internal/server/runtime/resource-dependency-graph.ts
|
|
2666
|
+
const emptyGraphFor = (resourceIds) => new Map(resourceIds.map((resourceId) => [resourceId, /* @__PURE__ */ new Set()]));
|
|
2667
|
+
const visitDependencies = ({ dependencies, path, resourceId, visited, visiting }) => {
|
|
2668
|
+
if (visiting.has(resourceId)) throw new Error(`Resource dependency cycle: ${[...path, resourceId].join(" -> ")}`);
|
|
2669
|
+
if (visited.has(resourceId)) return;
|
|
2670
|
+
visiting.add(resourceId);
|
|
2671
|
+
dependencies.get(resourceId)?.forEach((dependencyId) => {
|
|
2672
|
+
visitDependencies({
|
|
2673
|
+
dependencies,
|
|
2674
|
+
path: [...path, resourceId],
|
|
2675
|
+
resourceId: dependencyId,
|
|
2676
|
+
visited,
|
|
2677
|
+
visiting
|
|
2678
|
+
});
|
|
2679
|
+
});
|
|
2680
|
+
visiting.delete(resourceId);
|
|
2681
|
+
visited.add(resourceId);
|
|
2682
|
+
};
|
|
2683
|
+
const assertAcyclic = (dependencies) => {
|
|
2684
|
+
const visited = /* @__PURE__ */ new Set();
|
|
2685
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
2686
|
+
dependencies.forEach((_, resourceId) => {
|
|
2687
|
+
visitDependencies({
|
|
2688
|
+
dependencies,
|
|
2689
|
+
path: [],
|
|
2690
|
+
resourceId,
|
|
2691
|
+
visited,
|
|
2692
|
+
visiting
|
|
2693
|
+
});
|
|
2694
|
+
});
|
|
2695
|
+
};
|
|
2696
|
+
const createResourceDependencyGraph = (resources) => {
|
|
2697
|
+
const resourceIds = Object.keys(resources);
|
|
2698
|
+
if (resourceIds.some((resourceId) => !resourceId)) throw new Error("Resource IDs must not be empty");
|
|
2699
|
+
const dependencies = emptyGraphFor(resourceIds);
|
|
2700
|
+
const dependants = emptyGraphFor(resourceIds);
|
|
2701
|
+
Object.entries(resources).forEach(([resourceId, resource]) => {
|
|
2702
|
+
if (resource.kind !== "derived") return;
|
|
2703
|
+
Object.entries(resource.dependencies).forEach(([dependencyName, dependencyId]) => {
|
|
2704
|
+
if (!dependencyName) throw new Error(`Resource ${resourceId} has an empty dependency name`);
|
|
2705
|
+
if (!dependencyId) throw new Error(`Resource ${resourceId} has an empty dependency ID`);
|
|
2706
|
+
if (!dependencies.has(dependencyId)) throw new Error(`Unknown resource dependency: ${dependencyId}`);
|
|
2707
|
+
dependencies.get(resourceId)?.add(dependencyId);
|
|
2708
|
+
dependants.get(dependencyId)?.add(resourceId);
|
|
2709
|
+
});
|
|
2710
|
+
});
|
|
2711
|
+
assertAcyclic(dependencies);
|
|
2712
|
+
return Object.freeze({
|
|
2713
|
+
dependencies,
|
|
2714
|
+
dependants
|
|
2715
|
+
});
|
|
2716
|
+
};
|
|
2717
|
+
const collectInvalidatedResourceIds = (graph, resourceId, invalidated) => {
|
|
2718
|
+
if (invalidated.has(resourceId)) return;
|
|
2719
|
+
invalidated.add(resourceId);
|
|
2720
|
+
graph.dependants.get(resourceId)?.forEach((dependantId) => {
|
|
2721
|
+
collectInvalidatedResourceIds(graph, dependantId, invalidated);
|
|
2722
|
+
});
|
|
2723
|
+
};
|
|
2724
|
+
const getInvalidatedResourceIds = (graph, resourceId) => {
|
|
2725
|
+
const invalidated = /* @__PURE__ */ new Set();
|
|
2726
|
+
collectInvalidatedResourceIds(graph, resourceId, invalidated);
|
|
2727
|
+
return invalidated;
|
|
2728
|
+
};
|
|
2729
|
+
//#endregion
|
|
2730
|
+
//#region src/internal/server/runtime/runtime-resources.ts
|
|
2731
|
+
const createRuntimeResources = ({ definitions, lifecycle }) => {
|
|
2732
|
+
const graph = createResourceDependencyGraph(definitions);
|
|
2733
|
+
const invalidationListeners = /* @__PURE__ */ new Set();
|
|
2734
|
+
const contracts = /* @__PURE__ */ new Map();
|
|
2735
|
+
const resources = /* @__PURE__ */ new Map();
|
|
2736
|
+
Object.entries(definitions).forEach(([id, definition]) => {
|
|
2737
|
+
contracts.set(definition.contract, [...contracts.get(definition.contract) ?? [], id]);
|
|
2738
|
+
});
|
|
2739
|
+
const invalidateResource = (id, input, allInputs = false) => {
|
|
2740
|
+
if (lifecycle.signal.aborted) return;
|
|
2741
|
+
const ids = getInvalidatedResourceIds(graph, id);
|
|
2742
|
+
invalidationListeners.forEach((entry) => {
|
|
2743
|
+
if (ids.has(entry.resourceId) && (allInputs || isDeepStrictEqual(entry.input, input))) entry.listener();
|
|
2744
|
+
});
|
|
2745
|
+
};
|
|
2746
|
+
const invalidate = (contract, input) => {
|
|
2747
|
+
const ids = contracts.get(contract) ?? Object.entries(definitions).filter(([, definition]) => definition.contract.input === contract.input && definition.contract.output === contract.output).map(([id]) => id);
|
|
2748
|
+
if (!ids.length) throw new Error("Cannot invalidate a resource outside this runtime");
|
|
2749
|
+
ids.forEach((id) => {
|
|
2750
|
+
const definition = definitions[id];
|
|
2751
|
+
const parsedInput = input === void 0 ? void 0 : definition.contract.input.parse(input);
|
|
2752
|
+
invalidateResource(id, parsedInput, input === void 0);
|
|
2753
|
+
});
|
|
2754
|
+
};
|
|
2755
|
+
const context = (signal) => ({
|
|
2756
|
+
invalidate,
|
|
2757
|
+
signal: lifecycle.requestSignal(signal)
|
|
2758
|
+
});
|
|
2759
|
+
const read = async (id, rawInput, signal) => {
|
|
2760
|
+
const definition = definitions[id];
|
|
2761
|
+
const operationSignal = lifecycle.requestSignal(signal);
|
|
2762
|
+
operationSignal.throwIfAborted();
|
|
2763
|
+
const input = definition.contract.input.parse(rawInput);
|
|
2764
|
+
const rawOutput = definition.kind === "derived" ? definition.combine(Object.fromEntries(await Promise.all(Object.entries(definition.dependencies).map(async ([name, dependencyId]) => [name, await read(dependencyId, rawInput, signal)])))) : await definition.read(input, context(signal));
|
|
2765
|
+
operationSignal.throwIfAborted();
|
|
2766
|
+
return definition.contract.output.parse(rawOutput);
|
|
2767
|
+
};
|
|
2768
|
+
const activateWatchers = (id, rawInput, signal, activated, disposers) => {
|
|
2769
|
+
if (activated.has(id)) return;
|
|
2770
|
+
activated.add(id);
|
|
2771
|
+
const definition = definitions[id];
|
|
2772
|
+
const input = definition.contract.input.parse(rawInput);
|
|
2773
|
+
if (definition.kind === "subscription") {
|
|
2774
|
+
const dispose = definition.subscribe(input, () => invalidateResource(id, input), context(signal));
|
|
2775
|
+
if (typeof dispose !== "function") throw new Error(`Resource ${id} subscribe must return a disposer`);
|
|
2776
|
+
disposers.push(dispose);
|
|
2777
|
+
return;
|
|
2778
|
+
}
|
|
2779
|
+
if (definition.kind === "derived") graph.dependencies.get(id)?.forEach((dependencyId) => {
|
|
2780
|
+
activateWatchers(dependencyId, rawInput, signal, activated, disposers);
|
|
2781
|
+
});
|
|
2782
|
+
};
|
|
2783
|
+
Object.keys(definitions).forEach((name) => {
|
|
2784
|
+
resources.set(name, {
|
|
2785
|
+
read: (input, signal) => read(name, input, signal),
|
|
2786
|
+
subscribe: (rawInput, listener, signal) => {
|
|
2787
|
+
lifecycle.requestSignal(signal).throwIfAborted();
|
|
2788
|
+
const entry = {
|
|
2789
|
+
input: definitions[name].contract.input.parse(rawInput),
|
|
2790
|
+
listener,
|
|
2791
|
+
resourceId: name
|
|
2792
|
+
};
|
|
2793
|
+
const watcherDisposers = [];
|
|
2794
|
+
invalidationListeners.add(entry);
|
|
2795
|
+
try {
|
|
2796
|
+
activateWatchers(name, rawInput, signal, /* @__PURE__ */ new Set(), watcherDisposers);
|
|
2797
|
+
} catch (cause) {
|
|
2798
|
+
invalidationListeners.delete(entry);
|
|
2799
|
+
lifecycle.disposeAll(watcherDisposers).catch(() => void 0);
|
|
2800
|
+
throw cause;
|
|
2801
|
+
}
|
|
2802
|
+
return lifecycle.track(async () => {
|
|
2803
|
+
invalidationListeners.delete(entry);
|
|
2804
|
+
await lifecycle.disposeAll(watcherDisposers);
|
|
2805
|
+
}, signal);
|
|
2806
|
+
}
|
|
2807
|
+
});
|
|
2808
|
+
});
|
|
2809
|
+
return {
|
|
2810
|
+
context,
|
|
2811
|
+
get: (name) => resources.get(name),
|
|
2812
|
+
invalidate,
|
|
2813
|
+
names: [...resources.keys()]
|
|
2814
|
+
};
|
|
2815
|
+
};
|
|
2816
|
+
//#endregion
|
|
2817
|
+
//#region src/internal/server/runtime/runtime-streams.ts
|
|
2818
|
+
const createRuntimeStreams = ({ context, definitions, lifecycle }) => {
|
|
2819
|
+
const streams = /* @__PURE__ */ new Map();
|
|
2820
|
+
Object.entries(definitions).forEach(([name, definition]) => {
|
|
2821
|
+
if (!name) throw new Error("Stream names must not be empty");
|
|
2822
|
+
streams.set(name, { open: async (rawInput, emit, requestSignal, onError) => {
|
|
2823
|
+
const sessionController = new AbortController();
|
|
2824
|
+
const sessionSignal = requestSignal ? AbortSignal.any([requestSignal, sessionController.signal]) : sessionController.signal;
|
|
2825
|
+
const operationSignal = lifecycle.requestSignal(sessionSignal);
|
|
2826
|
+
operationSignal.throwIfAborted();
|
|
2827
|
+
const input = definition.contract.input.parse(rawInput);
|
|
2828
|
+
let acceptingMessages = true;
|
|
2829
|
+
let failed = false;
|
|
2830
|
+
let failure;
|
|
2831
|
+
let opened = false;
|
|
2832
|
+
const fail = (cause) => {
|
|
2833
|
+
if (failed || operationSignal.aborted) return;
|
|
2834
|
+
failed = true;
|
|
2835
|
+
failure = cause;
|
|
2836
|
+
acceptingMessages = false;
|
|
2837
|
+
sessionController.abort(cause);
|
|
2838
|
+
if (opened) onError?.(cause);
|
|
2839
|
+
};
|
|
2840
|
+
const session = await definition.open(input, {
|
|
2841
|
+
...context(sessionSignal),
|
|
2842
|
+
emit: (message) => {
|
|
2843
|
+
if (operationSignal.aborted) return;
|
|
2844
|
+
try {
|
|
2845
|
+
emit(definition.contract.serverMessage.parse(message));
|
|
2846
|
+
} catch (cause) {
|
|
2847
|
+
fail(cause);
|
|
2848
|
+
}
|
|
2849
|
+
},
|
|
2850
|
+
fail,
|
|
2851
|
+
signal: operationSignal
|
|
2852
|
+
});
|
|
2853
|
+
opened = true;
|
|
2854
|
+
if (failed || operationSignal.aborted) {
|
|
2855
|
+
await session.dispose?.();
|
|
2856
|
+
operationSignal.throwIfAborted();
|
|
2857
|
+
throw failure;
|
|
2858
|
+
}
|
|
2859
|
+
let messages = Promise.resolve();
|
|
2860
|
+
const dispose = lifecycle.track(async () => {
|
|
2861
|
+
acceptingMessages = false;
|
|
2862
|
+
sessionController.abort(/* @__PURE__ */ new Error("Stream session disposed"));
|
|
2863
|
+
await messages.catch(() => void 0);
|
|
2864
|
+
await session.dispose?.();
|
|
2865
|
+
}, requestSignal);
|
|
2866
|
+
return {
|
|
2867
|
+
dispose: async () => dispose(),
|
|
2868
|
+
send: async (rawMessage) => {
|
|
2869
|
+
if (!acceptingMessages) throw new Error("Stream session is closed");
|
|
2870
|
+
const message = definition.contract.clientMessage.parse(rawMessage);
|
|
2871
|
+
messages = messages.then(async () => {
|
|
2872
|
+
operationSignal.throwIfAborted();
|
|
2873
|
+
await session.onMessage?.(message);
|
|
2874
|
+
operationSignal.throwIfAborted();
|
|
2875
|
+
});
|
|
2876
|
+
return messages;
|
|
2877
|
+
}
|
|
2878
|
+
};
|
|
2879
|
+
} });
|
|
2880
|
+
});
|
|
2881
|
+
return {
|
|
2882
|
+
get: (name) => streams.get(name),
|
|
2883
|
+
names: [...streams.keys()]
|
|
2884
|
+
};
|
|
2885
|
+
};
|
|
2886
|
+
//#endregion
|
|
2887
|
+
//#region src/internal/server/runtime/create-runtime.ts
|
|
2888
|
+
const createRuntime = async ({ config, debug = false, notifications = { send: async () => void 0 }, signal }) => {
|
|
2889
|
+
const lifecycle = createRuntimeLifecycle(signal);
|
|
2890
|
+
try {
|
|
2891
|
+
const resources = createRuntimeResources({
|
|
2892
|
+
definitions: config.server.resources ?? {},
|
|
2893
|
+
lifecycle
|
|
2894
|
+
});
|
|
2895
|
+
const context = resources.context;
|
|
2896
|
+
const operations = createRuntimeOperations({
|
|
2897
|
+
context,
|
|
2898
|
+
definitions: config.server.operations ?? {},
|
|
2899
|
+
notifications
|
|
2900
|
+
});
|
|
2901
|
+
const streams = createRuntimeStreams({
|
|
2902
|
+
context,
|
|
2903
|
+
definitions: config.server.streams ?? {},
|
|
2904
|
+
lifecycle
|
|
2905
|
+
});
|
|
2906
|
+
lifecycle.signal.throwIfAborted();
|
|
2907
|
+
lifecycle.completePreparation();
|
|
2908
|
+
return {
|
|
2909
|
+
manifest: {
|
|
2910
|
+
debug,
|
|
2911
|
+
operations: [...operations.names],
|
|
2912
|
+
protocolVersion: 9,
|
|
2913
|
+
resources: [...resources.names],
|
|
2914
|
+
streams: [...streams.names]
|
|
2915
|
+
},
|
|
2916
|
+
dispose: lifecycle.dispose,
|
|
2917
|
+
getOperation: operations.get,
|
|
2918
|
+
getResource: resources.get,
|
|
2919
|
+
getStream: streams.get
|
|
2920
|
+
};
|
|
2921
|
+
} catch (cause) {
|
|
2922
|
+
await lifecycle.dispose(cause).catch(() => void 0);
|
|
2923
|
+
throw cause;
|
|
2924
|
+
}
|
|
2925
|
+
};
|
|
2926
|
+
//#endregion
|
|
2927
|
+
//#region src/internal/server/server-startup-options.ts
|
|
2928
|
+
const resolveServerStartupOptions = ({ config, configPath, overrides = {} }) => {
|
|
2929
|
+
const validatedOverrides = serverConfigRuntimeSchema.omit({ port: true }).extend({ port: z.number().int().min(0).max(65535).optional() }).parse(overrides);
|
|
2930
|
+
const configuredLogFile = config.logFile ? resolve(dirname(configPath), config.logFile) : void 0;
|
|
2931
|
+
const logFile = validatedOverrides.logFile ? resolve(validatedOverrides.logFile) : configuredLogFile;
|
|
2932
|
+
const configuredWebAssetsDir = config.productionWebAssetsDir ? resolve(dirname(configPath), config.productionWebAssetsDir) : void 0;
|
|
2933
|
+
const productionWebAssetsDir = validatedOverrides.productionWebAssetsDir ? resolve(validatedOverrides.productionWebAssetsDir) : configuredWebAssetsDir;
|
|
2934
|
+
return {
|
|
2935
|
+
host: validatedOverrides.host ?? config.host ?? "localhost",
|
|
2936
|
+
...logFile === void 0 ? {} : { logFile },
|
|
2937
|
+
port: validatedOverrides.port ?? config.port ?? 4200,
|
|
2938
|
+
...productionWebAssetsDir === void 0 ? {} : { productionWebAssetsDir },
|
|
2939
|
+
watch: validatedOverrides.watch ?? config.watch ?? true
|
|
2940
|
+
};
|
|
2941
|
+
};
|
|
2942
|
+
//#endregion
|
|
2943
|
+
//#region src/internal/server/start-application-server.ts
|
|
2944
|
+
const cleanupResources = async (cleanups) => {
|
|
2945
|
+
const errors = [];
|
|
2946
|
+
for (const cleanup of [...cleanups].reverse()) try {
|
|
2947
|
+
await cleanup();
|
|
2948
|
+
} catch (cause) {
|
|
2949
|
+
errors.push(cause);
|
|
2950
|
+
}
|
|
2951
|
+
if (errors.length > 0) throw new AggregateError(errors, "Failed to clean up server resources");
|
|
2952
|
+
};
|
|
2953
|
+
const createResources = () => {
|
|
2954
|
+
const cleanups = [];
|
|
2955
|
+
let closePromise;
|
|
2956
|
+
return {
|
|
2957
|
+
add: (cleanup) => cleanups.push(cleanup),
|
|
2958
|
+
close: () => {
|
|
2959
|
+
const next = closePromise ?? cleanupResources(cleanups);
|
|
2960
|
+
closePromise = next;
|
|
2961
|
+
return next;
|
|
2962
|
+
}
|
|
2963
|
+
};
|
|
2964
|
+
};
|
|
2965
|
+
const closeNodeServer = (server, cancelActiveWork) => {
|
|
2966
|
+
const closed = new Promise((resolveClose, reject) => {
|
|
2967
|
+
server.close((error) => {
|
|
2968
|
+
if (error && error.code !== "ERR_SERVER_NOT_RUNNING") {
|
|
2969
|
+
reject(error);
|
|
2970
|
+
return;
|
|
2971
|
+
}
|
|
2972
|
+
resolveClose();
|
|
2973
|
+
});
|
|
2974
|
+
});
|
|
2975
|
+
const cancellation = cancelActiveWork();
|
|
2976
|
+
if ("closeAllConnections" in server) server.closeAllConnections();
|
|
2977
|
+
return Promise.all([closed, cancellation]).then(() => void 0);
|
|
2978
|
+
};
|
|
2979
|
+
const getServerAddress = async (server) => {
|
|
2980
|
+
if (!server.address()) await once(server, "listening");
|
|
2981
|
+
const address = server.address();
|
|
2982
|
+
if (!address || typeof address === "string") throw new Error("Overmux server did not bind a TCP address");
|
|
2983
|
+
return address;
|
|
2984
|
+
};
|
|
2985
|
+
const registerProcessErrorLogging = (logger) => {
|
|
2986
|
+
const onUncaughtException = (cause, origin) => logger.log({
|
|
2987
|
+
details: errorDetails$1(cause),
|
|
2988
|
+
event: origin === "unhandledRejection" ? "unhandled-rejection" : "uncaught-exception",
|
|
2989
|
+
level: "error"
|
|
2990
|
+
});
|
|
2991
|
+
process.on("uncaughtExceptionMonitor", onUncaughtException);
|
|
2992
|
+
return () => {
|
|
2993
|
+
process.off("uncaughtExceptionMonitor", onUncaughtException);
|
|
2994
|
+
};
|
|
2995
|
+
};
|
|
2996
|
+
const loadServerSettings = async ({ configPath, options }) => {
|
|
2997
|
+
const { config } = await loadOvermuxConfig({
|
|
2998
|
+
aliases: options.configAliases,
|
|
2999
|
+
configPath
|
|
3000
|
+
});
|
|
3001
|
+
return {
|
|
3002
|
+
config,
|
|
3003
|
+
startup: resolveServerStartupOptions({
|
|
3004
|
+
config,
|
|
3005
|
+
configPath,
|
|
3006
|
+
overrides: options
|
|
3007
|
+
})
|
|
3008
|
+
};
|
|
3009
|
+
};
|
|
3010
|
+
const createAuthOrigins = ({ configuredOrigins, startup }) => {
|
|
3011
|
+
const urlHost = startup.host.includes(":") ? `[${startup.host}]` : startup.host;
|
|
3012
|
+
let values = (configuredOrigins ?? [`http://${urlHost}:${startup.port}`]).map(validateAuthOrigin);
|
|
3013
|
+
return {
|
|
3014
|
+
establish: (localUrl) => {
|
|
3015
|
+
values = (configuredOrigins ?? [localUrl]).map(validateAuthOrigin);
|
|
3016
|
+
return values;
|
|
3017
|
+
},
|
|
3018
|
+
get: () => values
|
|
3019
|
+
};
|
|
3020
|
+
};
|
|
3021
|
+
const createApplicationServices = async ({ config, logger, resources, startup }) => {
|
|
3022
|
+
const authOrigins = createAuthOrigins({
|
|
3023
|
+
configuredOrigins: config.auth.origins,
|
|
3024
|
+
startup
|
|
3025
|
+
});
|
|
3026
|
+
const authService = createAuthService({ config: config.auth });
|
|
3027
|
+
const auth = createAuthBoundary({
|
|
3028
|
+
origins: authOrigins.get,
|
|
3029
|
+
service: authService,
|
|
3030
|
+
trustedProxyPeer: config.auth.trustedProxyPeer
|
|
3031
|
+
});
|
|
3032
|
+
const backgroundNotifications = createBackgroundNotificationService({
|
|
3033
|
+
isSessionActive: authService.isSessionActive,
|
|
3034
|
+
logger,
|
|
3035
|
+
store: createFileBackgroundNotificationStore()
|
|
3036
|
+
});
|
|
3037
|
+
resources.add(authService.onSessionsRevoked((sessionIds) => {
|
|
3038
|
+
backgroundNotifications.disableSessions(sessionIds).catch((cause) => {
|
|
3039
|
+
logger.log({
|
|
3040
|
+
details: errorDetails$1(cause),
|
|
3041
|
+
event: "background-notification-session-cleanup-failed",
|
|
3042
|
+
level: "error"
|
|
3043
|
+
});
|
|
3044
|
+
});
|
|
3045
|
+
}));
|
|
3046
|
+
const notifications = createNotificationService({
|
|
3047
|
+
backgroundNotifications,
|
|
3048
|
+
logger
|
|
3049
|
+
});
|
|
3050
|
+
const runtime = await createRuntime({
|
|
3051
|
+
config,
|
|
3052
|
+
debug: logger.enabled,
|
|
3053
|
+
notifications
|
|
3054
|
+
});
|
|
3055
|
+
resources.add(runtime.dispose);
|
|
3056
|
+
return {
|
|
3057
|
+
auth,
|
|
3058
|
+
authOrigins,
|
|
3059
|
+
authService,
|
|
3060
|
+
backgroundNotifications,
|
|
3061
|
+
notifications,
|
|
3062
|
+
runtime
|
|
3063
|
+
};
|
|
3064
|
+
};
|
|
3065
|
+
const startApplicationTransport = async ({ auth, authOrigins, authService, backgroundNotifications, developmentWebTarget, logger, notifications, onRestartRequested, resources, runtime, startup }) => {
|
|
3066
|
+
let announceRestarting = () => void 0;
|
|
3067
|
+
const app = createHttpApp({
|
|
3068
|
+
auth,
|
|
3069
|
+
backgroundNotifications,
|
|
3070
|
+
developmentWebTarget,
|
|
3071
|
+
onRestartRequested: () => {
|
|
3072
|
+
announceRestarting();
|
|
3073
|
+
onRestartRequested();
|
|
3074
|
+
},
|
|
3075
|
+
runtime,
|
|
3076
|
+
serverLogger: logger,
|
|
3077
|
+
webAssetsDir: developmentWebTarget ? void 0 : startup.productionWebAssetsDir
|
|
3078
|
+
});
|
|
3079
|
+
const server = serve({
|
|
3080
|
+
fetch: app.fetch,
|
|
3081
|
+
hostname: startup.host,
|
|
3082
|
+
port: startup.port
|
|
3083
|
+
});
|
|
3084
|
+
resources.add(() => closeNodeServer(server, runtime.dispose));
|
|
3085
|
+
const webSockets = createWebSocketServer({
|
|
3086
|
+
auth,
|
|
3087
|
+
developmentWebTarget,
|
|
3088
|
+
runtime,
|
|
3089
|
+
server,
|
|
3090
|
+
serverLogger: logger
|
|
3091
|
+
});
|
|
3092
|
+
resources.add(webSockets.close);
|
|
3093
|
+
announceRestarting = webSockets.announceRestarting;
|
|
3094
|
+
notifications.attachLiveDelivery(webSockets.broadcastNotification);
|
|
3095
|
+
const address = await getServerAddress(server);
|
|
3096
|
+
const urlHost = startup.host.includes(":") ? `[${startup.host}]` : startup.host;
|
|
3097
|
+
const localUrl = `http://${urlHost}:${address.port}`;
|
|
3098
|
+
const controlApiHost = startup.host === "0.0.0.0" ? "127.0.0.1" : startup.host === "::" ? "[::1]" : urlHost;
|
|
3099
|
+
const origins = authOrigins.establish(localUrl);
|
|
3100
|
+
authService.setOrigins(origins);
|
|
3101
|
+
const instanceControl = await startInstanceControl({
|
|
3102
|
+
auth: authService,
|
|
3103
|
+
apiUrl: `http://${controlApiHost}:${address.port}`,
|
|
3104
|
+
port: address.port,
|
|
3105
|
+
url: origins[0]
|
|
3106
|
+
});
|
|
3107
|
+
resources.add(instanceControl.close);
|
|
3108
|
+
return {
|
|
3109
|
+
address,
|
|
3110
|
+
app,
|
|
3111
|
+
localUrl,
|
|
3112
|
+
webSockets
|
|
3113
|
+
};
|
|
3114
|
+
};
|
|
3115
|
+
const closeApplicationServer = async ({ app, logger, resources }) => {
|
|
3116
|
+
logger.log({ event: "server-stop" });
|
|
3117
|
+
app.beginShutdown();
|
|
3118
|
+
await resources.close();
|
|
3119
|
+
};
|
|
3120
|
+
const failApplicationServerStartup = async (cause, resources) => {
|
|
3121
|
+
try {
|
|
3122
|
+
await resources.close();
|
|
3123
|
+
} catch (cleanupCause) {
|
|
3124
|
+
throw new AggregateError([cause, cleanupCause], "Server startup and cleanup failed");
|
|
3125
|
+
}
|
|
3126
|
+
throw cause;
|
|
3127
|
+
};
|
|
3128
|
+
const startApplicationServer = async ({ onRestartRequested, options }) => {
|
|
3129
|
+
const logger = createServerLogger({
|
|
3130
|
+
enabled: options.debug ?? false,
|
|
3131
|
+
logFile: options.logFile ? resolve(options.logFile) : void 0
|
|
3132
|
+
});
|
|
3133
|
+
const resources = createResources();
|
|
3134
|
+
resources.add(registerProcessErrorLogging(logger));
|
|
3135
|
+
try {
|
|
3136
|
+
const configPath = resolve(options.configPath);
|
|
3137
|
+
logger.log({
|
|
3138
|
+
details: { configPath },
|
|
3139
|
+
event: "server-start"
|
|
3140
|
+
});
|
|
3141
|
+
const { config, startup } = await loadServerSettings({
|
|
3142
|
+
configPath,
|
|
3143
|
+
options
|
|
3144
|
+
});
|
|
3145
|
+
logger.setEnabled(Boolean(options.debug || config.debug));
|
|
3146
|
+
if (startup.logFile) logger.setLogFile(startup.logFile);
|
|
3147
|
+
const { auth, authOrigins, authService, backgroundNotifications, notifications, runtime } = await createApplicationServices({
|
|
3148
|
+
config,
|
|
3149
|
+
logger,
|
|
3150
|
+
resources,
|
|
3151
|
+
startup
|
|
3152
|
+
});
|
|
3153
|
+
const { address, app, localUrl, webSockets } = await startApplicationTransport({
|
|
3154
|
+
auth,
|
|
3155
|
+
authOrigins,
|
|
3156
|
+
authService,
|
|
3157
|
+
backgroundNotifications,
|
|
3158
|
+
developmentWebTarget: options.developmentWebTarget,
|
|
3159
|
+
logger,
|
|
3160
|
+
notifications,
|
|
3161
|
+
onRestartRequested,
|
|
3162
|
+
resources,
|
|
3163
|
+
runtime,
|
|
3164
|
+
startup
|
|
3165
|
+
});
|
|
3166
|
+
return {
|
|
3167
|
+
announceUpdateAvailable: webSockets.announceUpdateAvailable,
|
|
3168
|
+
close: () => closeApplicationServer({
|
|
3169
|
+
app,
|
|
3170
|
+
logger,
|
|
3171
|
+
resources
|
|
3172
|
+
}),
|
|
3173
|
+
host: startup.host,
|
|
3174
|
+
port: address.port,
|
|
3175
|
+
url: localUrl,
|
|
3176
|
+
watch: startup.watch
|
|
3177
|
+
};
|
|
3178
|
+
} catch (cause) {
|
|
3179
|
+
return failApplicationServerStartup(cause, resources);
|
|
3180
|
+
}
|
|
3181
|
+
};
|
|
3182
|
+
//#endregion
|
|
3183
|
+
//#region src/internal/server/coordinator/ipc-protocol.ts
|
|
3184
|
+
const childOptionsSchema = z.object({
|
|
3185
|
+
configAliases: z.record(z.string(), z.string()).optional(),
|
|
3186
|
+
configPath: z.string(),
|
|
3187
|
+
debug: z.boolean().optional(),
|
|
3188
|
+
developmentWebTarget: z.url().optional(),
|
|
3189
|
+
host: z.string().optional(),
|
|
3190
|
+
logFile: z.string().optional(),
|
|
3191
|
+
port: z.number().int().min(0).max(65535).optional(),
|
|
3192
|
+
productionWebAssetsDir: z.string().optional(),
|
|
3193
|
+
watch: z.boolean().optional()
|
|
3194
|
+
});
|
|
3195
|
+
const parentMessageSchema = z.discriminatedUnion("type", [
|
|
3196
|
+
z.object({
|
|
3197
|
+
options: childOptionsSchema,
|
|
3198
|
+
type: z.literal("start")
|
|
3199
|
+
}),
|
|
3200
|
+
z.object({ type: z.literal("update-available") }),
|
|
3201
|
+
z.object({ type: z.literal("stop") })
|
|
3202
|
+
]);
|
|
3203
|
+
z.discriminatedUnion("type", [
|
|
3204
|
+
z.object({
|
|
3205
|
+
host: z.string(),
|
|
3206
|
+
port: z.number().int().min(1).max(65535),
|
|
3207
|
+
type: z.literal("ready"),
|
|
3208
|
+
url: z.string(),
|
|
3209
|
+
watch: z.boolean()
|
|
3210
|
+
}),
|
|
3211
|
+
z.object({ type: z.literal("restart-requested") }),
|
|
3212
|
+
z.object({
|
|
3213
|
+
message: z.string(),
|
|
3214
|
+
type: z.literal("startup-failed")
|
|
3215
|
+
})
|
|
3216
|
+
]);
|
|
3217
|
+
const parseParentMessage = (value) => parentMessageSchema.parse(value);
|
|
3218
|
+
//#endregion
|
|
3219
|
+
//#region src/internal/server/coordinator/server-child.ts
|
|
3220
|
+
const send = (message) => {
|
|
3221
|
+
if (!process.connected) return;
|
|
3222
|
+
try {
|
|
3223
|
+
process.send?.(message, () => void 0);
|
|
3224
|
+
} catch {}
|
|
3225
|
+
};
|
|
3226
|
+
const disconnect = () => {
|
|
3227
|
+
if (!process.connected) return;
|
|
3228
|
+
try {
|
|
3229
|
+
process.disconnect();
|
|
3230
|
+
} catch {}
|
|
3231
|
+
};
|
|
3232
|
+
const runServerChild = () => {
|
|
3233
|
+
let serverPromise;
|
|
3234
|
+
let closing;
|
|
3235
|
+
const close = () => {
|
|
3236
|
+
if (closing) return closing;
|
|
3237
|
+
closing = serverPromise?.then((server) => server.close(), () => void 0) ?? Promise.resolve();
|
|
3238
|
+
return closing;
|
|
3239
|
+
};
|
|
3240
|
+
process.on("message", (raw) => {
|
|
3241
|
+
let message;
|
|
3242
|
+
try {
|
|
3243
|
+
message = parseParentMessage(raw);
|
|
3244
|
+
} catch (cause) {
|
|
3245
|
+
send({
|
|
3246
|
+
message: errorMessage(cause),
|
|
3247
|
+
type: "startup-failed"
|
|
3248
|
+
});
|
|
3249
|
+
return;
|
|
3250
|
+
}
|
|
3251
|
+
if (message.type === "update-available") {
|
|
3252
|
+
if (!closing) serverPromise?.then((server) => server.announceUpdateAvailable(), () => void 0);
|
|
3253
|
+
return;
|
|
3254
|
+
}
|
|
3255
|
+
if (message.type === "stop") {
|
|
3256
|
+
close().catch((cause) => {
|
|
3257
|
+
process.stderr.write(`[overmux] child shutdown failed: ${errorMessage(cause)}\n`);
|
|
3258
|
+
}).finally(disconnect);
|
|
3259
|
+
return;
|
|
3260
|
+
}
|
|
3261
|
+
if (serverPromise || closing) {
|
|
3262
|
+
send({
|
|
3263
|
+
message: "Server child received more than one start request",
|
|
3264
|
+
type: "startup-failed"
|
|
3265
|
+
});
|
|
3266
|
+
return;
|
|
3267
|
+
}
|
|
3268
|
+
serverPromise = startApplicationServer({
|
|
3269
|
+
onRestartRequested: () => send({ type: "restart-requested" }),
|
|
3270
|
+
options: message.options
|
|
3271
|
+
});
|
|
3272
|
+
serverPromise.then((server) => {
|
|
3273
|
+
if (closing) return;
|
|
3274
|
+
send({
|
|
3275
|
+
host: server.host,
|
|
3276
|
+
port: server.port,
|
|
3277
|
+
type: "ready",
|
|
3278
|
+
url: server.url,
|
|
3279
|
+
watch: server.watch
|
|
3280
|
+
});
|
|
3281
|
+
}).catch((cause) => {
|
|
3282
|
+
send({
|
|
3283
|
+
message: errorMessage(cause),
|
|
3284
|
+
type: "startup-failed"
|
|
3285
|
+
});
|
|
3286
|
+
disconnect();
|
|
3287
|
+
});
|
|
3288
|
+
});
|
|
3289
|
+
process.once("disconnect", () => {
|
|
3290
|
+
close().catch((cause) => {
|
|
3291
|
+
process.stderr.write(`[overmux] disconnected child shutdown failed: ${errorMessage(cause)}\n`);
|
|
3292
|
+
});
|
|
3293
|
+
});
|
|
3294
|
+
};
|
|
3295
|
+
runServerChild();
|
|
3296
|
+
//#endregion
|
|
3297
|
+
export {};
|
|
3298
|
+
|
|
3299
|
+
//# sourceMappingURL=server-child.js.map
|