overmux 0.0.9 → 0.0.11

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 (40) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/bin.js +1 -1
  3. package/dist/bin.js.map +1 -1
  4. package/dist/docs/400-reference/200-configuration.md +35 -0
  5. package/dist/docs/400-reference/500-server/400-notifications.md +4 -0
  6. package/dist/docs/400-reference/600-client/030-native-web-view.md +166 -0
  7. package/dist/docs/400-reference/600-client/300-tech-stack-recommendations.md +1 -0
  8. package/dist/docs/400-reference/700-cli/600-docs.md +1 -1
  9. package/dist/exports/client.d.ts +43 -7
  10. package/dist/exports/client.d.ts.map +1 -1
  11. package/dist/exports/client.js +172 -22
  12. package/dist/exports/client.js.map +1 -1
  13. package/dist/exports/index.js +1 -1
  14. package/dist/exports/index.js.map +1 -1
  15. package/dist/internal/server/coordinator/server-child.js +25 -1
  16. package/dist/internal/server/coordinator/server-child.js.map +1 -1
  17. package/dist/push/THIRD_PARTY_NOTICES.md +30 -0
  18. package/dist/push/badge.png +0 -0
  19. package/dist/push/icon.png +0 -0
  20. package/dist/push/sw.js +88 -0
  21. package/dist/push/sw.js.map +1 -0
  22. package/docs/400-reference/200-configuration.md +35 -0
  23. package/docs/400-reference/500-server/400-notifications.md +4 -0
  24. package/docs/400-reference/600-client/030-native-web-view.md +166 -0
  25. package/docs/400-reference/600-client/300-tech-stack-recommendations.md +1 -0
  26. package/docs/400-reference/700-cli/600-docs.md +1 -1
  27. package/package.json +3 -3
  28. package/src/internal/client/background-notifications.ts +3 -1
  29. package/src/internal/client/client-definition.ts +9 -13
  30. package/src/internal/client/host/desktop-host.ts +28 -2
  31. package/src/internal/client/host/hosted-pages/browser-notification-settings.tsx +8 -3
  32. package/src/internal/client/native-web-view.tsx +158 -0
  33. package/src/internal/client/push-assets/badge.png +0 -0
  34. package/src/internal/client/push-assets/icon.png +0 -0
  35. package/src/internal/client/push-service-worker.ts +118 -0
  36. package/src/internal/client/register-push-worker.ts +63 -0
  37. package/src/internal/client/shortcuts.tsx +17 -31
  38. package/src/internal/server/http/routes.ts +47 -2
  39. package/src/internal/shared/routes.ts +2 -0
  40. package/src/public/client.ts +5 -0
