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
@@ -6,11 +6,6 @@ import {
6
6
  matchesKeyBinding,
7
7
  type KeyBinding,
8
8
  } from "@overmux/keybindings";
9
- export {
10
- formatKeyBinding,
11
- keyBindingSchema,
12
- matchesKeyBinding,
13
- } from "@overmux/keybindings";
14
9
  export type { KeyBinding } from "@overmux/keybindings";
15
10
  import { z } from "zod";
16
11
 
@@ -28,18 +23,18 @@ export type ChordPrefix = {
28
23
  unmatched: "replay-to-focused-input";
29
24
  };
30
25
 
31
- export const shortcutBindingSchema = z.union([
26
+ const shortcutBindingSchema = z.union([
32
27
  keyBindingSchema,
33
28
  z.array(keyBindingSchema).min(2),
34
29
  ]);
35
- export const commandBindingSchema = z.union([
30
+ const commandBindingSchema = z.union([
36
31
  shortcutBindingSchema,
37
32
  z.object({
38
33
  binding: shortcutBindingSchema,
39
34
  when: z.object({ media: z.string().trim().min(1) }),
40
35
  }),
41
36
  ]);
42
- export const chordPrefixSchema = z.object({
37
+ const chordPrefixSchema = z.object({
43
38
  binding: keyBindingSchema,
44
39
  unmatched: z.literal("replay-to-focused-input"),
45
40
  });
@@ -132,6 +127,9 @@ export type ClientAppDefinition<
132
127
  chordPrefixes?: readonly ChordPrefix[];
133
128
  commands: TCommands;
134
129
  component: ComponentType;
130
+ // Receives validated same-instance deep-link routes, including query and fragment.
131
+ // Defaults to document navigation on the current origin; supply your router to avoid a reload.
132
+ navigate?: (route: string) => unknown;
135
133
  shortcutOverrides?: Partial<
136
134
  Record<Extract<keyof TCommands, string>, readonly CommandBinding[]>
137
135
  >;
@@ -0,0 +1,75 @@
1
+ import { parseDeepLink } from "@overmux/shared";
2
+ import type { ClientTransport } from "../transport";
3
+ import type {} from "./desktop-host";
4
+
5
+ const defaultNavigate = (route: string) => window.location.assign(route);
6
+
7
+ /**
8
+ * Intercepts overmux:// anchor activation inside the running app, including the
9
+ * temporary anchors created by xterm's default link handler. Without this,
10
+ * browser/PWA clicks dispatch the custom protocol to the OS, which fails in
11
+ * Android PWAs where it is not possible to install an Overmux protocol handler.
12
+ *
13
+ * Validates the original URL and compares its instance ID with authenticated
14
+ * runtime discovery. For same-instance links, preventDefault stops OS dispatch
15
+ * and navigate receives only the local path, query, and fragment. By default,
16
+ * this loads that route on the current origin; a supplied router callback avoids
17
+ * a document reload. Instance IDs are never treated as network hostnames.
18
+ *
19
+ * Valid cross-instance links remain Desktop's responsibility. Browsers/PWAs
20
+ * block unknown or different instances; all hosts block invalid links. Ordinary
21
+ * links and clicks already cancelled by application code are left untouched.
22
+ * Returns cleanup for the document listeners when the runtime unmounts.
23
+ */
24
+ export const installDeepLinkNavigation = ({
25
+ getInstance,
26
+ navigate = defaultNavigate,
27
+ }: {
28
+ getInstance: ClientTransport["getInstance"];
29
+ navigate?: (route: string) => unknown;
30
+ }) => {
31
+ const onClick = (event: MouseEvent) => {
32
+ if (event.defaultPrevented || event.button > 1) {
33
+ return;
34
+ }
35
+ const anchor = event
36
+ .composedPath()
37
+ .find(
38
+ (target): target is HTMLAnchorElement =>
39
+ target instanceof HTMLAnchorElement,
40
+ );
41
+ if (anchor?.protocol !== "overmux:") {
42
+ return;
43
+ }
44
+ let link;
45
+ try {
46
+ // Inspect the original attribute before URL normalization can hide unsafe text.
47
+ link = parseDeepLink(anchor.getAttribute("href") ?? "");
48
+ } catch {
49
+ event.preventDefault();
50
+ console.warn("Cannot open an invalid Overmux deep link.");
51
+ return;
52
+ }
53
+ if (link.instanceId !== getInstance()?.instanceId) {
54
+ // Desktop owns registry lookup and cross-instance confirmation. Browsers must
55
+ // never interpret an instance ID as a hostname or assume it names this server.
56
+ if (window.overmuxHost?.version !== 1) {
57
+ event.preventDefault();
58
+ console.warn(
59
+ "Cannot open an Overmux link to an unknown or different instance in this browser.",
60
+ );
61
+ }
62
+ return;
63
+ }
64
+ event.preventDefault();
65
+ navigate(link.route);
66
+ };
67
+ // Bubble after application handlers so preventDefault remains an explicit opt-out.
68
+ // Read identity on activation, not installation: discovery and reconnects can change it.
69
+ document.addEventListener("click", onClick);
70
+ document.addEventListener("auxclick", onClick);
71
+ return () => {
72
+ document.removeEventListener("click", onClick);
73
+ document.removeEventListener("auxclick", onClick);
74
+ };
75
+ };
@@ -1,4 +1,7 @@
1
1
  type DesktopHost = {
2
+ version: 1;
3
+ platform: string;
4
+ instance?: { report: (identity: { instanceId: string }) => void; version: 1 };
2
5
  clipboard?: { writeText: (text: string) => void; version: 1 };
3
6
  notifications?: { show: (notification: unknown) => void; version: 1 };
4
7
  };
@@ -0,0 +1,67 @@
1
+ import "./desktop-host";
2
+
3
+ type Platform = "macos" | "windows" | "linux" | "android" | "ios" | "unknown";
4
+ type BrowserNavigator = Navigator & {
5
+ standalone?: boolean;
6
+ userAgentData?: { platform: string };
7
+ };
8
+
9
+ const browserPlatform = (navigator: BrowserNavigator): Platform => {
10
+ const { userAgent, maxTouchPoints } = navigator;
11
+ const platform = navigator.userAgentData?.platform ?? navigator.platform;
12
+ const identity = `${platform} ${userAgent}`;
13
+ // iPadOS can advertise itself as a Mac when requesting desktop websites.
14
+ if (
15
+ /iPad|iPhone|iPod/i.test(identity) ||
16
+ (/Mac/i.test(identity) && maxTouchPoints > 1)
17
+ ) {
18
+ return "ios";
19
+ }
20
+ if (/Android/i.test(identity)) {
21
+ return "android";
22
+ }
23
+ if (/Win/i.test(identity)) {
24
+ return "windows";
25
+ }
26
+ if (/Mac/i.test(identity)) {
27
+ return "macos";
28
+ }
29
+ return /Linux/i.test(identity) ? "linux" : "unknown";
30
+ };
31
+
32
+ const desktopPlatform = (platform: string): Platform => {
33
+ if (platform === "darwin") {
34
+ return "macos";
35
+ }
36
+ if (platform === "win32") {
37
+ return "windows";
38
+ }
39
+ return platform === "linux" ? "linux" : "unknown";
40
+ };
41
+
42
+ export const installEnvironmentSelectors = () => {
43
+ const html = document.documentElement;
44
+ const host = window.overmuxHost;
45
+ const desktop = host?.version === 1;
46
+ const navigator: BrowserNavigator = window.navigator;
47
+ html.dataset.omPlatform = desktop
48
+ ? desktopPlatform(host.platform)
49
+ : browserPlatform(navigator);
50
+ if (desktop) {
51
+ html.dataset.omHost = "desktop";
52
+ return () => {};
53
+ }
54
+
55
+ // Fullscreen alone is not evidence of an installed app.
56
+ const displayMode = window.matchMedia(
57
+ "(display-mode: standalone), (display-mode: minimal-ui), (display-mode: window-controls-overlay)",
58
+ );
59
+ const updateHost = () => {
60
+ html.dataset.omHost =
61
+ displayMode.matches || navigator.standalone === true ? "pwa" : "browser";
62
+ };
63
+ updateHost();
64
+ displayMode.addEventListener("change", updateHost);
65
+ // The document owns these attributes; disposal only releases the live listener.
66
+ return () => displayMode.removeEventListener("change", updateHost);
67
+ };
@@ -0,0 +1,21 @@
1
+ import type { ClientTransport } from "../transport";
2
+ import type {} from "./desktop-host";
3
+
4
+ export const installInstanceForwarding = (transport: ClientTransport) => {
5
+ const report = () => {
6
+ const identity = transport.getInstance();
7
+ const bridge = window.overmuxHost?.instance;
8
+ if (!identity || bridge?.version !== 1) {
9
+ return;
10
+ }
11
+ try {
12
+ bridge.report({ instanceId: identity.instanceId });
13
+ } catch (cause) {
14
+ // A host integration failure must not interrupt browser discovery or traffic.
15
+ console.error("Overmux host instance reporting failed", cause);
16
+ }
17
+ };
18
+ const unsubscribe = transport.subscribeInstance(report);
19
+ report();
20
+ return unsubscribe;
21
+ };
@@ -21,6 +21,8 @@ import { OvermuxThemeScope } from "../theme-scope";
21
21
  import { createOvermuxServerApi } from "../browser-api";
22
22
  import { installBrowserLogForwarding } from "./browser-log-forwarding";
23
23
  import { installNotificationForwarding } from "./notification-forwarding";
24
+ import { installInstanceForwarding } from "./instance-forwarding";
25
+ import { installDeepLinkNavigation } from "./deep-link-navigation";
24
26
  import type { ClientAppDefinition } from "../client-definition";
25
27
  import { type RegisteredCommand, RuntimeContext } from "../commands";
26
28
  import { ShortcutHost } from "../shortcuts";
@@ -29,6 +31,12 @@ import { HostedLogoutPage } from "./hosted-pages/logout-page";
29
31
  import { HostedSettingsPage } from "./hosted-pages/settings-page";
30
32
  import { RecoveryScreen } from "./recovery-screen";
31
33
  import { UpdatePopover } from "./update-popover";
34
+ import { installEnvironmentSelectors } from "./environment";
35
+
36
+ // Userland owns createRoot. Initialize on import, before any React tree mounts.
37
+ const disposeEnvironment =
38
+ typeof window === "undefined" ? undefined : installEnvironmentSelectors();
39
+ import.meta.hot?.dispose(() => disposeEnvironment?.());
32
40
 
33
41
  type ErrorBoundaryProps = {
34
42
  children: ReactNode;
@@ -129,6 +137,15 @@ const OvermuxRuntime = ({
129
137
  [transport],
130
138
  );
131
139
  useEffect(() => installNotificationForwarding(transport), [transport]);
140
+ useEffect(() => installInstanceForwarding(transport), [transport]);
141
+ useEffect(
142
+ () =>
143
+ installDeepLinkNavigation({
144
+ getInstance: transport.getInstance,
145
+ navigate: definition.navigate,
146
+ }),
147
+ [transport, definition.navigate],
148
+ );
132
149
  const refreshCommands = useCallback(
133
150
  () => setRegistrationRevision((revision) => revision + 1),
134
151
  [],
@@ -19,15 +19,9 @@ export type {
19
19
  OvermuxThemeToken,
20
20
  } from "./theme-scope";
21
21
  export {
22
- chordPrefixSchema,
23
- commandBindingSchema,
24
22
  defineCommandRegistry,
25
23
  defineOvermuxClient,
26
- formatKeyBinding,
27
24
  formatShortcutBinding,
28
- keyBindingSchema,
29
- matchesKeyBinding,
30
- shortcutBindingSchema,
31
25
  } from "./client-definition";
32
26
  export { skipToken, useCommand, useCommands } from "./commands";
33
27
  export { useShortcutInputTarget } from "./shortcuts";
@@ -1,11 +1,21 @@
1
- import type { ContractInputArguments } from "../../public/index";
1
+ import type {
2
+ ContractInputArguments,
3
+ InstanceIdentity,
4
+ } from "../../public/index";
2
5
  import {
3
6
  useMutation,
4
7
  useQuery,
5
8
  useQueryClient,
6
9
  type UseMutationResult,
7
10
  } from "@tanstack/react-query";
8
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
11
+ import {
12
+ useCallback,
13
+ useEffect,
14
+ useMemo,
15
+ useRef,
16
+ useState,
17
+ useSyncExternalStore,
18
+ } from "react";
9
19
  import type { z } from "zod";
10
20
 
11
21
  import { skipToken, type SkipToken, useRuntime } from "./commands";
@@ -89,6 +99,10 @@ export type ResourceResult<T> =
89
99
  };
90
100
 
91
101
  export type StreamResult<TClientMessage, TServerMessage> = {
102
+ // Identifies one server-confirmed stream lifetime, not this hook or its stable methods.
103
+ // Undefined until stream-opened; reconnects and effect restarts receive a fresh identity.
104
+ // Replacement renders report opening, never readiness inherited from the old stream.
105
+ connectionId: symbol | undefined;
92
106
  close: () => void;
93
107
  error?: Error;
94
108
  send: (message: TClientMessage) => boolean;
@@ -190,71 +204,110 @@ const useStreamById = <TClientMessage, TServerMessage>(
190
204
  ): StreamResult<TClientMessage, TServerMessage> => {
191
205
  const { manifest, overmuxServerApi } = useRuntime();
192
206
  const streamAvailable = manifest.streams.includes(id);
207
+ const canonicalInput = useCanonicalInput(input);
208
+ // This scope owns the stream-opening effect. Fast Refresh invalidates the memo
209
+ // and restarts that effect; the previous scope's open state is never exposed as
210
+ // readiness for the replacement, even before its passive effect has run.
211
+ const scope = useMemo(
212
+ () => ({ canonicalInput, id, overmuxServerApi, streamAvailable }),
213
+ [canonicalInput, id, overmuxServerApi, streamAvailable],
214
+ );
193
215
  const [state, setState] = useState<{
216
+ scope: typeof scope;
217
+ connectionId?: symbol;
194
218
  error?: Error;
195
219
  status: "closed" | "open" | "opening";
196
- }>({ status: "opening" });
197
- const listeners = useMemo(
220
+ }>({ scope, status: "opening" });
221
+ // Unlike memoized callbacks, state survives Fast Refresh. Existing consumers
222
+ // keep their subscriptions and methods while the real stream lifetime changes.
223
+ const [listeners] = useState(
198
224
  () => new Set<(message: TServerMessage) => void>(),
199
- [],
200
225
  );
201
- const canonicalInput = useCanonicalInput(input);
202
226
  const streamRef = useRef<
203
227
  ReturnType<typeof overmuxServerApi.openStream> | undefined
204
228
  >(undefined);
205
229
  useEffect(() => {
206
230
  streamRef.current?.close();
207
231
  streamRef.current = undefined;
208
- if (!streamAvailable) {
209
- setState({ status: "closed" });
232
+ if (!scope.streamAvailable) {
233
+ setState({ scope, status: "closed" });
210
234
  return;
211
235
  }
212
- setState({ status: "opening" });
213
- const stream = overmuxServerApi.openStream({
214
- id,
215
- input: canonicalInput,
216
- onClose: () => setState({ status: "closed" }),
217
- onError: (error) => setState({ error, status: "closed" }),
218
- onOpen: () => setState({ status: "open" }),
236
+ setState({ scope, status: "opening" });
237
+ let active = true;
238
+ const stream = scope.overmuxServerApi.openStream({
239
+ id: scope.id,
240
+ input: scope.canonicalInput,
241
+ onClose: () => active && setState({ scope, status: "closed" }),
242
+ onError: (error) =>
243
+ active && setState({ scope, error, status: "closed" }),
244
+ // The transport reuses its handle on reconnect, but acknowledges a new server stream.
245
+ onOpen: () =>
246
+ active &&
247
+ setState({ scope, connectionId: Symbol(scope.id), status: "open" }),
219
248
  onMessage: (message) =>
249
+ active &&
220
250
  listeners.forEach((listener) => listener(message as TServerMessage)),
221
251
  });
222
- streamRef.current = stream;
223
- return () => {
224
- stream.close();
225
- if (streamRef.current === stream) {
226
- streamRef.current = undefined;
227
- }
252
+ const connection = {
253
+ close: () => {
254
+ if (!active) {
255
+ return;
256
+ }
257
+ // Old callbacks must not overwrite the stream created by an effect restart (including HMR).
258
+ active = false;
259
+ if (streamRef.current === connection) {
260
+ streamRef.current = undefined;
261
+ }
262
+ setState({ scope, status: "closed" });
263
+ stream.close();
264
+ },
265
+ send: stream.send,
228
266
  };
229
- }, [canonicalInput, id, listeners, overmuxServerApi, streamAvailable]);
230
- const close = useCallback(() => {
231
- streamRef.current?.close();
232
- streamRef.current = undefined;
233
- setState({ status: "closed" });
234
- }, []);
235
- const send = useCallback(
236
- (message: TClientMessage) => streamRef.current?.send(message) ?? false,
237
- [],
238
- );
239
- const subscribe = useCallback(
240
- (listener: (message: TServerMessage) => void) => {
267
+ streamRef.current = connection;
268
+ return connection.close;
269
+ }, [listeners, scope]);
270
+ const [methods] = useState(() => ({
271
+ close: () => streamRef.current?.close(),
272
+ send: (message: TClientMessage) =>
273
+ streamRef.current?.send(message) ?? false,
274
+ subscribe: (listener: (message: TServerMessage) => void) => {
241
275
  listeners.add(listener);
242
276
  return () => listeners.delete(listener);
243
277
  },
244
- [listeners],
245
- );
278
+ }));
279
+ if (!streamAvailable) {
280
+ return {
281
+ ...methods,
282
+ connectionId: undefined,
283
+ error: new Error(`Stream is not available: ${id}`),
284
+ status: "closed",
285
+ };
286
+ }
287
+ if (state.scope !== scope) {
288
+ return { ...methods, connectionId: undefined, status: "opening" };
289
+ }
246
290
  return {
247
- close,
248
- error: streamAvailable
249
- ? state.error
250
- : new Error(`Stream is not available: ${id}`),
251
- send,
252
- status: streamAvailable ? state.status : "closed",
253
- subscribe,
291
+ ...methods,
292
+ connectionId: state.connectionId,
293
+ error: state.error,
294
+ status: state.status,
254
295
  };
255
296
  };
256
297
 
298
+ const undiscoveredInstance = () => undefined;
299
+
300
+ const useInstance = (): InstanceIdentity | undefined => {
301
+ const { overmuxServerApi } = useRuntime();
302
+ return useSyncExternalStore(
303
+ overmuxServerApi.subscribeInstance,
304
+ overmuxServerApi.getInstance,
305
+ undiscoveredInstance,
306
+ );
307
+ };
308
+
257
309
  export const createOvermuxHooks = <TServerConfig>() => ({
310
+ useInstance,
258
311
  useOperation: <TId extends Extract<keyof Operations<TServerConfig>, string>>({
259
312
  id,
260
313
  signal,
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  clientProtocolMessageSchema,
3
+ createInstanceIdentity,
4
+ type InstanceIdentity,
3
5
  decodeProtocolFrame,
4
6
  encodeProtocolMessage,
5
7
  serverProtocolMessageSchema,
@@ -42,6 +44,8 @@ export type TransportStatus =
42
44
  | "disconnected";
43
45
 
44
46
  export type WebSocketTransport = {
47
+ getInstance: () => InstanceIdentity | undefined;
48
+ subscribeInstance: (listener: () => void) => () => void;
45
49
  activate: () => void;
46
50
  dispose: () => void;
47
51
  openStream: (input: {
@@ -99,6 +103,12 @@ export const createWebSocketTransport = ({
99
103
  let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
100
104
  let socket: WebSocket | undefined;
101
105
  let status: TransportStatus = "disconnected";
106
+ let instance: InstanceIdentity | undefined;
107
+ const instanceListeners = new Set<() => void>();
108
+ const setInstance = (next: InstanceIdentity | undefined) => {
109
+ instance = next;
110
+ instanceListeners.forEach((listener) => listener());
111
+ };
102
112
  const diagnosticQueue: ClientDiagnosticEvent[] = [];
103
113
  const requests = new Map<string, Request>();
104
114
  const subscriptions = new Map<string, Subscription>();
@@ -115,7 +125,7 @@ export const createWebSocketTransport = ({
115
125
  statusListeners.forEach((listener) => listener(status));
116
126
  };
117
127
  const send = (message: unknown) => {
118
- if (socket?.readyState !== WebSocket.OPEN) {
128
+ if (socket?.readyState !== WebSocket.OPEN || !instance) {
119
129
  return false;
120
130
  }
121
131
  // Apply outbound backpressure before queued messages consume unbounded memory.
@@ -189,6 +199,7 @@ export const createWebSocketTransport = ({
189
199
  requests.clear();
190
200
  };
191
201
  const reportDisconnect = () => {
202
+ setInstance(undefined);
192
203
  const error = disconnected();
193
204
  rejectRequests(error);
194
205
  subscriptions.forEach(({ onError }) => onError(error));
@@ -208,17 +219,8 @@ export const createWebSocketTransport = ({
208
219
  const next = createSocket();
209
220
  next.binaryType = "arraybuffer";
210
221
  socket = next;
211
- next.addEventListener("open", () => {
212
- if (socket !== next) {
213
- return;
214
- }
215
- reconnectDelay = 250;
216
- setStatus("connected");
217
- flushDiagnostics();
218
- restore();
219
- });
220
222
  next.addEventListener("message", (event) => {
221
- if (socket !== next) {
223
+ if (socket !== next || disposed) {
222
224
  return;
223
225
  }
224
226
  let decoded: unknown;
@@ -238,6 +240,19 @@ export const createWebSocketTransport = ({
238
240
  return;
239
241
  }
240
242
  const message = parsed.data;
243
+ if (message.type === "server-info" && !instance) {
244
+ setInstance(createInstanceIdentity(message.instanceId));
245
+ reconnectDelay = 250;
246
+ setStatus("connected");
247
+ flushDiagnostics();
248
+ restore();
249
+ return;
250
+ }
251
+ if (!instance || message.type === "server-info") {
252
+ reportDisconnect();
253
+ next.close(1002, "expected server discovery before traffic");
254
+ return;
255
+ }
241
256
  if (
242
257
  message.type === "update-available" ||
243
258
  message.type === "restarting"
@@ -312,6 +327,11 @@ export const createWebSocketTransport = ({
312
327
  };
313
328
 
314
329
  return {
330
+ getInstance: () => instance,
331
+ subscribeInstance: (listener) => {
332
+ instanceListeners.add(listener);
333
+ return () => instanceListeners.delete(listener);
334
+ },
315
335
  activate: () => {
316
336
  active = true;
317
337
  connect();
@@ -354,7 +374,7 @@ export const createWebSocketTransport = ({
354
374
  // Initial connection is not a failure: restore sends stream-open when the socket
355
375
  // opens, then stream-opened flushes queued messages. Actual failures still reject.
356
376
  stream.status =
357
- socket?.readyState === WebSocket.CONNECTING ||
377
+ status === "connecting" ||
358
378
  send({
359
379
  input,
360
380
  operationId: streamId,
@@ -129,7 +129,7 @@ const randomCode = () => {
129
129
  const durationMilliseconds = (
130
130
  duration: Exclude<
131
131
  AuthConfigDefinition["sessionLifetime"],
132
- "never" | undefined
132
+ "forever" | undefined
133
133
  >,
134
134
  ) => {
135
135
  const milliseconds = durationToMilliseconds(
@@ -357,7 +357,7 @@ export const createAuthService = ({
357
357
  const token = randomToken();
358
358
  const createdAt = new Date(now()).toISOString();
359
359
  const configuredLifetime =
360
- config.sessionLifetime && config.sessionLifetime !== "never"
360
+ config.sessionLifetime && config.sessionLifetime !== "forever"
361
361
  ? durationMilliseconds(config.sessionLifetime)
362
362
  : undefined;
363
363
  const expiresIn = lifetimeMs ?? configuredLifetime;