@pablopunk/webr 0.1.1
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/LICENSE +661 -0
- package/README.md +17 -0
- package/bin/webr.mjs +11 -0
- package/dist/client/_astro/App.BxlQhLhN.css +1 -0
- package/dist/client/_astro/App.CzxEZ_Dw.js +2 -0
- package/dist/client/_astro/ClientRouter.astro_astro_type_script_index_0_lang.CTgtIFyY.js +1 -0
- package/dist/client/_astro/addon-fit.jgKqzwky.js +1 -0
- package/dist/client/_astro/addon-web-links.C9lyQRxI.js +1 -0
- package/dist/client/_astro/client.BDkzSRUN.js +1 -0
- package/dist/client/_astro/client.Bbg9OBwT.js +1 -0
- package/dist/client/_astro/client.CLSsMr2g.js +9 -0
- package/dist/client/_astro/jetbrains-mono.CFJaiWaY.woff2 +0 -0
- package/dist/client/_astro/symbols-nerd.CEdkKAK8.woff2 +0 -0
- package/dist/client/_astro/xterm.B-l5AIf6.js +7 -0
- package/dist/client/apple-touch-icon.png +0 -0
- package/dist/client/brand-mark.png +0 -0
- package/dist/client/favicon-16.png +0 -0
- package/dist/client/favicon-32.png +0 -0
- package/dist/client/favicon.ico +0 -0
- package/dist/client/icon-192.png +0 -0
- package/dist/client/icon-512.png +0 -0
- package/dist/client/icon-maskable-512.png +0 -0
- package/dist/client/manifest.webmanifest +30 -0
- package/dist/server/chunks/App_BmsGAwGf.mjs +4314 -0
- package/dist/server/chunks/_id__u0ueX6GO.mjs +34 -0
- package/dist/server/chunks/_tab__DL6wyr-j.mjs +35 -0
- package/dist/server/chunks/_virtual_astro_server-island-manifest_C1Q2srgE.mjs +5 -0
- package/dist/server/chunks/_virtual_astro_session-driver_DS5V7T-N.mjs +6 -0
- package/dist/server/chunks/compiler_CpXox_xS.mjs +35 -0
- package/dist/server/chunks/console_CucE1SwS.mjs +170 -0
- package/dist/server/chunks/errors_BgIp4W2B.mjs +383 -0
- package/dist/server/chunks/index_DyyPce_k.mjs +22 -0
- package/dist/server/chunks/new_G7XChx5p.mjs +33 -0
- package/dist/server/chunks/node_BlixNyYV.mjs +1816 -0
- package/dist/server/chunks/noop-entrypoint_Z3zFhrGC.mjs +4 -0
- package/dist/server/chunks/remote_BxuVLM0r.mjs +169 -0
- package/dist/server/chunks/server_DS45LC4q.mjs +2496 -0
- package/dist/server/chunks/settings_DLV2Js3e.mjs +31 -0
- package/dist/server/chunks/sharp_CgNvPCpY.mjs +152 -0
- package/dist/server/chunks/vnode-children_CRmaSs5G.mjs +24 -0
- package/dist/server/entry.mjs +5837 -0
- package/dist/server/virtual_astro_middleware.mjs +4 -0
- package/drizzle/0000_gateway.sql +13 -0
- package/drizzle/0001_device_sessions.sql +9 -0
- package/drizzle/0002_audit_events.sql +7 -0
- package/drizzle/meta/_journal.json +1 -0
- package/package.json +88 -0
- package/server/cli.ts +93 -0
- package/server/command.ts +9 -0
- package/server/entry.ts +3 -0
- package/server/herdr-check.ts +37 -0
- package/server/home.ts +4 -0
- package/server/log-file.ts +52 -0
- package/server/package-info.ts +6 -0
- package/server/plugin/config.ts +14 -0
- package/server/plugin/dev.ts +27 -0
- package/server/plugin/files.ts +10 -0
- package/server/plugin/herdr.ts +33 -0
- package/server/plugin/index.ts +68 -0
- package/server/plugin/probe.ts +17 -0
- package/server/plugin/stop.ts +39 -0
- package/server/port.ts +8 -0
- package/server/run.ts +35 -0
- package/server/ssr.ts +23 -0
- package/server/start.ts +5 -0
- package/server/update/hint.ts +26 -0
- package/server/update/index.ts +33 -0
- package/server/update/latest.ts +33 -0
- package/server/update/package-manager.ts +24 -0
- package/server/update/versions.ts +15 -0
- package/src/lib/avatars.ts +15 -0
- package/src/lib/launch.ts +16 -0
- package/src/lib/machines.ts +41 -0
- package/src/lib/mock-store.ts +82 -0
- package/src/lib/models.ts +139 -0
- package/src/lib/project-icons.ts +120 -0
- package/src/lib/terminal-split.ts +23 -0
- package/src/lib/uuid.ts +10 -0
- package/src/server/auth/access.ts +22 -0
- package/src/server/auth/audit.ts +15 -0
- package/src/server/auth/auth.ts +134 -0
- package/src/server/auth/pair-page.ts +85 -0
- package/src/server/auth/pairing.ts +91 -0
- package/src/server/auth/presence.ts +8 -0
- package/src/server/auth/sessions.ts +48 -0
- package/src/server/directories.ts +21 -0
- package/src/server/dist.ts +3 -0
- package/src/server/host.ts +166 -0
- package/src/server/perf-log.ts +23 -0
- package/src/server/protocol/deadlines.ts +24 -0
- package/src/server/protocol/native.ts +10 -0
- package/src/server/protocol/ndjson.ts +22 -0
- package/src/server/protocol/socket.ts +81 -0
- package/src/server/protocol/validate.ts +17 -0
- package/src/server/public-urls.ts +17 -0
- package/src/server/runtime/herdr-actions.ts +122 -0
- package/src/server/runtime/launch.ts +55 -0
- package/src/server/runtime/manager.ts +187 -0
- package/src/server/runtime/reconcile.ts +51 -0
- package/src/server/runtime/supervisor.ts +92 -0
- package/src/server/runtime/target.ts +37 -0
- package/src/server/runtime/workspace.ts +24 -0
- package/src/server/storage/database.ts +88 -0
- package/src/server/storage/drizzle-node-sqlite.ts +18 -0
- package/src/server/storage/node-sqlite-client.ts +44 -0
- package/src/server/storage/schema.ts +25 -0
- package/src/server/tailscale-host.ts +17 -0
- package/src/server/terminal/cli.ts +44 -0
- package/src/server/terminal/hub.ts +140 -0
- package/src/server/terminal/writer.ts +38 -0
- package/src/server/transport/herdr-target.ts +148 -0
- package/src/server/transport/icons.ts +65 -0
- package/src/server/transport/identity.ts +5 -0
- package/src/server/transport/local-session.ts +65 -0
- package/src/server/transport/model-discovery.ts +18 -0
- package/src/server/transport/native-locations.ts +17 -0
- package/src/server/transport/process.ts +21 -0
- package/src/server/transport/registry.ts +27 -0
- package/src/server/transport/ssh.ts +47 -0
- package/src/server/uploads.ts +40 -0
- package/src/server/websockets.ts +62 -0
- package/src/shared/agent-kinds.ts +4 -0
- package/src/shared/frame.ts +38 -0
- package/src/shared/projects.ts +1 -0
- package/src/shared/retry.ts +1 -0
- package/src/shared/runtime.ts +33 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import type { TargetAdapter } from './target';
|
|
3
|
+
import type { NativeSnapshot } from '../protocol/native';
|
|
4
|
+
import { retryDelay } from '../../shared/retry';
|
|
5
|
+
const safeReason = (error: unknown, fallback: string) => error instanceof Error && ['connection_not_approved', 'unsupported_herdr_version', 'unsupported_herdr_schema', 'ssh_forward_failed', 'socket_unavailable', 'rpc_timeout', 'events_lost'].includes(error.message) ? error.message : fallback;
|
|
6
|
+
|
|
7
|
+
export class TargetSupervisor {
|
|
8
|
+
readonly generation = randomUUID();
|
|
9
|
+
revision = 0;
|
|
10
|
+
snapshot?: NativeSnapshot;
|
|
11
|
+
connected = false;
|
|
12
|
+
error?: string;
|
|
13
|
+
freshAt: string | null = null;
|
|
14
|
+
private dirty = false;
|
|
15
|
+
private reading = false;
|
|
16
|
+
private stopped = false;
|
|
17
|
+
private subscription?: () => void;
|
|
18
|
+
private timer?: ReturnType<typeof setTimeout>;
|
|
19
|
+
private health?: ReturnType<typeof setInterval>;
|
|
20
|
+
private epoch = 0;
|
|
21
|
+
private subscribedPanes = '';
|
|
22
|
+
private failures = 0;
|
|
23
|
+
private lastFailureAt = 0;
|
|
24
|
+
private waiters = new Set<{ floor: number; resolve: (snapshot: NativeSnapshot) => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> }>();
|
|
25
|
+
constructor(readonly target: TargetAdapter, private publish: () => void, private delay = 25) {}
|
|
26
|
+
async start() {
|
|
27
|
+
if (this.stopped) return;
|
|
28
|
+
const epoch = ++this.epoch;
|
|
29
|
+
try {
|
|
30
|
+
const unsubscribe = await this.target.subscribe(() => this.invalidate(), (reason) => this.disconnect(reason));
|
|
31
|
+
if (this.stopped || epoch !== this.epoch) { unsubscribe(); return; }
|
|
32
|
+
this.subscription = unsubscribe;
|
|
33
|
+
this.invalidate();
|
|
34
|
+
this.health ??= setInterval(() => this.invalidate(), 15_000);
|
|
35
|
+
this.health.unref();
|
|
36
|
+
} catch (error) { if (epoch === this.epoch) this.disconnect(safeReason(error, 'target_unavailable')); }
|
|
37
|
+
}
|
|
38
|
+
invalidate() {
|
|
39
|
+
this.dirty = true;
|
|
40
|
+
if (!this.timer && !this.reading && this.subscription) this.timer = setTimeout(() => { this.timer = undefined; void this.refresh(); }, this.delay);
|
|
41
|
+
}
|
|
42
|
+
readFresh(): Promise<NativeSnapshot> {
|
|
43
|
+
if (!this.subscription || this.stopped) return Promise.reject(new Error('machine_disconnected'));
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const waiter = { floor: this.revision + (this.reading ? 2 : 1), resolve, reject, timer: setTimeout(() => { this.waiters.delete(waiter); reject(new Error('snapshot_timeout')); }, 15_000) };
|
|
46
|
+
this.waiters.add(waiter); this.invalidate();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
stop() {
|
|
50
|
+
this.stopped = true; ++this.epoch;
|
|
51
|
+
clearTimeout(this.timer); clearInterval(this.health);
|
|
52
|
+
this.subscription?.(); this.subscription = undefined; this.target.close();
|
|
53
|
+
this.rejectWaiters();
|
|
54
|
+
}
|
|
55
|
+
private async refresh() {
|
|
56
|
+
if (this.reading || this.stopped || !this.subscription) return;
|
|
57
|
+
this.reading = true;
|
|
58
|
+
this.dirty = false;
|
|
59
|
+
const epoch = this.epoch;
|
|
60
|
+
try {
|
|
61
|
+
const snapshot = await this.target.snapshot();
|
|
62
|
+
if (epoch !== this.epoch || this.stopped) return;
|
|
63
|
+
this.snapshot = snapshot; this.connected = true; this.error = undefined;
|
|
64
|
+
if (Date.now() - this.lastFailureAt >= 30_000) this.failures = 0;
|
|
65
|
+
this.freshAt = new Date().toISOString(); ++this.revision; this.publish();
|
|
66
|
+
for (const waiter of this.waiters) if (this.revision >= waiter.floor) { this.waiters.delete(waiter); clearTimeout(waiter.timer); waiter.resolve(snapshot); }
|
|
67
|
+
const paneIds = snapshot.panes.map((pane) => pane.pane_id).sort();
|
|
68
|
+
const key = JSON.stringify(paneIds);
|
|
69
|
+
if (key !== this.subscribedPanes) {
|
|
70
|
+
const subscription = await this.target.subscribe(() => this.invalidate(), (reason) => this.disconnect(reason), paneIds);
|
|
71
|
+
if (epoch !== this.epoch || this.stopped) { subscription(); return; }
|
|
72
|
+
this.subscription?.(); this.subscription = subscription; this.subscribedPanes = key; this.dirty = true;
|
|
73
|
+
}
|
|
74
|
+
} catch (error) { if (epoch === this.epoch) this.disconnect(safeReason(error, 'snapshot_failed')); }
|
|
75
|
+
finally {
|
|
76
|
+
this.reading = false;
|
|
77
|
+
if (this.dirty && this.hasSubscription()) this.invalidate();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
private hasSubscription() { return !!this.subscription; }
|
|
81
|
+
private disconnect(reason: string) {
|
|
82
|
+
if (this.stopped) return;
|
|
83
|
+
++this.epoch; this.subscription?.(); this.subscription = undefined;
|
|
84
|
+
this.subscribedPanes = '';
|
|
85
|
+
this.connected = false; this.error = reason; this.freshAt = null; ++this.revision; this.publish();
|
|
86
|
+
this.lastFailureAt = Date.now();
|
|
87
|
+
this.rejectWaiters();
|
|
88
|
+
clearTimeout(this.timer);
|
|
89
|
+
this.timer = setTimeout(() => { this.timer = undefined; void this.start(); }, retryDelay(this.failures++));
|
|
90
|
+
}
|
|
91
|
+
private rejectWaiters() { for (const waiter of this.waiters) { clearTimeout(waiter.timer); waiter.reject(new Error('machine_disconnected')); } this.waiters.clear(); }
|
|
92
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Machine } from '../../lib/machines';
|
|
2
|
+
import type { NativeSnapshot } from '../protocol/native';
|
|
3
|
+
import type { TerminalFrame, TerminalStream } from '../terminal/cli';
|
|
4
|
+
import type { LaunchInput } from '../../shared/runtime';
|
|
5
|
+
import type { WorktreeEntry } from './herdr-actions';
|
|
6
|
+
import type { Icon } from '../transport/icons';
|
|
7
|
+
|
|
8
|
+
export type TerminalDirection = 'right' | 'down';
|
|
9
|
+
export type LaunchLocation = { projectId: string; localId?: string; logicalId?: string; path: string; workspaceId: string };
|
|
10
|
+
export type TargetAdapter = {
|
|
11
|
+
id: string; session: string; name: string;
|
|
12
|
+
fingerprint: string;
|
|
13
|
+
configVersion: number;
|
|
14
|
+
enabled?: boolean;
|
|
15
|
+
locations: LaunchLocation[];
|
|
16
|
+
writable: boolean;
|
|
17
|
+
acceptsLocalFiles?: boolean;
|
|
18
|
+
subscribe(onEvent: () => void, onClose: (reason: string) => void, paneIds?: string[]): Promise<() => void>;
|
|
19
|
+
snapshot(): Promise<NativeSnapshot>;
|
|
20
|
+
catalog(projectId?: string): Promise<Machine>;
|
|
21
|
+
canLaunch?(input: LaunchInput): boolean;
|
|
22
|
+
icon?(projectId: string): Promise<Icon | undefined>;
|
|
23
|
+
worktrees?(projectId: string): Promise<WorktreeEntry[]>;
|
|
24
|
+
openWorktree?(projectId: string, path: string): Promise<void>;
|
|
25
|
+
createWorkspace?(path: string, label: string, requestId: string): Promise<{ workspaceId: string; tabId: string; terminalId: string }>;
|
|
26
|
+
openTerminal(terminalId: string, mode: 'control' | 'observe', cols: number, rows: number, takeover: boolean, onFrame: (frame: TerminalFrame) => void, onClose: (reason: string) => void): TerminalStream;
|
|
27
|
+
create(input: LaunchInput, threadId: string): Promise<{ tabId: string; paneId: string; terminalId: string; workspaceId: string }>;
|
|
28
|
+
start(input: LaunchInput, paneId: string, threadId: string): Promise<void>;
|
|
29
|
+
prompt(paneId: string, prompt: string, terminalId: string, threadId: string, input: LaunchInput): Promise<void>;
|
|
30
|
+
scrollMode?(terminalId: string): Promise<'scrollback' | 'app'>;
|
|
31
|
+
focusPane?(paneId: string): Promise<void>;
|
|
32
|
+
discardTab?(tabId: string, removeWorktree: boolean): Promise<void>;
|
|
33
|
+
renameTab?(tabId: string, label: string, renameWorkspace: boolean): Promise<void>;
|
|
34
|
+
splitTerminal?(paneId: string, direction: TerminalDirection): Promise<string>;
|
|
35
|
+
closePane?(paneId: string): Promise<void>;
|
|
36
|
+
close(): void;
|
|
37
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import type { MetadataDatabase } from '../storage/database';
|
|
3
|
+
import type { TargetAdapter } from './target';
|
|
4
|
+
|
|
5
|
+
export async function createWorkspace(database: MetadataDatabase, target: TargetAdapter, key: string, path: string, label: string) {
|
|
6
|
+
if (!target.createWorkspace) throw new Error('workspace_creation_unavailable');
|
|
7
|
+
const setting = 'workspace_operation:' + key;
|
|
8
|
+
const input = JSON.stringify({ fingerprint: target.fingerprint, path, label });
|
|
9
|
+
const intent = database.sqlite.transaction(() => {
|
|
10
|
+
const saved = database.getSetting(setting);
|
|
11
|
+
if (saved) {
|
|
12
|
+
const operation = JSON.parse(saved);
|
|
13
|
+
if (operation.input !== input) throw new Error('idempotency_conflict');
|
|
14
|
+
return { ...operation, created: false };
|
|
15
|
+
}
|
|
16
|
+
const operation = { id: randomUUID(), input, state: 'unknown' };
|
|
17
|
+
database.setSetting(setting, JSON.stringify(operation));
|
|
18
|
+
return { ...operation, created: true };
|
|
19
|
+
}).immediate();
|
|
20
|
+
if (!intent.created) return intent.result ?? { operationId: intent.id, state: intent.state };
|
|
21
|
+
const result = await target.createWorkspace(path, label, intent.id);
|
|
22
|
+
database.setSetting(setting, JSON.stringify({ id: intent.id, input, state: 'ready', result }));
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { NodeSqliteClient } from './node-sqlite-client';
|
|
2
|
+
import { openMigratedDrizzle } from './drizzle-node-sqlite';
|
|
3
|
+
import { and, desc, eq, lte, max } from 'drizzle-orm';
|
|
4
|
+
import type { DeviceSession } from '../auth/sessions';
|
|
5
|
+
import type { AuditEvent } from '../auth/audit';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
8
|
+
import { mkdirSync, chmodSync } from 'node:fs';
|
|
9
|
+
import { dirname } from 'node:path';
|
|
10
|
+
import * as schema from './schema';
|
|
11
|
+
import type { Thread } from '../../lib/models';
|
|
12
|
+
import type { LaunchInput } from '../../shared/runtime';
|
|
13
|
+
|
|
14
|
+
const AUDIT_RETENTION = 200;
|
|
15
|
+
|
|
16
|
+
export class MetadataDatabase {
|
|
17
|
+
readonly sqlite: NodeSqliteClient;
|
|
18
|
+
readonly db;
|
|
19
|
+
constructor(path: string) {
|
|
20
|
+
if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
21
|
+
this.sqlite = new NodeSqliteClient(path);
|
|
22
|
+
if (path !== ':memory:') chmodSync(path, 0o600);
|
|
23
|
+
this.sqlite.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON');
|
|
24
|
+
this.db = openMigratedDrizzle(this.sqlite, schema, fileURLToPath(new URL('../../../drizzle', import.meta.url)));
|
|
25
|
+
}
|
|
26
|
+
close() { this.sqlite.close(); }
|
|
27
|
+
getSetting(key: string) { return this.db.select().from(schema.settings).where(eq(schema.settings.key, key)).get()?.value; }
|
|
28
|
+
setSetting(key: string, value: string) { this.db.insert(schema.settings).values({ key, value }).onConflictDoUpdate({ target: schema.settings.key, set: { value } }).run(); }
|
|
29
|
+
threadRows(machineId?: string, session?: string) {
|
|
30
|
+
return this.db.select().from(schema.threads).where(machineId ? and(eq(schema.threads.machineId, machineId), session ? eq(schema.threads.session, session) : undefined) : undefined).all();
|
|
31
|
+
}
|
|
32
|
+
nextAvatarIndex() { return (this.db.select({ last: max(schema.threads.avatar) }).from(schema.threads).get()?.last ?? -1) + 1; }
|
|
33
|
+
saveThread(thread: Thread, anchors: string[], alias: string | null = null) {
|
|
34
|
+
const row = { id: thread.id, machineId: thread.machineId, session: thread.session, alias, metadata: thread, anchors, avatar: thread.avatarIndex };
|
|
35
|
+
this.db.insert(schema.threads).values(row).onConflictDoUpdate({ target: schema.threads.id, set: row }).run();
|
|
36
|
+
}
|
|
37
|
+
deleteThread(id: string) {
|
|
38
|
+
this.db.transaction(() => {
|
|
39
|
+
this.db.delete(schema.operations).where(eq(schema.operations.threadId, id)).run();
|
|
40
|
+
this.db.delete(schema.threads).where(eq(schema.threads.id, id)).run();
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
beginLaunch(accountId: string, key: string, input: LaunchInput, thread: Thread) {
|
|
44
|
+
const hash = createHash('sha256').update(JSON.stringify(input)).digest('hex');
|
|
45
|
+
return this.db.transaction(() => {
|
|
46
|
+
const existing = this.db.select().from(schema.operations).where(and(eq(schema.operations.accountId, accountId), eq(schema.operations.key, key))).get();
|
|
47
|
+
if (existing) {
|
|
48
|
+
if (existing.hash !== hash) throw new Error('idempotency_conflict');
|
|
49
|
+
return { operation: existing, created: false };
|
|
50
|
+
}
|
|
51
|
+
const operation = { id: randomUUID(), accountId, key, hash, threadId: thread.id, input, state: 'pending', step: 'validate', result: {} };
|
|
52
|
+
this.db.insert(schema.operations).values(operation).run();
|
|
53
|
+
this.saveThread(thread, []);
|
|
54
|
+
return { operation, created: true };
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
updateOperation(id: string, state: string, step: string, result: Record<string, unknown>) {
|
|
58
|
+
this.db.update(schema.operations).set({ state, step, result }).where(eq(schema.operations.id, id)).run();
|
|
59
|
+
}
|
|
60
|
+
operation(id: string) { return this.db.select().from(schema.operations).where(eq(schema.operations.id, id)).get(); }
|
|
61
|
+
operations() { return this.db.select().from(schema.operations).all(); }
|
|
62
|
+
markInterruptedLaunches() {
|
|
63
|
+
for (const operation of this.operations()) if (operation.state === 'pending' || operation.state === 'running') this.updateOperation(operation.id, 'unknown', operation.step, operation.result);
|
|
64
|
+
}
|
|
65
|
+
registerProfile(id: string, session: string, metadata: string, locations: { projectId: string; path: string }[]) {
|
|
66
|
+
const previous = this.db.select().from(schema.profiles).where(eq(schema.profiles.id, id)).get();
|
|
67
|
+
const configVersion = previous ? previous.configVersion + Number(previous.metadata !== metadata || previous.session !== session) : 1;
|
|
68
|
+
this.db.transaction(() => {
|
|
69
|
+
this.db.insert(schema.profiles).values({ id, session, metadata, configVersion }).onConflictDoUpdate({ target: schema.profiles.id, set: { session, metadata, configVersion } }).run();
|
|
70
|
+
this.db.delete(schema.projectLocations).where(eq(schema.projectLocations.machineId, id)).run();
|
|
71
|
+
for (const location of locations) this.db.insert(schema.projectLocations).values({ id: id + ':' + location.projectId, machineId: id, projectId: location.projectId, path: location.path }).run();
|
|
72
|
+
});
|
|
73
|
+
return configVersion;
|
|
74
|
+
}
|
|
75
|
+
addDeviceSession(session: DeviceSession & { tokenHash: string }) { this.db.insert(schema.deviceSessions).values(session).run(); }
|
|
76
|
+
deviceSessionByTokenHash(tokenHash: string) { return this.db.select().from(schema.deviceSessions).where(eq(schema.deviceSessions.tokenHash, tokenHash)).get(); }
|
|
77
|
+
deviceSessions() { return this.db.select({ id: schema.deviceSessions.id, name: schema.deviceSessions.name, createdAt: schema.deviceSessions.createdAt, lastSeenAt: schema.deviceSessions.lastSeenAt }).from(schema.deviceSessions).all(); }
|
|
78
|
+
touchDeviceSession(id: string, lastSeenAt: number) { this.db.update(schema.deviceSessions).set({ lastSeenAt }).where(eq(schema.deviceSessions.id, id)).run(); }
|
|
79
|
+
deleteDeviceSession(id: string) { return this.db.delete(schema.deviceSessions).where(eq(schema.deviceSessions.id, id)).run().changes > 0; }
|
|
80
|
+
addAuditEvent(event: Omit<AuditEvent, 'id'>) {
|
|
81
|
+
this.db.transaction(() => {
|
|
82
|
+
this.db.insert(schema.auditEvents).values(event).run();
|
|
83
|
+
const oldest = this.db.select({ id: schema.auditEvents.id }).from(schema.auditEvents).orderBy(desc(schema.auditEvents.id)).limit(1).offset(AUDIT_RETENTION).get();
|
|
84
|
+
if (oldest) this.db.delete(schema.auditEvents).where(lte(schema.auditEvents.id, oldest.id)).run();
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
auditEvents(limit: number): AuditEvent[] { return this.db.select().from(schema.auditEvents).orderBy(desc(schema.auditEvents.id)).limit(limit).all() as AuditEvent[]; }
|
|
88
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createTableRelationsHelpers, extractTablesRelationalConfig } from 'drizzle-orm';
|
|
2
|
+
import { BetterSQLiteSession } from 'drizzle-orm/better-sqlite3/session';
|
|
3
|
+
import { readMigrationFiles } from 'drizzle-orm/migrator';
|
|
4
|
+
import { BaseSQLiteDatabase, SQLiteSyncDialect } from 'drizzle-orm/sqlite-core';
|
|
5
|
+
import type { NodeSqliteClient, RunResult } from './node-sqlite-client';
|
|
6
|
+
|
|
7
|
+
export type NodeSqliteDrizzle<Schema extends Record<string, unknown>> = BaseSQLiteDatabase<'sync', RunResult, Schema>;
|
|
8
|
+
|
|
9
|
+
/** drizzle's better-sqlite3 `drizzle()` and `migrate()` import the native package, so build both from its session classes. */
|
|
10
|
+
export function openMigratedDrizzle<Schema extends Record<string, unknown>>(client: NodeSqliteClient, schema: Schema, migrationsFolder: string): NodeSqliteDrizzle<Schema> {
|
|
11
|
+
const dialect = new SQLiteSyncDialect();
|
|
12
|
+
const relations = extractTablesRelationalConfig(schema, createTableRelationsHelpers);
|
|
13
|
+
const relationalSchema = { fullSchema: schema, schema: relations.tables, tableNamesMap: relations.tableNamesMap };
|
|
14
|
+
const session = new BetterSQLiteSession(client as never, dialect, relationalSchema);
|
|
15
|
+
const config = { migrationsFolder };
|
|
16
|
+
dialect.migrate(readMigrationFiles(config), session, config);
|
|
17
|
+
return new BaseSQLiteDatabase('sync', dialect, session, relationalSchema) as NodeSqliteDrizzle<Schema>;
|
|
18
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { DatabaseSync, type StatementSync, type SQLInputValue } from 'node:sqlite';
|
|
2
|
+
|
|
3
|
+
export type RunResult = { changes: number | bigint; lastInsertRowid: number | bigint };
|
|
4
|
+
type BindValues = SQLInputValue[];
|
|
5
|
+
type Behavior = 'deferred' | 'immediate' | 'exclusive';
|
|
6
|
+
|
|
7
|
+
class NodeSqliteStatement {
|
|
8
|
+
constructor(private readonly statement: StatementSync) { this.statement.setReturnArrays(false); }
|
|
9
|
+
run(...parameters: BindValues) { return this.statement.run(...parameters); }
|
|
10
|
+
all(...parameters: BindValues) { return this.statement.all(...parameters); }
|
|
11
|
+
get(...parameters: BindValues) { return this.statement.get(...parameters); }
|
|
12
|
+
raw() { this.statement.setReturnArrays(true); return this; }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function runInTransaction<Args extends unknown[], Result>(database: DatabaseSync, behavior: Behavior, work: (...args: Args) => Result) {
|
|
16
|
+
return (...args: Args) => {
|
|
17
|
+
database.exec(`BEGIN ${behavior}`);
|
|
18
|
+
try {
|
|
19
|
+
const result = work(...args);
|
|
20
|
+
database.exec('COMMIT');
|
|
21
|
+
return result;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (database.isTransaction) database.exec('ROLLBACK');
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The slice of the better-sqlite3 client API that drizzle's synchronous SQLite driver calls, backed by `node:sqlite`. */
|
|
30
|
+
export class NodeSqliteClient {
|
|
31
|
+
private readonly database: DatabaseSync;
|
|
32
|
+
constructor(path: string) { this.database = new DatabaseSync(path); }
|
|
33
|
+
exec(sql: string) { this.database.exec(sql); }
|
|
34
|
+
close() { this.database.close(); }
|
|
35
|
+
prepare(sql: string) { return new NodeSqliteStatement(this.database.prepare(sql)); }
|
|
36
|
+
transaction<Args extends unknown[], Result>(work: (...args: Args) => Result) {
|
|
37
|
+
const inTransaction = (behavior: Behavior) => runInTransaction(this.database, behavior, work);
|
|
38
|
+
return Object.assign(inTransaction('deferred'), {
|
|
39
|
+
deferred: inTransaction('deferred'),
|
|
40
|
+
immediate: inTransaction('immediate'),
|
|
41
|
+
exclusive: inTransaction('exclusive'),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { sqliteTable, text, integer, uniqueIndex } from 'drizzle-orm/sqlite-core';
|
|
2
|
+
import type { Thread } from '../../lib/models';
|
|
3
|
+
import type { LaunchInput } from '../../shared/runtime';
|
|
4
|
+
|
|
5
|
+
export const threads = sqliteTable('app_threads', {
|
|
6
|
+
id: text('id').primaryKey(), machineId: text('machine_id').notNull(), session: text('session').notNull(),
|
|
7
|
+
alias: text('alias'), metadata: text('metadata', { mode: 'json' }).$type<Thread>().notNull(),
|
|
8
|
+
anchors: text('anchors', { mode: 'json' }).$type<string[]>().notNull(), avatar: integer('avatar').notNull(),
|
|
9
|
+
}, (table) => [uniqueIndex('native_alias').on(table.machineId, table.session, table.alias)]);
|
|
10
|
+
export const operations = sqliteTable('launch_operations', {
|
|
11
|
+
id: text('id').primaryKey(), accountId: text('account_id').notNull(), key: text('key').notNull(), hash: text('hash').notNull(), threadId: text('thread_id').notNull(),
|
|
12
|
+
input: text('input', { mode: 'json' }).$type<LaunchInput>().notNull(), state: text('state').notNull(), step: text('step').notNull(),
|
|
13
|
+
result: text('result', { mode: 'json' }).$type<Record<string, unknown>>().notNull(),
|
|
14
|
+
}, (table) => [uniqueIndex('account_operation_key').on(table.accountId, table.key)]);
|
|
15
|
+
export const settings = sqliteTable('app_settings', { key: text('key').primaryKey(), value: text('value').notNull() });
|
|
16
|
+
export const profiles = sqliteTable('machine_profiles', { id: text('id').primaryKey(), session: text('session').notNull(), configVersion: integer('config_version').notNull(), metadata: text('metadata').notNull() });
|
|
17
|
+
export const projectLocations = sqliteTable('project_locations', { id: text('id').primaryKey(), machineId: text('machine_id').notNull(), projectId: text('project_id').notNull(), path: text('path').notNull() });
|
|
18
|
+
export const deviceSessions = sqliteTable('device_sessions', {
|
|
19
|
+
id: text('id').primaryKey(), tokenHash: text('token_hash').notNull(), name: text('name').notNull(),
|
|
20
|
+
createdAt: integer('created_at').notNull(), lastSeenAt: integer('last_seen_at').notNull(),
|
|
21
|
+
}, (table) => [uniqueIndex('device_session_token').on(table.tokenHash)]);
|
|
22
|
+
export const auditEvents = sqliteTable('audit_events', {
|
|
23
|
+
id: integer('id').primaryKey({ autoIncrement: true }), at: integer('at').notNull(), kind: text('kind').notNull(),
|
|
24
|
+
source: text('source'), deviceName: text('device_name'),
|
|
25
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
|
|
4
|
+
const run = promisify(execFile);
|
|
5
|
+
const TAILSCALE_BINARIES = ['tailscale', '/Applications/Tailscale.app/Contents/MacOS/Tailscale'];
|
|
6
|
+
|
|
7
|
+
const magicDnsName = (statusJson: string) => {
|
|
8
|
+
const name = (JSON.parse(statusJson) as { Self?: { DNSName?: string } }).Self?.DNSName;
|
|
9
|
+
return name ? name.replace(/\.$/, '') : undefined;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export async function detectTailscaleHost() {
|
|
13
|
+
for (const binary of TAILSCALE_BINARIES) {
|
|
14
|
+
try { return magicDnsName((await run(binary, ['status', '--json'], { timeout: 3000 })).stdout); } catch { /* try the next binary */ }
|
|
15
|
+
}
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { spawn, type SpawnOptions } from 'node:child_process';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { NdjsonParser } from '../protocol/ndjson';
|
|
4
|
+
import { MAX_FRAME_BYTES } from '../../shared/frame';
|
|
5
|
+
import { OrderedWriter } from './writer';
|
|
6
|
+
|
|
7
|
+
const frameSchema = z.object({ type: z.literal('terminal.frame'), encoding: z.literal('ansi'), seq: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), width: z.number().int().min(1).max(500), height: z.number().int().min(1).max(300), full: z.boolean(), bytes: z.string().max(Math.ceil(MAX_FRAME_BYTES / 3) * 4).regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/) });
|
|
8
|
+
export type TerminalFrame = { seq: number; width: number; height: number; full: boolean; bytes: Uint8Array };
|
|
9
|
+
export type TerminalStream = { send(command: Record<string, unknown>): void; close(): void; disconnect?(): void };
|
|
10
|
+
const closeReason = (text: string, fallback: string) => /controller.*(already|conflict|in use)|already.*(controlled|controller|attached client)/i.test(text) ? 'controller_conflict' : /controller.*(replaced|taken.over)|terminal attach taken over/i.test(text) ? 'taken_over' : fallback;
|
|
11
|
+
|
|
12
|
+
export function openCliStream(command: string, args: string[], options: SpawnOptions, onFrame: (frame: TerminalFrame) => void, onClose: (reason: string) => void): TerminalStream {
|
|
13
|
+
const child = spawn(command, args, { ...options, stdio: ['pipe', 'pipe', 'pipe'], shell: false });
|
|
14
|
+
child.stdin!.on('error', () => {});
|
|
15
|
+
let closed = false;
|
|
16
|
+
let first = true;
|
|
17
|
+
let stderrBytes = 0;
|
|
18
|
+
let stderr = '';
|
|
19
|
+
const writer = new OrderedWriter(child.stdin!, () => finish('writer_failed'));
|
|
20
|
+
const finish = (reason: string, release = true) => {
|
|
21
|
+
if (closed) return;
|
|
22
|
+
closed = true; clearTimeout(timer); writer.close();
|
|
23
|
+
if (release) child.stdin?.end(JSON.stringify({ type: 'terminal.release' }) + '\n');
|
|
24
|
+
else { child.stdin?.destroy(); child.kill('SIGTERM'); }
|
|
25
|
+
const kill = setTimeout(() => child.kill('SIGTERM'), 200);
|
|
26
|
+
kill.unref(); child.once('exit', () => clearTimeout(kill));
|
|
27
|
+
onClose(reason);
|
|
28
|
+
};
|
|
29
|
+
const timer = setTimeout(() => finish('terminal_timeout'), 5000);
|
|
30
|
+
const parser = new NdjsonParser((record) => {
|
|
31
|
+
const closedRecord = z.object({ type: z.literal('terminal.closed'), reason: z.string() }).safeParse(record);
|
|
32
|
+
if (closedRecord.success) { finish(closeReason(closedRecord.data.reason, 'terminal_closed')); return; }
|
|
33
|
+
const value = frameSchema.parse(record);
|
|
34
|
+
const bytes = Buffer.from(value.bytes, 'base64');
|
|
35
|
+
if (bytes.length > MAX_FRAME_BYTES || (first && !value.full)) throw new Error('baseline_required');
|
|
36
|
+
first = false; clearTimeout(timer);
|
|
37
|
+
onFrame({ ...value, bytes });
|
|
38
|
+
}, 400 * 1024);
|
|
39
|
+
child.stdout!.on('data', (chunk: Buffer) => { try { parser.push(chunk); } catch { finish('invalid_terminal_frame'); } });
|
|
40
|
+
child.stderr!.on('data', (chunk: Buffer) => { stderrBytes += chunk.length; if (stderrBytes > 16 * 1024) finish('terminal_stderr_limit'); else stderr += chunk.toString(); });
|
|
41
|
+
child.on('error', () => finish('terminal_unavailable'));
|
|
42
|
+
child.on('exit', () => { try { parser.end(); } catch { finish('partial_terminal_frame'); } finish(closeReason(stderr, 'terminal_exited')); });
|
|
43
|
+
return { send: (command) => { if (closed) throw new Error('terminal_closed'); writer.send(command); }, close: () => finish('released'), disconnect: () => finish('disconnected', false) };
|
|
44
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { WebSocket } from 'ws';
|
|
2
|
+
import type { RuntimeManager } from '../runtime/manager';
|
|
3
|
+
import { encodeFrame, FrameSequence } from '../../shared/frame';
|
|
4
|
+
import type { TerminalAction } from '../../shared/runtime';
|
|
5
|
+
import type { TerminalFrame, TerminalStream } from './cli';
|
|
6
|
+
import { encodeUserInput } from './writer';
|
|
7
|
+
import { perfLog } from '../perf-log';
|
|
8
|
+
|
|
9
|
+
type Lease = { id: number; generation: number; terminalId: string; machineId: string; threadId: string; mode: string; stream?: TerminalStream; outstanding: Map<number, number>; bytes: number; sequence: FrameSequence; baselineReady: boolean; outputSeq: number; started: boolean; baselineSeq?: number; expectedSize?: [number, number]; timer?: ReturnType<typeof setTimeout>; scrollAt?: number; scrollMode?: { value: 'scrollback' | 'app'; checkedAt: number } };
|
|
10
|
+
const MAX_WHEEL_TICKS = 30;
|
|
11
|
+
const SCROLL_MODE_CHECK_MS = 1000;
|
|
12
|
+
type SharedControl = { stream: TerminalStream; sequence: FrameSequence; members: Map<Lease, string>; closed: boolean };
|
|
13
|
+
export class TerminalHub {
|
|
14
|
+
private viewers = new Map<string, { socket: WebSocket; leases: Map<number, Lease>; bytes: number }>();
|
|
15
|
+
private controllers = new Map<string, SharedControl>();
|
|
16
|
+
private expiry: ReturnType<typeof setInterval>;
|
|
17
|
+
constructor(private manager: RuntimeManager, private streamBudget = 512 * 1024, private totalBudget = 2 * 1024 * 1024) {
|
|
18
|
+
this.expiry = setInterval(() => { for (const [owner, viewer] of this.viewers) for (const lease of viewer.leases.values()) if (lease.mode === 'control') { try { if (!manager.binding(lease.machineId, lease.threadId, lease.terminalId).writable) this.release(owner, lease.id, 'capability_revoked'); } catch { this.release(owner, lease.id, 'binding_invalid'); } } }, 250);
|
|
19
|
+
this.expiry.unref();
|
|
20
|
+
manager.on('projection', () => { for (const [owner, viewer] of this.viewers) for (const lease of viewer.leases.values()) { try { manager.binding(lease.machineId, lease.threadId, lease.terminalId); } catch { this.release(owner, lease.id, 'binding_invalid'); } } });
|
|
21
|
+
}
|
|
22
|
+
close() { clearInterval(this.expiry); for (const owner of this.viewers.keys()) this.detach(owner); }
|
|
23
|
+
attach(owner: string, socket: WebSocket) { if (this.viewers.has(owner)) throw new Error('duplicate_terminal_socket'); this.viewers.set(owner, { socket, leases: new Map(), bytes: 0 }); }
|
|
24
|
+
detach(owner: string) { const viewer = this.viewers.get(owner); if (!viewer) return; for (const lease of [...viewer.leases.values()]) this.release(owner, lease.id, 'connection_closed'); this.viewers.delete(owner); }
|
|
25
|
+
action(owner: string, action: TerminalAction) {
|
|
26
|
+
const viewer = this.viewers.get(owner);
|
|
27
|
+
if (!viewer) throw new Error('viewer_missing');
|
|
28
|
+
if (action.type === 'open') {
|
|
29
|
+
if (viewer.leases.size >= 16 || viewer.leases.has(action.streamId)) throw new Error('stream_limit');
|
|
30
|
+
const target = this.manager.binding(action.machineId, action.threadId, action.terminalId);
|
|
31
|
+
if (action.mode === 'control' && !target.writable) throw new Error('read_only_target');
|
|
32
|
+
const controllerKey = action.machineId + '\0' + action.terminalId;
|
|
33
|
+
const lease: Lease = { id: action.streamId, generation: action.generation, machineId: action.machineId, threadId: action.threadId, terminalId: action.terminalId, mode: action.mode, outstanding: new Map(), bytes: 0, sequence: new FrameSequence(), baselineReady: false, outputSeq: 0, started: false };
|
|
34
|
+
viewer.leases.set(lease.id, lease);
|
|
35
|
+
try {
|
|
36
|
+
lease.stream = action.mode === 'control'
|
|
37
|
+
? this.joinControl(controllerKey, owner, lease, action, target)
|
|
38
|
+
: target.openTerminal(action.terminalId, action.mode, action.cols, action.rows, action.takeover, (frame) => this.deliver(owner, lease, frame), (reason) => this.release(owner, lease.id, reason));
|
|
39
|
+
if (viewer.leases.get(lease.id) !== lease) lease.stream.close();
|
|
40
|
+
else this.notice(viewer.socket, { type: 'stream.opened', streamId: lease.id, generation: lease.generation, writable: action.mode === 'control' });
|
|
41
|
+
} catch (error) { this.release(owner, lease.id, 'open_failed'); throw error; }
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const lease = viewer.leases.get(action.streamId);
|
|
45
|
+
if (!lease || lease.generation !== action.generation) throw new Error('stale_lease');
|
|
46
|
+
if (action.type === 'release') { this.release(owner, lease.id, 'released'); return; }
|
|
47
|
+
if (action.type === 'ack') {
|
|
48
|
+
const bytes = lease.outstanding.get(action.seq);
|
|
49
|
+
if (!bytes || lease.outstanding.keys().next().value !== action.seq) throw new Error('invalid_ack');
|
|
50
|
+
lease.outstanding.delete(action.seq); lease.bytes -= bytes; viewer.bytes -= bytes;
|
|
51
|
+
if (action.seq === lease.baselineSeq) lease.baselineReady = true;
|
|
52
|
+
clearTimeout(lease.timer); lease.timer = lease.outstanding.size ? setTimeout(() => this.release(owner, lease.id, 'ack_timeout'), 5000) : undefined;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const target = this.manager.binding(lease.machineId, lease.threadId, lease.terminalId);
|
|
56
|
+
if (lease.mode !== 'control') throw new Error('observer_read_only');
|
|
57
|
+
if (!target.writable) { this.release(owner, lease.id, 'capability_revoked'); throw new Error('read_only_target'); }
|
|
58
|
+
if (action.type !== 'resize' && !lease.baselineReady) throw new Error('baseline_not_acknowledged');
|
|
59
|
+
if (action.type === 'input') lease.stream?.send({ type: 'terminal.input', text: encodeUserInput(action.text, action.paste) });
|
|
60
|
+
if (action.type === 'resize') { lease.baselineReady = false; lease.baselineSeq = undefined; lease.expectedSize = [action.cols, action.rows]; lease.stream?.send({ type: 'terminal.resize', cols: action.cols, rows: action.rows }); }
|
|
61
|
+
if (action.type === 'scroll') lease.scrollAt ??= performance.now();
|
|
62
|
+
if (action.type === 'scroll') this.scroll(lease, target, action);
|
|
63
|
+
if (action.type === 'mouse') lease.stream?.send({ type: 'terminal.mouse', action: action.action, button: action.button, column: action.column, row: action.row, modifiers: action.modifiers });
|
|
64
|
+
}
|
|
65
|
+
private joinControl(key: string, owner: string, lease: Lease, action: Extract<TerminalAction, { type: 'open' }>, target: ReturnType<RuntimeManager['binding']>): TerminalStream {
|
|
66
|
+
let shared = this.controllers.get(key);
|
|
67
|
+
if (shared) {
|
|
68
|
+
shared.members.set(lease, owner); lease.expectedSize = [action.cols, action.rows];
|
|
69
|
+
shared.stream.send({ type: 'terminal.resize', cols: action.cols, rows: action.rows });
|
|
70
|
+
} else {
|
|
71
|
+
const created: SharedControl = { stream: undefined as unknown as TerminalStream, sequence: new FrameSequence(), members: new Map([[lease, owner]]), closed: false };
|
|
72
|
+
this.controllers.set(key, created);
|
|
73
|
+
try { created.stream = target.openTerminal(action.terminalId, 'control', action.cols, action.rows, action.takeover, (frame) => this.fanOut(key, created, frame), (reason) => this.closeShared(key, created, reason)); }
|
|
74
|
+
catch (error) { this.controllers.delete(key); throw error; }
|
|
75
|
+
shared = created;
|
|
76
|
+
}
|
|
77
|
+
const joined = shared;
|
|
78
|
+
return { send: (command) => joined.stream.send(command), close: () => this.leave(key, joined, lease) };
|
|
79
|
+
}
|
|
80
|
+
private leave(key: string, shared: SharedControl, lease: Lease) {
|
|
81
|
+
shared.members.delete(lease);
|
|
82
|
+
if (shared.members.size || shared.closed) return;
|
|
83
|
+
shared.closed = true;
|
|
84
|
+
if (this.controllers.get(key) === shared) this.controllers.delete(key);
|
|
85
|
+
shared.stream.close();
|
|
86
|
+
}
|
|
87
|
+
private closeShared(key: string, shared: SharedControl, reason: string) {
|
|
88
|
+
shared.closed = true;
|
|
89
|
+
if (this.controllers.get(key) === shared) this.controllers.delete(key);
|
|
90
|
+
const members = [...shared.members]; shared.members.clear();
|
|
91
|
+
for (const [lease, owner] of members) this.release(owner, lease.id, reason);
|
|
92
|
+
}
|
|
93
|
+
private fanOut(key: string, shared: SharedControl, frame: TerminalFrame) {
|
|
94
|
+
try { shared.sequence.accept({ ...frame, streamId: 0, generation: 0 }); } catch { shared.stream.close(); this.closeShared(key, shared, 'stream_resync_required'); return; }
|
|
95
|
+
for (const [lease, owner] of [...shared.members]) this.deliver(owner, lease, frame);
|
|
96
|
+
}
|
|
97
|
+
private deliver(owner: string, lease: Lease, upstream: TerminalFrame) {
|
|
98
|
+
const viewer = this.viewers.get(owner);
|
|
99
|
+
if (!viewer || viewer.leases.get(lease.id) !== lease) return;
|
|
100
|
+
if (!lease.started) { if (!upstream.full) return; lease.started = true; }
|
|
101
|
+
const frame = { ...upstream, seq: ++lease.outputSeq, streamId: lease.id, generation: lease.generation };
|
|
102
|
+
try {
|
|
103
|
+
lease.sequence.accept(frame);
|
|
104
|
+
const packet = encodeFrame(frame);
|
|
105
|
+
if (lease.bytes + packet.length > this.streamBudget || viewer.bytes + packet.length > this.totalBudget || viewer.socket.bufferedAmount + packet.length > this.totalBudget) throw new Error('viewer_overload');
|
|
106
|
+
lease.outstanding.set(frame.seq, packet.length); lease.bytes += packet.length; viewer.bytes += packet.length;
|
|
107
|
+
if (frame.full && !lease.baselineReady && (!lease.expectedSize || frame.width === lease.expectedSize[0] && frame.height === lease.expectedSize[1])) lease.baselineSeq = frame.seq;
|
|
108
|
+
lease.timer ??= setTimeout(() => this.release(owner, lease.id, 'ack_timeout'), 5000);
|
|
109
|
+
viewer.socket.send(packet, { binary: true });
|
|
110
|
+
this.logScrollReply(lease, frame.full, packet.length, viewer.socket.bufferedAmount);
|
|
111
|
+
} catch { this.release(owner, lease.id, 'stream_resync_required'); }
|
|
112
|
+
}
|
|
113
|
+
/** Herdr always puts wheel ticks of full-screen apps at the top-left cell, so those get SGR wheel reports at the pointer instead. */
|
|
114
|
+
private scroll(lease: Lease, target: ReturnType<RuntimeManager['binding']>, action: { direction: 'up' | 'down'; lines: number; column?: number; row?: number }) {
|
|
115
|
+
this.refreshScrollMode(lease, target);
|
|
116
|
+
if (lease.scrollMode?.value !== 'app') { lease.stream?.send({ type: 'terminal.scroll', direction: action.direction, lines: action.lines }); return; }
|
|
117
|
+
const report = `\x1b[<${action.direction === 'up' ? 64 : 65};${action.column ?? 1};${action.row ?? 1}M`;
|
|
118
|
+
lease.stream?.send({ type: 'terminal.input', text: report.repeat(Math.min(action.lines, MAX_WHEEL_TICKS)) });
|
|
119
|
+
}
|
|
120
|
+
private refreshScrollMode(lease: Lease, target: ReturnType<RuntimeManager['binding']>) {
|
|
121
|
+
if (!target.scrollMode || lease.scrollMode && Date.now() - lease.scrollMode.checkedAt < SCROLL_MODE_CHECK_MS) return;
|
|
122
|
+
lease.scrollMode = { value: lease.scrollMode?.value ?? 'scrollback', checkedAt: Date.now() };
|
|
123
|
+
void target.scrollMode(lease.terminalId).then((value) => { lease.scrollMode = { value, checkedAt: Date.now() }; }, () => undefined);
|
|
124
|
+
}
|
|
125
|
+
private logScrollReply(lease: Lease, full: boolean, bytes: number, buffered: number) {
|
|
126
|
+
if (lease.scrollAt === undefined) return;
|
|
127
|
+
perfLog('server', { event: 'scroll.reply', herdrRoundTripMs: performance.now() - lease.scrollAt, full, bytes, socketBufferedBytes: buffered });
|
|
128
|
+
lease.scrollAt = undefined;
|
|
129
|
+
}
|
|
130
|
+
stats() { return { viewers: this.viewers.size, controllers: this.controllers.size, bytes: [...this.viewers.values()].reduce((total, viewer) => total + viewer.bytes, 0) }; }
|
|
131
|
+
private release(owner: string, id: number, reason: string) {
|
|
132
|
+
const viewer = this.viewers.get(owner);
|
|
133
|
+
const lease = viewer?.leases.get(id);
|
|
134
|
+
if (!viewer || !lease) return;
|
|
135
|
+
viewer.leases.delete(id); viewer.bytes -= lease.bytes; clearTimeout(lease.timer);
|
|
136
|
+
lease.stream?.close();
|
|
137
|
+
this.notice(viewer.socket, { type: 'stream.closed', streamId: id, generation: lease.generation, reason });
|
|
138
|
+
}
|
|
139
|
+
private notice(socket: WebSocket, value: Record<string, unknown>) { if (socket.readyState === 1) socket.send(JSON.stringify(value)); }
|
|
140
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Writable } from 'node:stream';
|
|
2
|
+
|
|
3
|
+
export class OrderedWriter {
|
|
4
|
+
private queue: Buffer[] = [];
|
|
5
|
+
private bytes = 0;
|
|
6
|
+
private writing = false;
|
|
7
|
+
private closed = false;
|
|
8
|
+
private timer?: ReturnType<typeof setTimeout>;
|
|
9
|
+
constructor(private sink: Writable, private fail: () => void, private budget = 32 * 1024, private deadline = 2000) {}
|
|
10
|
+
send(command: Record<string, unknown>) {
|
|
11
|
+
if (this.closed) throw new Error('writer_closed');
|
|
12
|
+
const bytes = Buffer.from(JSON.stringify(command) + '\n');
|
|
13
|
+
if (this.bytes + bytes.length > this.budget) { this.close(); this.fail(); throw new Error('input_overload'); }
|
|
14
|
+
this.queue.push(bytes); this.bytes += bytes.length; this.flush();
|
|
15
|
+
}
|
|
16
|
+
close() { this.closed = true; this.queue = []; this.bytes = 0; clearTimeout(this.timer); }
|
|
17
|
+
private flush() {
|
|
18
|
+
if (this.writing || this.closed || !this.queue.length) return;
|
|
19
|
+
this.writing = true;
|
|
20
|
+
const next = this.queue.shift()!;
|
|
21
|
+
this.timer = setTimeout(() => { this.close(); this.fail(); }, this.deadline);
|
|
22
|
+
this.sink.write(next, (error?: Error | null) => {
|
|
23
|
+
clearTimeout(this.timer);
|
|
24
|
+
this.writing = false;
|
|
25
|
+
if (this.closed) return;
|
|
26
|
+
this.bytes -= next.length;
|
|
27
|
+
if (error) { this.close(); this.fail(); return; }
|
|
28
|
+
this.flush();
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function encodeUserInput(text: string, paste: boolean): string {
|
|
34
|
+
if (Buffer.byteLength(text) > 8192 || text.includes('\x1b[200~') || text.includes('\x1b[201~') || text.includes('\0')) throw new Error('invalid_input');
|
|
35
|
+
if (!paste) return text;
|
|
36
|
+
if (/[\x00-\x08\x0b-\x1f\x7f]/.test(text)) throw new Error('invalid_paste');
|
|
37
|
+
return '\x1b[200~' + text + '\x1b[201~';
|
|
38
|
+
}
|