@@ -0,0 +1,118 @@
1
+ import { notificationLinkSchema } from "@overmux/shared";
2
+ import { notificationEventSchema } from "../shared/protocol";
3
+ import {
4
+ pushNotificationBadgePath,
5
+ pushNotificationIconPath,
6
+ } from "../shared/routes";
7
+
8
+ // Only the worker APIs this push-only entry uses. No fetch handler or offline shell.
9
+ type WorkerClient = {
10
+ url: string;
11
+ focus: () => Promise<WorkerClient>;
12
+ navigate: (url: string) => Promise<WorkerClient | null>;
13
+ };
14
+ type LifetimeEvent = { waitUntil: (work: Promise<unknown>) => void };
15
+ type WorkerEvents = {
16
+ install: LifetimeEvent;
17
+ push: LifetimeEvent & { data?: { json: () => unknown } };
18
+ notificationclick: LifetimeEvent & {
19
+ notification: { close: () => void; data?: { link?: unknown } };
20
+ };
21
+ };
22
+ const worker = globalThis as unknown as {
23
+ addEventListener: <Type extends keyof WorkerEvents>(
24
+ type: Type,
25
+ listener: (event: WorkerEvents[Type]) => void,
26
+ ) => void;
27
+ clients: {
28
+ matchAll: (options: {
29
+ includeUncontrolled: boolean;
30
+ type: "window";
31
+ }) => Promise<WorkerClient[]>;
32
+ openWindow: (url: string) => Promise<WorkerClient | null>;
33
+ };
34
+ location: Location;
35
+ registration: {
36
+ showNotification: (
37
+ title: string,
38
+ options: NotificationOptions,
39
+ ) => Promise<void>;
40
+ };
41
+ skipWaiting: () => Promise<void>;
42
+ };
43
+
44
+ const notificationDestination = (link: unknown) => {
45
+ const parsed = notificationLinkSchema.safeParse(link);
46
+ if (!parsed.success) {
47
+ return undefined;
48
+ }
49
+ const destination = new URL(parsed.data, worker.location.origin);
50
+ // Notification links support relative paths and HTTP URLs, but background clicks
51
+ // must never send an authenticated PWA window to another origin.
52
+ return destination.origin === worker.location.origin
53
+ ? destination.href
54
+ : undefined;
55
+ };
56
+
57
+ const focusOrOpen = async (link: unknown) => {
58
+ const destination = notificationDestination(link);
59
+ const clients = await worker.clients.matchAll({
60
+ includeUncontrolled: true,
61
+ type: "window",
62
+ });
63
+ const client =
64
+ clients.find((candidate) => candidate.url === destination) ??
65
+ clients.find(
66
+ (candidate) => new URL(candidate.url).origin === worker.location.origin,
67
+ );
68
+ if (!client) {
69
+ await worker.clients.openWindow(
70
+ destination ?? new URL("/", worker.location.origin).href,
71
+ );
72
+ return;
73
+ }
74
+ // Without a safe link, preserve the user's current route. A disappearing client
75
+ // can return null from navigate; open a fresh window rather than lose the click.
76
+ const navigated =
77
+ destination && client.url !== destination
78
+ ? await client.navigate(destination)
79
+ : client;
80
+ if (navigated) {
81
+ await navigated.focus();
82
+ } else {
83
+ await worker.clients.openWindow(destination!);
84
+ }
85
+ };
86
+
87
+ worker.addEventListener("install", (event) => {
88
+ // This worker owns no caches or page traffic, so replacing it need not wait for tabs.
89
+ event.waitUntil(worker.skipWaiting());
90
+ });
91
+
92
+ worker.addEventListener("push", (event) => {
93
+ let input: unknown;
94
+ try {
95
+ input = event.data?.json();
96
+ } catch {
97
+ return;
98
+ }
99
+ const payload = notificationEventSchema.safeParse(input);
100
+ if (!payload.success) {
101
+ return;
102
+ }
103
+ const { notification } = payload.data;
104
+ event.waitUntil(
105
+ worker.registration.showNotification(notification.title, {
106
+ body: notification.body,
107
+ // Bypass cached transparent icons after adding the light background.
108
+ icon: `${pushNotificationIconPath}?v=2`,
109
+ badge: pushNotificationBadgePath,
110
+ data: { link: notification.open?.link },
111
+ }),
112
+ );
113
+ });
114
+
115
+ worker.addEventListener("notificationclick", (event) => {
116
+ event.notification.close();
117
+ event.waitUntil(focusOrOpen(event.notification.data?.link));
118
+ });
@@ -0,0 +1,63 @@
1
+ // Bound registration as well as activation: serviceWorker.ready can wait forever
2
+ // when no worker exists, and an installing worker can fail or stall indefinitely.
3
+ export const registerPushWorker = () =>
4
+ new Promise<ServiceWorkerRegistration>((resolve, reject) => {
5
+ let watched: ServiceWorker | null = null;
6
+ let settled = false;
7
+ const finish = (
8
+ error?: Error,
9
+ registration?: ServiceWorkerRegistration,
10
+ ) => {
11
+ if (settled) {
12
+ return;
13
+ }
14
+ settled = true;
15
+ clearTimeout(timeout);
16
+ watched?.removeEventListener("statechange", onStateChange);
17
+ if (error) {
18
+ reject(error);
19
+ } else {
20
+ resolve(registration!);
21
+ }
22
+ };
23
+ let registered: ServiceWorkerRegistration | undefined;
24
+ const onStateChange = () => {
25
+ if (watched?.state === "activated") {
26
+ finish(undefined, registered);
27
+ } else if (watched?.state === "redundant") {
28
+ finish(
29
+ new Error("The background notification worker failed to activate"),
30
+ );
31
+ }
32
+ };
33
+ const timeout = setTimeout(() => {
34
+ finish(
35
+ new Error("Timed out starting the background notification worker"),
36
+ );
37
+ }, 10_000);
38
+ void navigator.serviceWorker
39
+ .register("/sw.js", { scope: "/", updateViaCache: "none" })
40
+ .then(
41
+ (registration) => {
42
+ // The browser registration itself cannot be cancelled; ignore late results.
43
+ if (settled) {
44
+ return;
45
+ }
46
+ registered = registration;
47
+ watched =
48
+ registration.active ??
49
+ registration.installing ??
50
+ registration.waiting;
51
+ if (!watched) {
52
+ finish(
53
+ new Error("No background notification worker was installed"),
54
+ );
55
+ return;
56
+ }
57
+ watched.addEventListener("statechange", onStateChange);
58
+ onStateChange();
59
+ },
60
+ (error: unknown) =>
61
+ finish(error instanceof Error ? error : new Error(String(error))),
62
+ );
63
+ });
@@ -8,9 +8,12 @@ import {
8
8
  type RefObject,
9
9
  } from "react";
