overmux 0.0.3 → 0.0.5

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.
Files changed (159) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +2 -2
  3. package/dist/auth/.vite/manifest.json +2 -2
  4. package/dist/auth/assets/{index-Bx8JhwVi.css → index-BM9gETvx.css} +1 -1
  5. package/dist/auth/assets/{index-CHIE7qYj.js → index-DabAfcs1.js} +1 -1
  6. package/dist/auth/index.html +2 -2
  7. package/dist/bin.js +219 -126
  8. package/dist/bin.js.map +1 -1
  9. package/dist/docs/000-index.md +2 -4
  10. package/dist/docs/100-introduction/200-how-overmux-works.md +126 -2
  11. package/{docs/100-introduction/300-why-overmux.md → dist/docs/100-introduction/300-why-i-built-overmux.md} +1 -1
  12. package/dist/docs/200-getting-started/100-install-and-run-overmux.md +2 -2
  13. package/dist/docs/200-getting-started/400-secure-with-https/200-tailscale-serve.md +3 -3
  14. package/dist/docs/200-getting-started/400-secure-with-https/300-cloudflare-tunnel.md +2 -2
  15. package/dist/docs/200-getting-started/400-secure-with-https/400-self-hosted-reverse-proxy.md +2 -2
  16. package/dist/docs/400-reference/100-project-structure.md +83 -0
  17. package/dist/docs/400-reference/200-configuration.md +257 -0
  18. package/dist/docs/400-reference/300-storage-locations.md +46 -0
  19. package/dist/docs/400-reference/400-authentication-and-security.md +37 -0
  20. package/dist/docs/400-reference/500-server/000-index.md +13 -0
  21. package/dist/docs/400-reference/500-server/100-resources.md +191 -0
  22. package/dist/docs/400-reference/500-server/200-operations.md +111 -0
  23. package/dist/docs/400-reference/500-server/300-streams.md +140 -0
  24. package/dist/docs/400-reference/500-server/400-notifications.md +32 -0
  25. package/dist/docs/400-reference/500-server/500-api.md +118 -0
  26. package/dist/docs/400-reference/600-client/000-index.md +9 -0
  27. package/dist/docs/400-reference/600-client/100-api.md +370 -0
  28. package/dist/docs/400-reference/600-client/200-theming.md +94 -0
  29. package/dist/docs/400-reference/600-client/300-tech-stack-recommendations.md +12 -0
  30. package/{docs/400-reference/400-cli → dist/docs/400-reference/700-cli}/050-init.md +2 -4
  31. package/dist/docs/400-reference/{400-cli → 700-cli}/100-serve.md +9 -4
  32. package/{docs/400-reference/400-cli → dist/docs/400-reference/700-cli}/200-auth.md +1 -4
  33. package/{docs/400-reference/400-cli → dist/docs/400-reference/700-cli}/300-call.md +1 -4
  34. package/dist/docs/400-reference/700-cli/350-instance.md +21 -0
  35. package/dist/docs/400-reference/700-cli/600-docs.md +128 -0
  36. package/{docs/400-reference/400-cli → dist/docs/400-reference/700-cli}/700-desktop.md +1 -3
  37. package/dist/docs/500-hosted-pages.md +0 -1
  38. package/dist/exports/client.d.ts +19 -15
  39. package/dist/exports/client.d.ts.map +1 -1
  40. package/dist/exports/client.js +240 -58
  41. package/dist/exports/client.js.map +1 -1
  42. package/dist/exports/{index-D3_Dpy_T.d.ts → index-Cz3xkCa4.d.ts} +62 -36
  43. package/dist/exports/index-Cz3xkCa4.d.ts.map +1 -0
  44. package/dist/exports/index.d.ts +2 -2
  45. package/dist/exports/index.js +19 -5
  46. package/dist/exports/index.js.map +1 -1
  47. package/dist/exports/{notifications-kDp16bU_.js → notifications-BFAD3QQl.js} +34 -4
  48. package/dist/exports/notifications-BFAD3QQl.js.map +1 -0
  49. package/dist/exports/server.d.ts +1 -44
  50. package/dist/exports/server.d.ts.map +1 -1
  51. package/dist/exports/server.js +8 -1018
  52. package/dist/exports/server.js.map +1 -1
  53. package/dist/internal/server/coordinator/server-child.js +153 -85
  54. package/dist/internal/server/coordinator/server-child.js.map +1 -1
  55. package/docs/000-index.md +2 -4
  56. package/docs/100-introduction/200-how-overmux-works.md +126 -2
  57. package/{dist/docs/100-introduction/300-why-overmux.md → docs/100-introduction/300-why-i-built-overmux.md} +1 -1
  58. package/docs/200-getting-started/100-install-and-run-overmux.md +2 -2
  59. package/docs/200-getting-started/400-secure-with-https/200-tailscale-serve.md +3 -3
  60. package/docs/200-getting-started/400-secure-with-https/300-cloudflare-tunnel.md +2 -2
  61. package/docs/200-getting-started/400-secure-with-https/400-self-hosted-reverse-proxy.md +2 -2
  62. package/docs/400-reference/100-project-structure.md +83 -0
  63. package/docs/400-reference/200-configuration.md +257 -0
  64. package/docs/400-reference/300-storage-locations.md +46 -0
  65. package/docs/400-reference/400-authentication-and-security.md +37 -0
  66. package/docs/400-reference/500-server/000-index.md +13 -0
  67. package/docs/400-reference/500-server/100-resources.md +191 -0
  68. package/docs/400-reference/500-server/200-operations.md +111 -0
  69. package/docs/400-reference/500-server/300-streams.md +140 -0
  70. package/docs/400-reference/500-server/400-notifications.md +32 -0
  71. package/docs/400-reference/500-server/500-api.md +118 -0
  72. package/docs/400-reference/600-client/000-index.md +9 -0
  73. package/docs/400-reference/600-client/100-api.md +370 -0
  74. package/docs/400-reference/600-client/200-theming.md +94 -0
  75. package/docs/400-reference/600-client/300-tech-stack-recommendations.md +12 -0
  76. package/{dist/docs/400-reference/400-cli → docs/400-reference/700-cli}/050-init.md +2 -4
  77. package/docs/400-reference/{400-cli → 700-cli}/100-serve.md +9 -4
  78. package/{dist/docs/400-reference/400-cli → docs/400-reference/700-cli}/200-auth.md +1 -4
  79. package/{dist/docs/400-reference/400-cli → docs/400-reference/700-cli}/300-call.md +1 -4
  80. package/docs/400-reference/700-cli/350-instance.md +21 -0
  81. package/docs/400-reference/700-cli/600-docs.md +128 -0
  82. package/{dist/docs/400-reference/400-cli → docs/400-reference/700-cli}/700-desktop.md +1 -3
  83. package/docs/500-hosted-pages.md +0 -1
  84. package/package.json +3 -2
  85. package/src/internal/cli/app.ts +5 -5
  86. package/src/internal/cli/commands/docs-ai-context.ts +33 -0
  87. package/src/internal/cli/commands/docs.ts +19 -2
  88. package/src/internal/cli/commands/init-template.ts +1 -1
  89. package/src/internal/cli/commands/instance.ts +58 -0
  90. package/src/internal/cli/commands/serve.ts +3 -0
  91. package/src/internal/cli/login.ts +9 -9
  92. package/src/internal/cli/parse-port.ts +2 -1
  93. package/src/internal/client/auth/auth-shell.css +17 -1
  94. package/src/internal/client/auth/auth-shell.tsx +2 -11
  95. package/src/internal/client/auth/login-code-input.tsx +140 -0
  96. package/src/internal/client/browser-api.ts +17 -3
  97. package/src/internal/client/client-definition.ts +6 -8
  98. package/src/internal/client/host/deep-link-navigation.ts +75 -0
  99. package/src/internal/client/host/desktop-host.ts +3 -0
  100. package/src/internal/client/host/environment.ts +67 -0
  101. package/src/internal/client/host/instance-forwarding.ts +21 -0
  102. package/src/internal/client/host/overmux-host.tsx +17 -0
  103. package/src/internal/client/index.ts +0 -6
  104. package/src/internal/client/overmux-react.ts +95 -42
  105. package/src/internal/client/websocket.ts +32 -12
  106. package/src/internal/server/auth/auth-service.ts +2 -2
  107. package/src/internal/server/auth/instance-control.ts +89 -17
  108. package/src/internal/server/coordinator/ipc-protocol.ts +0 -1
  109. package/src/internal/server/runtime/create-runtime.ts +15 -2
  110. package/src/internal/server/runtime/runtime-instance.ts +47 -0
  111. package/src/internal/server/runtime/runtime-operations.ts +9 -3
  112. package/src/internal/server/runtime/runtime-resources.ts +15 -32
  113. package/src/internal/server/runtime/runtime-streams.ts +5 -6
  114. package/src/internal/server/server-logger.ts +5 -10
  115. package/src/internal/server/server-startup-options.ts +14 -14
  116. package/src/internal/server/start-application-server.ts +24 -19
  117. package/src/internal/server/websocket-server.ts +7 -0
  118. package/src/internal/shared/index.ts +2 -0
  119. package/src/internal/shared/protocol.ts +10 -1
  120. package/src/public/ai-context.ts +7 -29
  121. package/src/public/client.ts +1 -6
  122. package/src/public/config.ts +163 -31
  123. package/src/public/index.ts +2 -0
  124. package/src/public/server.ts +1 -16
  125. package/dist/docs/100-introduction/100-what-is-overmux.md +0 -7
  126. package/dist/docs/300-fundamentals/100-project-structure.md +0 -23
  127. package/dist/docs/300-fundamentals/200-configuration.md +0 -3
  128. package/dist/docs/300-fundamentals/300-theming.md +0 -3
  129. package/dist/docs/300-fundamentals/400-server.md +0 -3
  130. package/dist/docs/300-fundamentals/500-client.md +0 -3
  131. package/dist/docs/300-fundamentals/600-operations.md +0 -3
  132. package/dist/docs/300-fundamentals/700-resources.md +0 -3
  133. package/dist/docs/300-fundamentals/800-streams.md +0 -3
  134. package/dist/docs/300-fundamentals/900-authentication-and-security.md +0 -3
  135. package/dist/docs/400-reference/100-configuration.md +0 -3
  136. package/dist/docs/400-reference/200-server-api.md +0 -3
  137. package/dist/docs/400-reference/300-client-api.md +0 -20
  138. package/dist/docs/400-reference/400-cli/400-check.md +0 -20
  139. package/dist/docs/400-reference/400-cli/500-ai-context.md +0 -102
  140. package/dist/docs/400-reference/400-cli/600-docs.md +0 -23
  141. package/dist/exports/index-D3_Dpy_T.d.ts.map +0 -1
  142. package/dist/exports/notifications-kDp16bU_.js.map +0 -1
  143. package/docs/100-introduction/100-what-is-overmux.md +0 -7
  144. package/docs/300-fundamentals/100-project-structure.md +0 -23
  145. package/docs/300-fundamentals/200-configuration.md +0 -3
  146. package/docs/300-fundamentals/300-theming.md +0 -3
  147. package/docs/300-fundamentals/400-server.md +0 -3
  148. package/docs/300-fundamentals/500-client.md +0 -3
  149. package/docs/300-fundamentals/600-operations.md +0 -3
  150. package/docs/300-fundamentals/700-resources.md +0 -3
  151. package/docs/300-fundamentals/800-streams.md +0 -3
  152. package/docs/300-fundamentals/900-authentication-and-security.md +0 -3
  153. package/docs/400-reference/100-configuration.md +0 -3
  154. package/docs/400-reference/200-server-api.md +0 -3
  155. package/docs/400-reference/300-client-api.md +0 -20
  156. package/docs/400-reference/400-cli/400-check.md +0 -20
  157. package/docs/400-reference/400-cli/500-ai-context.md +0 -102
  158. package/docs/400-reference/400-cli/600-docs.md +0 -23
  159. package/src/internal/cli/commands/ai.ts +0 -44
