overmux 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +7 -0
- package/dist/auth/.vite/manifest.json +11 -0
- package/dist/auth/assets/index-Bx8JhwVi.css +1 -0
- package/dist/auth/assets/index-CHIE7qYj.js +72 -0
- package/dist/auth/index.html +14 -0
- package/dist/bin.js +4504 -7870
- package/dist/bin.js.map +1 -0
- package/dist/docs/000-index.md +21 -0
- package/dist/docs/100-introduction/100-what-is-overmux.md +7 -0
- package/dist/docs/100-introduction/200-how-overmux-works.md +7 -0
- package/dist/docs/100-introduction/300-why-overmux.md +22 -0
- package/dist/docs/200-getting-started/100-install-and-run-overmux.md +51 -0
- package/dist/docs/200-getting-started/200-install-overmux-desktop.md +41 -0
- package/dist/docs/200-getting-started/300-install-overmux-pwa.mdx +92 -0
- package/dist/docs/200-getting-started/400-secure-with-https/100-choose-an-https-setup.md +14 -0
- package/dist/docs/200-getting-started/400-secure-with-https/200-tailscale-serve.md +44 -0
- package/dist/docs/200-getting-started/400-secure-with-https/300-cloudflare-tunnel.md +59 -0
- package/dist/docs/200-getting-started/400-secure-with-https/400-self-hosted-reverse-proxy.md +32 -0
- package/dist/docs/300-fundamentals/100-project-structure.md +3 -0
- package/dist/docs/300-fundamentals/200-configuration.md +3 -0
- package/dist/docs/300-fundamentals/300-theming.md +3 -0
- package/dist/docs/300-fundamentals/400-server.md +3 -0
- package/dist/docs/300-fundamentals/500-client.md +3 -0
- package/dist/docs/300-fundamentals/600-operations.md +3 -0
- package/dist/docs/300-fundamentals/700-resources.md +3 -0
- package/dist/docs/300-fundamentals/800-streams.md +3 -0
- package/dist/docs/300-fundamentals/900-authentication-and-security.md +3 -0
- package/dist/docs/400-reference/100-configuration.md +3 -0
- package/dist/docs/400-reference/200-server-api.md +3 -0
- package/dist/docs/400-reference/300-client-api.md +3 -0
- package/dist/docs/400-reference/400-cli/100-serve.md +33 -0
- package/dist/docs/400-reference/400-cli/200-auth.md +52 -0
- package/dist/docs/400-reference/400-cli/300-call.md +28 -0
- package/dist/docs/400-reference/400-cli/400-check.md +20 -0
- package/dist/docs/400-reference/400-cli/500-ai-context.md +102 -0
- package/dist/docs/400-reference/400-cli/600-docs.md +23 -0
- package/dist/docs/400-reference/400-cli/700-desktop.md +35 -0
- package/dist/docs/500-hosted-pages.md +31 -0
- package/dist/exports/client.d.ts +237 -0
- package/dist/exports/client.d.ts.map +1 -0
- package/dist/exports/client.js +1643 -0
- package/dist/exports/client.js.map +1 -0
- package/dist/exports/index-D3_Dpy_T.d.ts +155 -0
- package/dist/exports/index-D3_Dpy_T.d.ts.map +1 -0
- package/dist/exports/index.d.ts +2 -0
- package/dist/exports/index.js +49 -0
- package/dist/exports/index.js.map +1 -0
- package/dist/exports/notifications-kDp16bU_.js +56 -0
- package/dist/exports/notifications-kDp16bU_.js.map +1 -0
- package/dist/exports/server.d.ts +63 -0
- package/dist/exports/server.d.ts.map +1 -0
- package/dist/exports/server.js +1120 -0
- package/dist/exports/server.js.map +1 -0
- package/dist/exports/style.css +368 -0
- package/dist/internal/server/coordinator/server-child.d.ts +1 -0
- package/dist/internal/server/coordinator/server-child.js +3299 -0
- package/dist/internal/server/coordinator/server-child.js.map +1 -0
- package/docs/000-index.md +21 -0
- package/docs/100-introduction/100-what-is-overmux.md +7 -0
- package/docs/100-introduction/200-how-overmux-works.md +7 -0
- package/docs/100-introduction/300-why-overmux.md +22 -0
- package/docs/200-getting-started/100-install-and-run-overmux.md +51 -0
- package/docs/200-getting-started/200-install-overmux-desktop.md +41 -0
- package/docs/200-getting-started/300-install-overmux-pwa.mdx +92 -0
- package/docs/200-getting-started/400-secure-with-https/100-choose-an-https-setup.md +14 -0
- package/docs/200-getting-started/400-secure-with-https/200-tailscale-serve.md +44 -0
- package/docs/200-getting-started/400-secure-with-https/300-cloudflare-tunnel.md +59 -0
- package/docs/200-getting-started/400-secure-with-https/400-self-hosted-reverse-proxy.md +32 -0
- package/docs/300-fundamentals/100-project-structure.md +3 -0
- package/docs/300-fundamentals/200-configuration.md +3 -0
- package/docs/300-fundamentals/300-theming.md +3 -0
- package/docs/300-fundamentals/400-server.md +3 -0
- package/docs/300-fundamentals/500-client.md +3 -0
- package/docs/300-fundamentals/600-operations.md +3 -0
- package/docs/300-fundamentals/700-resources.md +3 -0
- package/docs/300-fundamentals/800-streams.md +3 -0
- package/docs/300-fundamentals/900-authentication-and-security.md +3 -0
- package/docs/400-reference/100-configuration.md +3 -0
- package/docs/400-reference/200-server-api.md +3 -0
- package/docs/400-reference/300-client-api.md +3 -0
- package/docs/400-reference/400-cli/100-serve.md +33 -0
- package/docs/400-reference/400-cli/200-auth.md +52 -0
- package/docs/400-reference/400-cli/300-call.md +28 -0
- package/docs/400-reference/400-cli/400-check.md +20 -0
- package/docs/400-reference/400-cli/500-ai-context.md +102 -0
- package/docs/400-reference/400-cli/600-docs.md +23 -0
- package/docs/400-reference/400-cli/700-desktop.md +35 -0
- package/docs/500-hosted-pages.md +31 -0
- package/package.json +59 -18
- package/src/internal/cli/app.ts +72 -0
- package/src/internal/cli/bin.ts +3 -0
- package/src/internal/cli/commands/ai.ts +44 -0
- package/src/internal/cli/commands/auth.ts +195 -0
- package/src/internal/cli/commands/call.ts +128 -0
- package/src/internal/cli/commands/check.ts +48 -0
- package/src/internal/cli/commands/desktop-installer.ts +446 -0
- package/src/internal/cli/commands/desktop-release.ts +190 -0
- package/src/internal/cli/commands/desktop.ts +47 -0
- package/src/internal/cli/commands/docs.ts +34 -0
- package/src/internal/cli/commands/serve.ts +322 -0
- package/src/internal/cli/environment.ts +54 -0
- package/src/internal/cli/login.ts +46 -0
- package/src/internal/cli/managed-vite.ts +267 -0
- package/src/internal/cli/parse-port.ts +8 -0
- package/src/internal/client/auth/auth-api.ts +53 -0
- package/src/internal/client/auth/auth-shell.css +74 -0
- package/src/internal/client/auth/auth-shell.tsx +167 -0
- package/src/internal/client/auth/entry.tsx +26 -0
- package/src/internal/client/auth/index.html +13 -0
- package/src/internal/client/background-notifications.ts +194 -0
- package/src/internal/client/browser-api.ts +97 -0
- package/src/internal/client/client-definition.ts +184 -0
- package/src/internal/client/commands.tsx +260 -0
- package/src/internal/client/host/browser-log-forwarding.ts +149 -0
- package/src/internal/client/host/hosted-pages/browser-notification-settings.tsx +118 -0
- package/src/internal/client/host/hosted-pages/hosted-page.css +87 -0
- package/src/internal/client/host/hosted-pages/hosted-page.tsx +9 -0
- package/src/internal/client/host/hosted-pages/logout-page.tsx +75 -0
- package/src/internal/client/host/hosted-pages/settings-page.tsx +45 -0
- package/src/internal/client/host/notification-forwarding.ts +22 -0
- package/src/internal/client/host/overmux-host.tsx +200 -0
- package/src/internal/client/host/recovery-screen.css +75 -0
- package/src/internal/client/host/recovery-screen.tsx +17 -0
- package/src/internal/client/host/update-popover.css +82 -0
- package/src/internal/client/host/update-popover.tsx +166 -0
- package/src/internal/client/index.ts +45 -0
- package/src/internal/client/overmux-react.ts +283 -0
- package/src/internal/client/shortcuts.tsx +373 -0
- package/src/internal/client/theme-scope.css +114 -0
- package/src/internal/client/theme-scope.tsx +107 -0
- package/src/internal/client/transport.ts +69 -0
- package/src/internal/client/websocket.ts +441 -0
- package/src/internal/server/auth/auth-http.ts +414 -0
- package/src/internal/server/auth/auth-service.ts +627 -0
- package/src/internal/server/auth/auth-shell-http.ts +90 -0
- package/src/internal/server/auth/instance-control.ts +381 -0
- package/src/internal/server/check-overmux.ts +364 -0
- package/src/internal/server/coordinator/ipc-protocol.ts +44 -0
- package/src/internal/server/coordinator/server-child.ts +108 -0
- package/src/internal/server/coordinator/server-coordinator.ts +262 -0
- package/src/internal/server/coordinator/update-watcher.ts +54 -0
- package/src/internal/server/http/authenticated-http-app-test-helper.ts +45 -0
- package/src/internal/server/http/background-notification-handlers.ts +83 -0
- package/src/internal/server/http/control-handlers.ts +44 -0
- package/src/internal/server/http/create-http-app.ts +53 -0
- package/src/internal/server/http/development-web-backend.ts +83 -0
- package/src/internal/server/http/operation-handler.ts +93 -0
- package/src/internal/server/http/request-logging.ts +34 -0
- package/src/internal/server/http/routes.ts +241 -0
- package/src/internal/server/http/runtime-manifest-handler.ts +12 -0
- package/src/internal/server/index.ts +16 -0
- package/src/internal/server/notifications/background-notification-payload.ts +74 -0
- package/src/internal/server/notifications/background-notification-service.ts +139 -0
- package/src/internal/server/notifications/background-notification-store.ts +162 -0
- package/src/internal/server/notifications/notification-service.ts +70 -0
- package/src/internal/server/notifications/private-json-file.ts +84 -0
- package/src/internal/server/paths.ts +13 -0
- package/src/internal/server/rate-limiter.ts +58 -0
- package/src/internal/server/runtime/create-runtime.ts +87 -0
- package/src/internal/server/runtime/resource-dependency-graph.ts +128 -0
- package/src/internal/server/runtime/runtime-lifecycle.ts +95 -0
- package/src/internal/server/runtime/runtime-operations.ts +78 -0
- package/src/internal/server/runtime/runtime-resources.ts +201 -0
- package/src/internal/server/runtime/runtime-streams.ts +130 -0
- package/src/internal/server/server-logger.ts +110 -0
- package/src/internal/server/server-startup-options.ts +51 -0
- package/src/internal/server/start-application-server.ts +412 -0
- package/src/internal/server/start-overmux-server.ts +26 -0
- package/src/internal/server/test-port.ts +23 -0
- package/src/internal/server/vite-websocket-proxy.ts +201 -0
- package/src/internal/server/websocket-connection.ts +586 -0
- package/src/internal/server/websocket-limits.ts +2 -0
- package/src/internal/server/websocket-server.ts +311 -0
- package/src/internal/shared/background-notifications.ts +37 -0
- package/src/internal/shared/index.ts +2 -0
- package/src/internal/shared/protocol.ts +317 -0
- package/src/internal/shared/routes.ts +3 -0
- package/src/public/ai-context.ts +44 -0
- package/src/public/client.ts +53 -0
- package/src/public/config.ts +287 -0
- package/src/public/contracts.ts +55 -0
- package/src/public/index.ts +57 -0
- package/src/public/notifications.ts +10 -0
- package/src/public/server.ts +16 -0
- package/dist/public/core.ts +0 -119
- package/dist/public/protocol.ts +0 -51
- package/dist/public/terminal.ts +0 -23
- package/dist/web/assets/index-DC___IUw.css +0 -2
- package/dist/web/assets/index-DF9Cptse.js +0 -74
- package/dist/web/assets/terminal-component-BrP-ENHg.css +0 -1
- package/dist/web/assets/terminal-component-DyLEbMrx.js +0 -36
- package/dist/web/index.html +0 -14
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Defines replaceable persistence for background-notification keys and subscriptions.
|
|
2
|
+
// The file implementation owns the required XDG data location and atomic updates.
|
|
3
|
+
import {
|
|
4
|
+
backgroundNotificationSubscriptionSchema,
|
|
5
|
+
type BackgroundNotificationSubscription,
|
|
6
|
+
} from "../../shared/index";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { isDeepStrictEqual } from "node:util";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
|
|
12
|
+
import { getOvermuxPaths } from "../paths";
|
|
13
|
+
import {
|
|
14
|
+
createPrivateJsonExclusively,
|
|
15
|
+
readPrivateJson,
|
|
16
|
+
replacePrivateJsonAtomically,
|
|
17
|
+
} from "./private-json-file";
|
|
18
|
+
|
|
19
|
+
const vapidKeysSchema = z
|
|
20
|
+
.object({ privateKey: z.string().min(1), publicKey: z.string().min(1) })
|
|
21
|
+
.strict();
|
|
22
|
+
const storedSubscriptionSchema = z.object({
|
|
23
|
+
sessionId: z.string(),
|
|
24
|
+
subscription: backgroundNotificationSubscriptionSchema,
|
|
25
|
+
});
|
|
26
|
+
const subscriptionsSchema = z.array(storedSubscriptionSchema);
|
|
27
|
+
|
|
28
|
+
type VapidKeys = z.infer<typeof vapidKeysSchema>;
|
|
29
|
+
export type StoredBackgroundNotificationSubscription = z.infer<
|
|
30
|
+
typeof storedSubscriptionSchema
|
|
31
|
+
>;
|
|
32
|
+
|
|
33
|
+
export type BackgroundNotificationStore = {
|
|
34
|
+
getOrCreateKeys: (generate: () => VapidKeys) => Promise<VapidKeys>;
|
|
35
|
+
listSubscriptions: () => Promise<
|
|
36
|
+
readonly StoredBackgroundNotificationSubscription[]
|
|
37
|
+
>;
|
|
38
|
+
removeSessionSubscriptions: (
|
|
39
|
+
sessionIds: ReadonlySet<string>,
|
|
40
|
+
) => Promise<void>;
|
|
41
|
+
removeSubscription: (endpoint: string) => Promise<void>;
|
|
42
|
+
upsertSubscription: (
|
|
43
|
+
sessionId: string,
|
|
44
|
+
subscription: BackgroundNotificationSubscription,
|
|
45
|
+
) => Promise<void>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const backgroundNotificationDataDirectory = ({
|
|
49
|
+
environment,
|
|
50
|
+
homeDirectory,
|
|
51
|
+
}: {
|
|
52
|
+
environment: NodeJS.ProcessEnv;
|
|
53
|
+
homeDirectory: string;
|
|
54
|
+
}) =>
|
|
55
|
+
join(
|
|
56
|
+
getOvermuxPaths({ environment, homeDir: homeDirectory }).dataDir,
|
|
57
|
+
"background-notifications",
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
export const createFileBackgroundNotificationStore = ({
|
|
61
|
+
environment = process.env,
|
|
62
|
+
homeDirectory = homedir(),
|
|
63
|
+
}: {
|
|
64
|
+
environment?: NodeJS.ProcessEnv;
|
|
65
|
+
homeDirectory?: string;
|
|
66
|
+
} = {}): BackgroundNotificationStore => {
|
|
67
|
+
const directory = backgroundNotificationDataDirectory({
|
|
68
|
+
environment,
|
|
69
|
+
homeDirectory,
|
|
70
|
+
});
|
|
71
|
+
const keysPath = join(directory, "vapid.json");
|
|
72
|
+
const subscriptionsPath = join(directory, "subscriptions.json");
|
|
73
|
+
let pendingSubscriptionWrite = Promise.resolve();
|
|
74
|
+
|
|
75
|
+
const readSubscriptions = async () =>
|
|
76
|
+
(await readPrivateJson(subscriptionsPath, subscriptionsSchema)) ?? [];
|
|
77
|
+
|
|
78
|
+
const updateSubscriptions = (
|
|
79
|
+
update: (
|
|
80
|
+
subscriptions: StoredBackgroundNotificationSubscription[],
|
|
81
|
+
) => StoredBackgroundNotificationSubscription[] | undefined,
|
|
82
|
+
) => {
|
|
83
|
+
const operation = pendingSubscriptionWrite.then(async () => {
|
|
84
|
+
const subscriptions = await readSubscriptions();
|
|
85
|
+
const updated = update(subscriptions);
|
|
86
|
+
if (updated) {
|
|
87
|
+
await replacePrivateJsonAtomically(subscriptionsPath, updated);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
pendingSubscriptionWrite = operation.catch(() => undefined);
|
|
91
|
+
return operation;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
getOrCreateKeys: async (generate) => {
|
|
96
|
+
const existing = await readPrivateJson(keysPath, vapidKeysSchema);
|
|
97
|
+
if (existing) {
|
|
98
|
+
return existing;
|
|
99
|
+
}
|
|
100
|
+
const generated = vapidKeysSchema.parse(generate());
|
|
101
|
+
try {
|
|
102
|
+
await createPrivateJsonExclusively(keysPath, generated);
|
|
103
|
+
return generated;
|
|
104
|
+
} catch (cause) {
|
|
105
|
+
if ((cause as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
106
|
+
throw cause;
|
|
107
|
+
}
|
|
108
|
+
const concurrentlyCreated = await readPrivateJson(
|
|
109
|
+
keysPath,
|
|
110
|
+
vapidKeysSchema,
|
|
111
|
+
);
|
|
112
|
+
if (!concurrentlyCreated) {
|
|
113
|
+
throw cause;
|
|
114
|
+
}
|
|
115
|
+
return concurrentlyCreated;
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
listSubscriptions: async () => {
|
|
119
|
+
await pendingSubscriptionWrite;
|
|
120
|
+
return readSubscriptions();
|
|
121
|
+
},
|
|
122
|
+
removeSubscription: (endpoint) =>
|
|
123
|
+
updateSubscriptions((subscriptions) => {
|
|
124
|
+
if (
|
|
125
|
+
!subscriptions.some(
|
|
126
|
+
(entry) => entry.subscription.endpoint === endpoint,
|
|
127
|
+
)
|
|
128
|
+
) {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
return subscriptions.filter(
|
|
132
|
+
(entry) => entry.subscription.endpoint !== endpoint,
|
|
133
|
+
);
|
|
134
|
+
}),
|
|
135
|
+
removeSessionSubscriptions: (sessionIds) =>
|
|
136
|
+
updateSubscriptions((subscriptions) => {
|
|
137
|
+
const updated = subscriptions.filter(
|
|
138
|
+
(entry) => !sessionIds.has(entry.sessionId),
|
|
139
|
+
);
|
|
140
|
+
return updated.length === subscriptions.length ? undefined : updated;
|
|
141
|
+
}),
|
|
142
|
+
upsertSubscription: (sessionId, subscription) => {
|
|
143
|
+
const parsed =
|
|
144
|
+
backgroundNotificationSubscriptionSchema.parse(subscription);
|
|
145
|
+
return updateSubscriptions((subscriptions) => {
|
|
146
|
+
const entry = { sessionId, subscription: parsed };
|
|
147
|
+
const existing = subscriptions.find(
|
|
148
|
+
(candidate) => candidate.subscription.endpoint === parsed.endpoint,
|
|
149
|
+
);
|
|
150
|
+
if (isDeepStrictEqual(existing, entry)) {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
return [
|
|
154
|
+
...subscriptions.filter(
|
|
155
|
+
(candidate) => candidate.subscription.endpoint !== parsed.endpoint,
|
|
156
|
+
),
|
|
157
|
+
entry,
|
|
158
|
+
];
|
|
159
|
+
});
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Orchestrates operation notifications across live and background delivery.
|
|
2
|
+
// Provider-specific details stay behind the background-notification service.
|
|
3
|
+
import {
|
|
4
|
+
notificationSchema,
|
|
5
|
+
type Notification,
|
|
6
|
+
type Notifications,
|
|
7
|
+
} from "../../../public/index";
|
|
8
|
+
|
|
9
|
+
import { errorDetails, type ServerLogger } from "../server-logger";
|
|
10
|
+
import type { BackgroundNotificationService } from "./background-notification-service";
|
|
11
|
+
|
|
12
|
+
type NotificationService = Notifications & {
|
|
13
|
+
attachLiveDelivery: (deliver: (notification: Notification) => void) => void;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const deliverSafely = async ({
|
|
17
|
+
deliver,
|
|
18
|
+
event,
|
|
19
|
+
logger,
|
|
20
|
+
}: {
|
|
21
|
+
deliver: () => void | Promise<void>;
|
|
22
|
+
event: string;
|
|
23
|
+
logger?: ServerLogger;
|
|
24
|
+
}) => {
|
|
25
|
+
try {
|
|
26
|
+
await deliver();
|
|
27
|
+
} catch (cause) {
|
|
28
|
+
logger?.log({
|
|
29
|
+
details: errorDetails(cause),
|
|
30
|
+
event,
|
|
31
|
+
level: "error",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const createNotificationService = ({
|
|
37
|
+
backgroundNotifications,
|
|
38
|
+
logger,
|
|
39
|
+
}: {
|
|
40
|
+
backgroundNotifications: BackgroundNotificationService;
|
|
41
|
+
logger?: ServerLogger;
|
|
42
|
+
}): NotificationService => {
|
|
43
|
+
let liveDeliveryAttached = false;
|
|
44
|
+
let deliverLive: (notification: Notification) => void = () => undefined;
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
send: async (input) => {
|
|
48
|
+
const notification = notificationSchema.parse(input);
|
|
49
|
+
await Promise.all([
|
|
50
|
+
deliverSafely({
|
|
51
|
+
deliver: () => deliverLive(notification),
|
|
52
|
+
event: "live-notification-dispatch-failure",
|
|
53
|
+
logger,
|
|
54
|
+
}),
|
|
55
|
+
deliverSafely({
|
|
56
|
+
deliver: () => backgroundNotifications.send(notification),
|
|
57
|
+
event: "background-notification-dispatch-failure",
|
|
58
|
+
logger,
|
|
59
|
+
}),
|
|
60
|
+
]);
|
|
61
|
+
},
|
|
62
|
+
attachLiveDelivery: (deliver) => {
|
|
63
|
+
if (liveDeliveryAttached) {
|
|
64
|
+
throw new Error("Live delivery is already attached");
|
|
65
|
+
}
|
|
66
|
+
liveDeliveryAttached = true;
|
|
67
|
+
deliverLive = deliver;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Provides private, schema-validated JSON files with atomic persistence: directories and files
|
|
3
|
+
* use owner-only modes; writes fsync a temporary file before rename replacement or hard-link
|
|
4
|
+
* exclusive publication so concurrent first creation safely converges.
|
|
5
|
+
*/
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import {
|
|
8
|
+
chmod,
|
|
9
|
+
link,
|
|
10
|
+
mkdir,
|
|
11
|
+
open,
|
|
12
|
+
readFile,
|
|
13
|
+
rename,
|
|
14
|
+
rm,
|
|
15
|
+
} from "node:fs/promises";
|
|
16
|
+
import { dirname } from "node:path";
|
|
17
|
+
import type { z } from "zod";
|
|
18
|
+
|
|
19
|
+
const missingFile = (cause: unknown) =>
|
|
20
|
+
(cause as NodeJS.ErrnoException).code === "ENOENT";
|
|
21
|
+
|
|
22
|
+
const preparePrivateDirectory = async (path: string) => {
|
|
23
|
+
await mkdir(path, { mode: 0o700, recursive: true });
|
|
24
|
+
await chmod(path, 0o700);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const writeTemporaryPrivateJson = async (path: string, value: unknown) => {
|
|
28
|
+
await preparePrivateDirectory(dirname(path));
|
|
29
|
+
const temporaryPath = `${path}.${process.pid}.${randomUUID()}`;
|
|
30
|
+
try {
|
|
31
|
+
const handle = await open(temporaryPath, "wx", 0o600);
|
|
32
|
+
try {
|
|
33
|
+
await handle.writeFile(`${JSON.stringify(value)}\n`);
|
|
34
|
+
await handle.sync();
|
|
35
|
+
} finally {
|
|
36
|
+
await handle.close();
|
|
37
|
+
}
|
|
38
|
+
return temporaryPath;
|
|
39
|
+
} catch (cause) {
|
|
40
|
+
await rm(temporaryPath, { force: true });
|
|
41
|
+
throw cause;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const readPrivateJson = async <TSchema extends z.ZodType>(
|
|
46
|
+
path: string,
|
|
47
|
+
schema: TSchema,
|
|
48
|
+
): Promise<z.output<TSchema> | undefined> => {
|
|
49
|
+
try {
|
|
50
|
+
const contents = await readFile(path, "utf8");
|
|
51
|
+
await chmod(path, 0o600);
|
|
52
|
+
return schema.parse(JSON.parse(contents));
|
|
53
|
+
} catch (cause) {
|
|
54
|
+
if (missingFile(cause)) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
throw cause;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const replacePrivateJsonAtomically = async (
|
|
62
|
+
path: string,
|
|
63
|
+
value: unknown,
|
|
64
|
+
) => {
|
|
65
|
+
const temporaryPath = await writeTemporaryPrivateJson(path, value);
|
|
66
|
+
try {
|
|
67
|
+
await rename(temporaryPath, path);
|
|
68
|
+
} catch (cause) {
|
|
69
|
+
await rm(temporaryPath, { force: true });
|
|
70
|
+
throw cause;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const createPrivateJsonExclusively = async (
|
|
75
|
+
path: string,
|
|
76
|
+
value: unknown,
|
|
77
|
+
) => {
|
|
78
|
+
const temporaryPath = await writeTemporaryPrivateJson(path, value);
|
|
79
|
+
try {
|
|
80
|
+
await link(temporaryPath, path);
|
|
81
|
+
} finally {
|
|
82
|
+
await rm(temporaryPath, { force: true });
|
|
83
|
+
}
|
|
84
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getOvermuxPaths,
|
|
3
|
+
type GetOvermuxPathsOptions,
|
|
4
|
+
type OvermuxPaths,
|
|
5
|
+
} from "@overmux/shared/node";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
export { getOvermuxPaths };
|
|
9
|
+
export type { GetOvermuxPathsOptions, OvermuxPaths };
|
|
10
|
+
|
|
11
|
+
export const getDefaultConfigPath = (
|
|
12
|
+
options: Pick<GetOvermuxPathsOptions, "homeDir" | "xdgConfigHome"> = {},
|
|
13
|
+
) => join(getOvermuxPaths(options).configDir, "overmux.config.ts");
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export type RateLimiter = {
|
|
2
|
+
attemptsByKey: Map<string, number[]>;
|
|
3
|
+
globalAttempts: number[];
|
|
4
|
+
maxAttemptsPerKey: number;
|
|
5
|
+
maxGlobalAttempts: number;
|
|
6
|
+
maxTrackedKeys: number;
|
|
7
|
+
windowMs: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const createRateLimiter = ({
|
|
11
|
+
maxAttemptsPerKey,
|
|
12
|
+
maxGlobalAttempts,
|
|
13
|
+
maxTrackedKeys,
|
|
14
|
+
windowMs,
|
|
15
|
+
}: {
|
|
16
|
+
maxAttemptsPerKey: number;
|
|
17
|
+
maxGlobalAttempts: number;
|
|
18
|
+
maxTrackedKeys: number;
|
|
19
|
+
windowMs: number;
|
|
20
|
+
}): RateLimiter => ({
|
|
21
|
+
attemptsByKey: new Map(),
|
|
22
|
+
globalAttempts: [],
|
|
23
|
+
maxAttemptsPerKey,
|
|
24
|
+
maxGlobalAttempts,
|
|
25
|
+
maxTrackedKeys,
|
|
26
|
+
windowMs,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const hasRateLimitCapacity = ({
|
|
30
|
+
currentTime = Date.now(),
|
|
31
|
+
key,
|
|
32
|
+
limiter,
|
|
33
|
+
}: {
|
|
34
|
+
currentTime?: number;
|
|
35
|
+
key: string;
|
|
36
|
+
limiter: RateLimiter;
|
|
37
|
+
}) => {
|
|
38
|
+
const threshold = currentTime - limiter.windowMs;
|
|
39
|
+
const recent = (limiter.attemptsByKey.get(key) ?? []).filter(
|
|
40
|
+
(attempt) => attempt > threshold,
|
|
41
|
+
);
|
|
42
|
+
limiter.globalAttempts = limiter.globalAttempts.filter(
|
|
43
|
+
(attempt) => attempt > threshold,
|
|
44
|
+
);
|
|
45
|
+
if (
|
|
46
|
+
recent.length >= limiter.maxAttemptsPerKey ||
|
|
47
|
+
limiter.globalAttempts.length >= limiter.maxGlobalAttempts
|
|
48
|
+
) {
|
|
49
|
+
limiter.attemptsByKey.set(key, recent);
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
limiter.attemptsByKey.set(key, [...recent, currentTime]);
|
|
53
|
+
limiter.globalAttempts.push(currentTime);
|
|
54
|
+
if (limiter.attemptsByKey.size > limiter.maxTrackedKeys) {
|
|
55
|
+
limiter.attemptsByKey.clear();
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Composes one process-lifetime runtime from cohesive lifecycle and protocol components.
|
|
2
|
+
|
|
3
|
+
import type { RuntimeConfigDefinition } from "@overmux/shared/node";
|
|
4
|
+
|
|
5
|
+
import type { Notifications } from "../../../public/index";
|
|
6
|
+
import { protocolVersion, type RuntimeManifest } from "../../shared/index";
|
|
7
|
+
|
|
8
|
+
import { createRuntimeLifecycle } from "./runtime-lifecycle";
|
|
9
|
+
import {
|
|
10
|
+
createRuntimeOperations,
|
|
11
|
+
type RuntimeOperation,
|
|
12
|
+
type RuntimeOperationDefinitions,
|
|
13
|
+
} from "./runtime-operations";
|
|
14
|
+
import {
|
|
15
|
+
createRuntimeResources,
|
|
16
|
+
type RuntimeResource,
|
|
17
|
+
type RuntimeResourceDefinitions,
|
|
18
|
+
} from "./runtime-resources";
|
|
19
|
+
import {
|
|
20
|
+
createRuntimeStreams,
|
|
21
|
+
type RuntimeStream,
|
|
22
|
+
type RuntimeStreamDefinitions,
|
|
23
|
+
} from "./runtime-streams";
|
|
24
|
+
|
|
25
|
+
export type Runtime = {
|
|
26
|
+
manifest: RuntimeManifest;
|
|
27
|
+
dispose: () => Promise<void>;
|
|
28
|
+
getOperation: (name: string) => RuntimeOperation | undefined;
|
|
29
|
+
getResource: (name: string) => RuntimeResource | undefined;
|
|
30
|
+
getStream: (name: string) => RuntimeStream | undefined;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type CreateRuntimeOptions = {
|
|
34
|
+
config: RuntimeConfigDefinition;
|
|
35
|
+
debug?: boolean;
|
|
36
|
+
notifications?: Notifications;
|
|
37
|
+
signal?: AbortSignal;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const createRuntime = async ({
|
|
41
|
+
config,
|
|
42
|
+
debug = false,
|
|
43
|
+
notifications = { send: async () => undefined },
|
|
44
|
+
signal,
|
|
45
|
+
}: CreateRuntimeOptions): Promise<Runtime> => {
|
|
46
|
+
const lifecycle = createRuntimeLifecycle(signal);
|
|
47
|
+
try {
|
|
48
|
+
const resources = createRuntimeResources({
|
|
49
|
+
definitions: (config.server.resources ??
|
|
50
|
+
{}) as RuntimeResourceDefinitions,
|
|
51
|
+
lifecycle,
|
|
52
|
+
});
|
|
53
|
+
const context = resources.context;
|
|
54
|
+
const operations = createRuntimeOperations({
|
|
55
|
+
context,
|
|
56
|
+
definitions: (config.server.operations ??
|
|
57
|
+
{}) as RuntimeOperationDefinitions,
|
|
58
|
+
notifications,
|
|
59
|
+
});
|
|
60
|
+
const streams = createRuntimeStreams({
|
|
61
|
+
context,
|
|
62
|
+
definitions: (config.server.streams ?? {}) as RuntimeStreamDefinitions,
|
|
63
|
+
lifecycle,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
lifecycle.signal.throwIfAborted();
|
|
67
|
+
lifecycle.completePreparation();
|
|
68
|
+
|
|
69
|
+
const manifest: RuntimeManifest = {
|
|
70
|
+
debug,
|
|
71
|
+
operations: [...operations.names],
|
|
72
|
+
protocolVersion,
|
|
73
|
+
resources: [...resources.names],
|
|
74
|
+
streams: [...streams.names],
|
|
75
|
+
};
|
|
76
|
+
return {
|
|
77
|
+
manifest,
|
|
78
|
+
dispose: lifecycle.dispose,
|
|
79
|
+
getOperation: operations.get,
|
|
80
|
+
getResource: resources.get,
|
|
81
|
+
getStream: streams.get,
|
|
82
|
+
};
|
|
83
|
+
} catch (cause) {
|
|
84
|
+
await lifecycle.dispose(cause).catch(() => undefined);
|
|
85
|
+
throw cause;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Resource invalidation follows relationships among configured resources.
|
|
2
|
+
// This model owns those relationships and their invariants before runtime work begins.
|
|
3
|
+
|
|
4
|
+
import type { ResourceDefinition } from "../../../public/index";
|
|
5
|
+
|
|
6
|
+
// A dependency supplies a derived resource's value; a dependant consumes that value.
|
|
7
|
+
// Keeping both directions makes reads follow dependencies while invalidation follows dependants.
|
|
8
|
+
export type ResourceDependencyGraph = Readonly<{
|
|
9
|
+
dependencies: ReadonlyMap<string, ReadonlySet<string>>;
|
|
10
|
+
dependants: ReadonlyMap<string, ReadonlySet<string>>;
|
|
11
|
+
}>;
|
|
12
|
+
|
|
13
|
+
type MutableResourceGraph = Map<string, Set<string>>;
|
|
14
|
+
|
|
15
|
+
const emptyGraphFor = (resourceIds: readonly string[]): MutableResourceGraph =>
|
|
16
|
+
new Map(resourceIds.map((resourceId) => [resourceId, new Set<string>()]));
|
|
17
|
+
|
|
18
|
+
const visitDependencies = ({
|
|
19
|
+
dependencies,
|
|
20
|
+
path,
|
|
21
|
+
resourceId,
|
|
22
|
+
visited,
|
|
23
|
+
visiting,
|
|
24
|
+
}: {
|
|
25
|
+
dependencies: ReadonlyMap<string, ReadonlySet<string>>;
|
|
26
|
+
path: readonly string[];
|
|
27
|
+
resourceId: string;
|
|
28
|
+
visited: Set<string>;
|
|
29
|
+
visiting: Set<string>;
|
|
30
|
+
}) => {
|
|
31
|
+
if (visiting.has(resourceId)) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`Resource dependency cycle: ${[...path, resourceId].join(" -> ")}`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
if (visited.has(resourceId)) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
visiting.add(resourceId);
|
|
40
|
+
dependencies.get(resourceId)?.forEach((dependencyId) => {
|
|
41
|
+
visitDependencies({
|
|
42
|
+
dependencies,
|
|
43
|
+
path: [...path, resourceId],
|
|
44
|
+
resourceId: dependencyId,
|
|
45
|
+
visited,
|
|
46
|
+
visiting,
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
visiting.delete(resourceId);
|
|
50
|
+
visited.add(resourceId);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const assertAcyclic = (
|
|
54
|
+
dependencies: ReadonlyMap<string, ReadonlySet<string>>,
|
|
55
|
+
) => {
|
|
56
|
+
// Derived reads recurse through dependencies, so a cycle could never produce a value or finish activation.
|
|
57
|
+
const visited = new Set<string>();
|
|
58
|
+
const visiting = new Set<string>();
|
|
59
|
+
dependencies.forEach((_, resourceId) => {
|
|
60
|
+
visitDependencies({
|
|
61
|
+
dependencies,
|
|
62
|
+
path: [],
|
|
63
|
+
resourceId,
|
|
64
|
+
visited,
|
|
65
|
+
visiting,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const createResourceDependencyGraph = (
|
|
71
|
+
resources: Readonly<Record<string, ResourceDefinition>>,
|
|
72
|
+
): ResourceDependencyGraph => {
|
|
73
|
+
const resourceIds = Object.keys(resources);
|
|
74
|
+
if (resourceIds.some((resourceId) => !resourceId)) {
|
|
75
|
+
throw new Error("Resource IDs must not be empty");
|
|
76
|
+
}
|
|
77
|
+
const dependencies = emptyGraphFor(resourceIds);
|
|
78
|
+
const dependants = emptyGraphFor(resourceIds);
|
|
79
|
+
|
|
80
|
+
Object.entries(resources).forEach(([resourceId, resource]) => {
|
|
81
|
+
if (resource.kind !== "derived") {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
Object.entries(
|
|
85
|
+
resource.dependencies as Readonly<Record<string, string>>,
|
|
86
|
+
).forEach(([dependencyName, dependencyId]) => {
|
|
87
|
+
if (!dependencyName) {
|
|
88
|
+
throw new Error(`Resource ${resourceId} has an empty dependency name`);
|
|
89
|
+
}
|
|
90
|
+
if (!dependencyId) {
|
|
91
|
+
throw new Error(`Resource ${resourceId} has an empty dependency ID`);
|
|
92
|
+
}
|
|
93
|
+
if (!dependencies.has(dependencyId)) {
|
|
94
|
+
throw new Error(`Unknown resource dependency: ${dependencyId}`);
|
|
95
|
+
}
|
|
96
|
+
dependencies.get(resourceId)?.add(dependencyId);
|
|
97
|
+
dependants.get(dependencyId)?.add(resourceId);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
assertAcyclic(dependencies);
|
|
102
|
+
return Object.freeze({ dependencies, dependants });
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const collectInvalidatedResourceIds = (
|
|
106
|
+
graph: ResourceDependencyGraph,
|
|
107
|
+
resourceId: string,
|
|
108
|
+
invalidated: Set<string>,
|
|
109
|
+
) => {
|
|
110
|
+
if (invalidated.has(resourceId)) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
invalidated.add(resourceId);
|
|
114
|
+
graph.dependants.get(resourceId)?.forEach((dependantId) => {
|
|
115
|
+
collectInvalidatedResourceIds(graph, dependantId, invalidated);
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const getInvalidatedResourceIds = (
|
|
120
|
+
graph: ResourceDependencyGraph,
|
|
121
|
+
resourceId: string,
|
|
122
|
+
): ReadonlySet<string> => {
|
|
123
|
+
// A changed value invalidates itself and the full reverse-edge closure, not just immediate consumers.
|
|
124
|
+
// Set membership is essential for diamonds: a shared dependant must notify once despite multiple paths.
|
|
125
|
+
const invalidated = new Set<string>();
|
|
126
|
+
collectInvalidatedResourceIds(graph, resourceId, invalidated);
|
|
127
|
+
return invalidated;
|
|
128
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Composes runtime and request cancellation while owning asynchronous cleanup.
|
|
2
|
+
// Tracked disposers run exactly once, and aggregate shutdown reports every failure.
|
|
3
|
+
|
|
4
|
+
import type { RuntimeDisposer } from "../../../public/index";
|
|
5
|
+
|
|
6
|
+
export type RuntimeLifecycle = {
|
|
7
|
+
completePreparation: () => void;
|
|
8
|
+
dispose: (reason?: unknown) => Promise<void>;
|
|
9
|
+
disposeAll: (disposers: Iterable<RuntimeDisposer>) => Promise<void>;
|
|
10
|
+
requestSignal: (signal?: AbortSignal) => AbortSignal;
|
|
11
|
+
signal: AbortSignal;
|
|
12
|
+
track: (dispose: RuntimeDisposer, signal?: AbortSignal) => RuntimeDisposer;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const asError = (cause: unknown) =>
|
|
16
|
+
cause instanceof Error ? cause : new Error(String(cause));
|
|
17
|
+
|
|
18
|
+
const disposeAll = async (disposers: Iterable<RuntimeDisposer>) => {
|
|
19
|
+
const results = await Promise.allSettled(
|
|
20
|
+
[...disposers].map(async (dispose) => dispose()),
|
|
21
|
+
);
|
|
22
|
+
const errors = results.flatMap((result) =>
|
|
23
|
+
result.status === "rejected" ? [asError(result.reason)] : [],
|
|
24
|
+
);
|
|
25
|
+
if (errors.length) {
|
|
26
|
+
throw new AggregateError(errors, "Runtime disposal failed");
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const createRuntimeLifecycle = (
|
|
31
|
+
preparationSignal?: AbortSignal,
|
|
32
|
+
): RuntimeLifecycle => {
|
|
33
|
+
const controller = new AbortController();
|
|
34
|
+
const activeDisposers = new Set<RuntimeDisposer>();
|
|
35
|
+
let disposal: Promise<void> | undefined;
|
|
36
|
+
const abortPreparation = () => controller.abort(preparationSignal?.reason);
|
|
37
|
+
preparationSignal?.addEventListener("abort", abortPreparation, {
|
|
38
|
+
once: true,
|
|
39
|
+
});
|
|
40
|
+
if (preparationSignal?.aborted) {
|
|
41
|
+
abortPreparation();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const completePreparation = () => {
|
|
45
|
+
preparationSignal?.removeEventListener("abort", abortPreparation);
|
|
46
|
+
};
|
|
47
|
+
const requestSignal = (signal?: AbortSignal) =>
|
|
48
|
+
signal ? AbortSignal.any([controller.signal, signal]) : controller.signal;
|
|
49
|
+
const track = (
|
|
50
|
+
dispose: RuntimeDisposer,
|
|
51
|
+
signal?: AbortSignal,
|
|
52
|
+
): RuntimeDisposer => {
|
|
53
|
+
let pending: Promise<void> | undefined;
|
|
54
|
+
const disposeOnce = () => {
|
|
55
|
+
if (pending) {
|
|
56
|
+
return pending;
|
|
57
|
+
}
|
|
58
|
+
controller.signal.removeEventListener("abort", onAbort);
|
|
59
|
+
signal?.removeEventListener("abort", onAbort);
|
|
60
|
+
activeDisposers.delete(disposeOnce);
|
|
61
|
+
pending = Promise.resolve().then(dispose);
|
|
62
|
+
return pending;
|
|
63
|
+
};
|
|
64
|
+
const onAbort = () => {
|
|
65
|
+
void disposeOnce().catch(() => undefined);
|
|
66
|
+
};
|
|
67
|
+
activeDisposers.add(disposeOnce);
|
|
68
|
+
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
69
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
70
|
+
if (controller.signal.aborted || signal?.aborted) {
|
|
71
|
+
onAbort();
|
|
72
|
+
}
|
|
73
|
+
return disposeOnce;
|
|
74
|
+
};
|
|
75
|
+
const dispose = (reason: unknown = new Error("Runtime disposed")) => {
|
|
76
|
+
if (disposal) {
|
|
77
|
+
return disposal;
|
|
78
|
+
}
|
|
79
|
+
completePreparation();
|
|
80
|
+
const pending = [...activeDisposers];
|
|
81
|
+
activeDisposers.clear();
|
|
82
|
+
controller.abort(reason);
|
|
83
|
+
disposal = disposeAll(pending);
|
|
84
|
+
return disposal;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
completePreparation,
|
|
89
|
+
dispose,
|
|
90
|
+
disposeAll,
|
|
91
|
+
requestSignal,
|
|
92
|
+
signal: controller.signal,
|
|
93
|
+
track,
|
|
94
|
+
};
|
|
95
|
+
};
|