10
10
  import {
11
+ keyBindingSequence,
11
12
  matchesKeyBinding,
12
- platformBinding,
13
+ matchesShortcutBindingPrefix,
14
+ normalizedShortcutBinding,
13
15
  type KeyBinding,
16
+ type ShortcutBinding,
14
17
  } from "@overmux/keybindings";
15
18
 
16
19
  import {
@@ -19,7 +22,6 @@ import {
19
22
  shortcutMediaQueries,
20
23
  type ClientAppDefinition,
21
24
  type CommandHandle,
22
- type ShortcutBinding,
23
25
  } from "./client-definition";
24
26
  import {
25
27
  CommandsContext,
@@ -29,25 +31,6 @@ import {
29
31
  type RegisteredCommand,
30
32
  } from "./commands";
31
33
 
32
- const bindingSequence = (binding: ShortcutBinding): KeyBinding[] =>
33
- typeof binding === "string" ? [binding] : [...binding];
34
-
35
- const normalizedBinding = (binding: ShortcutBinding): string[] =>
36
- bindingSequence(binding).map((key) => platformBinding(key));
37
-
38
- const sequenceMatches = (
39
- binding: ShortcutBinding,
40
- pending: string[],
41
- event: KeyboardEvent,
42
- ) => {
43
- const sequence = normalizedBinding(binding);
44
- return (
45
- sequence.length > pending.length &&
46
- pending.every((key, index) => sequence[index] === key) &&
47
- matchesKeyBinding(sequence[pending.length] as KeyBinding, event)
48
- );
49
- };
50
-
51
34
  const isTextInput = (element: Element | null) =>
52
35
  element instanceof HTMLInputElement ||
53
36
  element instanceof HTMLSelectElement ||
@@ -67,7 +50,7 @@ const acceptsTextInput = (bindings: readonly ShortcutBinding[]) => {
67
50
  return true;
68
51
  }
69
52
  return bindings.some((binding) => {
70
- const sequence = bindingSequence(binding);
53
+ const sequence = keyBindingSequence(binding);
71
54
  return (
72
55
  sequence.length > 1 ||
73
56
  sequence[0]?.startsWith("F") ||
@@ -260,12 +243,12 @@ export const ShortcutHost = ({
260
243
  window.addEventListener("focusin", refreshFocus);
261
244
  return () => window.removeEventListener("focusin", refreshFocus);
262
245
  }, []);
246
+ const shortcuts = flattenShortcuts(
247
+ definition,
248
+ (media) => window.matchMedia(media).matches,
249
+ );
263
250
  useEffect(() => {
264
- const shortcuts = flattenShortcuts(
265
- definition,
266
- (media) => window.matchMedia(media).matches,
267
- );
268
- let pending: string[] = [];
251
+ let pending: KeyBinding[] = [];
269
252
  let pendingEvents: KeyboardEvent[] = [];
270
253
  let pendingTarget: ShortcutInputTarget | undefined;
271
254
  let timeout: number | undefined;
@@ -302,8 +285,11 @@ export const ShortcutHost = ({
302
285
  shortcut.bindings
303
286
  .filter(
304
287
  (binding) =>
305
- sequenceMatches(binding, pending, event) &&
306
- canRunShortcut(shortcut, event, registrations),
288
+ matchesShortcutBindingPrefix({
289
+ binding,
290
+ input: event,
291
+ pending,
292
+ }) && canRunShortcut(shortcut, event, registrations),
307
293
  )
308
294
  .map((binding) => ({ binding, shortcut })),
309
295
  );
@@ -317,7 +303,7 @@ export const ShortcutHost = ({
317
303
  }
318
304
  const complete = matches.find(
319
305
  ({ binding }) =>
320
- normalizedBinding(binding).length === pending.length + 1,
306
+ normalizedShortcutBinding(binding).length === pending.length + 1,
321
307
  );
322
308
  if (complete) {
323
309
  clearPending();
@@ -336,7 +322,7 @@ export const ShortcutHost = ({
336
322
  }
337
323
  pending = [
338
324
  ...pending,
339
- normalizedBinding(matches[0]!.binding)[pending.length]!,
325
+ normalizedShortcutBinding(matches[0]!.binding)[pending.length]!,
340
326
  ];
341
327
  pendingEvents = [...pendingEvents, event];
342
328
  event.preventDefault();
@@ -1,6 +1,8 @@
1
1
  import { serveStatic } from "@hono/node-server/serve-static";
2
2
  import type { Handler, Hono, MiddlewareHandler } from "hono";
3
- import { join } from "node:path";
3
+ import { readFile } from "node:fs/promises";
4
+ import { basename, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
4
6
 
5
7
  import {
6
8
  createHttpAuthenticationHandlers,
@@ -21,7 +23,11 @@ import { createOperationHandler } from "./operation-handler";
21
23
  import { createRequestLoggingMiddleware } from "./request-logging";
22
24
  import { createRuntimeManifestHandler } from "./runtime-manifest-handler";
23
25
  import { overmuxLogoutPath, overmuxSettingsPath } from "@overmux/shared";
24
- import { viteInfrastructurePath } from "../../shared/routes";
26
+ import {
27
+ pushNotificationBadgePath,
28
+ pushNotificationIconPath,
29
+ viteInfrastructurePath,
30
+ } from "../../shared/routes";
25
31
 
26
32
  // Registration order is the public/protected boundary. Authentication state,
27
33
  // login, and health are registered before the session gate; runtime APIs and
@@ -105,6 +111,28 @@ export const registerHttpRoutes = ({
105
111
  handler: createRequestLoggingMiddleware({ serverLogger }),
106
112
  },
107
113
  // BEGIN: UNAUTHENTICATED
114
+ // Public branding must remain fetchable when a push wakes the worker after
115
+ // session expiry. Serve only these packaged PNGs, never userland assets.
116
+ ...[pushNotificationIconPath, pushNotificationBadgePath].map(
117
+ (path): RouteDescriptor => ({
118
+ method: "GET",
119
+ path,
120
+ handler: async (context) => {
121
+ const asset = await readFile(
122
+ fileURLToPath(
123
+ new URL(
124
+ `../push/${basename(path)}`,
125
+ import.meta.resolve("overmux"),
126
+ ),
127
+ ),
128
+ );
129
+ context.header("Cache-Control", "public, max-age=86400");
130
+ context.header("Content-Type", "image/png");
131
+ context.header("X-Content-Type-Options", "nosniff");
132
+ return context.body(new Uint8Array(asset));
133
+ },
134
+ }),
135
+ ),
108
136
  {
109
137
  method: "GET",
110
138
  path: "/_overmux/auth-shell/*",
@@ -138,6 +166,23 @@ export const registerHttpRoutes = ({
138
166
  handler: authentication.authenticate,
139
167
  },
140
168
  // BEGIN: AUTHENTICATED
169
+ // Runtime-owned in both Vite development and production; never the SPA fallback.
170
+ {
171
+ method: "GET",
172
+ path: "/sw.js",
173
+ handler: async (context) => {
174
+ const script = await readFile(
175
+ fileURLToPath(
176
+ new URL("../push/sw.js", import.meta.resolve("overmux")),
177
+ ),
178
+ "utf8",
179
+ );
180
+ context.header("Cache-Control", "no-cache");
181
+ context.header("Content-Type", "text/javascript; charset=UTF-8");
182
+ context.header("X-Content-Type-Options", "nosniff");
183
+ return context.body(script);
184
+ },
185
+ },
141
186
  {
142
187
  method: "POST",
143
188
  path: "/api/auth/logout",
@@ -1,3 +1,5 @@
1
+ export const pushNotificationIconPath = "/_overmux/push/icon.png";
2
+ export const pushNotificationBadgePath = "/_overmux/push/badge.png";
1
3
  export const runtimeWebSocketPath = "/api/socket";
2
4
  export const viteHmrPath = "/_overmux/vite/hmr";
3
5
  export const viteInfrastructurePath = "/_overmux/vite/*";
@@ -14,6 +14,11 @@ export {
14
14
  type StreamResult,
15
15
  } from "../internal/client/overmux-react";
16
16
  export { OvermuxHost } from "../internal/client/host/overmux-host";
17
+ export { NativeWebView } from "../internal/client/native-web-view";
18
+ export type {
19
+ NativeWebViewProps,
20
+ NativeWebViewLoadError,
21
+ } from "../internal/client/native-web-view";
17
22
  export {
18
23
  OvermuxPortal,
19
24
  OvermuxThemeScope,