Repo IDE: TypeScript language server
Status summary#
Implemented and live-verified end to end (diagnostics, hover, autocomplete, cross-file propagation, repo-tsconfig honoring — screenshots in the PR). Main pieces: a per-repo web worker hosting a @typescript/vfs environment, a host session that seeds it from HEAD + the working tree and keeps it synced by reconciliation, and the @valtown/codemirror-ts extension bundle attached in RepoEditorPane. Nothing known missing within scope; npm type acquisition is the stacked typm follow-up.
Ask (verbatim, from Misha — spinoff 5 of the repos mini-IDE task)#
TypeScript language server. Run the typescript compiler somehow or other. Given we need a language server this could be tricky — maybe monaco has it built in? Some clever person has likely got this working with codemirror though. Worst case open to using monaco just for typescript.
The mini-IDE task file already answered the "how": the REPL in this codebase does exactly this — @valtown/codemirror-ts extensions talking over comlink to a web worker that hosts a @typescript/vfs virtual TypeScript environment (itx-repl-typescript.worker.ts, itx-repl-autocomplete.ts). This task extends that single-file setup to the repo IDE's multi-file working tree.
Scope (v1)#
- Diagnostics (red squigglies), hover type info, and autocomplete for
.ts/.tsx/.js/.jsx(and.mts/.cts/.mjs/.cjs) buffers in the repo IDE editor. - Multi-file awareness: the worker's virtual fs is seeded with the repo's TypeScript-relevant files (sources +
.jsonforresolveJsonModule), overlaid with the in-browser working tree, so relative imports between repo files resolve and an edit in one buffer is visible from another. - ⚠️ Type acquisition for external npm imports is OUT OF SCOPE. That's the
typmfollow-up (spinoff 6, a stacked PR on this branch). Until then, bare-specifier imports are typedanyvia adeclare module "*"ambient wildcard — the same trick the REPL uses. TS only consults ambient wildcards for non-relative specifiers and real resolved files always win, so typm dropping.d.tsfiles into/node_modules/...in the same vfs later Just Works with no seam changes. See "The typm seam" below.
Design decisions (assumptions marked ⚠️)#
- Worker, not monaco: one web worker per repo (
repo-typescript.worker.tsalongside the other repo-ide modules), built withcreateWorkerfrom@valtown/codemirror-ts/workerexactly like the REPL, plus repo-specific comlink methods for seeding and multi-file sync. - Extensions attach from the IDE side (apps/os), not packages/ui: the TS extensions need repo context — a per-repo worker, itx reads for seeding, the working-tree store subscription.
SourceCodeBlockstays a dumb editor that acceptscodeMirrorExtensions; the repo IDE passes the TS bundle in, the same split the REPL already uses. - Seeding: at worker init the host lists HEAD files, reads every TypeScript-relevant path (⚠️ capped at 500 files — project repos are small; a bigger repo gets the first 500 sorted paths, with
tsconfig.jsonpinned past the cap so repo compiler options never silently revert, and a console warning), applies the working-tree overlay (write entries override HEAD, deletes drop), and ships the map to the worker. Repo pathsrc/x.tsmaps to vfs path/src/x.ts. The pure sync math (seed-path selection, overlay, diff) lives inrepo-typescript-vfs.tswith its spec. - Ongoing sync — reconcile, don't event-chase: the host-side session subscribes to the repo's
WorkingTreeStoreand, on every change, computes the desired vfs contents (effective working/staged entry per path, else cached HEAD content) and diffs against what it last pushed — updates viaupdateFile, removals viadeleteFile. Creates, edits, discards, deletes, and renames all fall out of the same diff, and it doubles as the per-keystroke buffer sync (the editor writes every change into the store), so the stocktsSyncWorkerextension is deliberately unused — one sync path instead of two racing ones. - Commit / HEAD moves: the session survives commits — on a new HEAD oid it refetches head contents, re-subscribes to the new oid's working-tree store, and reconciles. No worker churn, keeps the language service warm.
- Lifecycle: a module-level registry holds ONE active session; acquiring a different repo's session terminates the previous worker (same pattern as
workingTreeStore, and satisfies "don't leak workers when navigating between repos"). React reaches it through a tanstackuseQuery(no useEffect/useState) — editors render immediately and squigglies attach when the worker is ready.gcTime: 0so a cached query can never hand a later visit a terminated session. - Compiler options: bundler-ish defaults matching how these repos are actually built (
moduleResolution: Bundler,allowImportingTsExtensions,strict,lib: es2022 + dom + dom.iterable,jsx: react-jsx,allowJs,resolveJsonModule,noEmit), overridable by a whitelist of type-level options from the repo's owntsconfig.json(see checklist — the stretch landed). - JSX without react types: with
jsx: react-jsxand no real react in the vfs, TS wantsJSX.IntrinsicElements. The seed prelude declares a permissive globalJSXnamespace so.tsxstays quiet-but-untyped until typm brings the real react types (whose own JSX namespace then wins). - Empty files:
@typescript/vfstreats empty-string content as a missing file (getScriptSnapshotfalsiness bug — likely the reason the REPL seeds"\n"). The worker normalizes every write of""to"\n". - Readonly Index view: no TS extensions on the staged-snapshot pseudo-file — it's an inspection surface; working-tree buffers are where the language server lives.
- TS2347 suppressed in the editor's lint lane only (
workerFacade.getLints, not the worker): "Untyped function calls may not accept type arguments" is guaranteed noise while every npm import isany(e.g. the template'skv.get<number>(…)), but the language service itself stays honest for future whole-repo consumers. typm's real types silence it naturally, at which point the suppression can go.
The typm seam (for the follow-up agent)#
The worker exposes setFiles(files: Record<string, string>) / deleteFiles(paths: string[]) over comlink, and the vfs uses real resolution (moduleResolution: Bundler) with the declare module "*" wildcard as the lowest-priority fallback. typm therefore needs no protocol changes: acquire types for the repo's package.json dependencies, then setFiles({ "/node_modules/<pkg>/package.json": …, "/node_modules/<pkg>/**/*.d.ts": … }) — resolution starts finding real types and the wildcard stops matching. The host session (repo-typescript.ts) is the natural place to hang the acquisition trigger (it already sees package.json content during seeding). Two related cleanups typm unlocks: drop the TS2347 diagnostic filter and the permissive global JSX namespace (real react types supersede both).
⚠️ One hazard the follow-up MUST respect: never let the comlink proxy become reachable from React-visible values (props/state/query data). React 19's dev-mode component performance logging stringifies changed props, and String(comlinkProxy) throws, aborting the entire React commit — this presented as the editor silently unmounting. workerFacade in repo-typescript.ts exists for exactly this; extend the facade rather than handing out the proxy.
Checklist#
- Worker:
repo-typescript.worker.ts— vfs env from CDN default lib map + seed files,createWorkersurface,initializeRepo/setFiles/deleteFiles/getAutocompletionWithDocscomlink methods, empty-file normalization — alsoensurePathExistsso lint/hover on a just-created path can't throw before the sync lands - Worker: bundler-flavored default compiler options + permissive
JSXglobal +declare module "*"prelude —libmust use full filenames (lib.dom.iterable.d.ts); dotted short names parse as filenames and env creation throws TS6054 - Host:
repo-typescript.tssession — seed (listFiles + capped readFile fan-out + working-tree overlay), store subscription reconciliation, HEAD-move resync, single-active-worker registry —RepoTypeScriptSession#ensureSyncedserialized on a promise chain;#pushedmap is the diff baseline - Host:
useRepoTypeScriptExtensionshook (tanstack query, no useEffect) returning the per-path extension bundle (facet, linter, autocomplete override with docs, hover) — plus a.cm-tooltipz-index theme;gcTime: 0per the lifecycle note; failures logged to console (editor works without the service by design) - Editor integration: merge TS extensions into
RepoEditorPane's editable editor (plain and diff modes; not the readonly Index view) — also on never-committed files, which previously short-circuited to no extensions - Reuse
getAutocompletionWithDocs/itxReplAutocompleteWorkerfrom the REPL rather than duplicating — imported from../itx-repl-autocomplete-worker.ts/../itx-repl-autocomplete.ts - Stretch: honor whitelisted
tsconfig.jsoncompilerOptions from the repo —repoCompilerOptionsin the worker,ts.parseConfigFileTextToJson+convertCompilerOptionsFromJson, whitelist of type-level options; verified live: the demo repo'snoUnusedLocals: trueproduced "'wrong' is declared but its value is never read" -
pnpm typecheck && pnpm lint && pnpm format && pnpm testclean — apps/os lane: 76 files / 584 tests passed post-merge; repo-ide has no unit suite (same as the mini-IDE PR — verified live instead) - Live verification on local dev (the acceptance bar) — playwright-style walkthrough on project
test, repo/repos/demo(src/greet.ts+src/main.ts): squiggly + tooltip on a type error, hover showing(alias) greet(name: string): Greeting, autocomplete listingmessage/loudfrom the imported interface, and a signature edit in greet.ts producing TS2554 in the untouched main.ts buffer. Screenshots in the PR. - Merge origin/main (post-#1763/#1765) — clean merge, no conflicts; IDE re-verified live on the merged head
Follow-ups deliberately left out#
- typm (spinoff 6): type acquisition for npm deps — stacked PR on this branch; seam above.
- Diagnostics for non-open files (a Problems panel / tree annotations for type errors across the repo) — the worker can already compute them (its diagnostics are deliberately unfiltered; the editor's TS2347 suppression lives host-side in
workerFacade.getLints); surfacing is a product decision. - Live tsconfig edits re-configuring the language service (options are fixed per worker init; a reload picks up changes). tsconfig
extendsis also not followed — chasing npm-hosted config chains is typm territory. - Go-to-definition / find-references (needs editor UI beyond what
@valtown/codemirror-tsships). - The dev server's first hit on the worker route pays a one-time vite bundling cost for the
typescriptpackage (~tens of seconds cold, then cached). Fine for dev; production serves a prebuilt chunk. - Rework
SourceCodeBlockto reconfigure extensions via a CodeMirrorCompartmentinstead of destroying/recreating theEditorView(from review): today ANY extension-identity change rebuilds the view, losing cursor/scroll/undo (the focus/selection half is separately fixed on #1770's branch). This PR minimizes churn on its side (placeholderData: keepPreviousData+ memoizing on the reference-stable parts, so a commit no longer flips extensions at all), but the once-per-file-open rebuild when the TS query first resolves is inherent to the recreate approach, and every future extension-changing feature pays it again. - Port
workerFacadetoitx-repl.tsx(from review): the REPL still stores the raw comlink proxy in React state — the exact React-19-dev-mode stringify hazard this PR documents. It happens not to crash there today, but it's a landmine; the facade pattern transplants directly.
Implementation log#
- Studied the REPL worker trio (
itx-repl-typescript.worker.ts,itx-repl-autocomplete*.ts) and@valtown/codemirror-ts2.3.1 +@typescript/vfs1.6.4 internals before writing anything. Notable vfs findings baked into the design:getScriptSnapshotdrops empty-string files;createFile/updateFilemaintain the root-file list so files created after env construction participate fully;deleteFileis safe on missing files. - The itx
Reposurface haslistFiles()+ per-pathreadFile()only (no bulk snapshot on the public surface), so seeding is a capped parallel readFile fan-out. - Live verification flushed out three genuinely nasty integration bugs, each invisible without driving the real app (commit 41692c54d):
- TS6054 on dotted lib short names —
lib: ["dom.iterable"]parses as a file named/dom.iterable; env creation throws. Full lib filenames fix it. - React 19 dev perf-logging vs comlink proxies —
logComponentRenderstringifies changed props;String(comlinkProxy)throws "Cannot convert object to primitive value", aborting the commit and unmounting the editor with no stack pointing anywhere useful. Fixed with a plain delegating facade (workerFacade). - vite mid-session dep re-optimization — the IDE's lazy
import("@codemirror/view")was a never-before-discovered dep; re-optimization split@codemirror/stateinto two instances, sostate.facet(tsFacetWorker)read a foreign facet's default (undefined.getLintserrors). Fixed by pre-bundling the lazy codemirror family inoptimizeDeps.include— which also hardens the REPL's identical lazy-import pattern.
- TS6054 on dotted lib short names —
- Dropped
tsSyncWorkerdeliberately: every editor change already lands in theWorkingTreeStoresynchronously, so the store-subscription reconcile IS the buffer sync; two push paths would race and double-post every keystroke. - Merged origin/main (#1763 GitHub panel, #1765 root-repo hack et al.) cleanly; no conflicts, re-verified live and re-ran the gates on the merged head.
- Review round (all five inline findings accepted and fixed, re-verified live):
- Terminate-mid-seed race — a session evicted while awaiting the seed fan-out could still subscribe to the module-level store afterwards; its reconcile would then throw released-proxy errors inside the store's listener loop, starving later listeners.
#terminatedflag checked after every await and in#reconcile. - Seed-window keystrokes dropped — subscribing before
initializeReporesolved recorded pushes the worker's pre-envupdateFilesilently discarded. Now: subscribe only after the env exists, then reconcile once to catch mid-seed edits. - tsconfig.json lost to the seed cap — lexicographic sort puts it after
src/, so >500-file repos silently reverted to default compiler options. Pinned past the cap (repoSeedPaths). - EditorView churn on commit — the queryKey flip through
undefinedrebuilt the editor twice per commit (cursor/scroll/undo loss).placeholderData: keepPreviousData+ memoizing on the reference-stable parts (facade, module namespaces) makes the extension array identity survive the refetch entirely. The once-per-open rebuild is inherent toSourceCodeBlock's recreate-on-extensions-change approach — Compartment rework listed as a follow-up. - TS2347 filter moved host-side (
workerFacade.getLints) so the worker's diagnostics stay unfiltered for future consumers.
- The pure sync math moved to
repo-typescript-vfs.tswith a spec (repo-typescript-vfs.test.ts) driving a realWorkingTreeStore— covering the overlay, working-over-staged precedence, diff batches, and the tsconfig pin.
- Terminate-mid-seed race — a session evicted while awaiting the seed fan-out could still subscribe to the module-level store afterwards; its reconcile would then throw released-proxy errors inside the store's listener loop, starving later listeners.