@keith9681/dsh-sidebar 0.15.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/LICENSE +21 -0
- package/README.md +0 -0
- package/README_EN.md +0 -0
- package/cordis.patch.yml +49 -0
- package/lib/client-editor.js +34651 -0
- package/lib/client-mermaid.js +200629 -0
- package/lib/client-registry.js +13388 -0
- package/lib/client-terminal.js +8904 -0
- package/lib/client.js +13388 -0
- package/lib/index.js +3959 -0
- package/lib/invariant.js +22 -0
- package/lib/types/agent-pty.d.ts +226 -0
- package/lib/types/browser-probe.d.ts +15 -0
- package/lib/types/bundle-route.d.ts +12 -0
- package/lib/types/client/BrowserView.d.ts +22 -0
- package/lib/types/client/DiffTab.d.ts +6 -0
- package/lib/types/client/DiffView.d.ts +51 -0
- package/lib/types/client/EditorHost.d.ts +12 -0
- package/lib/types/client/FileTree.d.ts +36 -0
- package/lib/types/client/GitView.d.ts +8 -0
- package/lib/types/client/OrphanedTab.d.ts +11 -0
- package/lib/types/client/PdfView.d.ts +6 -0
- package/lib/types/client/RenderBoundary.d.ts +32 -0
- package/lib/types/client/SandboxStatusBar.d.ts +10 -0
- package/lib/types/client/SideCardSection.d.ts +74 -0
- package/lib/types/client/SideChatView.d.ts +16 -0
- package/lib/types/client/Sidebar.d.ts +6 -0
- package/lib/types/client/SubagentView.d.ts +16 -0
- package/lib/types/client/TabBar.d.ts +43 -0
- package/lib/types/client/TerminalView.d.ts +24 -0
- package/lib/types/client/TextEditor.d.ts +10 -0
- package/lib/types/client/TreePanel.d.ts +23 -0
- package/lib/types/client/UploadOverlay.d.ts +20 -0
- package/lib/types/client/add-plugin-modal.d.ts +15 -0
- package/lib/types/client/api.d.ts +268 -0
- package/lib/types/client/binary-download.d.ts +12 -0
- package/lib/types/client/breakpoints.d.ts +12 -0
- package/lib/types/client/browser.d.ts +50 -0
- package/lib/types/client/builtins/index.d.ts +18 -0
- package/lib/types/client/builtins/tabs.d.ts +11 -0
- package/lib/types/client/builtins/viewers.d.ts +3 -0
- package/lib/types/client/chunk-loader.d.ts +124 -0
- package/lib/types/client/chunks/editor.d.ts +9 -0
- package/lib/types/client/chunks/mermaid.d.ts +10 -0
- package/lib/types/client/chunks/terminal.d.ts +9 -0
- package/lib/types/client/cm-themes.d.ts +24 -0
- package/lib/types/client/conversation-draft.d.ts +14 -0
- package/lib/types/client/desktop-env.d.ts +35 -0
- package/lib/types/client/editable-state.d.ts +27 -0
- package/lib/types/client/editor-load.d.ts +66 -0
- package/lib/types/client/frame-batcher.d.ts +24 -0
- package/lib/types/client/icons.d.ts +57 -0
- package/lib/types/client/image-types.d.ts +3 -0
- package/lib/types/client/ime-guard.d.ts +36 -0
- package/lib/types/client/index.d.ts +19 -0
- package/lib/types/client/intercept.d.ts +33 -0
- package/lib/types/client/lang.d.ts +12 -0
- package/lib/types/client/lazy-chunk.d.ts +25 -0
- package/lib/types/client/link-intercept.d.ts +42 -0
- package/lib/types/client/locales.d.ts +366 -0
- package/lib/types/client/mermaid-blocks.d.ts +41 -0
- package/lib/types/client/mermaid-sanitize.d.ts +2 -0
- package/lib/types/client/mermaid.d.ts +12 -0
- package/lib/types/client/open-when-sized.d.ts +20 -0
- package/lib/types/client/open-with-settings.d.ts +4 -0
- package/lib/types/client/open-with.d.ts +87 -0
- package/lib/types/client/openpath-intercept.d.ts +41 -0
- package/lib/types/client/paths.d.ts +35 -0
- package/lib/types/client/pdf-types.d.ts +2 -0
- package/lib/types/client/plugin-settings.d.ts +8 -0
- package/lib/types/client/plugins-shared.d.ts +27 -0
- package/lib/types/client/plugins-tabs.d.ts +3 -0
- package/lib/types/client/plugins-viewers.d.ts +3 -0
- package/lib/types/client/prefs.d.ts +41 -0
- package/lib/types/client/produced-files.d.ts +23 -0
- package/lib/types/client/selection-payload.d.ts +27 -0
- package/lib/types/client/service.d.ts +440 -0
- package/lib/types/client/settings-nav-icon.d.ts +19 -0
- package/lib/types/client/shell-presets.d.ts +48 -0
- package/lib/types/client/sidechat-transcript.d.ts +103 -0
- package/lib/types/client/split-pane.d.ts +30 -0
- package/lib/types/client/state.d.ts +359 -0
- package/lib/types/client/subagent-detect.d.ts +54 -0
- package/lib/types/client/subagent-jobs.d.ts +63 -0
- package/lib/types/client/tab-content-memo.d.ts +33 -0
- package/lib/types/client/terminal-font.d.ts +20 -0
- package/lib/types/client/theme.d.ts +45 -0
- package/lib/types/client/titlebar-strip.d.ts +23 -0
- package/lib/types/client/upload.d.ts +69 -0
- package/lib/types/client/wco.d.ts +47 -0
- package/lib/types/config.d.ts +64 -0
- package/lib/types/context-types.d.ts +603 -0
- package/lib/types/fs-operations.d.ts +28 -0
- package/lib/types/fs-search.d.ts +24 -0
- package/lib/types/fs-tree.d.ts +51 -0
- package/lib/types/git.d.ts +82 -0
- package/lib/types/html-route.d.ts +59 -0
- package/lib/types/index.d.ts +47 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/jobs-routes.d.ts +45 -0
- package/lib/types/open-external.d.ts +24 -0
- package/lib/types/prefs-shared.d.ts +225 -0
- package/lib/types/pty-deps.d.ts +78 -0
- package/lib/types/pty-manager.d.ts +168 -0
- package/lib/types/sidechat-core.d.ts +172 -0
- package/lib/types/sidechat-routes.d.ts +30 -0
- package/lib/types/subagent-activity.d.ts +44 -0
- package/lib/types/subagent-live-route.d.ts +44 -0
- package/lib/types/tools.d.ts +31 -0
- package/lib/types/trust-fence.d.ts +24 -0
- package/lib/types/wire.d.ts +38 -0
- package/package.json +173 -0
- package/scripts/install.ps1 +289 -0
- package/scripts/install.sh +287 -0
- package/src/agent-pty.ts +527 -0
- package/src/browser-probe.ts +26 -0
- package/src/bundle-route.ts +129 -0
- package/src/client/BrowserView.tsx +253 -0
- package/src/client/DiffTab.tsx +110 -0
- package/src/client/DiffView.tsx +305 -0
- package/src/client/EditorHost.tsx +525 -0
- package/src/client/FileTree.tsx +653 -0
- package/src/client/GitView.tsx +558 -0
- package/src/client/OrphanedTab.tsx +27 -0
- package/src/client/PdfView.tsx +110 -0
- package/src/client/RenderBoundary.tsx +49 -0
- package/src/client/SandboxStatusBar.tsx +60 -0
- package/src/client/SideCardSection.module.css +960 -0
- package/src/client/SideCardSection.tsx +1152 -0
- package/src/client/SideChatView.module.css +437 -0
- package/src/client/SideChatView.tsx +644 -0
- package/src/client/Sidebar.tsx +1148 -0
- package/src/client/SubagentView.module.css +547 -0
- package/src/client/SubagentView.tsx +883 -0
- package/src/client/TabBar.tsx +305 -0
- package/src/client/TerminalView.tsx +383 -0
- package/src/client/TextEditor.tsx +500 -0
- package/src/client/TreePanel.tsx +273 -0
- package/src/client/UploadOverlay.tsx +62 -0
- package/src/client/add-plugin-modal.tsx +199 -0
- package/src/client/api.ts +335 -0
- package/src/client/binary-download.tsx +23 -0
- package/src/client/breakpoints.ts +51 -0
- package/src/client/browser.ts +119 -0
- package/src/client/builtins/index.ts +37 -0
- package/src/client/builtins/tabs.tsx +331 -0
- package/src/client/builtins/viewers.tsx +125 -0
- package/src/client/chunk-loader.ts +351 -0
- package/src/client/chunks/editor.tsx +9 -0
- package/src/client/chunks/mermaid.tsx +10 -0
- package/src/client/chunks/terminal.tsx +9 -0
- package/src/client/cm-themes.ts +129 -0
- package/src/client/conversation-draft.ts +29 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/desktop-env.ts +72 -0
- package/src/client/editable-state.ts +70 -0
- package/src/client/editor-load.ts +92 -0
- package/src/client/frame-batcher.ts +56 -0
- package/src/client/icons.tsx +161 -0
- package/src/client/image-types.ts +8 -0
- package/src/client/ime-guard.ts +47 -0
- package/src/client/index.tsx +369 -0
- package/src/client/intercept.tsx +110 -0
- package/src/client/lang.ts +103 -0
- package/src/client/layout.css +94 -0
- package/src/client/lazy-chunk.tsx +89 -0
- package/src/client/link-intercept.ts +73 -0
- package/src/client/locales.ts +734 -0
- package/src/client/mermaid-blocks.ts +110 -0
- package/src/client/mermaid-sanitize.ts +93 -0
- package/src/client/mermaid.tsx +401 -0
- package/src/client/open-when-sized.ts +43 -0
- package/src/client/open-with-settings.tsx +135 -0
- package/src/client/open-with.ts +221 -0
- package/src/client/openpath-intercept.ts +62 -0
- package/src/client/paths.ts +47 -0
- package/src/client/pdf-types.ts +4 -0
- package/src/client/plugin-settings.ts +43 -0
- package/src/client/plugins-shared.ts +29 -0
- package/src/client/plugins-tabs.ts +87 -0
- package/src/client/plugins-viewers.ts +30 -0
- package/src/client/prefs.ts +207 -0
- package/src/client/produced-files.ts +91 -0
- package/src/client/selection-payload.ts +86 -0
- package/src/client/service.ts +838 -0
- package/src/client/settings-nav-icon.ts +45 -0
- package/src/client/shell-presets.ts +83 -0
- package/src/client/sidebar.module.css +2577 -0
- package/src/client/sidechat-transcript.ts +321 -0
- package/src/client/split-pane.tsx +322 -0
- package/src/client/state.ts +1175 -0
- package/src/client/subagent-detect.ts +137 -0
- package/src/client/subagent-jobs.ts +161 -0
- package/src/client/tab-content-memo.ts +46 -0
- package/src/client/terminal-font.ts +28 -0
- package/src/client/theme.ts +110 -0
- package/src/client/titlebar-strip.ts +37 -0
- package/src/client/upload.ts +187 -0
- package/src/client/wco.ts +120 -0
- package/src/config.ts +155 -0
- package/src/context-types.ts +610 -0
- package/src/fs-operations.ts +99 -0
- package/src/fs-search.ts +86 -0
- package/src/fs-tree.ts +158 -0
- package/src/git.ts +244 -0
- package/src/html-route.ts +106 -0
- package/src/index.ts +1132 -0
- package/src/invariant.ts +32 -0
- package/src/jobs-routes.ts +257 -0
- package/src/open-external.ts +90 -0
- package/src/prefs-shared.ts +268 -0
- package/src/pty-deps.ts +240 -0
- package/src/pty-manager.ts +376 -0
- package/src/sidechat-core.ts +468 -0
- package/src/sidechat-routes.ts +344 -0
- package/src/subagent-activity.ts +91 -0
- package/src/subagent-live-route.ts +93 -0
- package/src/tools.ts +479 -0
- package/src/trust-fence.ts +77 -0
- package/src/wire.ts +100 -0
package/src/agent-pty.ts
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-owned terminal registry: a uuid-keyed table of long-lived PTY
|
|
3
|
+
* sessions created by the model through the `terminal_create` tool. Each
|
|
4
|
+
* handle survives across tool calls (and across WebSocket disconnects from
|
|
5
|
+
* the sidebar view) until the model calls `terminal_close` or the user
|
|
6
|
+
* closes the corresponding sidebar tab — tmux semantics, scoped per agent
|
|
7
|
+
* session.
|
|
8
|
+
*
|
|
9
|
+
* This is a parallel registry to {@link PtyManager}: UI tabs are keyed by
|
|
10
|
+
* `${sessionId}:${tabId}` and capped per session, while agent terminals are
|
|
11
|
+
* keyed by uuid and uncapped (the model is trusted to close unused ones).
|
|
12
|
+
* Both registries share the same shell resolver and spawn-helper fix.
|
|
13
|
+
*/
|
|
14
|
+
import { randomUUID } from 'node:crypto'
|
|
15
|
+
import type { IPty } from 'node-pty'
|
|
16
|
+
import { ensureSpawnHelper, shellSpawnArgs } from './pty-manager.ts'
|
|
17
|
+
import { loadRequiredNodePty, type NodePtyModule } from './pty-deps.ts'
|
|
18
|
+
import { SidebarError } from './wire.ts'
|
|
19
|
+
|
|
20
|
+
/** Per-agent-terminal transcript bound (bytes kept for replay and reads). */
|
|
21
|
+
const TRANSCRIPT_LIMIT = 1 << 20
|
|
22
|
+
|
|
23
|
+
/** POSIX signals the registry forwards to a live pty. */
|
|
24
|
+
export const ALLOWED_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGHUP', 'SIGTSTP'] as const
|
|
25
|
+
/** Signal name accepted by `signal()`. */
|
|
26
|
+
export type AgentTerminalSignal = (typeof ALLOWED_SIGNALS)[number]
|
|
27
|
+
|
|
28
|
+
/** Default read page size (lines) when the caller omits `count`. */
|
|
29
|
+
export const DEFAULT_READ_COUNT = 500
|
|
30
|
+
|
|
31
|
+
/** Smallest pty dimension the registry accepts (mirrors the tool contract). */
|
|
32
|
+
export const TERMINAL_DIM_MIN = 2
|
|
33
|
+
/** Largest pty dimension the registry accepts (mirrors the tool contract). */
|
|
34
|
+
export const TERMINAL_DIM_MAX = 1024
|
|
35
|
+
|
|
36
|
+
/** Clamp one cols×rows pair into the supported pty range (flooring decimals). */
|
|
37
|
+
export function clampDims(cols: number, rows: number): { cols: number; rows: number } {
|
|
38
|
+
const clamp = (value: number): number =>
|
|
39
|
+
Math.min(TERMINAL_DIM_MAX, Math.max(TERMINAL_DIM_MIN, Math.floor(value)))
|
|
40
|
+
return { cols: clamp(cols), rows: clamp(rows) }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Serializable snapshot of one agent terminal — the shape the model sees
|
|
45
|
+
* through `terminal_list` and the sidebar sees through the push endpoint.
|
|
46
|
+
* Carries no pty reference and no transcript (those are reached through
|
|
47
|
+
* dedicated read/attach paths), and no sessionId: ownership is registry
|
|
48
|
+
* internals, scoped by the caller (list filters by session, the push
|
|
49
|
+
* endpoint scopes by its query param), never part of the serialized view.
|
|
50
|
+
*/
|
|
51
|
+
export interface AgentTerminalSnapshot {
|
|
52
|
+
/** Stable opaque handle the model passes back to other terminal_* tools. */
|
|
53
|
+
uuid: string
|
|
54
|
+
/** Display title the model chose at create time. */
|
|
55
|
+
title: string
|
|
56
|
+
/** The command the model asked to run at create time (verbatim). */
|
|
57
|
+
command: string
|
|
58
|
+
/** Whether the top-level process has exited. */
|
|
59
|
+
exited: boolean
|
|
60
|
+
/** Exit code if exited normally; absent until the process exits. */
|
|
61
|
+
exitCode?: number | null
|
|
62
|
+
/** Exit signal name if the process was killed by a signal; null otherwise. */
|
|
63
|
+
exitSignal?: string | null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Map a POSIX signal number to its conventional name (best-effort). */
|
|
67
|
+
const SIGNAL_NAMES: Record<number, string> = {
|
|
68
|
+
1: 'SIGHUP', 2: 'SIGINT', 3: 'SIGQUIT', 4: 'SIGILL', 6: 'SIGABRT',
|
|
69
|
+
9: 'SIGKILL', 11: 'SIGSEGV', 13: 'SIGPIPE', 14: 'SIGALRM', 15: 'SIGTERM',
|
|
70
|
+
17: 'SIGCHLD', 18: 'SIGCONT', 19: 'SIGSTOP', 20: 'SIGTSTP',
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Convert a raw signal number to a name (or null when absent/unknown). */
|
|
74
|
+
function signalNameOf(signal: number | null | undefined): string | null {
|
|
75
|
+
if (signal === null || signal === undefined) return null
|
|
76
|
+
return SIGNAL_NAMES[signal] ?? `signal ${signal}`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Locate the first occurrence of `needle` in `transcript`, returning its line/column. */
|
|
80
|
+
function locateNeedle(transcript: string, needle: string): { line: number; column: number } | undefined {
|
|
81
|
+
if (needle === '') return undefined
|
|
82
|
+
const idx = transcript.indexOf(needle)
|
|
83
|
+
if (idx === -1) return undefined
|
|
84
|
+
// Walk the transcript up to the match index, counting newlines to derive
|
|
85
|
+
// the 0-based line; the column is the offset within that line.
|
|
86
|
+
let line = 0
|
|
87
|
+
let lineStart = 0
|
|
88
|
+
for (let i = 0; i < idx; i += 1) {
|
|
89
|
+
if (transcript.charCodeAt(i) === 0x0a /* \n */) {
|
|
90
|
+
line += 1
|
|
91
|
+
lineStart = i + 1
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { line, column: idx - lineStart }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** One live agent terminal. */
|
|
98
|
+
export interface AgentTerminalHandle {
|
|
99
|
+
/** Stable opaque handle. */
|
|
100
|
+
uuid: string
|
|
101
|
+
/** Owning conversation id. */
|
|
102
|
+
sessionId: string
|
|
103
|
+
/** Display title. */
|
|
104
|
+
title: string
|
|
105
|
+
/** The command written to stdin right after spawn. */
|
|
106
|
+
command: string
|
|
107
|
+
/** The working directory the process was spawned with. */
|
|
108
|
+
cwd: string
|
|
109
|
+
/** The live pty process. */
|
|
110
|
+
pty: IPty
|
|
111
|
+
/** Output accumulated since spawn (bounded; head dropped when over the limit). */
|
|
112
|
+
transcript: string
|
|
113
|
+
/** Whether the top-level process exited (transcript stays replayable). */
|
|
114
|
+
exited: boolean
|
|
115
|
+
/** Exit code once known. */
|
|
116
|
+
exitCode?: number | null
|
|
117
|
+
/** Exit signal number once known (POSIX only; undefined on Windows). */
|
|
118
|
+
exitSignal?: number | null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Read result shape (mirrors the official tool-pty terminal_read contract). */
|
|
122
|
+
export interface AgentTerminalReadResult {
|
|
123
|
+
/** The slice of transcript text for the requested page. */
|
|
124
|
+
text: string
|
|
125
|
+
/** Total lines in the retained transcript (the page may be a subset). */
|
|
126
|
+
totalLines: number
|
|
127
|
+
/** 0-based index of the first line in `text` (inclusive). */
|
|
128
|
+
lineBegin: number
|
|
129
|
+
/** 0-based index of the last line in `text` (exclusive). */
|
|
130
|
+
lineEnd: number
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Outcome of {@link AgentPtyRegistry.waitFor}. */
|
|
134
|
+
export type AgentTerminalWaitResult =
|
|
135
|
+
| {
|
|
136
|
+
/** The needle was found in the transcript. */
|
|
137
|
+
kind: 'found'
|
|
138
|
+
/** The matched substring. */
|
|
139
|
+
needle: string
|
|
140
|
+
/** 0-based line index (in the retained transcript) where the needle first appeared. */
|
|
141
|
+
line: number
|
|
142
|
+
/** 0-based column index within that line where the match starts. */
|
|
143
|
+
column: number
|
|
144
|
+
/** Elapsed wall-clock milliseconds from the wait start to the match. */
|
|
145
|
+
elapsedMs: number
|
|
146
|
+
}
|
|
147
|
+
| {
|
|
148
|
+
/** The needle did not appear before the timeout. */
|
|
149
|
+
kind: 'timeout'
|
|
150
|
+
/** The needle that was awaited. */
|
|
151
|
+
needle: string
|
|
152
|
+
/** The configured timeout in milliseconds. */
|
|
153
|
+
timeoutMs: number
|
|
154
|
+
/** Total lines retained when the timeout fired (call terminal_read to inspect). */
|
|
155
|
+
totalLines: number
|
|
156
|
+
}
|
|
157
|
+
| {
|
|
158
|
+
/** The terminal exited before the needle appeared. */
|
|
159
|
+
kind: 'exited'
|
|
160
|
+
/** The needle that was awaited. */
|
|
161
|
+
needle: string
|
|
162
|
+
/** The exit code, if known. */
|
|
163
|
+
exitCode?: number | null
|
|
164
|
+
/** The exit signal name, if the process was killed by a signal. */
|
|
165
|
+
exitSignal?: string | null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Snapshot projection of a handle (drops the pty reference and transcript). */
|
|
169
|
+
export function snapshotOf(handle: AgentTerminalHandle): AgentTerminalSnapshot {
|
|
170
|
+
const out: AgentTerminalSnapshot = {
|
|
171
|
+
uuid: handle.uuid,
|
|
172
|
+
title: handle.title,
|
|
173
|
+
command: handle.command,
|
|
174
|
+
exited: handle.exited,
|
|
175
|
+
}
|
|
176
|
+
if (handle.exited) {
|
|
177
|
+
out.exitCode = handle.exitCode ?? null
|
|
178
|
+
out.exitSignal = signalNameOf(handle.exitSignal)
|
|
179
|
+
}
|
|
180
|
+
return out
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The agent terminal registry. The constructor takes the resolved shell
|
|
185
|
+
* binary (the same `defaultShell()` the UI-tab registry uses) and runs the
|
|
186
|
+
* spawn-helper chmod fix once at construction so the first agent terminal
|
|
187
|
+
* does not race a lazy fixer.
|
|
188
|
+
*/
|
|
189
|
+
export class AgentPtyRegistry {
|
|
190
|
+
private readonly sessions = new Map<string, AgentTerminalHandle>()
|
|
191
|
+
private readonly changeListeners = new Set<() => void>()
|
|
192
|
+
|
|
193
|
+
constructor(
|
|
194
|
+
private readonly shell: string,
|
|
195
|
+
private readonly shellArgs: string[] = [],
|
|
196
|
+
/** The loaded node-pty module (injected so a broken install degrades instead of crashing the plugin). */
|
|
197
|
+
private readonly nodePty: NodePtyModule = loadRequiredNodePty(),
|
|
198
|
+
) {
|
|
199
|
+
ensureSpawnHelper()
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Spawn one agent terminal: start the shell in `cwd`, then write
|
|
204
|
+
* `command + '\n'` to stdin so the command runs in the fresh shell. The
|
|
205
|
+
* terminal stays alive after the command exits — the model can send more
|
|
206
|
+
* input through `terminal_send` until it calls `terminal_close` or the
|
|
207
|
+
* user closes the sidebar tab. An empty `command` spawns a bare shell.
|
|
208
|
+
* @returns the new handle's uuid (the model-facing opaque id).
|
|
209
|
+
*/
|
|
210
|
+
create(
|
|
211
|
+
sessionId: string,
|
|
212
|
+
title: string,
|
|
213
|
+
command: string,
|
|
214
|
+
cwd: string,
|
|
215
|
+
cols = 80,
|
|
216
|
+
rows = 24,
|
|
217
|
+
shell?: string,
|
|
218
|
+
shellArgs?: string[],
|
|
219
|
+
): string {
|
|
220
|
+
const uuid = randomUUID()
|
|
221
|
+
const dims = clampDims(cols, rows)
|
|
222
|
+
const pty = this.nodePty.spawn(shell ?? this.shell, shellSpawnArgs(shellArgs ?? this.shellArgs), {
|
|
223
|
+
name: 'xterm-256color',
|
|
224
|
+
cols: dims.cols,
|
|
225
|
+
rows: dims.rows,
|
|
226
|
+
cwd,
|
|
227
|
+
env: { ...process.env },
|
|
228
|
+
})
|
|
229
|
+
const handle: AgentTerminalHandle = {
|
|
230
|
+
uuid,
|
|
231
|
+
sessionId,
|
|
232
|
+
title,
|
|
233
|
+
command,
|
|
234
|
+
cwd,
|
|
235
|
+
pty,
|
|
236
|
+
transcript: '',
|
|
237
|
+
exited: false,
|
|
238
|
+
}
|
|
239
|
+
pty.onData((data) => {
|
|
240
|
+
handle.transcript += data
|
|
241
|
+
if (handle.transcript.length > TRANSCRIPT_LIMIT) {
|
|
242
|
+
handle.transcript = handle.transcript.slice(handle.transcript.length - TRANSCRIPT_LIMIT)
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
pty.onExit(({ exitCode, signal }) => {
|
|
246
|
+
handle.exited = true
|
|
247
|
+
handle.exitCode = exitCode
|
|
248
|
+
handle.exitSignal = signal
|
|
249
|
+
this.notify()
|
|
250
|
+
})
|
|
251
|
+
if (command !== '') {
|
|
252
|
+
// Write the command + Enter so it runs in the freshly spawned shell.
|
|
253
|
+
// Use \r (carriage return) — the actual character a terminal sends for
|
|
254
|
+
// the Enter key — not \n (line feed). PowerShell treats a bare \n as a
|
|
255
|
+
// soft line break (continuation prompt ">>") rather than a command
|
|
256
|
+
// submit; \r is the cross-shell Enter semantics on both POSIX and Windows.
|
|
257
|
+
try {
|
|
258
|
+
pty.write(`${command}\r`)
|
|
259
|
+
} catch {
|
|
260
|
+
// A spawn that failed between onData and onExit surfaces its own
|
|
261
|
+
// exit; the create call still returns the uuid so the model can
|
|
262
|
+
// read the transcript and see the failure.
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
this.sessions.set(uuid, handle)
|
|
266
|
+
this.notify()
|
|
267
|
+
return uuid
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** All live agent terminals belonging to one conversation. */
|
|
271
|
+
list(sessionId: string): AgentTerminalSnapshot[] {
|
|
272
|
+
const out: AgentTerminalSnapshot[] = []
|
|
273
|
+
for (const handle of this.sessions.values()) {
|
|
274
|
+
if (handle.sessionId === sessionId) out.push(snapshotOf(handle))
|
|
275
|
+
}
|
|
276
|
+
return out
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Resolve a live handle by uuid, or throw `not-found`. */
|
|
280
|
+
private expect(uuid: string): AgentTerminalHandle {
|
|
281
|
+
const handle = this.sessions.get(uuid)
|
|
282
|
+
if (handle === undefined) {
|
|
283
|
+
throw new SidebarError('not-found', `agent terminal "${uuid}" not found`, 404)
|
|
284
|
+
}
|
|
285
|
+
return handle
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Resolve a live handle that belongs to `sessionId`, or throw `not-found`.
|
|
290
|
+
* The model-facing tools call this before every uuid-keyed operation: a
|
|
291
|
+
* uuid from another session is indistinguishable from an unknown one, so a
|
|
292
|
+
* model can never reach (or probe) a terminal it does not own.
|
|
293
|
+
*/
|
|
294
|
+
assertOwned(uuid: string, sessionId: string): AgentTerminalHandle {
|
|
295
|
+
const handle = this.expect(uuid)
|
|
296
|
+
if (handle.sessionId !== sessionId) {
|
|
297
|
+
throw new SidebarError('not-found', `agent terminal "${uuid}" not found`, 404)
|
|
298
|
+
}
|
|
299
|
+
return handle
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Resolve a handle's snapshot, or undefined if it does not exist. */
|
|
303
|
+
snapshot(uuid: string): AgentTerminalSnapshot | undefined {
|
|
304
|
+
const handle = this.sessions.get(uuid)
|
|
305
|
+
return handle === undefined ? undefined : snapshotOf(handle)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Write raw text to a terminal's stdin (tmux `send-keys` semantics). */
|
|
309
|
+
send(uuid: string, text: string): void {
|
|
310
|
+
const handle = this.expect(uuid)
|
|
311
|
+
if (handle.exited) {
|
|
312
|
+
throw new SidebarError('bad-request', `agent terminal "${uuid}" has exited`, 400)
|
|
313
|
+
}
|
|
314
|
+
handle.pty.write(text)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Read one bounded page of the retained transcript. `offset` is a 0-based
|
|
319
|
+
* line index from the start of the retained transcript (default 0);
|
|
320
|
+
* `count` caps the page size (default 500). A negative `offset` reads
|
|
321
|
+
* from the end (e.g. -50 reads the last 50 lines). Returns `totalLines`
|
|
322
|
+
* so the model can paginate.
|
|
323
|
+
*/
|
|
324
|
+
read(uuid: string, offset?: number, count?: number): AgentTerminalReadResult {
|
|
325
|
+
const handle = this.expect(uuid)
|
|
326
|
+
const lines = handle.transcript.split('\n')
|
|
327
|
+
const totalLines = lines.length
|
|
328
|
+
const pageSize = Math.max(1, Math.min(count ?? DEFAULT_READ_COUNT, DEFAULT_READ_COUNT))
|
|
329
|
+
let start: number
|
|
330
|
+
if (offset === undefined || offset === 0) {
|
|
331
|
+
start = 0
|
|
332
|
+
} else if (offset < 0) {
|
|
333
|
+
// Negative offset: read from the end (e.g. -50 → last 50 lines).
|
|
334
|
+
start = Math.max(0, totalLines + offset)
|
|
335
|
+
} else {
|
|
336
|
+
start = Math.min(offset, totalLines)
|
|
337
|
+
}
|
|
338
|
+
const end = Math.min(start + pageSize, totalLines)
|
|
339
|
+
const slice = lines.slice(start, end).join('\n')
|
|
340
|
+
return {
|
|
341
|
+
text: slice,
|
|
342
|
+
totalLines,
|
|
343
|
+
lineBegin: start,
|
|
344
|
+
lineEnd: end,
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Resize a terminal's pty, clamped to the 2..1024 sane range.
|
|
350
|
+
* @returns the dimensions actually applied (the caller echoes these, so the
|
|
351
|
+
* reported value always matches the pty).
|
|
352
|
+
*/
|
|
353
|
+
resize(uuid: string, cols: number, rows: number): { cols: number; rows: number } {
|
|
354
|
+
const handle = this.expect(uuid)
|
|
355
|
+
const dims = clampDims(cols, rows)
|
|
356
|
+
if (!handle.exited) handle.pty.resize(dims.cols, dims.rows)
|
|
357
|
+
return dims
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Wait for `needle` to appear in a terminal's transcript, or for the
|
|
362
|
+
* terminal to exit, or for the timeout to elapse — whichever happens
|
|
363
|
+
* first. The wait polls the live transcript every ~50ms and short-circuits
|
|
364
|
+
* on `signal` abort (re-thrown as the abort reason so the tool layer
|
|
365
|
+
* surfaces cancellation).
|
|
366
|
+
*
|
|
367
|
+
* The match scans the FULL retained transcript on each poll, not just the
|
|
368
|
+
* delta since the last poll — a needle that scrolled past the most recent
|
|
369
|
+
* chunk but is still within the ~1 MiB bound is still a match. The
|
|
370
|
+
* returned line/column locate the FIRST occurrence (oldest), which is what
|
|
371
|
+
* a user watching the terminal would have seen first.
|
|
372
|
+
*
|
|
373
|
+
* The implementation uses polling (not pty onData subscription) because
|
|
374
|
+
* node-pty's onData fires before the registry's own onData listener
|
|
375
|
+
* updates the transcript (listener order is not guaranteed), and on
|
|
376
|
+
* Windows ConPTY output can arrive in bursts with batching delays that
|
|
377
|
+
* make event-driven wakeups unreliable. A 50ms poll is fast enough for
|
|
378
|
+
* interactive use and simple enough to be obviously correct.
|
|
379
|
+
* @param uuid - terminal to watch.
|
|
380
|
+
* @param needle - substring to search for (case-sensitive, verbatim).
|
|
381
|
+
* @param timeoutMs - max wait; default 10000 (10s). Clamped to ≥100ms.
|
|
382
|
+
* @param signal - caller-owned cancellation; aborts the wait re-throwing.
|
|
383
|
+
* @returns one of `found` / `timeout` / `exited`.
|
|
384
|
+
*/
|
|
385
|
+
async waitFor(
|
|
386
|
+
uuid: string,
|
|
387
|
+
needle: string,
|
|
388
|
+
timeoutMs = 10_000,
|
|
389
|
+
signal?: AbortSignal,
|
|
390
|
+
): Promise<AgentTerminalWaitResult> {
|
|
391
|
+
if (needle === '') {
|
|
392
|
+
throw new SidebarError('bad-request', 'needle must be a non-empty string', 400)
|
|
393
|
+
}
|
|
394
|
+
const handle = this.expect(uuid)
|
|
395
|
+
const timeout = Math.max(100, Math.floor(timeoutMs))
|
|
396
|
+
const start = Date.now()
|
|
397
|
+
const deadline = start + timeout
|
|
398
|
+
// Fast path: already exited, or the needle is already in the transcript
|
|
399
|
+
// (a `terminal_send` may have produced the expected output before this
|
|
400
|
+
// call even started).
|
|
401
|
+
if (handle.exited) {
|
|
402
|
+
return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
|
|
403
|
+
}
|
|
404
|
+
const firstHit = locateNeedle(handle.transcript, needle)
|
|
405
|
+
if (firstHit !== undefined) {
|
|
406
|
+
return { kind: 'found', needle, line: firstHit.line, column: firstHit.column, elapsedMs: Date.now() - start }
|
|
407
|
+
}
|
|
408
|
+
// Poll loop: check the transcript every 50ms, exit on match / exit /
|
|
409
|
+
// abort / timeout. The handle is read live each iteration (its transcript
|
|
410
|
+
// and exited fields mutate as the pty produces output).
|
|
411
|
+
while (true) {
|
|
412
|
+
if (signal?.aborted) signal.throwIfAborted()
|
|
413
|
+
if (handle.exited) {
|
|
414
|
+
return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
|
|
415
|
+
}
|
|
416
|
+
const hit = locateNeedle(handle.transcript, needle)
|
|
417
|
+
if (hit !== undefined) {
|
|
418
|
+
return { kind: 'found', needle, line: hit.line, column: hit.column, elapsedMs: Date.now() - start }
|
|
419
|
+
}
|
|
420
|
+
if (Date.now() >= deadline) {
|
|
421
|
+
return { kind: 'timeout', needle, timeoutMs: timeout, totalLines: handle.transcript.split('\n').length }
|
|
422
|
+
}
|
|
423
|
+
await new Promise(resolve => {
|
|
424
|
+
const t = setTimeout(resolve, 50)
|
|
425
|
+
// Allow the Node process to exit even if the timer is pending.
|
|
426
|
+
if (typeof t === 'object' && 'unref' in t) (t as { unref: () => void }).unref()
|
|
427
|
+
})
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Send a POSIX signal to a terminal's foreground process.
|
|
433
|
+
*
|
|
434
|
+
* Two delivery paths, by signal kind:
|
|
435
|
+
* - **Interactive control signals** (SIGINT, SIGTSTP) are delivered by
|
|
436
|
+
* writing the corresponding control character to the pty stdin. This is
|
|
437
|
+
* how a real terminal sends Ctrl+C / Ctrl+Z: the byte hits the kernel
|
|
438
|
+
* line discipline (POSIX ISIG mode) or the ConPTY input pipeline
|
|
439
|
+
* (Windows), which translates it into a SIGINT/SIGTSTP for the
|
|
440
|
+
* foreground process group. This works on every platform — calling
|
|
441
|
+
* `node-pty.kill('SIGINT')` throws on Windows and is fragile on POSIX,
|
|
442
|
+
* but writing `\x03` is universally correct.
|
|
443
|
+
* - **Termination signals** (SIGKILL, SIGTERM, SIGHUP) use `pty.kill()`,
|
|
444
|
+
* which maps to the platform's process-termination path (POSIX
|
|
445
|
+
* `kill(2)`, Windows `TerminateProcess`). These cannot be faked with
|
|
446
|
+
* control characters.
|
|
447
|
+
*/
|
|
448
|
+
signal(uuid: string, signal: AgentTerminalSignal): void {
|
|
449
|
+
const handle = this.expect(uuid)
|
|
450
|
+
if (handle.exited) return
|
|
451
|
+
// Ctrl+C → 0x03 (ETX), Ctrl+Z → 0x1A (SUB). Writing these to the pty
|
|
452
|
+
// master is the cross-platform way to deliver the signal to the
|
|
453
|
+
// foreground process group through the terminal's input pipeline.
|
|
454
|
+
if (signal === 'SIGINT' || signal === 'SIGTSTP') {
|
|
455
|
+
const ctrlByte = signal === 'SIGINT' ? '\x03' : '\x1a'
|
|
456
|
+
try {
|
|
457
|
+
handle.pty.write(ctrlByte)
|
|
458
|
+
} catch {
|
|
459
|
+
// A pty that rejects writes is already tearing down; the next
|
|
460
|
+
// onExit will mark it exited. Not an error from the caller's view.
|
|
461
|
+
}
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
// SIGKILL / SIGTERM / SIGHUP: use the process-termination path.
|
|
465
|
+
try {
|
|
466
|
+
handle.pty.kill(signal)
|
|
467
|
+
} catch {
|
|
468
|
+
// node-pty on Windows rejects named signals other than the default;
|
|
469
|
+
// fall back to the default kill (TerminateProcess on Windows,
|
|
470
|
+
// SIGKILL-equivalent on POSIX) so the signal still takes effect.
|
|
471
|
+
try {
|
|
472
|
+
handle.pty.kill()
|
|
473
|
+
} catch {
|
|
474
|
+
// Already exited or gone; nothing left to kill.
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Close a terminal and drop its state. Idempotent: a second close of the
|
|
481
|
+
* same uuid is a no-op. Returns true iff a live handle was actually
|
|
482
|
+
* dropped.
|
|
483
|
+
*/
|
|
484
|
+
close(uuid: string): boolean {
|
|
485
|
+
const handle = this.sessions.get(uuid)
|
|
486
|
+
if (handle === undefined) return false
|
|
487
|
+
this.sessions.delete(uuid)
|
|
488
|
+
try {
|
|
489
|
+
handle.pty.kill()
|
|
490
|
+
} catch {
|
|
491
|
+
// Already exited or gone; nothing left to kill.
|
|
492
|
+
}
|
|
493
|
+
this.notify()
|
|
494
|
+
return true
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Resolve a live handle by uuid (for the WS attach path). */
|
|
498
|
+
get(uuid: string): AgentTerminalHandle | undefined {
|
|
499
|
+
return this.sessions.get(uuid)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Subscribe to registry changes (create / close / exit). The sidebar push
|
|
504
|
+
* endpoint uses this to forward snapshots to the connected view. Returns
|
|
505
|
+
* the unsubscribe function.
|
|
506
|
+
*/
|
|
507
|
+
subscribe(listener: () => void): () => void {
|
|
508
|
+
this.changeListeners.add(listener)
|
|
509
|
+
return () => { this.changeListeners.delete(listener) }
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Close every agent terminal (plugin teardown). */
|
|
513
|
+
disposeAll(): void {
|
|
514
|
+
for (const uuid of [...this.sessions.keys()]) this.close(uuid)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Fire every change listener (callers wrap in try/catch if needed). */
|
|
518
|
+
private notify(): void {
|
|
519
|
+
for (const listener of [...this.changeListeners]) {
|
|
520
|
+
try {
|
|
521
|
+
listener()
|
|
522
|
+
} catch {
|
|
523
|
+
// A listener throwing must not break the others or the registry.
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for the `browser.probe` route (sidebar browser): the host
|
|
3
|
+
* fetches the response HEADERS of a URL the user is browsing and the client
|
|
4
|
+
* decides whether the target site forbids being embedded (X-Frame-Options /
|
|
5
|
+
* CSP frame-ancestors are exactly the signals the browser enforces when it
|
|
6
|
+
* refuses an iframe load). Kept dependency-free so the parser is
|
|
7
|
+
* unit-testable.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Extract the `frame-ancestors` source list of a Content-Security-Policy
|
|
12
|
+
* header, or undefined when the directive is absent (or empty). The
|
|
13
|
+
* directive is the only one with a source list; sources are space-separated
|
|
14
|
+
* tokens (`'none'`, `'self'`, `*`, or origins).
|
|
15
|
+
*/
|
|
16
|
+
export function extractFrameAncestors(csp: string | null): string[] | undefined {
|
|
17
|
+
if (csp === null) return undefined
|
|
18
|
+
for (const directive of csp.split(';')) {
|
|
19
|
+
const parts = directive.trim().split(/\s+/)
|
|
20
|
+
if (parts[0] === 'frame-ancestors') {
|
|
21
|
+
const sources = parts.slice(1).filter(source => source !== '')
|
|
22
|
+
return sources.length === 0 ? undefined : sources
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return undefined
|
|
26
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy chunk route: serves the client bundle's chunk scripts
|
|
3
|
+
* (/sidebar/bundle/<name>.js). The official /plugins/<id>/client.js route
|
|
4
|
+
* cannot serve arbitrary file names, so the plugin serves its own split
|
|
5
|
+
* bundles (lib/client-<name>.js) here; the client injects the script on
|
|
6
|
+
* first use of the feature that needs it (see src/client/chunk-loader.ts).
|
|
7
|
+
*
|
|
8
|
+
* Caching contract: every response carries `cache-control: no-cache` plus an
|
|
9
|
+
* ETag (content hash, memoized per file by mtime/size) and honors
|
|
10
|
+
* If-None-Match — the browser revalidates each fetch, but a 304 avoids
|
|
11
|
+
* re-downloading multi-MB chunks that did not change (page refresh, HMR
|
|
12
|
+
* re-activation). Same browser-trust fence as every other /sidebar route;
|
|
13
|
+
* only allowlisted chunk names are servable (no path traversal).
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from 'node:crypto'
|
|
16
|
+
import { stat, readFile } from 'node:fs/promises'
|
|
17
|
+
import { dirname, join } from 'node:path'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
19
|
+
import type { Context, SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts'
|
|
20
|
+
|
|
21
|
+
/** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */
|
|
22
|
+
export const CHUNK_NAMES = ['terminal', 'editor', 'mermaid'] as const
|
|
23
|
+
export type ChunkName = (typeof CHUNK_NAMES)[number]
|
|
24
|
+
|
|
25
|
+
/** Directory of this host-half module (lib/ — the chunk scripts live next to it). */
|
|
26
|
+
const LIB_DIR = dirname(fileURLToPath(import.meta.url))
|
|
27
|
+
|
|
28
|
+
/** sha1 content hash shortened to 12 hex chars (same shape as the client-modules rev). */
|
|
29
|
+
function shortHash(input: string | Buffer): string {
|
|
30
|
+
return createHash('sha1').update(input).digest('hex').slice(0, 12)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface ChunkEtag {
|
|
34
|
+
mtimeMs: number
|
|
35
|
+
size: number
|
|
36
|
+
etag: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** ETag memo: recompute the content hash only when the file's stat changed. */
|
|
40
|
+
const etags = new Map<string, ChunkEtag>()
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The chunk file's ETag (quoted hash), or undefined when the file is
|
|
44
|
+
* missing. Hash is recomputed only when mtime/size changed (hashing a
|
|
45
|
+
* multi-MB chunk per request is wasteful).
|
|
46
|
+
*/
|
|
47
|
+
async function etagOf(name: ChunkName, chunkDir: string): Promise<string | undefined> {
|
|
48
|
+
const path = join(chunkDir, `client-${name}.js`)
|
|
49
|
+
const key = `${chunkDir}:${name}`
|
|
50
|
+
try {
|
|
51
|
+
const info = await stat(path)
|
|
52
|
+
const memo = etags.get(key)
|
|
53
|
+
if (memo !== undefined && memo.mtimeMs === info.mtimeMs && memo.size === info.size) {
|
|
54
|
+
return memo.etag
|
|
55
|
+
}
|
|
56
|
+
const etag = `"${shortHash(await readFile(path))}"`
|
|
57
|
+
etags.set(key, { mtimeMs: info.mtimeMs, size: info.size, etag })
|
|
58
|
+
return etag
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build the /sidebar/bundle route handler. `fence` is the shared browser-
|
|
66
|
+
* trust check every /sidebar route applies; `chunkDir` is the directory the
|
|
67
|
+
* chunk scripts live in (overridable for tests).
|
|
68
|
+
*/
|
|
69
|
+
export function createBundleRouteHandler(
|
|
70
|
+
fence: (req: SidebarHttpRequest) => boolean,
|
|
71
|
+
chunkDir: string = LIB_DIR,
|
|
72
|
+
): (req: SidebarHttpRequest, res: SidebarHttpResponse) => Promise<void> {
|
|
73
|
+
return async (req, res): Promise<void> => {
|
|
74
|
+
if (!fence(req)) {
|
|
75
|
+
res.writeHead(403)
|
|
76
|
+
res.end('forbidden')
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
80
|
+
res.writeHead(405)
|
|
81
|
+
res.end()
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
|
85
|
+
const match = /^\/sidebar\/bundle\/([a-z0-9-]+)\.js$/.exec(pathname)
|
|
86
|
+
const name = match?.[1] as ChunkName | undefined
|
|
87
|
+
if (name === undefined || !(CHUNK_NAMES as readonly string[]).includes(name)) {
|
|
88
|
+
res.writeHead(404)
|
|
89
|
+
res.end('not found')
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
const etag = await etagOf(name, chunkDir)
|
|
93
|
+
if (etag === undefined) {
|
|
94
|
+
// Registered name but unreadable (bundle not built yet): loud 404.
|
|
95
|
+
res.writeHead(404)
|
|
96
|
+
res.end('not found')
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
if (req.headers['if-none-match'] === etag) {
|
|
100
|
+
// Revalidation hit: unchanged chunk, no body — avoids re-downloading
|
|
101
|
+
// multi-MB scripts on page refresh / HMR re-activation.
|
|
102
|
+
res.writeHead(304, { 'cache-control': 'no-cache', etag })
|
|
103
|
+
res.end()
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const body = await readFile(join(chunkDir, `client-${name}.js`))
|
|
108
|
+
res.writeHead(200, {
|
|
109
|
+
'content-type': 'text/javascript; charset=utf-8',
|
|
110
|
+
'cache-control': 'no-cache',
|
|
111
|
+
etag,
|
|
112
|
+
})
|
|
113
|
+
res.end(body)
|
|
114
|
+
} catch {
|
|
115
|
+
// Read raced a delete/rebuild between the stat and the read.
|
|
116
|
+
res.writeHead(404)
|
|
117
|
+
res.end('not found')
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Register the /sidebar/bundle route (disposed with the fiber). */
|
|
123
|
+
export function registerBundleRoute(ctx: Context, fence: (req: SidebarHttpRequest) => boolean): () => void {
|
|
124
|
+
return ctx.webServer.register({
|
|
125
|
+
kind: 'prefix',
|
|
126
|
+
path: '/sidebar/bundle',
|
|
127
|
+
handler: createBundleRouteHandler(fence),
|
|
128
|
+
})
|
|
129
|
+
}
|