@@ -1,20 +1,20 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
- import { chmod, mkdir, readdir, rm } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { chmod, mkdir, open, readdir, rm } from "node:fs/promises";
3
4
  import { createConnection, createServer, type Socket } from "node:net";
4
- import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { z } from "zod";
7
7
 
8
+ import { instanceIdSchema } from "@overmux/shared";
9
+
8
10
  import { getOvermuxPaths } from "../paths";
9
- import {
10
- readPrivateJson,
11
- replacePrivateJsonAtomically,
12
- } from "../notifications/private-json-file";
11
+ import { replacePrivateJsonAtomically } from "../notifications/private-json-file";
13
12
  import type { AuthService } from "./auth-service";
14
13
 
15
14
  const maxControlMessageBytes = 16_384;
16
15
 
17
16
  const controlRequestSchema = z.discriminatedUnion("type", [
17
+ z.object({ type: z.literal("get-instance") }).strict(),
18
18
  z.object({ type: z.literal("ping") }).strict(),
19
19
  z.object({ type: z.literal("create-login") }).strict(),
20
20
  z.object({ type: z.literal("issue-bearer") }).strict(),
@@ -44,6 +44,9 @@ const sessionSchema = z
44
44
  .strict();
45
45
 
46
46
  const controlResponseSchemas = {
47
+ "get-instance": z
48
+ .object({ instanceId: instanceIdSchema, ok: z.literal(true) })
49
+ .strict(),
47
50
  "create-login": z
48
51
  .object({ login: loginGrantSchema, ok: z.literal(true) })
49
52
  .strict(),
@@ -88,9 +91,20 @@ type ControlResponseByType = {
88
91
  >;
89
92
  };
90
93
 
91
- const runtimeDirectory = () => {
92
- const paths = getOvermuxPaths();
93
- const preferred = paths.runtimeDir ?? join(paths.stateDir, "runtime");
94
+ const currentUid = () => {
95
+ if (!process.getuid) {
96
+ throw new Error("Overmux instance control requires a Unix user ID");
97
+ }
98
+ return process.getuid();
99
+ };
100
+
101
+ export const runtimeDirectory = () => {
102
+ const fallback = `/tmp/overmux-${currentUid()}`;
103
+ const preferred = getOvermuxPaths().runtimeDir;
104
+ if (!preferred) {
105
+ return fallback;
106
+ }
107
+ // Leave room for the slash, 16-character ID and .sock on macOS and Linux.
94
108
  if (Buffer.byteLength(preferred) <= 80) {
95
109
  return preferred;
96
110
  }
@@ -98,7 +112,51 @@ const runtimeDirectory = () => {
98
112
  .update(preferred)
99
113
  .digest("hex")
100
114
  .slice(0, 12);
101
- return join(tmpdir(), `overmux-${process.getuid?.() ?? "user"}-${suffix}`);
115
+ return `${fallback}-${suffix}`;
116
+ };
117
+
118
+ const secureRuntimeDirectory = async (directory: string) => {
119
+ // Only the final component must not be a symlink: macOS /tmp is a symlink.
120
+ // Validate and chmod the same inode, never a path that could be swapped
121
+ // between checking ownership and changing permissions. The sticky /tmp
122
+ // parent (or user-controlled XDG parent) then protects our owned directory
123
+ // from replacement by other users. Same-user processes are already trusted.
124
+ const handle = await open(
125
+ directory,
126
+ constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
127
+ );
128
+ try {
129
+ const metadata = await handle.stat();
130
+ if (!metadata.isDirectory() || metadata.uid !== currentUid()) {
131
+ throw new Error(`Unsafe Overmux runtime directory: ${directory}`);
132
+ }
133
+ await handle.chmod(0o700);
134
+ } finally {
135
+ await handle.close();
136
+ }
137
+ };
138
+
139
+ const readRegistration = async (path: string) => {
140
+ // An existing directory may have been writable before we secured it. Do not
141
+ // trust planted symlinks, foreign-owned files or publicly writable records.
142
+ // O_NONBLOCK also prevents a planted FIFO from hanging discovery on open.
143
+ const handle = await open(
144
+ path,
145
+ constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
146
+ );
147
+ try {
148
+ const metadata = await handle.stat();
149
+ if (
150
+ !metadata.isFile() ||
151
+ metadata.uid !== currentUid() ||
152
+ (metadata.mode & 0o077) !== 0
153
+ ) {
154
+ return undefined;
155
+ }
156
+ return registrationSchema.parse(JSON.parse(await handle.readFile("utf8")));
157
+ } finally {
158
+ await handle.close();
159
+ }
102
160
  };
103
161
 
104
162
  const readNewlineFrame = ({
@@ -164,7 +222,18 @@ const writeResponse = (socket: Socket, response: unknown) => {
164
222
  socket.end(`${JSON.stringify(response)}\n`);
165
223
  };
166
224
 
167
- const handleRequest = async (auth: AuthService, request: ControlRequest) => {
225
+ const handleRequest = async ({
226
+ auth,
227
+ instanceId,
228
+ request,
229
+ }: {
230
+ auth: AuthService;
231
+ instanceId: string;
232
+ request: ControlRequest;
233
+ }) => {
234
+ if (request.type === "get-instance") {
235
+ return { instanceId, ok: true };
236
+ }
168
237
  if (request.type === "ping") {
169
238
  return { ok: true };
170
239
  }
@@ -185,9 +254,11 @@ const handleRequest = async (auth: AuthService, request: ControlRequest) => {
185
254
 
186
255
  const handleConnection = async ({
187
256
  auth,
257
+ instanceId,
188
258
  socket,
189
259
  }: {
190
260
  auth: AuthService;
261
+ instanceId: string;
191
262
  socket: Socket;
192
263
  }) => {
193
264
  try {
@@ -196,7 +267,7 @@ const handleConnection = async ({
196
267
  socket,
197
268
  });
198
269
  const request = controlRequestSchema.parse(JSON.parse(frame.toString()));
199
- writeResponse(socket, await handleRequest(auth, request));
270
+ writeResponse(socket, await handleRequest({ auth, instanceId, request }));
200
271
  } catch (cause) {
201
272
  writeResponse(socket, {
202
273
  error: cause instanceof Error ? cause.message : "Invalid control request",
@@ -214,17 +285,19 @@ const handleConnection = async ({
214
285
  export const startInstanceControl = async ({
215
286
  auth,
216
287
  apiUrl,
288
+ instanceId,
217
289
  port,
218
290
  url,
219
291
  }: {
220
292
  auth: AuthService;
221
293
  apiUrl: string;
294
+ instanceId: string;
222
295
  port: number;
223
296
  url: string;
224
297
  }): Promise<InstanceControl> => {
225
298
  const directory = runtimeDirectory();
226
299
  await mkdir(directory, { mode: 0o700, recursive: true });
227
- await chmod(directory, 0o700);
300
+ await secureRuntimeDirectory(directory);
228
301
  const id = randomBytes(8).toString("hex");
229
302
  const socketPath = join(directory, `${id}.sock`);
230
303
  const registrationPath = join(directory, `${id}.json`);
@@ -232,7 +305,7 @@ export const startInstanceControl = async ({
232
305
  const server = createServer((socket) => {
233
306
  sockets.add(socket);
234
307
  socket.once("close", () => sockets.delete(socket));
235
- void handleConnection({ auth, socket });
308
+ void handleConnection({ auth, instanceId, socket });
236
309
  });
237
310
  const close = async () => {
238
311
  sockets.forEach((socket) => socket.destroy());
@@ -289,6 +362,7 @@ export const discoverInstance = async (
289
362
  const directory = runtimeDirectory();
290
363
  let names: string[];
291
364
  try {
365
+ await secureRuntimeDirectory(directory);
292
366
  names = await readdir(directory);
293
367
  } catch (cause) {
294
368
  if ((cause as NodeJS.ErrnoException).code === "ENOENT") {
@@ -301,9 +375,7 @@ export const discoverInstance = async (
301
375
  names
302
376
  .filter((name) => name.endsWith(".json"))
303
377
  .map((name) =>
304
- readPrivateJson(join(directory, name), registrationSchema).catch(
305
- () => undefined,
306
- ),
378
+ readRegistration(join(directory, name)).catch(() => undefined),
307
379
  ),
308
380
  )
309
381
  ).filter((registration): registration is InstanceRegistration =>
@@ -9,7 +9,6 @@ const childOptionsSchema = z.object({
9
9
  debug: z.boolean().optional(),
10
10
  developmentWebTarget: z.url().optional(),
11
11
  host: z.string().optional(),
12
- logFile: z.string().optional(),
13
12
  port: z.number().int().min(0).max(65_535).optional(),
14
13
  productionWebAssetsDir: z.string().optional(),
15
14
  watch: z.boolean().optional(),
@@ -2,7 +2,12 @@
2
2
 
3
3
  import type { RuntimeConfigDefinition } from "@overmux/shared/node";
4
4
 
5
- import type { Notifications } from "../../../public/index";
5
+ import type {
6
+ InstanceContext,
7
+ InstanceIdentity,
8
+ Notifications,
9
+ } from "../../../public/index";
10
+ import { createRuntimeInstance, defaultInstanceId } from "./runtime-instance";
6
11
  import { protocolVersion, type RuntimeManifest } from "../../shared/index";
7
12
 
8
13
  import { createRuntimeLifecycle } from "./runtime-lifecycle";
@@ -23,6 +28,8 @@ import {
23
28
  } from "./runtime-streams";
24
29
 
25
30
  export type Runtime = {
31
+ instance: InstanceContext;
32
+ establishInstance: (port: number) => InstanceIdentity;
26
33
  manifest: RuntimeManifest;
27
34
  dispose: () => Promise<void>;
28
35
  getOperation: (name: string) => RuntimeOperation | undefined;
@@ -39,15 +46,19 @@ type CreateRuntimeOptions = {
39
46
 
40
47
  export const createRuntime = async ({
41
48
  config,
42
- debug = false,
49
+ debug = true,
43
50
  notifications = { send: async () => undefined },
44
51
  signal,
45
52
  }: CreateRuntimeOptions): Promise<Runtime> => {
46
53
  const lifecycle = createRuntimeLifecycle(signal);
54
+ const instance = createRuntimeInstance(
55
+ config.instanceId ?? defaultInstanceId,
56
+ );
47
57
  try {
48
58
  const resources = createRuntimeResources({
49
59
  definitions: (config.server.resources ??
50
60
  {}) as RuntimeResourceDefinitions,
61
+ instance: instance.context,
51
62
  lifecycle,
52
63
  });
53
64
  const context = resources.context;
@@ -74,6 +85,8 @@ export const createRuntime = async ({
74
85
  streams: [...streams.names],
75
86
  };
76
87
  return {
88
+ instance: instance.context,
89
+ establishInstance: instance.establish,
77
90
  manifest,
78
91
  dispose: lifecycle.dispose,
79
92
  getOperation: operations.get,
@@ -0,0 +1,47 @@
1
+ import { hostname } from "node:os";
2
+ import type { RuntimeConfigDefinition } from "@overmux/shared/node";
3
+ import {
4
+ createInstanceIdentity,
5
+ type InstanceIdentity,
6
+ } from "../../shared/index";
7
+ import type { InstanceContext } from "../../../public/config";
8
+
9
+ export const defaultInstanceId = ({ port }: { port: number }) =>
10
+ `${hostname()}-${port}`;
11
+
12
+ // The scope exists during preparation, but identity is immutable once the listener
13
+ // binds. No handler or connection may observe it before establishment.
14
+ export const createRuntimeInstance = (
15
+ configured: NonNullable<RuntimeConfigDefinition["instanceId"]>,
16
+ ) => {
17
+ let identity: InstanceIdentity | undefined;
18
+ const get = () => {
19
+ if (!identity) {
20
+ throw new Error("Overmux instance identity is not ready");
21
+ }
22
+ return identity;
23
+ };
24
+ const context: InstanceContext = {
25
+ getInstanceId: () => get().instanceId,
26
+ getDeepLinkPrefix: () => get().deepLinkPrefix,
27
+ };
28
+ return {
29
+ context,
30
+ establish: (port: number) => {
31
+ if (identity) {
32
+ throw new Error("Overmux instance identity is already established");
33
+ }
34
+ try {
35
+ identity = createInstanceIdentity(
36
+ typeof configured === "function" ? configured({ port }) : configured,
37
+ );
38
+ } catch (cause) {
39
+ throw new Error(
40
+ "Invalid Overmux instance ID; configure instanceId with a canonical lowercase ID",
41
+ { cause },
42
+ );
43
+ }
44
+ return identity;
45
+ },
46
+ };
47
+ };
@@ -2,12 +2,14 @@
2
2
  // Lookup metadata stays aligned with the names published in the runtime manifest.
3
3
 
4
4
  import type {
5
- HandlerContext,
5
+ OperationContext,
6
6
  OperationDefinition,
7
7
  } from "../../../public/index";
8
8
  import type { Notifications } from "../../../public/index";
9
9
  import { z } from "zod";
10
10
 
11
+ import type { RuntimeHandlerContext } from "./runtime-resources";
12
+
11
13
  export class OperationValidationError extends Error {
12
14
  constructor(
13
15
  readonly phase: "input" | "output",
@@ -27,14 +29,18 @@ export type RuntimeOperations = {
27
29
  names: readonly string[];
28
30
  };
29
31
 
30
- type AnyOperation = OperationDefinition<z.ZodTypeAny, z.ZodTypeAny>;
32
+ type AnyOperation = OperationDefinition<
33
+ z.ZodTypeAny,
34
+ z.ZodTypeAny,
35
+ OperationContext<Record<string, unknown>>
36
+ >;
31
37
 
32
38
  export type RuntimeOperationDefinitions = Readonly<
33
39
  Record<string, AnyOperation>
34
40
  >;
35
41
 
36
42
  type CreateRuntimeOperationsOptions = {
37
- context: (signal?: AbortSignal) => HandlerContext;
43
+ context: (signal?: AbortSignal) => RuntimeHandlerContext;
38
44
  definitions: RuntimeOperationDefinitions;
39
45
  notifications: Notifications;
40
46
  };
@@ -5,7 +5,6 @@ import {
5
5
  type ResourceDefinition,
6
6
  type RuntimeDisposer,
7
7
  } from "../../../public/index";
8
- import type { ResourceContract } from "../../../public/index";
9
8
  import { isDeepStrictEqual } from "node:util";
10
9
 
11
10
  import {
@@ -27,10 +26,12 @@ export type RuntimeResourceDefinitions = Readonly<
27
26
  Record<string, ResourceDefinition>
28
27
  >;
29
28
 
29
+ export type RuntimeHandlerContext = HandlerContext<Record<string, unknown>>;
30
+
30
31
  export type RuntimeResources = {
31
- context: (signal?: AbortSignal) => HandlerContext;
32
+ context: (signal?: AbortSignal) => RuntimeHandlerContext;
32
33
  get: (name: string) => RuntimeResource | undefined;
33
- invalidate: (contract: ResourceContract, input?: unknown) => void;
34
+ invalidate: RuntimeHandlerContext["invalidate"];
34
35
  names: readonly string[];
35
36
  };
36
37
 
@@ -42,25 +43,19 @@ type InvalidationListener = {
42
43
 
43
44
  type CreateRuntimeResourcesOptions = {
44
45
  definitions: RuntimeResourceDefinitions;
46
+ instance: HandlerContext["instance"];
45
47
  lifecycle: RuntimeLifecycle;
46
48
  };
47
49
 
48
50
  export const createRuntimeResources = ({
49
51
  definitions,
52
+ instance,
50
53
  lifecycle,
51
54
  }: CreateRuntimeResourcesOptions): RuntimeResources => {
52
55
  const graph = createResourceDependencyGraph(definitions);
53
56
  const invalidationListeners = new Set<InvalidationListener>();
54
- const contracts = new Map<ResourceContract, string[]>();
55
57
  const resources = new Map<string, RuntimeResource>();
56
58
 
57
- Object.entries(definitions).forEach(([id, definition]) => {
58
- contracts.set(definition.contract, [
59
- ...(contracts.get(definition.contract) ?? []),
60
- id,
61
- ]);
62
- });
63
-
64
59
  const invalidateResource = (
65
60
  id: string,
66
61
  input: unknown,
@@ -80,30 +75,18 @@ export const createRuntimeResources = ({
80
75
  });
81
76
  };
82
77
 
83
- const invalidate = (contract: ResourceContract, input?: unknown) => {
84
- const ids =
85
- contracts.get(contract) ??
86
- Object.entries(definitions)
87
- .filter(
88
- ([, definition]) =>
89
- definition.contract.input === contract.input &&
90
- definition.contract.output === contract.output,
91
- )
92
- .map(([id]) => id);
93
- if (!ids.length) {
94
- throw new Error("Cannot invalidate a resource outside this runtime");
78
+ const invalidate = (resourceId: string, input?: unknown) => {
79
+ if (!resources.has(resourceId)) {
80
+ throw new Error(`Unknown resource ID: ${resourceId}`);
95
81
  }
96
- ids.forEach((id) => {
97
- const definition = definitions[id]!;
98
- const parsedInput =
99
- input === undefined
100
- ? undefined
101
- : definition.contract.input.parse(input);
102
- invalidateResource(id, parsedInput, input === undefined);
103
- });
82
+ const definition = definitions[resourceId]!;
83
+ const parsedInput =
84
+ input === undefined ? undefined : definition.contract.input.parse(input);
85
+ invalidateResource(resourceId, parsedInput, input === undefined);
104
86
  };
105
87
 
106
- const context = (signal?: AbortSignal): HandlerContext => ({
88
+ const context = (signal?: AbortSignal): RuntimeHandlerContext => ({
89
+ instance,
107
90
  invalidate,
108
91
  signal: lifecycle.requestSignal(signal),
109
92
  });
@@ -1,13 +1,11 @@
1
1
  // Owns named stream registration and ordered, validated session messaging.
2
2
  // Session abort, failure, and disposal remain exactly-once across every close path.
3
3
 
4
- import type {
5
- HandlerContext,
6
- StreamHandlerDefinition,
7
- } from "../../../public/index";
4
+ import type { StreamHandlerDefinition } from "../../../public/index";
8
5
  import { z } from "zod";
9
6
 
10
7
  import type { RuntimeLifecycle } from "./runtime-lifecycle";
8
+ import type { RuntimeHandlerContext } from "./runtime-resources";
11
9
 
12
10
  export type RuntimeStreamSession = {
13
11
  dispose: () => Promise<void>;
@@ -31,13 +29,14 @@ export type RuntimeStreams = {
31
29
  type AnyStream = StreamHandlerDefinition<
32
30
  z.ZodTypeAny,
33
31
  z.ZodTypeAny,
34
- z.ZodTypeAny
32
+ z.ZodTypeAny,
33
+ RuntimeHandlerContext
35
34
  >;
36
35
 
37
36
  export type RuntimeStreamDefinitions = Readonly<Record<string, AnyStream>>;
38
37
 
39
38
  type CreateRuntimeStreamsOptions = {
40
- context: (signal?: AbortSignal) => HandlerContext;
39
+ context: (signal?: AbortSignal) => RuntimeHandlerContext;
41
40
  definitions: RuntimeStreamDefinitions;
42
41
  lifecycle: RuntimeLifecycle;
43
42
  };
@@ -26,7 +26,6 @@ export type ServerLogger = {
26
26
 
27
27
  type ConfigurableServerLogger = ServerLogger & {
28
28
  setEnabled: (enabled: boolean) => void;
29
- setLogFile: (logFile: string) => void;
30
29
  };
31
30
 
32
31
  const lifecycleEvents = new Set([
@@ -38,7 +37,7 @@ const lifecycleEvents = new Set([
38
37
  "websocket-open",
39
38
  ]);
40
39
 
41
- export const getDefaultServerLogFile = ({
40
+ export const getServerLogFile = ({
42
41
  homeDir,
43
42
  xdgStateHome,
44
43
  }: {
@@ -62,12 +61,11 @@ const safeJson = (value: unknown) => {
62
61
 
63
62
  export const createServerLogger = ({
64
63
  enabled,
65
- logFile = getDefaultServerLogFile(),
64
+ logFile,
66
65
  }: {
67
66
  enabled: boolean;
68
- logFile?: string;
67
+ logFile: string;
69
68
  }): ConfigurableServerLogger => {
70
- let activeLogFile = logFile;
71
69
  let debugEnabled = enabled;
72
70
  return {
73
71
  get enabled() {
@@ -88,16 +86,13 @@ export const createServerLogger = ({
88
86
  source: entry.source ?? "server",
89
87
  ...entry,
90
88
  });
91
- mkdirSync(dirname(activeLogFile), { recursive: true });
92
- appendFileSync(activeLogFile, `${record}\n`, "utf8");
89
+ mkdirSync(dirname(logFile), { recursive: true });
90
+ appendFileSync(logFile, `${record}\n`, "utf8");
93
91
  process.stderr.write(`[overmux:debug] ${record}\n`);
94
92
  },
95
93
  setEnabled: (nextEnabled) => {
96
94
  debugEnabled = nextEnabled;
97
95
  },
98
- setLogFile: (nextLogFile) => {
99
- activeLogFile = nextLogFile;
100
- },
101
96
  };
102
97
  };
103
98
 
@@ -7,9 +7,17 @@ import { z } from "zod";
7
7
 
8
8
  import type { ServerConfigDefinition } from "../../public/index";
9
9
 
10
+ const serverStartupOverridesRuntimeSchema = serverConfigRuntimeSchema
11
+ .omit({ port: true })
12
+ .extend({
13
+ debug: z.boolean().optional(),
14
+ // Port 0 asks the OS for a free port for internal listeners and tests; it must not leak into user config.
15
+ port: z.number().int().min(0).max(65_535).optional(),
16
+ });
17
+
10
18
  export type ResolvedServerStartupOptions = {
19
+ debug: boolean;
11
20
  host: string;
12
- logFile?: string;
13
21
  port: number;
14
22
  productionWebAssetsDir?: string;
15
23
  watch: boolean;
@@ -22,18 +30,10 @@ export const resolveServerStartupOptions = ({
22
30
  }: {
23
31
  config: RuntimeConfigDefinition;
24
32
  configPath: string;
25
- overrides?: ServerConfigDefinition;
33
+ overrides?: ServerConfigDefinition & { debug?: boolean };
26
34
  }): ResolvedServerStartupOptions => {
27
- const validatedOverrides = serverConfigRuntimeSchema
28
- .omit({ port: true })
29
- .extend({ port: z.number().int().min(0).max(65_535).optional() })
30
- .parse(overrides);
31
- const configuredLogFile = config.logFile
32
- ? resolve(dirname(configPath), config.logFile)
33
- : undefined;
34
- const logFile = validatedOverrides.logFile
35
- ? resolve(validatedOverrides.logFile)
36
- : configuredLogFile;
35
+ const validatedOverrides =
36
+ serverStartupOverridesRuntimeSchema.parse(overrides);
37
37
  const configuredWebAssetsDir = config.productionWebAssetsDir
38
38
  ? resolve(dirname(configPath), config.productionWebAssetsDir)
39
39
  : undefined;
@@ -42,9 +42,9 @@ export const resolveServerStartupOptions = ({
42
42
  : configuredWebAssetsDir;
43
43
 
44
44
  return {
45
+ debug: validatedOverrides.debug ?? config.debug ?? true,
45
46
  host: validatedOverrides.host ?? config.host ?? "localhost",
46
- ...(logFile === undefined ? {} : { logFile }),
47
- port: validatedOverrides.port ?? config.port ?? 4200,
47
+ port: validatedOverrides.port ?? config.port ?? 4242,
48
48
  ...(productionWebAssetsDir === undefined ? {} : { productionWebAssetsDir }),
49
49
  watch: validatedOverrides.watch ?? config.watch ?? true,
50
50
  };
@@ -29,6 +29,7 @@ import { createRuntime, type Runtime } from "./runtime/create-runtime";
29
29
  import {
30
30
  createServerLogger,
31
31
  errorDetails,
32
+ getServerLogFile,
32
33
  type ServerLogger,
33
34
  } from "./server-logger";
34
35
  import {
@@ -40,7 +41,6 @@ import type { OvermuxServerOptions } from "./start-overmux-server";
40
41
  type ResourceCleanup = () => void | Promise<void>;
41
42
  type ServerResources = ReturnType<typeof createResources>;
42
43
  type NotificationService = ReturnType<typeof createNotificationService>;
43
- type ApplicationLogger = ReturnType<typeof createServerLogger>;
44
44
  type StartApplicationServerOptions = {
45
45
  onRestartRequested: () => void;
46
46
  options: OvermuxServerOptions;
@@ -279,24 +279,20 @@ const startApplicationTransport = async ({
279
279
  ? undefined
280
280
  : startup.productionWebAssetsDir,
281
281
  });
282
+ // Binding allocates the actual port. Reject early HTTP traffic until identity
283
+ // and trusted origins are established; WebSocket upgrades attach only after that.
284
+ let ready = false;
282
285
  const server = serve({
283
- fetch: app.fetch,
286
+ fetch: (request, env) =>
287
+ ready
288
+ ? app.fetch(request, env)
289
+ : new Response("Starting", { status: 503 }),
284
290
  hostname: startup.host,
285
291
  port: startup.port,
286
292
  });
287
293
  resources.add(() => closeNodeServer(server, runtime.dispose));
288
- const webSockets = createWebSocketServer({
289
- auth,
290
- developmentWebTarget,
291
- runtime,
292
- server,
293
- serverLogger: logger,
294
- });
295
- resources.add(webSockets.close);
296
- announceRestarting = webSockets.announceRestarting;
297
- notifications.attachLiveDelivery(webSockets.broadcastNotification);
298
-
299
294
  const address = await getServerAddress(server);
295
+ runtime.establishInstance(address.port);
300
296
  const urlHost = startup.host.includes(":")
301
297
  ? `[${startup.host}]`
302
298
  : startup.host;
@@ -309,9 +305,21 @@ const startApplicationTransport = async ({
309
305
  : urlHost;
310
306
  const origins = authOrigins.establish(localUrl);
311
307
  authService.setOrigins(origins);
308
+ const webSockets = createWebSocketServer({
309
+ auth,
310
+ developmentWebTarget,
311
+ runtime,
312
+ server,
313
+ serverLogger: logger,
314
+ });
315
+ resources.add(webSockets.close);
316
+ announceRestarting = webSockets.announceRestarting;
317
+ notifications.attachLiveDelivery(webSockets.broadcastNotification);
318
+ ready = true;
312
319
  const instanceControl = await startInstanceControl({
313
320
  auth: authService,
314
321
  apiUrl: `http://${controlApiHost}:${address.port}`,
322
+ instanceId: runtime.instance.getInstanceId(),
315
323
  port: address.port,
316
324
  url: origins[0]!,
317
325
  });
@@ -353,8 +361,8 @@ export const startApplicationServer = async ({
353
361
  options,
354
362
  }: StartApplicationServerOptions): Promise<ApplicationServer> => {
355
363
  const logger = createServerLogger({
356
- enabled: options.debug ?? false,
357
- logFile: options.logFile ? resolve(options.logFile) : undefined,
364
+ enabled: options.debug ?? true,
365
+ logFile: getServerLogFile(),
358
366
  });
359
367
  const resources = createResources();
360
368
  resources.add(registerProcessErrorLogging(logger));
@@ -366,10 +374,7 @@ export const startApplicationServer = async ({
366
374
  configPath,
367
375
  options,
368
376
  });
369
- logger.setEnabled(Boolean(options.debug || config.debug));
370
- if (startup.logFile) {
371
- logger.setLogFile(startup.logFile);
372
- }
377
+ logger.setEnabled(startup.debug);
373
378
  const {
374
379
  auth,
375
380
  authOrigins,
@@ -247,6 +247,13 @@ export const createWebSocketServer = ({
247
247
  sessionSockets,
248
248
  socket: webSocket,
249
249
  });
250
+ // Discovery precedes all connection traffic, including sticky announcements.
251
+ webSocket.send(
252
+ encodeProtocolFrame({
253
+ type: "server-info",
254
+ instanceId: runtime.instance.getInstanceId(),
255
+ }),
256
+ );
250
257
  webSocketServer.emit("connection", webSocket, request);
251
258
  attachWebSocketConnection({ runtime, serverLogger, socket: webSocket });
252
259
  if (updateAvailable) {
@@ -1,2 +1,4 @@
1
1
  export * from "./background-notifications";
2
2
  export * from "./protocol";
3
+ export { createInstanceIdentity, instanceIdSchema } from "@overmux/shared";
4
+ export type { InstanceIdentity } from "@overmux/shared";