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.
- package/CHANGELOG.md +44 -0
- package/README.md +2 -2
- package/dist/auth/.vite/manifest.json +2 -2
- package/dist/auth/assets/{index-Bx8JhwVi.css → index-BM9gETvx.css} +1 -1
- package/dist/auth/assets/{index-CHIE7qYj.js → index-DabAfcs1.js} +1 -1
- package/dist/auth/index.html +2 -2
- package/dist/bin.js +219 -126
- package/dist/bin.js.map +1 -1
- package/dist/docs/000-index.md +2 -4
- package/dist/docs/100-introduction/200-how-overmux-works.md +126 -2
- package/{docs/100-introduction/300-why-overmux.md → dist/docs/100-introduction/300-why-i-built-overmux.md} +1 -1
- package/dist/docs/200-getting-started/100-install-and-run-overmux.md +2 -2
- package/dist/docs/200-getting-started/400-secure-with-https/200-tailscale-serve.md +3 -3
- package/dist/docs/200-getting-started/400-secure-with-https/300-cloudflare-tunnel.md +2 -2
- package/dist/docs/200-getting-started/400-secure-with-https/400-self-hosted-reverse-proxy.md +2 -2
- package/dist/docs/400-reference/100-project-structure.md +83 -0
- package/dist/docs/400-reference/200-configuration.md +257 -0
- package/dist/docs/400-reference/300-storage-locations.md +46 -0
- package/dist/docs/400-reference/400-authentication-and-security.md +37 -0
- package/dist/docs/400-reference/500-server/000-index.md +13 -0
- package/dist/docs/400-reference/500-server/100-resources.md +191 -0
- package/dist/docs/400-reference/500-server/200-operations.md +111 -0
- package/dist/docs/400-reference/500-server/300-streams.md +140 -0
- package/dist/docs/400-reference/500-server/400-notifications.md +32 -0
- package/dist/docs/400-reference/500-server/500-api.md +118 -0
- package/dist/docs/400-reference/600-client/000-index.md +9 -0
- package/dist/docs/400-reference/600-client/100-api.md +370 -0
- package/dist/docs/400-reference/600-client/200-theming.md +94 -0
- package/dist/docs/400-reference/600-client/300-tech-stack-recommendations.md +12 -0
- package/{docs/400-reference/400-cli → dist/docs/400-reference/700-cli}/050-init.md +2 -4
- package/dist/docs/400-reference/{400-cli → 700-cli}/100-serve.md +9 -4
- package/{docs/400-reference/400-cli → dist/docs/400-reference/700-cli}/200-auth.md +1 -4
- package/{docs/400-reference/400-cli → dist/docs/400-reference/700-cli}/300-call.md +1 -4
- package/dist/docs/400-reference/700-cli/350-instance.md +21 -0
- package/dist/docs/400-reference/700-cli/600-docs.md +128 -0
- package/{docs/400-reference/400-cli → dist/docs/400-reference/700-cli}/700-desktop.md +1 -3
- package/dist/docs/500-hosted-pages.md +0 -1
- package/dist/exports/client.d.ts +19 -15
- package/dist/exports/client.d.ts.map +1 -1
- package/dist/exports/client.js +240 -58
- package/dist/exports/client.js.map +1 -1
- package/dist/exports/{index-D3_Dpy_T.d.ts → index-Cz3xkCa4.d.ts} +62 -36
- package/dist/exports/index-Cz3xkCa4.d.ts.map +1 -0
- package/dist/exports/index.d.ts +2 -2
- package/dist/exports/index.js +19 -5
- package/dist/exports/index.js.map +1 -1
- package/dist/exports/{notifications-kDp16bU_.js → notifications-BFAD3QQl.js} +34 -4
- package/dist/exports/notifications-BFAD3QQl.js.map +1 -0
- package/dist/exports/server.d.ts +1 -44
- package/dist/exports/server.d.ts.map +1 -1
- package/dist/exports/server.js +8 -1018
- package/dist/exports/server.js.map +1 -1
- package/dist/internal/server/coordinator/server-child.js +153 -85
- package/dist/internal/server/coordinator/server-child.js.map +1 -1
- package/docs/000-index.md +2 -4
- package/docs/100-introduction/200-how-overmux-works.md +126 -2
- package/{dist/docs/100-introduction/300-why-overmux.md → docs/100-introduction/300-why-i-built-overmux.md} +1 -1
- package/docs/200-getting-started/100-install-and-run-overmux.md +2 -2
- package/docs/200-getting-started/400-secure-with-https/200-tailscale-serve.md +3 -3
- package/docs/200-getting-started/400-secure-with-https/300-cloudflare-tunnel.md +2 -2
- package/docs/200-getting-started/400-secure-with-https/400-self-hosted-reverse-proxy.md +2 -2
- package/docs/400-reference/100-project-structure.md +83 -0
- package/docs/400-reference/200-configuration.md +257 -0
- package/docs/400-reference/300-storage-locations.md +46 -0
- package/docs/400-reference/400-authentication-and-security.md +37 -0
- package/docs/400-reference/500-server/000-index.md +13 -0
- package/docs/400-reference/500-server/100-resources.md +191 -0
- package/docs/400-reference/500-server/200-operations.md +111 -0
- package/docs/400-reference/500-server/300-streams.md +140 -0
- package/docs/400-reference/500-server/400-notifications.md +32 -0
- package/docs/400-reference/500-server/500-api.md +118 -0
- package/docs/400-reference/600-client/000-index.md +9 -0
- package/docs/400-reference/600-client/100-api.md +370 -0
- package/docs/400-reference/600-client/200-theming.md +94 -0
- package/docs/400-reference/600-client/300-tech-stack-recommendations.md +12 -0
- package/{dist/docs/400-reference/400-cli → docs/400-reference/700-cli}/050-init.md +2 -4
- package/docs/400-reference/{400-cli → 700-cli}/100-serve.md +9 -4
- package/{dist/docs/400-reference/400-cli → docs/400-reference/700-cli}/200-auth.md +1 -4
- package/{dist/docs/400-reference/400-cli → docs/400-reference/700-cli}/300-call.md +1 -4
- package/docs/400-reference/700-cli/350-instance.md +21 -0
- package/docs/400-reference/700-cli/600-docs.md +128 -0
- package/{dist/docs/400-reference/400-cli → docs/400-reference/700-cli}/700-desktop.md +1 -3
- package/docs/500-hosted-pages.md +0 -1
- package/package.json +3 -2
- package/src/internal/cli/app.ts +5 -5
- package/src/internal/cli/commands/docs-ai-context.ts +33 -0
- package/src/internal/cli/commands/docs.ts +19 -2
- package/src/internal/cli/commands/init-template.ts +1 -1
- package/src/internal/cli/commands/instance.ts +58 -0
- package/src/internal/cli/commands/serve.ts +3 -0
- package/src/internal/cli/login.ts +9 -9
- package/src/internal/cli/parse-port.ts +2 -1
- package/src/internal/client/auth/auth-shell.css +17 -1
- package/src/internal/client/auth/auth-shell.tsx +2 -11
- package/src/internal/client/auth/login-code-input.tsx +140 -0
- package/src/internal/client/browser-api.ts +17 -3
- package/src/internal/client/client-definition.ts +6 -8
- package/src/internal/client/host/deep-link-navigation.ts +75 -0
- package/src/internal/client/host/desktop-host.ts +3 -0
- package/src/internal/client/host/environment.ts +67 -0
- package/src/internal/client/host/instance-forwarding.ts +21 -0
- package/src/internal/client/host/overmux-host.tsx +17 -0
- package/src/internal/client/index.ts +0 -6
- package/src/internal/client/overmux-react.ts +95 -42
- package/src/internal/client/websocket.ts +32 -12
- package/src/internal/server/auth/auth-service.ts +2 -2
- package/src/internal/server/auth/instance-control.ts +89 -17
- package/src/internal/server/coordinator/ipc-protocol.ts +0 -1
- package/src/internal/server/runtime/create-runtime.ts +15 -2
- package/src/internal/server/runtime/runtime-instance.ts +47 -0
- package/src/internal/server/runtime/runtime-operations.ts +9 -3
- package/src/internal/server/runtime/runtime-resources.ts +15 -32
- package/src/internal/server/runtime/runtime-streams.ts +5 -6
- package/src/internal/server/server-logger.ts +5 -10
- package/src/internal/server/server-startup-options.ts +14 -14
- package/src/internal/server/start-application-server.ts +24 -19
- package/src/internal/server/websocket-server.ts +7 -0
- package/src/internal/shared/index.ts +2 -0
- package/src/internal/shared/protocol.ts +10 -1
- package/src/public/ai-context.ts +7 -29
- package/src/public/client.ts +1 -6
- package/src/public/config.ts +163 -31
- package/src/public/index.ts +2 -0
- package/src/public/server.ts +1 -16
- package/dist/docs/100-introduction/100-what-is-overmux.md +0 -7
- package/dist/docs/300-fundamentals/100-project-structure.md +0 -23
- package/dist/docs/300-fundamentals/200-configuration.md +0 -3
- package/dist/docs/300-fundamentals/300-theming.md +0 -3
- package/dist/docs/300-fundamentals/400-server.md +0 -3
- package/dist/docs/300-fundamentals/500-client.md +0 -3
- package/dist/docs/300-fundamentals/600-operations.md +0 -3
- package/dist/docs/300-fundamentals/700-resources.md +0 -3
- package/dist/docs/300-fundamentals/800-streams.md +0 -3
- package/dist/docs/300-fundamentals/900-authentication-and-security.md +0 -3
- package/dist/docs/400-reference/100-configuration.md +0 -3
- package/dist/docs/400-reference/200-server-api.md +0 -3
- package/dist/docs/400-reference/300-client-api.md +0 -20
- package/dist/docs/400-reference/400-cli/400-check.md +0 -20
- package/dist/docs/400-reference/400-cli/500-ai-context.md +0 -102
- package/dist/docs/400-reference/400-cli/600-docs.md +0 -23
- package/dist/exports/index-D3_Dpy_T.d.ts.map +0 -1
- package/dist/exports/notifications-kDp16bU_.js.map +0 -1
- package/docs/100-introduction/100-what-is-overmux.md +0 -7
- package/docs/300-fundamentals/100-project-structure.md +0 -23
- package/docs/300-fundamentals/200-configuration.md +0 -3
- package/docs/300-fundamentals/300-theming.md +0 -3
- package/docs/300-fundamentals/400-server.md +0 -3
- package/docs/300-fundamentals/500-client.md +0 -3
- package/docs/300-fundamentals/600-operations.md +0 -3
- package/docs/300-fundamentals/700-resources.md +0 -3
- package/docs/300-fundamentals/800-streams.md +0 -3
- package/docs/300-fundamentals/900-authentication-and-security.md +0 -3
- package/docs/400-reference/100-configuration.md +0 -3
- package/docs/400-reference/200-server-api.md +0 -3
- package/docs/400-reference/300-client-api.md +0 -20
- package/docs/400-reference/400-cli/400-check.md +0 -20
- package/docs/400-reference/400-cli/500-ai-context.md +0 -102
- package/docs/400-reference/400-cli/600-docs.md +0 -23
- 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
|
-
|
|
26
|
+
const shortcutBindingSchema = z.union([
|
|
32
27
|
keyBindingSchema,
|
|
33
28
|
z.array(keyBindingSchema).min(2),
|
|
34
29
|
]);
|
|
35
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
(message:
|
|
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
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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 !== "
|
|
360
|
+
config.sessionLifetime && config.sessionLifetime !== "forever"
|
|
361
361
|
? durationMilliseconds(config.sessionLifetime)
|
|
362
362
|
: undefined;
|
|
363
363
|
const expiresIn = lifetimeMs ?? configuredLifetime;
|