@stacksjs/defaults 0.75.74 → 0.75.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/ai/skills/stacks-actions/SKILL.md +1 -1
  2. package/ai/skills/stacks-dashboard/SKILL.md +42 -7
  3. package/ai/skills/stacks-payments/SKILL.md +11 -0
  4. package/app/Actions/Dashboard/DashboardHomeAction.test.ts +33 -1
  5. package/app/Actions/Dashboard/DashboardHomeAction.ts +63 -25
  6. package/app/Actions/Dashboard/Remote/RemoteCommandIndexAction.ts +3 -0
  7. package/app/Actions/Dashboard/Remote/RemoteTerminalCloseAction.ts +25 -0
  8. package/app/Actions/Dashboard/Remote/RemoteTerminalInputAction.ts +25 -0
  9. package/app/Actions/Dashboard/Remote/RemoteTerminalOpenAction.ts +39 -0
  10. package/app/Actions/Dashboard/Remote/RemoteTerminalResizeAction.ts +25 -0
  11. package/app/Actions/Dashboard/Remote/RemoteTerminalStreamAction.ts +36 -0
  12. package/app/Actions/Dashboard/Remote/remote-commands.ts +30 -1
  13. package/app/Actions/Dashboard/Remote/remote-routes.test.ts +4 -4
  14. package/app/Actions/Dashboard/Remote/remote-terminal.test.ts +463 -0
  15. package/app/Actions/Dashboard/Remote/remote-terminal.ts +399 -0
  16. package/app/Actions/Dashboard/Remote/ssh-runner.ts +82 -1
  17. package/app/Actions/Dashboard/Remote/terminal-sessions.ts +80 -0
  18. package/functions/dashboard-api.ts +13 -0
  19. package/functions/remote-terminal-element.ts +287 -0
  20. package/functions/remote.test.ts +52 -0
  21. package/functions/remote.ts +136 -0
  22. package/functions/xterm-styles.d.ts +9 -0
  23. package/ide/vscode/package.json +1 -1
  24. package/package.json +5 -3
  25. package/resources/components/Dashboard/Remote/RemoteConsole.stx +162 -0
  26. package/resources/functions/dashboard/sidebar.ts +3 -0
  27. package/routes/dashboard-api.ts +7 -0
  28. package/views/dashboard/remote/index.stx +8 -0
