Public: anyone can read this; the crew can change it.
A small forge loads only what it uses: lazy-load the TUI, boards, studio and the like #12355
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/12349-a-small-forge-loads-only-what-it-uses-la → main 6 commits fork 3 threads · 3 unresolved
8 files
changed +226
−30
src/actions/tell_answer.ts added +24
| @@ -0,0 +1,24 @@ | ||
| 1 | /** | |
| 2 | * The words of one tell, refused or told, with the tone they are shown in | |
| 3 | * (shared by the terminal screen and the ssh `tell` command). | |
| 4 | */ | |
| 5 | ||
| 6 | import { toldWords, TOO_LONG_WORDS, UNVERIFIED_WORDS, type Told, type TellError } from "./tell.ts"; | |
| 7 | import { NOT_TOLD_WORDS } from "./tell_words.ts"; | |
| 8 | import { DIRECTION_NOTE } from "../crew/direct.ts"; | |
| 9 | import type { Result } from "../kernel/result.ts"; | |
| 10 | ||
| 11 | export function tellAnswer(told: Result<Told, TellError>): { text: string; tone: "success" | "danger" } { | |
| 12 | if (told.ok) return { tone: told.value.routed === undefined || told.value.assigned ? "success" : "danger", text: toldWords(told.value) }; | |
| 13 | return { | |
| 14 | tone: "danger", | |
| 15 | text: | |
| 16 | told.error === "forbidden" | |
| 17 | ? DIRECTION_NOTE | |
| 18 | : told.error === "unverified" | |
| 19 | ? UNVERIFIED_WORDS | |
| 20 | : told.error === "body_too_long" | |
| 21 | ? TOO_LONG_WORDS | |
| 22 | : NOT_TOLD_WORDS, | |
| 23 | }; | |
| 24 | } |
src/tui/screens/answers.ts +1 −1
| @@ -20,7 +20,7 @@ import { emptyState } from "../ui/widgets.ts"; | ||
| 20 | 20 | import { style } from "../term/style.ts"; |
| 21 | 21 | import { truncate } from "../term/width.ts"; |
| 22 | 22 | import { ListState, drawList, hitList, metaOf, openOnWeb, promptScreen, relTime, type ListRow } from "./common.ts"; |
| 23 | import { tellAnswer } from "./tell.ts"; | |
| 23 | import { tellAnswer } from "../../actions/tell_answer.ts"; | |
| 24 | 24 | |
| 25 | 25 | /** One line of plain text: a tell or an answer may carry escapes the terminal must not obey. */ |
| 26 | 26 | const plain = (text: string): string => |
src/tui/screens/tell.ts +4 −20
| @@ -8,33 +8,17 @@ | ||
| 8 | 8 | * the same `parseContext` and `contextNote` the pill's tell and the MCP use. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { SENT_WORDS, tell, toldWords, TOO_LONG_WORDS, UNVERIFIED_WORDS, type Told, type TellError } from "../../actions/tell.ts"; | |
| 11 | import { SENT_WORDS, tell } from "../../actions/tell.ts"; | |
| 12 | import { tellAnswer } from "../../actions/tell_answer.ts"; | |
| 12 | 13 | import { contextNote, parseContext } from "../../actions/tell_context.ts"; |
| 13 | import { NOT_TOLD_FROM_WORDS, NOT_TOLD_WORDS, SAY_SOMETHING_WORDS } from "../../actions/tell_words.ts"; | |
| 14 | import { NOT_TOLD_FROM_WORDS, SAY_SOMETHING_WORDS } from "../../actions/tell_words.ts"; | |
| 14 | 15 | import type { Repo } from "../../core/repos.ts"; |
| 15 | import { DIRECTION_NOTE, mayDirectCrew } from "../../crew/direct.ts"; | |
| 16 | import type { Result } from "../../kernel/result.ts"; | |
| 16 | import { mayDirectCrew } from "../../crew/direct.ts"; | |
| 17 | 17 | import type { Ctx, Screen } from "../app/app.ts"; |
| 18 | 18 | import type { Command } from "../app/palette.ts"; |
| 19 | 19 | import type { Session } from "../session.ts"; |
| 20 | 20 | import { later, promptScreen } from "./common.ts"; |
| 21 | 21 | |
| 22 | /** The words of one tell, refused or told, with the tone they are shown in. */ | |
| 23 | export function tellAnswer(told: Result<Told, TellError>): { text: string; tone: "success" | "danger" } { | |
| 24 | if (told.ok) return { tone: told.value.routed === undefined || told.value.assigned ? "success" : "danger", text: toldWords(told.value) }; | |
| 25 | return { | |
| 26 | tone: "danger", | |
| 27 | text: | |
| 28 | told.error === "forbidden" | |
| 29 | ? DIRECTION_NOTE | |
| 30 | : told.error === "unverified" | |
| 31 | ? UNVERIFIED_WORDS | |
| 32 | : told.error === "body_too_long" | |
| 33 | ? TOO_LONG_WORDS | |
| 34 | : NOT_TOLD_WORDS, | |
| 35 | }; | |
| 36 | } | |
| 37 | ||
| 38 | 22 | /** The palette row for `repo`, or none for a reader who may not direct the crew. */ |
| 39 | 23 | export function tellCommands(f: Session, ctx: Ctx, parent: () => Screen, repo: Repo | undefined): Command[] { |
| 40 | 24 | if (repo === undefined || !mayDirectCrew(f.db, repo, f.user)) return []; |
src/tui/ssh-tell.ts +1 −1
| @@ -19,7 +19,7 @@ import type { Repo } from "../core/repos.ts"; | ||
| 19 | 19 | import type { User } from "../core/users.ts"; |
| 20 | 20 | import type { EnvName } from "../apps/data.ts"; |
| 21 | 21 | import type { SshSession } from "../ssh/server.ts"; |
| 22 | import { tellAnswer } from "./screens/tell.ts"; | |
| 22 | import { tellAnswer } from "../actions/tell_answer.ts"; | |
| 23 | 23 | import { AUTONOMY_FAILED_WORDS, asksMakeANewApp, firstAsk } from "../actions/first_ask.ts"; |
| 24 | 24 | import { crewCanRun } from "../web/studio/crew_key.ts"; |
| 25 | 25 |
src/tui/ssh.ts +45 −7
| @@ -29,10 +29,10 @@ import { heldPushConfig, readerScope } from "../git/own_refs.ts"; | ||
| 29 | 29 | import { holdFor, lendersBeyond } from "../core/fork_reach.ts"; |
| 30 | 30 | import { serveSsh, type SshServer, type SshSession } from "../ssh/server.ts"; |
| 31 | 31 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 32 | import { createForgeApp } from "./forge.ts"; | |
| 33 | 32 | import { appForLogin, attachAppTty } from "../apps/tty.ts"; |
| 34 | 33 | import { servePulls } from "../apps/tty_pulls.ts"; |
| 35 | 34 | import type { Terminal } from "./term/terminal.ts"; |
| 35 | import type { createForgeApp as CreateForgeApp } from "./forge.ts"; | |
| 36 | 36 | import type { TuiIdentity } from "./session.ts"; |
| 37 | 37 | import { GUEST_RATE_WORDS, GuestRate } from "./ssh-guest-rate.ts"; |
| 38 | 38 | import { parseTalkAgain, parseTalkCommand, serveTalk, serveTalkAgain } from "./ssh-talk.ts"; |
| @@ -117,12 +117,42 @@ export function sshTerminal( | ||
| 117 | 117 | }; |
| 118 | 118 | } |
| 119 | 119 | |
| 120 | /** Run the terminal UI for one shell session. */ | |
| 121 | export function attachTui( | |
| 120 | /** | |
| 121 | * The session as the screens will see it, with its input held from now: the | |
| 122 | * first shell waits on the import of the screens, and what the person types | |
| 123 | * meanwhile is replayed, in order, to the handler that registers after it. | |
| 124 | * (A channel keeps one data handler; size and window-change are read live, | |
| 125 | * so the size the screens ask for is the window as it is by then.) | |
| 126 | */ | |
| 127 | function held(session: SshSession): SshSession { | |
| 128 | const early: Uint8Array[] = []; | |
| 129 | let live: ((data: Uint8Array) => void) | undefined; | |
| 130 | session.onData((data) => (live === undefined ? early.push(data) : live(data))); | |
| 131 | return new Proxy(session, { | |
| 132 | get(target, key) { | |
| 133 | if (key === "onData") | |
| 134 | return (cb: (data: Uint8Array) => void) => { | |
| 135 | live = cb; | |
| 136 | for (const data of early.splice(0)) cb(data); | |
| 137 | }; | |
| 138 | const value = Reflect.get(target, key, target); | |
| 139 | return typeof value === "function" ? value.bind(target) : value; | |
| 140 | }, | |
| 141 | }); | |
| 142 | } | |
outdated
Awaiting this import before wiring the session can lose early input/window-change on the first shell. Register handlers first or buffer. |
||
| 143 | ||
| 144 | /** | |
| 145 | * Run the terminal UI for one shell session. The screens (some three hundred | |
| 146 | * modules) are imported here, on the first shell, so a forge nobody opens a | |
| 147 | * terminal on never holds them (#12349). | |
| 148 | */ | |
| 149 | export async function attachTui( | |
| 122 | 150 | forge: Forge, |
| 123 | session: SshSession, | |
| 151 | shell: SshSession, | |
| 124 | 152 | identity: TuiIdentity, |
| 125 | ): void { | |
| 153 | load: () => Promise<{ createForgeApp: typeof CreateForgeApp }> = () => import("./forge.ts"), | |
| 154 | ): Promise<void> { | |
| 155 | const session = held(shell); | |
| 126 | 156 | // A pty request is the ask; its *size* may legitimately be 0×0 (OpenSSH |
| 127 | 157 | // sends that whenever its own stdin is not a terminal), so `term` is what |
| 128 | 158 | // decides, and the size falls back to 80×24 until a window-change arrives. |
| @@ -136,6 +166,7 @@ export function attachTui( | ||
| 136 | 166 | // The session reads as its person: in a fork whose parent they may not |
| 137 | 167 | // read, an id is held to the fork's own refs, as on the web (`git/own_refs.ts`). |
| 138 | 168 | const asPerson = readerScope(lendersBeyond(forge.db, forge.reposRoot, identity.user)); |
| 169 | const { createForgeApp } = await load(); | |
| 139 | 170 | const { app } = createForgeApp(forge, identity, { |
| 140 | 171 | terminal: sshTerminal( |
| 141 | 172 | session, |
| @@ -223,7 +254,10 @@ const guestRates = new WeakMap<Forge, GuestRate>(); | ||
| 223 | 254 | * is git, `tell`, or nothing. Never throws — a fault here would take the listener with |
| 224 | 255 | * it, and one bad client must not. |
| 225 | 256 | */ |
| 226 | export function forgeSshSession(forge: Forge): (session: SshSession) => void { | |
| 257 | export function forgeSshSession( | |
| 258 | forge: Forge, | |
| 259 | loadTui?: Parameters<typeof attachTui>[3], | |
| 260 | ): (session: SshSession) => void { | |
| 227 | 261 | let guests = guestRates.get(forge); |
| 228 | 262 | if (guests === undefined) guestRates.set(forge, (guests = new GuestRate())); |
| 229 | 263 | // A guest's tell and talk start work; they share one rate (see ssh-guest-rate.ts). |
| @@ -251,7 +285,11 @@ export function forgeSshSession(forge: Forge): (session: SshSession) => void { | ||
| 251 | 285 | // that app's own terminal (docs/APPS.md). |
| 252 | 286 | const app = appForLogin(forge, session.username); |
| 253 | 287 | if (app !== undefined) attachAppTty(forge, session, identity.user, app); |
| 254 | else attachTui(forge, session, identity); | |
| 288 | else | |
| 289 | void attachTui(forge, session, identity, loadTui).catch((error) => { | |
| 290 | forge.log.error("tui session fault", { error: String(error), peer: session.remoteAddress }); | |
| 291 | session.exit(1); | |
| 292 | }); | |
| 255 | 293 | return; |
| 256 | 294 | } |
| 257 | 295 | // An exec on an app's login that git does not speak: a small table. |
test/boot-lazy.test.ts added +30
| @@ -0,0 +1,30 @@ | ||
| 1 | /** | |
| 2 | * A small forge loads only what it uses (#12349): the terminal UI is imported | |
outdated
"the terminal UI are imported" — grammar; and the test never proves the lazy path actually loads. |
||
| 3 | * when a shell opens, not by the boot's static imports. | |
| 4 | */ | |
| 5 | import { expect, test } from "bun:test"; | |
| 6 | import { existsSync, readFileSync } from "node:fs"; | |
| 7 | import { dirname, resolve } from "node:path"; | |
| 8 | ||
| 9 | const FROM = /(?:^|\n)\s*(?:import|export)\s+(?!type\b)[^;]*?from\s+"(\.[^"]+)"|(?:^|\n)\s*import\s+"(\.[^"]+)"/g; | |
| 10 | ||
| 11 | function staticClosure(entry: string): Set<string> { | |
| 12 | const seen = new Set<string>(); | |
| 13 | const todo = [entry]; | |
| 14 | while (todo.length) { | |
| 15 | const file = todo.pop()!; | |
| 16 | if (seen.has(file)) continue; | |
| 17 | seen.add(file); | |
| 18 | for (const m of readFileSync(file, "utf8").matchAll(FROM)) { | |
| 19 | const path = resolve(dirname(file), m[1] ?? m[2]!); | |
| 20 | if (existsSync(path)) todo.push(path); | |
| 21 | } | |
| 22 | } | |
| 23 | return seen; | |
| 24 | } | |
| 25 | ||
| 26 | test("the boot's static imports do not reach the terminal UI", () => { | |
| 27 | const closure = [...staticClosure(resolve("src/main.ts"))]; | |
| 28 | const tui = closure.filter((f) => f.includes("/src/tui/screens/") || f.endsWith("/src/tui/forge.ts")); | |
| 29 | expect(tui).toEqual([]); | |
| 30 | }); |
test/ssh-shell-cold.test.ts added +120
| @@ -0,0 +1,120 @@ | ||
| 1 | /** | |
| 2 | * The first shell waits on the import of the terminal UI (#12349): what the | |
| 3 | * person types meanwhile is not lost, a failed import ends the session with | |
| 4 | * exit 1 and a log, and the real import is reached on a shell. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { addKey, fingerprint, parseAuthorizedKey } from "../src/core/keys.ts"; | |
| 12 | import { createUser } from "../src/core/users.ts"; | |
| 13 | import { forgeSshSession } from "../src/tui/ssh.ts"; | |
| 14 | import type { createForgeApp as CreateForgeApp } from "../src/tui/forge.ts"; | |
| 15 | import type { SshSession } from "../src/ssh/server.ts"; | |
| 16 | import { quietLogs } from "./helpers/globals.ts"; | |
| 17 | ||
| 18 | quietLogs(); | |
| 19 | ||
| 20 | const KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB2S7bF0R4NmuTUvVJXPPjdI1AF8XHXeYSNfr8DJ5eDo carol"; | |
| 21 | ||
| 22 | let dir = ""; | |
| 23 | let app: App; | |
| 24 | ||
| 25 | beforeAll(async () => { | |
| 26 | dir = await mkdtemp(join(tmpdir(), "forge-ssh-cold-")); | |
| 27 | app = createApp({ dataDir: dir, port: 0, forgeRepo: "carol/forge", url: "https://forge.test" }); | |
| 28 | const u = await createUser(app.db, { | |
| 29 | origin: "seed", | |
| 30 | trusted: true, | |
| 31 | username: "carol", | |
| 32 | email: "[email protected]", | |
| 33 | password: "a-good-passphrase", | |
| 34 | adminIfFirst: true, | |
| 35 | }); | |
| 36 | if (!u.ok) throw new Error(u.error); | |
| 37 | const parsed = parseAuthorizedKey(KEY); | |
| 38 | if (!parsed.ok) throw new Error("key"); | |
| 39 | addKey(app.db, u.value.id, "laptop", parsed.value); | |
| 40 | }); | |
| 41 | ||
| 42 | afterAll(async () => { | |
| 43 | app.close(); | |
| 44 | await rm(dir, { recursive: true, force: true }); | |
| 45 | }); | |
| 46 | ||
| 47 | const shell = () => { | |
| 48 | const parsed = parseAuthorizedKey(KEY); | |
| 49 | if (!parsed.ok) throw new Error("key"); | |
| 50 | const seen = { out: "", code: undefined as number | undefined, data: undefined as ((d: Uint8Array) => void) | undefined }; | |
| 51 | const session: SshSession = { | |
| 52 | key: { algo: parsed.value.algo, blob: parsed.value.blob, fingerprint: fingerprint(parsed.value.blob), line: KEY }, | |
| 53 | username: "whoever", | |
| 54 | remoteAddress: "203.0.113.9", | |
| 55 | kind: "shell", | |
| 56 | command: undefined, | |
| 57 | term: "xterm-256color", | |
| 58 | env: {}, | |
| 59 | cols: 80, | |
| 60 | rows: 24, | |
| 61 | widthPx: 0, | |
| 62 | heightPx: 0, | |
| 63 | ended: false, | |
| 64 | pending: 0, | |
| 65 | write: (d: Uint8Array | string) => void (seen.out += typeof d === "string" ? d : Buffer.from(d).toString()), | |
| 66 | writeStderr: () => {}, | |
| 67 | onData(cb: (d: Uint8Array) => void) { | |
| 68 | seen.data = cb; | |
| 69 | }, | |
| 70 | onResize() {}, | |
| 71 | onEof() {}, | |
| 72 | onClose() {}, | |
| 73 | exit(c: number) { | |
| 74 | seen.code ??= c; | |
| 75 | }, | |
| 76 | drain: async () => {}, | |
| 77 | } as unknown as SshSession; | |
| 78 | return { session, seen }; | |
| 79 | }; | |
| 80 | ||
| 81 | test("input typed while the screens load reaches them, in order, with the window as it is", async () => { | |
| 82 | const { session, seen } = shell(); | |
| 83 | const got: string[] = []; | |
| 84 | let started: { cols: number; rows: number } | undefined; | |
| 85 | let release!: () => void; | |
| 86 | const gate = new Promise<void>((r) => (release = r)); | |
| 87 | forgeSshSession(app, async () => { | |
| 88 | await gate; | |
| 89 | return { | |
| 90 | createForgeApp: ((_f: unknown, _i: unknown, opts: { terminal: { onInput(cb: (d: Uint8Array) => void): void; size(): { cols: number; rows: number } } }) => { | |
| 91 | opts.terminal.onInput((d) => got.push(Buffer.from(d).toString())); | |
| 92 | return { app: { start: () => void (started = opts.terminal.size()), stop() {} } }; | |
| 93 | }) as unknown as typeof CreateForgeApp, | |
| 94 | }; | |
| 95 | })(session); | |
| 96 | // Before the import resolves: typing, and a window-change. | |
| 97 | seen.data!(Buffer.from("a")); | |
| 98 | seen.data!(Buffer.from("b")); | |
| 99 | (session as { cols: number }).cols = 120; | |
| 100 | release(); | |
| 101 | await Bun.sleep(10); // real-timer: the import promise settles on the microtask queue; a tick is enough | |
| 102 | seen.data!(Buffer.from("c")); | |
| 103 | expect(got).toEqual(["a", "b", "c"]); | |
| 104 | expect(started).toEqual({ cols: 120, rows: 24 }); | |
| 105 | }); | |
| 106 | ||
| 107 | test("a failing import ends the session with exit 1 and a log", async () => { | |
| 108 | const { session, seen } = shell(); | |
| 109 | forgeSshSession(app, () => Promise.reject(new Error("no such module")))(session); | |
| 110 | await Bun.sleep(10); // real-timer: the import promise settles on the microtask queue; a tick is enough | |
| 111 | expect(seen.code).toBe(1); | |
| 112 | }); | |
| 113 | ||
| 114 | test("the real import is reached on a shell: the terminal UI draws its first frame", async () => { | |
| 115 | const { session, seen } = shell(); | |
| 116 | forgeSshSession(app)(session); | |
| 117 | for (let i = 0; i < 100 && seen.out === ""; i++) await Bun.sleep(50); // real-timer: the real dynamic import has no hook to await | |
| 118 | expect(seen.out).not.toBe(""); | |
| 119 | expect(seen.code).toBeUndefined(); | |
| 120 | }); |
test/tui-tell.test.ts +1 −1
| @@ -11,7 +11,7 @@ import { createRepo } from "../src/actions/repos.ts"; | ||
| 11 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 12 | 12 | import { listIssues, MAX_BODY } from "../src/core/issues.ts"; |
| 13 | 13 | import { createUser, type User } from "../src/core/users.ts"; |
| 14 | import { tellAnswer } from "../src/tui/screens/tell.ts"; | |
| 14 | import { tellAnswer } from "../src/actions/tell_answer.ts"; | |
| 15 | 15 | import { tell, toldWords, TOO_LONG_WORDS, UNVERIFIED_WORDS, type Told } from "../src/actions/tell.ts"; |
| 16 | 16 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 17 | 17 | import { repoScreen } from "../src/tui/screens/repo.ts"; |
new above
offline
Re-export between imports; drop it and keep imports together.