@@ -0,0 +1,399 @@
1
+ import type { UserModel } from '@stacksjs/orm'
2
+ import type { RemoteHost } from './remote-commands'
3
+ import { randomBytes } from 'node:crypto'
4
+ import { RemoteCommandError, resolveHost } from './remote-commands'
5
+
6
+ /**
7
+ * Interactive terminal sessions into configured hosts (stacksjs/stacks#960).
8
+ *
9
+ * The command runner beside this runs named operations. This is the shell, so
10
+ * every rule the runner has holds here and three more are added:
11
+ *
12
+ * - **A host must opt in** with `terminal: true`. Declaring a host for named
13
+ * commands does not make it a host anyone may open a shell on.
14
+ * - **Its own gate**, `open-remote-terminal`, receiving the host key. Without
15
+ * it defined, every session is refused - the same fail-closed default as
16
+ * `run-remote-command`, and a separate ability because a terminal is every
17
+ * command at once.
18
+ * - **A session belongs to the user who opened it.** Its id is 128 random
19
+ * bits, and another user naming it gets the same 404 as an id that never
20
+ * existed, so neither guessing nor a leaked id reaches someone else's shell.
21
+ *
22
+ * And a session does not outlive its purpose: it ends after `idleTimeoutMs`
23
+ * without input, after `maxDurationMs` in all, and `detachGraceMs` after its
24
+ * last viewer goes away - the remote shell is killed, never left running on
25
+ * the box with nobody attached. Every session is recorded when it opens, what
26
+ * was typed into it line by line, and when and why it closed.
27
+ */
28
+
29
+ /** A running terminal process, local PTY or test double. */
30
+ export interface TerminalProcess {
31
+ write: (data: string) => void
32
+ resize: (cols: number, rows: number) => void
33
+ kill: () => void
34
+ /** Resolves with the exit code once the process has ended, however it ended. */
35
+ exited: Promise<number>
36
+ }
37
+
38
+ /** Starts the session's process. Injected so the rules are testable without a network. */
39
+ export type TerminalSpawner = (
40
+ host: RemoteHost,
41
+ size: TerminalSize,
42
+ onOutput: (bytes: Uint8Array) => void,
43
+ ) => Promise<TerminalProcess> | TerminalProcess
44
+
45
+ export interface TerminalSize {
46
+ cols: number
47
+ rows: number
48
+ }
49
+
50
+ export type TerminalCloseReason = 'closed' | 'exited' | 'idle' | 'max-duration' | 'detached' | 'shutdown'
51
+
52
+ /** Where sessions are recorded. Injected for the same reason. */
53
+ export interface TerminalAuditSink {
54
+ opened: (entry: { user: string, hostKey: string, session: string, at: string }) => Promise<void> | void
55
+ /** One line of input as typed, or what was pending when the session ended. */
56
+ input: (entry: { user: string, hostKey: string, session: string, at: string, line: string }) => Promise<void> | void
57
+ closed: (entry: {
58
+ user: string
59
+ hostKey: string
60
+ session: string
61
+ at: string
62
+ reason: TerminalCloseReason
63
+ exitCode: number | null
64
+ durationMs: number
65
+ bytesIn: number
66
+ bytesOut: number
67
+ }) => Promise<void> | void
68
+ }
69
+
70
+ /** Whether `user` may open a terminal on the host with this key. */
71
+ export type TerminalAuthorizer = (user: UserModel | null, hostKey: string) => Promise<boolean> | boolean
72
+
73
+ /** What a viewer receives: output in order, then the end. */
74
+ export type TerminalEvent =
75
+ | { type: 'output', seq: number, data: Uint8Array }
76
+ | { type: 'exit', reason: TerminalCloseReason, exitCode: number | null }
77
+
78
+ export interface TerminalSessionOptions {
79
+ spawn: TerminalSpawner
80
+ audit: TerminalAuditSink
81
+ /** End a session after this long without input. Default 15 minutes. */
82
+ idleTimeoutMs?: number
83
+ /** End a session this long after it opened, whatever is happening. Default 2 hours. */
84
+ maxDurationMs?: number
85
+ /** End a session this long after its last viewer left. Default 30 seconds. */
86
+ detachGraceMs?: number
87
+ /** Output kept for a viewer that reconnects. Default 256 KiB. */
88
+ replayBytes?: number
89
+ /** Concurrent sessions one user may hold. Default 4. */
90
+ maxSessionsPerUser?: number
91
+ /** Record what is typed. Default true. */
92
+ recordInput?: boolean
93
+ }
94
+
95
+ /** The largest single input a request may carry: a paste, not a file upload. */
96
+ export const MAX_INPUT_CHARS = 64 * 1024
97
+
98
+ const DEFAULTS = {
99
+ idleTimeoutMs: 15 * 60_000,
100
+ maxDurationMs: 2 * 60 * 60_000,
101
+ detachGraceMs: 30_000,
102
+ replayBytes: 256 * 1024,
103
+ maxSessionsPerUser: 4,
104
+ recordInput: true,
105
+ }
106
+
107
+ interface Session {
108
+ id: string
109
+ owner: string
110
+ identity: string
111
+ host: RemoteHost
112
+ process: TerminalProcess
113
+ openedAt: number
114
+ seq: number
115
+ backlog: Array<{ seq: number, data: Uint8Array }>
116
+ backlogBytes: number
117
+ viewers: Set<(event: TerminalEvent) => void>
118
+ pendingInput: string
119
+ bytesIn: number
120
+ bytesOut: number
121
+ ended: boolean
122
+ idleTimer?: ReturnType<typeof setTimeout>
123
+ maxTimer?: ReturnType<typeof setTimeout>
124
+ detachTimer?: ReturnType<typeof setTimeout>
125
+ }
126
+
127
+ function ownerOf(user: UserModel | null): string {
128
+ if (!user)
129
+ throw new RemoteCommandError('Authentication is required.', 401)
130
+ const id = (user as { id?: unknown }).id
131
+ if (id === undefined || id === null)
132
+ throw new RemoteCommandError('Authentication is required.', 401)
133
+ return String(id)
134
+ }
135
+
136
+ /** Columns and rows a real terminal could have; anything else is refused. */
137
+ export function normalizeSize(cols: unknown, rows: unknown): TerminalSize {
138
+ const c = Number(cols)
139
+ const r = Number(rows)
140
+ if (!Number.isInteger(c) || !Number.isInteger(r) || c < 2 || r < 2 || c > 1000 || r > 500)
141
+ throw new RemoteCommandError('A terminal size is whole columns and rows, 2 to 1000 by 2 to 500.', 422)
142
+ return { cols: c, rows: r }
143
+ }
144
+
145
+ export class TerminalSessions {
146
+ private readonly sessions = new Map<string, Session>()
147
+ private readonly options: Required<TerminalSessionOptions>
148
+
149
+ constructor(options: TerminalSessionOptions) {
150
+ this.options = { ...DEFAULTS, ...options }
151
+ }
152
+
153
+ /** Sessions open now, for tests and shutdown. */
154
+ get size(): number {
155
+ return this.sessions.size
156
+ }
157
+
158
+ /**
159
+ * Resolve, authorize, record, spawn. The audit entry is written before the
160
+ * process starts, so a session that hangs on connect is still on record.
161
+ */
162
+ async open(input: {
163
+ user: UserModel | null
164
+ hosts: readonly RemoteHost[]
165
+ hostKey: unknown
166
+ authorizer?: TerminalAuthorizer
167
+ cols: unknown
168
+ rows: unknown
169
+ }): Promise<{ id: string, host: string }> {
170
+ const owner = ownerOf(input.user)
171
+ const host = resolveHost(input.hosts, input.hostKey)
172
+
173
+ if (!host.terminal)
174
+ throw new RemoteCommandError(`Host "${host.key}" does not allow terminal sessions; set \`terminal: true\` on it in config/remote.ts.`, 403)
175
+
176
+ // Fails CLOSED, as `run-remote-command` does.
177
+ if (!input.authorizer)
178
+ throw new RemoteCommandError('Terminal sessions are not authorized on this application.', 403)
179
+ if (!await input.authorizer(input.user, host.key))
180
+ throw new RemoteCommandError(`You are not permitted to open a terminal on "${host.key}".`, 403)
181
+
182
+ const size = normalizeSize(input.cols, input.rows)
183
+ const held = [...this.sessions.values()].filter(session => session.owner === owner).length
184
+ if (held >= this.options.maxSessionsPerUser)
185
+ throw new RemoteCommandError(`You already have ${held} terminal sessions open; close one first.`, 429)
186
+
187
+ const id = randomBytes(16).toString('hex')
188
+ const identity = String((input.user as { email?: unknown }).email ?? owner)
189
+ await this.options.audit.opened({ user: identity, hostKey: host.key, session: id, at: new Date().toISOString() })
190
+
191
+ const session = {
192
+ id,
193
+ owner,
194
+ identity,
195
+ host,
196
+ openedAt: Date.now(),
197
+ seq: 0,
198
+ backlog: [],
199
+ backlogBytes: 0,
200
+ viewers: new Set(),
201
+ pendingInput: '',
202
+ bytesIn: 0,
203
+ bytesOut: 0,
204
+ ended: false,
205
+ } as unknown as Session
206
+
207
+ let process: TerminalProcess
208
+ try {
209
+ process = await this.options.spawn(host, size, bytes => this.output(session, bytes))
210
+ }
211
+ catch (error) {
212
+ await this.options.audit.closed({
213
+ user: identity,
214
+ hostKey: host.key,
215
+ session: id,
216
+ at: new Date().toISOString(),
217
+ reason: 'exited',
218
+ exitCode: null,
219
+ durationMs: Date.now() - session.openedAt,
220
+ bytesIn: 0,
221
+ bytesOut: 0,
222
+ })
223
+ throw error
224
+ }
225
+
226
+ session.process = process
227
+ this.sessions.set(id, session)
228
+
229
+ session.maxTimer = setTimeout(() => void this.end(session, 'max-duration'), this.options.maxDurationMs)
230
+ this.touch(session)
231
+ // Nobody is watching yet: if no viewer attaches, the shell does not stay.
232
+ this.armDetach(session)
233
+
234
+ void process.exited.then(code => this.end(session, 'exited', code))
235
+
236
+ return { id, host: host.key }
237
+ }
238
+
239
+ /** Type into the session. */
240
+ async input(id: string, user: UserModel | null, data: unknown): Promise<void> {
241
+ const session = this.find(id, user)
242
+ if (typeof data !== 'string' || data.length === 0)
243
+ throw new RemoteCommandError('Input is a non-empty string.', 422)
244
+ if (data.length > MAX_INPUT_CHARS)
245
+ throw new RemoteCommandError(`Input is at most ${MAX_INPUT_CHARS} characters at a time.`, 413)
246
+
247
+ session.process.write(data)
248
+ session.bytesIn += Buffer.byteLength(data)
249
+ this.touch(session)
250
+ if (this.options.recordInput)
251
+ await this.recordInput(session, data)
252
+ }
253
+
254
+ resize(id: string, user: UserModel | null, cols: unknown, rows: unknown): void {
255
+ const session = this.find(id, user)
256
+ const size = normalizeSize(cols, rows)
257
+ session.process.resize(size.cols, size.rows)
258
+ }
259
+
260
+ async close(id: string, user: UserModel | null): Promise<void> {
261
+ await this.end(this.find(id, user), 'closed')
262
+ }
263
+
264
+ /** End every session, for a server shutting down. */
265
+ async closeAll(): Promise<void> {
266
+ await Promise.all([...this.sessions.values()].map(session => this.end(session, 'shutdown')))
267
+ }
268
+
269
+ /**
270
+ * Output from `afterSeq` on, then live output until the session ends or the
271
+ * viewer stops reading. A viewer that reconnects passes the last sequence it
272
+ * saw and misses nothing still in the replay buffer.
273
+ */
274
+ watch(id: string, user: UserModel | null, afterSeq = 0): AsyncIterable<TerminalEvent> {
275
+ const session = this.find(id, user)
276
+ const queue: TerminalEvent[] = session.backlog
277
+ .filter(chunk => chunk.seq > afterSeq)
278
+ .map(chunk => ({ type: 'output' as const, seq: chunk.seq, data: chunk.data }))
279
+ let wake: (() => void) | undefined
280
+ let done = false
281
+
282
+ const viewer = (event: TerminalEvent): void => {
283
+ queue.push(event)
284
+ wake?.()
285
+ }
286
+ session.viewers.add(viewer)
287
+ if (session.detachTimer) {
288
+ clearTimeout(session.detachTimer)
289
+ session.detachTimer = undefined
290
+ }
291
+
292
+ const detach = (): void => {
293
+ if (done)
294
+ return
295
+ done = true
296
+ session.viewers.delete(viewer)
297
+ this.armDetach(session)
298
+ }
299
+
300
+ return {
301
+ [Symbol.asyncIterator]: () => ({
302
+ next: async (): Promise<IteratorResult<TerminalEvent>> => {
303
+ while (queue.length === 0) {
304
+ if (done)
305
+ return { value: undefined, done: true }
306
+ await new Promise<void>((resolve) => { wake = resolve })
307
+ wake = undefined
308
+ }
309
+ const event = queue.shift()!
310
+ if (event.type === 'exit')
311
+ detach()
312
+ return { value: event, done: false }
313
+ },
314
+ return: async (): Promise<IteratorResult<TerminalEvent>> => {
315
+ detach()
316
+ wake?.()
317
+ return { value: undefined, done: true }
318
+ },
319
+ }),
320
+ }
321
+ }
322
+
323
+ /** The session with this id, if this user owns it. Anyone else gets the 404 a missing id gets. */
324
+ private find(id: unknown, user: UserModel | null): Session {
325
+ const owner = ownerOf(user)
326
+ const session = typeof id === 'string' ? this.sessions.get(id) : undefined
327
+ if (!session || session.owner !== owner || session.ended)
328
+ throw new RemoteCommandError('No such terminal session.', 404)
329
+ return session
330
+ }
331
+
332
+ private output(session: Session, data: Uint8Array): void {
333
+ if (session.ended)
334
+ return
335
+ const seq = ++session.seq
336
+ session.bytesOut += data.byteLength
337
+ session.backlog.push({ seq, data })
338
+ session.backlogBytes += data.byteLength
339
+ while (session.backlogBytes > this.options.replayBytes && session.backlog.length > 1)
340
+ session.backlogBytes -= session.backlog.shift()!.data.byteLength
341
+ for (const viewer of session.viewers)
342
+ viewer({ type: 'output', seq, data })
343
+ }
344
+
345
+ private touch(session: Session): void {
346
+ if (session.idleTimer)
347
+ clearTimeout(session.idleTimer)
348
+ session.idleTimer = setTimeout(() => void this.end(session, 'idle'), this.options.idleTimeoutMs)
349
+ }
350
+
351
+ private armDetach(session: Session): void {
352
+ if (session.ended || session.viewers.size > 0 || session.detachTimer)
353
+ return
354
+ session.detachTimer = setTimeout(() => void this.end(session, 'detached'), this.options.detachGraceMs)
355
+ }
356
+
357
+ /** Lines are recorded as they are completed; a partial line waits for its end. */
358
+ private async recordInput(session: Session, data: string): Promise<void> {
359
+ session.pendingInput += data
360
+ const lines = session.pendingInput.split(/\r\n|\r|\n/)
361
+ session.pendingInput = lines.pop() ?? ''
362
+ for (const line of lines)
363
+ await this.options.audit.input({ user: session.identity, hostKey: session.host.key, session: session.id, at: new Date().toISOString(), line })
364
+ }
365
+
366
+ private async end(session: Session, reason: TerminalCloseReason, exitCode: number | null = null): Promise<void> {
367
+ if (session.ended)
368
+ return
369
+ session.ended = true
370
+ this.sessions.delete(session.id)
371
+ for (const timer of [session.idleTimer, session.maxTimer, session.detachTimer]) {
372
+ if (timer)
373
+ clearTimeout(timer)
374
+ }
375
+
376
+ if (reason !== 'exited')
377
+ session.process.kill()
378
+
379
+ if (this.options.recordInput && session.pendingInput) {
380
+ await this.options.audit.input({ user: session.identity, hostKey: session.host.key, session: session.id, at: new Date().toISOString(), line: session.pendingInput })
381
+ session.pendingInput = ''
382
+ }
383
+
384
+ for (const viewer of session.viewers)
385
+ viewer({ type: 'exit', reason, exitCode })
386
+
387
+ await this.options.audit.closed({
388
+ user: session.identity,
389
+ hostKey: session.host.key,
390
+ session: session.id,
391
+ at: new Date().toISOString(),
392
+ reason,
393
+ exitCode,
394
+ durationMs: Date.now() - session.openedAt,
395
+ bytesIn: session.bytesIn,
396
+ bytesOut: session.bytesOut,
397
+ })
398
+ }
399
+ }
@@ -1,9 +1,10 @@
1
1
  import type { RemoteAuditSink, RemoteCommand, RemoteHost, RemoteRunner } from './remote-commands'
2
+ import type { TerminalAuditSink, TerminalSpawner } from './remote-terminal'
2
3
  import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
4
  import { tmpdir } from 'node:os'
4
5
  import { join } from 'node:path'
5
6
  import { log } from '@stacksjs/logging'
6
- import { sshArgv } from './remote-commands'
7
+ import { sshArgv, sshTerminalArgv } from './remote-commands'
7
8
 
8
9
  /**
9
10
  * The SSH transport and audit sink behind `runRemoteCommand`
@@ -92,3 +93,83 @@ export const loggingAuditSink: RemoteAuditSink = {
92
93
  await log.info(`[remote] ${entry.user} finished ${entry.commandKey} on ${entry.hostKey}: ${outcome} in ${entry.durationMs}ms`)
93
94
  },
94
95
  }
96
+
97
+ /**
98
+ * Terminal sessions as a local PTY running `ssh -tt` (stacksjs/stacks#960).
99
+ *
100
+ * The local PTY is what the command runner could not have: Bun's spawn gained
101
+ * one (`terminal`), and with it a window resize in the browser reaches the
102
+ * remote shell - `resize()` here changes the local PTY's size, ssh sees the
103
+ * SIGWINCH and forwards it. Without it the remote side stayed 80x24.
104
+ *
105
+ * The child gets a minimal environment rather than the app server's, which
106
+ * holds the application's secrets: ssh needs a PATH, a HOME for its own files,
107
+ * and a TERM to forward.
108
+ *
109
+ * `argvFor` is the seam a test uses to run a local command under the same PTY.
110
+ */
111
+ export function createPtySpawner(argvFor: (host: RemoteHost, knownHostsPath: string) => string[] = sshTerminalArgv): TerminalSpawner {
112
+ return (host, size, onOutput) => {
113
+ const dir = mkdtempSync(join(tmpdir(), 'stacks-terminal-'))
114
+ const knownHostsPath = join(dir, 'known_hosts')
115
+
116
+ try {
117
+ writeFileSync(knownHostsPath, host.knownHosts.endsWith('\n') ? host.knownHosts : `${host.knownHosts}\n`)
118
+ chmodSync(knownHostsPath, 0o600)
119
+
120
+ const proc = Bun.spawn(argvFor(host, knownHostsPath), {
121
+ env: {
122
+ PATH: process.env.PATH ?? '/usr/bin:/bin',
123
+ HOME: process.env.HOME ?? tmpdir(),
124
+ TERM: 'xterm-256color',
125
+ LANG: process.env.LANG ?? 'en_US.UTF-8',
126
+ },
127
+ terminal: {
128
+ cols: size.cols,
129
+ rows: size.rows,
130
+ // The buffer is the PTY's own and is reused; a viewer may read the
131
+ // chunk after the next one has arrived.
132
+ data: (_terminal, bytes) => onOutput(new Uint8Array(bytes)),
133
+ },
134
+ })
135
+ const terminal = proc.terminal!
136
+
137
+ return {
138
+ write: data => terminal.write(data),
139
+ resize: (cols, rows) => terminal.resize(cols, rows),
140
+ kill: () => proc.kill(),
141
+ exited: proc.exited.finally(() => {
142
+ terminal.close()
143
+ rmSync(dir, { force: true, recursive: true })
144
+ }),
145
+ }
146
+ }
147
+ catch (error) {
148
+ rmSync(dir, { force: true, recursive: true })
149
+ throw error
150
+ }
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Terminal sessions recorded in the application log, for the reasons the
156
+ * command runner's sink gives: append-only evidence belongs outside the
157
+ * database the dashboard can edit.
158
+ *
159
+ * Input is recorded as typed, line by line. That includes anything typed at a
160
+ * password prompt, which is one more reason the hosts reached this way should
161
+ * authenticate with keys and grant sudo without one - and why the log, not the
162
+ * database, holds it.
163
+ */
164
+ export const loggingTerminalAuditSink: TerminalAuditSink = {
165
+ async opened(entry) {
166
+ await log.info(`[terminal] ${entry.user} opened ${entry.session} on ${entry.hostKey} at ${entry.at}`)
167
+ },
168
+ async input(entry) {
169
+ await log.info(`[terminal] ${entry.user} ${entry.session} on ${entry.hostKey}: ${JSON.stringify(entry.line)}`)
170
+ },
171
+ async closed(entry) {
172
+ const code = entry.exitCode === null ? '' : `, exit ${entry.exitCode}`
173
+ await log.info(`[terminal] ${entry.user} closed ${entry.session} on ${entry.hostKey}: ${entry.reason}${code} after ${entry.durationMs}ms, ${entry.bytesIn} bytes in, ${entry.bytesOut} out`)
174
+ },
175
+ }
@@ -0,0 +1,80 @@
1
+ import type { TerminalAuthorizer, TerminalEvent } from './remote-terminal'
2
+ import { Gate } from '@stacksjs/auth'
3
+ import { TerminalSessions } from './remote-terminal'
4
+ import { createPtySpawner, loggingTerminalAuditSink } from './ssh-runner'
5
+
6
+ /**
7
+ * The server's terminal sessions (stacksjs/stacks#960), and how they reach the
8
+ * browser.
9
+ *
10
+ * Sessions live in this process. Production runs one server process, so the
11
+ * stream and the input for a session land on the same one; during the
12
+ * overlap of a zero-downtime deploy a request can reach the other release,
13
+ * which answers 404 and the dashboard reports the session as ended - closed,
14
+ * not crossed.
15
+ */
16
+ export const terminalSessions: TerminalSessions = new TerminalSessions({
17
+ spawn: createPtySpawner(),
18
+ audit: loggingTerminalAuditSink,
19
+ })
20
+
21
+ /**
22
+ * The `open-remote-terminal` gate, or undefined when the app has not defined
23
+ * it - and `TerminalSessions.open` refuses in that case rather than proceeding.
24
+ */
25
+ export function terminalAuthorizer(): TerminalAuthorizer | undefined {
26
+ return Gate.has('open-remote-terminal')
27
+ ? (user, hostKey) => Gate.allows('open-remote-terminal', user, hostKey)
28
+ : undefined
29
+ }
30
+
31
+ /** How often a quiet stream says it is still there, inside every proxy's idle limit. */
32
+ export const HEARTBEAT_MS = 15_000
33
+
34
+ /**
35
+ * One terminal event as server-sent events. Output is base64, because PTY
36
+ * output is bytes - a multi-byte character can be split across two chunks,
37
+ * and decoding each chunk as text on its own would corrupt it. The `id` is the
38
+ * sequence number a reconnecting viewer sends back.
39
+ */
40
+ export function encodeTerminalEvent(event: TerminalEvent): string {
41
+ if (event.type === 'output')
42
+ return `id: ${event.seq}\nevent: output\ndata: ${Buffer.from(event.data).toString('base64')}\n\n`
43
+ return `event: exit\ndata: ${JSON.stringify({ reason: event.reason, exitCode: event.exitCode })}\n\n`
44
+ }
45
+
46
+ /**
47
+ * A session's events as an SSE body, with a comment line every
48
+ * {@link HEARTBEAT_MS} so neither Bun's idle timeout nor a proxy closes a
49
+ * terminal nobody is typing in. Cancelling the body - the tab closing - stops
50
+ * watching, which starts the session's detach grace.
51
+ */
52
+ export function terminalEventStream(events: AsyncIterable<TerminalEvent>, heartbeatMs = HEARTBEAT_MS): ReadableStream<Uint8Array> {
53
+ const encoder = new TextEncoder()
54
+ const iterator = events[Symbol.asyncIterator]()
55
+ let heartbeat: ReturnType<typeof setInterval> | undefined
56
+
57
+ return new ReadableStream<Uint8Array>({
58
+ start(controller) {
59
+ controller.enqueue(encoder.encode(': connected\n\n'))
60
+ heartbeat = setInterval(() => controller.enqueue(encoder.encode(': ping\n\n')), heartbeatMs)
61
+ },
62
+ async pull(controller) {
63
+ const { value, done } = await iterator.next()
64
+ if (done) {
65
+ clearInterval(heartbeat)
66
+ controller.close()
67
+ return
68
+ }
69
+ controller.enqueue(encoder.encode(encodeTerminalEvent(value)))
70
+ if (value.type === 'exit') {
71
+ clearInterval(heartbeat)
72
+ controller.close()
73
+ }
74
+ },
75
+ async cancel() {
76
+ clearInterval(heartbeat)
77
+ await iterator.return?.()
78
+ },
79
+ })
80
+ }
@@ -110,6 +110,19 @@ export async function dashboardApi<T = unknown>(path: string, options: ApiReques
110
110
  return payload as T
111
111
  }
112
112
 
113
+ /**
114
+ * Open a guarded dashboard stream - server-sent events read through `fetch`,
115
+ * because `EventSource` cannot send the bearer token the dashboard
116
+ * authenticates with. Throws on a non-2xx response, as `dashboardApi` does;
117
+ * the caller reads `res.body`.
118
+ */
119
+ export async function dashboardStream(path: string, options: Pick<ApiRequestOptions, 'signal'> = {}): Promise<Response> {
120
+ const res = await dashboardResponse(path, options, 'text/event-stream')
121
+ if (!res.ok)
122
+ throw await dashboardResponseError(res)
123
+ return res
124
+ }
125
+
113
126
  /**
114
127
  * Download a guarded dashboard resource while preserving bearer and session
115
128
  * authentication. The object URL only exists for the duration of the browser