A whole lot of changes - #63
Merged
Merged
Conversation
index.js and types.d.ts become index.ts and types.ts, compiled to dist/ by tsc. The plugin was already JSDoc-annotated and type-checked through checkJs, so this is a mechanical conversion rather than a rewrite, done first so that the Vite hook fixes still to come land in a file the compiler can help with. The payoff is upgrade safety: consuming vite's own .d.ts means a Vite major that changes a hook shape fails the build instead of failing silently at runtime. The package now ships type declarations too. Behaviour is unchanged except for the daemon path, which gains a ".." because the plugin is emitted to dist/ while the daemon is still published to bin/ at the package root. strict stays off; turning it on is a separate change with real errors to fix. Type-checking of docs/scripts/command.js moves to tsconfig.docs.json so it keeps its checkJs coverage now that the main config emits. Also wire up the root build: bun install runs the daemon and plugin builds, so a fresh clone works without knowing to run them by hand. This matters more than before, since dist/ is generated and gitignored where index.js used to be committed. The plugin's own postinstall is now only the consumer install hook and delegates to build:daemon, so the root no longer reaches into another package's lifecycle script. Rewrite ROADMAP.md around a review of the plugin against the Vite 8.2.2 and Fable 5.14 sources. Completed items are removed rather than ticked off, and the remaining ones now carry source references: the HMR pipeline (handleHotUpdate is deprecated and never fires for created or deleted files), daemon process handling, builds that exit 0 on F# errors, and replacing postinstall with a prebuilt daemon package.
Spawning the daemon through a shell hid every startup failure. With shell: true a missing dotnet became "the shell exited 127" rather than a spawn error, so the JSON-RPC request never settled and buildStart hung forever. Since Vite awaits buildStart before httpServer.listen, the dev server printed no URL and no error at all. Dropping the shell also fixes paths containing spaces and makes kill() reach the daemon instead of a wrapper. Alongside that: stderr was piped but never read, which would deadlock the daemon once the 64KB pipe buffer filled, and there was no error or exit handler, so a daemon that died took every pending request down with it silently. Requests are now raced against process failure and report what went wrong. SIGINT stops the daemon too; Vite only installs a SIGTERM listener, and while Ctrl+C signals the whole process group anyway, a SIGINT aimed at this process alone would orphan it. The daemon then moves behind a FableDaemon interface in its own module. It owns the child process for its lifetime, so process handling, the JSON-RPC endpoint and the positional wire format stop leaking into the plugin: three call sites indexing fields[0]/[1]/[2] out of an any[] become four typed methods, and PluginState drops dotnetProcess and endpoint for a single daemon field. That also gives the plugin a seam to run against a stub, which is what makes the HMR batching race testable without spawning dotnet and compiling F# for real. Behaviour is otherwise unchanged.
There were no JavaScript-side tests at all, which is uncomfortable given the plugin's remaining bugs are timing bugs in the HMR path. Those are hard to reach through the real daemon: reproducing them means controlling when a compile finishes, and a real compile takes seconds and finishes when it likes. So the daemon is injected rather than constructed. fablePlugin keeps its signature and now delegates to createFablePlugin, which takes the factory as an argument; tests pass a stub that answers from canned data, records its calls, and can be told to fail or to hold a compile open until the test releases it. The whole suite runs with dotnet absent from PATH and never spawns a process, so it is fast and needs no toolchain. Seventeen tests cover fsproj selection, Debug/Release, option pass-through, project cracking and file watching, transform output with JSX on and off, hot update recompiles, overlay errors, change coalescing, MSBuild-triggered re-cracks, and daemon disposal. One is a characterization test: builds currently survive an unavailable daemon, and it says so and points at the roadmap item that will change it. Sources move to src/ and tests to tests/ so the two are distinguishable, with tsconfig.test.json type-checking both while the build config emits only src/. Wired into bun run test:plugin, the root test script alongside dotnet test, and both CI workflows. Also drop a leftover console.log that dumped the whole HMR error payload to the terminal; the test output made it obvious.
Formatting was checked but nothing linted the TypeScript, so the only feedback on the plugin sources came from tsc. oxlint now runs over the repository with @nojaf/oxlint-plugin-annotate-non-primitives, which asks for a written type wherever it is not obvious from the initializer. Inference is convenient while writing and unhelpful while reading, and this code is read far more than it is written. That flagged 90 places, all in code added over the last few commits, and fixing them pushed the tests towards named types rather than repeated inline shapes. The rule is scoped to **/*.ts through an override: it also fired on docs/scripts/command.js and the changelog updater, where annotations are not expressible at all, so applying it there could never be satisfied. The changelog updater moves to TypeScript, which costs nothing because bun runs it directly, and gains a type check it never had via tsconfig.scripts.json. docs/scripts/command.js stays JavaScript: the docs pages load it straight through an import map with no bundler anywhere in that pipeline, so porting it would mean introducing a build step to satisfy a lint rule. It remains checked through checkJs. Every lint script now runs oxlint before its type checks, at the root and in the package, with the type checks split into lint:types so the root does not run oxlint twice. The CI step was called "TypeScript check", which is no longer what it does, so it is now just "Lint".
A project that did not compile still built successfully and exited 0. projectChanged caught everything, buildStart caught it again, and logError went through logger.warn so Vite never even set its error flag. The transform hook then returned nothing for the files Fable had not produced, which left Vite parsing the F# source as JavaScript: the user got a syntax error pointing at `module Foo` rather than the type error that actually caused it. Builds now stop. Any error-severity diagnostic aborts, as does a failed crack or compile, and an F# file Fable never compiled is reported through this.error instead of reaching the JavaScript parser. Warnings still let the build through, and logError reports through logger.error so Vite records that something went wrong. Dev deliberately keeps the old behaviour: the server stays up after a failed compile so the browser overlay can show the diagnostic and the next edit can fix it. Only build mode is fatal. Covered by four tests, verified to fail without the change. Two of them were initially green for the wrong reasons and are worth knowing about: bun types expect().rejects.toThrow() as returning void, so awaiting it does nothing, and the transform assertion matched a TypeError raised by the harness itself, whose message quotes the call expression and so contained the string being matched. The plugin context stub now implements error() with Rollup's throwing semantics.
handleHotUpdate is on Vite's deprecation list, and it is also narrower than it looks: Vite only ever dispatches it for updates, so files being created or deleted never reached the plugin at all. hotUpdate receives all three, and brings this.environment.hot with it, which settles the server.hot deprecation at the same time. The bigger problem was underneath. Every in-flight change awaited one shared promise, so a file edited while another was still compiling got answered by the previous compile's diagnostics. It was reported to the browser before it had been compiled, and its own diagnostics were then dropped on the floor because the promise had already settled. Changes now land in coalescing batches that each carry their own result, with compiles chained so only one runs at a time. That replaces the RxJS pipeline with a small queue, so rxjs and promise.withresolvers are gone; withResolvers itself is a local helper rather than the native one, which only exists from Node 22 while Vite still supports 20.19. Four smaller faults went with it. Editing a signature file did nothing until an implementation file happened to be touched. Editing an fsproj re-cracked the project but told the browser nothing, leaving stale modules loaded. A changed file that nothing imports returned an empty array, which Vite reads as "handled, do nothing", so the edit vanished silently. And only the edited module was invalidated, never the files whose output changed downstream of it. That last one needs care in both directions. Fable recompiles everything downstream of an edit, but nearly all of that output is identical to what was already served, and reporting it drags modules that cannot accept a hot update into the update — one dead end turns the whole thing into a page reload. Comparing against the previous output means an F# React component now hot-updates through Fast Refresh instead of reloading the page. Plain modules still reload, correctly: Fable emits no accept handlers, so there is no boundary to stop at. Covered by ten plugin tests, including the race, and a daemon test that breaks a signature file and asserts the error lands on the implementation. The sample project gains a Greeting.fsi/Greeting.fs pair rendered into the page heading so the signature-file path can be tried by hand.
strict was still off from the checkJs era, so the type checker had been running over the plugin all along with the useful half disabled. Turning it on surfaced seven errors, one of them real: configResolved derived the project directory from resolvedConfig.configFile, which is optional, so a project without a Vite config file — or one created programmatically — reached fs.readdir(undefined). It now uses resolvedConfig.root, which is always resolved and is anyway the correct directory when root differs from where the config file lives. The rest were the same shape: state.fsproj is string | null and was going straight to the daemon. requireFsproj turns a missing project into an error at the point of use, so a build fails saying so instead of cracking null. Two tests cover the fix, because every existing test passed an explicit fsproj and discovery therefore never ran. oxlint warnings now fail rather than being printed and ignored. A dead interface had already survived a green CI run that way. The sample gets its Bun runtime from its own bunfig.toml instead of `bunx --bun` in every script, so the scripts are plain `vite`. Bun reads bunfig.toml from the directory a command starts in and does not walk up, so a [run] block at the repo root looks right and does nothing — hence the second file rather than one at the root. Vite DevTools now runs, which needed patches/crossws@0.4.12.patch: its Node WebSocket adapter refuses to start when Bun is in globalThis, and the path DevTools takes hardcodes that adapter even though devframe ships a Bun transport. Reported as devframes/devframe#317; the patch is pinned to that exact version until it lands. vite-plugin-inspect stays alongside it with build: true, because the DevTools panel is injected client-side behind an OTP fragment and is invisible from a terminal, whereas Inspect writes .vite-inspect/reports/ as plain JSON — same information, readable without a browser. CLAUDE.md, with AGENTS.md symlinked to it, records only what cannot be worked out from the repo: the local Vite and Fable checkouts to verify hook contracts against, how to read transform output without a browser, and the traps that cost real time here — Vite's SPA fallback answering 200 for any path, bunfig not propagating, and bun typing expect().rejects.toThrow() as void so awaiting it does nothing.
Two hook-contract bugs, plus the CI and script gaps found alongside them.
The transform hook returned `map: null`, which in the Rollup and Vite
contract means "I did not move code, keep the previous mapping". That is
false here — the hook replaces F# with JavaScript — and downstream stages
took it at face value, generating a map whose `sources` named a `.fs`
file while its `sourcesContent` held the compiled JavaScript. Devtools
duly showed an F# filename containing JavaScript, which is worse than no
map at all. `{ mappings: "" }` is how Vite's own plugins say a mapping was
lost. Real F#-to-JS source maps stay blocked on Fable, where
FileWriter.AddSourceMapping is a no-op.
compileProject built its compiled-output map by iterating the source file
list and indexing the daemon's response with an already-normalised path,
while fsharpFileChanged normalised the daemon's keys on the way in. The
two sets are not the same — signature files are reported as sources and
never compiled — and the mismatched lookup would yield undefined for
every entry if the daemon ever reported a non-POSIX path. Both paths now
key off what the daemon returned.
CI ran the plugin tests but never `dotnet test`, so the daemon suite,
including the signature-file test added with the hotUpdate work, was not
running there at all. Added as its own step in both workflows.
Also adds a top-level `ci` script that runs lint, formatting and tests in
parallel, for use before committing.
The roadmap's claim that `.fsx` files are matched but never compiled was
wrong and is dropped rather than acted on: the daemon filters only `.fsi`,
so a script listed in the fsproj compiles like any other file, and one
that is not listed fails exactly as a stray `.fs` would.
The MSBuild configuration was derived from env.MODE, which only equals "production" for the default build mode. Any custom mode compiled the F# in Debug and then bundled it as production output — `vite build --mode staging` shipped a Debug build with no sign anything was off. It now follows the command, which was already being captured on the very next line for isBuild. A `configuration` option overrides it for the case where you do want the other one, such as a production bundle that keeps assertions. configuration is typed "Debug" | "Release" rather than string, in the plugin state and in the daemon request, so a typo cannot reach MSBuild. Also corrects the fsproj recipe, which still said the plugin looks for a project next to vite.config.js. Discovery moved to the Vite root when strict mode surfaced that configFile is optional, and the two differ whenever root is set.
noReflection and exclude both change what Fable emits, but neither was part of the design time build cache key. Setting either left the cached build valid, so the previous output was reused and stale JavaScript was served with nothing to indicate it — the kind of thing people work around by deleting obj/ and never find out about. Both are now in the key, with their own invalidation reasons so the log names the option that changed. The cached record also carries a format version. Protobuf deserialises fields that did not exist yet as their defaults, so a cache written before this change would report noReflection = false and an empty exclude, which falsely matches anyone running the defaults and leaves the same bug in place. The version makes those caches invalid once instead. Options themselves were never validated: they went through Object.assign and anything unrecognised was merged in and ignored. In a vite.config.js that is invisible, so a misspelled option looked like it had no effect. resolveOptions now owns the defaults and rejects unknown or badly typed keys, suggesting the intended one where it can. Vite validates its own config by hand and pulls in no schema library, so this does the same rather than adding a runtime dependency. Type declarations shipped already but were half useful: PluginOptions was never re-exported, so a vite.config.ts could not name what it was passing. It and FableConfiguration are exported now, behind an exports map, and stripInternal keeps the test-only seam out of the published surface. Documents every option, including noReflection and exclude, which had never been mentioned anywhere despite working.
`transform.filter` was a bare `/\.(fs|fsx)$/`, so any id with a query never reached the handler and the raw F# went on to the JavaScript parser. Widen the filter with `makeIdFiltersToMatchWithQuery`, the convention Vite's own plugins follow, and drop the query before looking the id up in `compilableFiles`, which is keyed by file path. `?raw` and `?url` are excluded. Those ask for the file rather than the module it compiles to, and `vite:asset` has already answered them by the time a pre transform runs; compiling over that answer would replace the F# source string with the compiled module.
The mailbox loop replied from inside each message arm, so an exception that escaped one killed the agent with the request unanswered. `PostAndAsyncReply` has no timeout, so the plugin then waited inside `buildStart` forever — no URL, no overlay, nothing on screen — and every later request queued behind a loop that was gone. `mailbox.Error` was subscribed with `fun _ -> ()`, so none of it was reported. Serving a message is now a separate function the loop wraps in a try/with: the pending reply channel gets the message's own `Error` case and the loop carries on with the unchanged model. Recursion happens outside that try, so a long-lived agent does not stack one exception handler per message served. Failures are also made visible. The daemon logs to stderr as well as its `ILogger`, which is a `NullLogger` unless VITE_PLUGIN_FABLE_DEBUG is set, and stdout is reserved for the JSON-RPC framing; the plugin already forwards stderr to the Vite logger. If the agent itself dies the model is gone and nothing can be served again, so the process exits instead of accepting requests it will never answer. Also collapse `dotnet_msbuild_with_defines` into `dotnet_msbuild`. Nothing ever passed a non-empty `defines`, so the `DefineConstants` branch was unreachable.
Vite awaits `buildStart` before `httpServer.listen`, so cracking the project there kept the dev server off its port for the whole first compile: no URL printed, no overlay, nothing to look at while Fable ran. The daemon now starts in `configureServer`, which runs earlier and whose result nothing awaits, and the wait moves to `transform`. That is per request and already filtered to `.fs`, so the server boots at once and only a request for F# blocks. `hotUpdate` waits too — an edit landing before the first crack would otherwise be dropped as "not ours", since the project's file list is still empty. `vite build` keeps cracking in `buildStart`, where blocking is what you want. A failed crack is now reported from `transform` instead of logged as a warning, so the reason reaches the browser overlay rather than only the terminal, and Vite no longer hands raw F# to its JavaScript parser. Dev has no plugin context to call `this.addWatchFile` on, so watching goes through the server's watcher, and only for files outside the root — the same rule `ensureWatchedFile` applies, since the dev watcher already covers everything under it. The test harness follows Vite's real hook order, and `boot` returns without waiting for the first compile so a test can observe what the plugin does while the daemon is still cracking.
The plugin transformed JSX and left Fast Refresh to chance. Refresh only
survived because Vite's oxc pass sniffs the emitted code for a
`react/jsx-runtime` import, which the plugin's own transform happened to
inject. Nothing said so, and nothing said what to do when it did not
hold.
Ownership of the JSX transform is not a preference: `vite:oxc` forces
`lang: "js"` for any id whose extension is not a JavaScript one, which
disables JSX parsing, so JSX inside a `.fs` module is a parse error
there. The plugin has to finish the job, and now says why in a comment.
It also deliberately does not ask for `refresh`, because `vite:oxc` runs
over the output afterwards and applies it — asking here as well registers
every component twice.
Drop `jsx: "preserve"`. It cannot produce an importable module in any
configuration: with plugin-react present the module fails to parse, and
without it Vite's import analysis rejects it. It now fails when the
config loads instead of as a 500 on first request.
Warn when `.fs` components will not Fast Refresh. The check reads the
resolved `oxc.jsxRefreshInclude`, which plugin-react sets from `include`
whether or not the React Compiler is on, rather than the `refresh` flag,
which is also false during a build and when `compiler: true` hands
refresh to `vite:react-compiler`.
Documentation, verified against a running fsdocs server:
- `react({ compiler: true })` works on Fable output and is the more
robust setup, since it applies refresh explicitly rather than by sniff.
- Migrate the theme to `light-dark()`. fsdocs only sets `data-theme` once
the toggle has written to localStorage, so overriding colours under
`[data-theme="dark"]` left a pale header on a dark page for anyone
arriving in OS dark mode. Panels now derive from `--panel-background`,
added in fsdocs 22.0.1.
- Style tables, which fsdocs ships no CSS for at all. Scoped away from
`table.pre`, which is how fsdocs renders code snippets.
- Work around three fsdocs markdown quirks: `\|` is never unescaped in a
table cell, `**` around a span starting with a code span does not
become `<strong>`, and an unlabelled fence is parsed as F#.
- Fix in-page links. fsdocs keeps the heading's case and replaces
non-alphanumerics with `-`, so GitHub-style lowercase anchors never
resolved.
Add a `docs` script running `fsdocs watch` with the same flags CI builds
with.
A plain `vite dev` on the five-file sample printed 26 `[fable]` lines
before the page loaded, and none of it could be turned off: `logDebug`
called `logger.info` with a dimmed colour, so "debug" output reached the
user exactly like everything else. Paths were absolute where Vite prints
them relative, `transform` logged a line per file on every request, and
the wording read like a progress trace ("about to type-checked",
"dependent file X changed." during a crack where nothing had changed).
The default is now one line per compile, naming the project or the files
and how long it took, alongside diagnostics, errors and warnings. Paths
are relative to the Vite root, and stay absolute only when they fall
outside it, where a run of `../..` would be worse.
`debug` brings the detail back — every hook, every file transformed,
where `fable-library` resolved, the cracking and type-checking timings,
and the daemon's own output. It is a plugin option so it can be turned on
in a config and committed, rather than remembered as an env var at the
call site. `VITE_PLUGIN_FABLE_DEBUG` still works and does one thing more:
it starts the daemon's log viewer, which lives in the compiler process
and so is beyond the option's reach.
Also fold `logCritical` into `logError`, which it duplicated byte for
byte, and move the log-viewer banner off `console.log` onto Vite's
logger, where it gets a prefix and a timestamp instead of printing at
import time.
Finally, stop telling people to check their .NET SDK when the daemon
crashes mid-session. That advice fits a daemon that never started; a
process that dies later is a different bug, and now says so.
Every edit to a `.fs` file was compiled twice. `handleHMRUpdate` takes a single timestamp per file change and then calls `hotUpdate` for every environment in `server.environments`, which in dev means `client` and `ssr` even for a project that never renders on the server. The coalescing window cannot help: the calls arrive one after another, so the first compile has already finished by the time the second one lands. Widening the window would mean holding every edit for the length of a full compile. Keying the in-flight compile on the timestamp Vite already computed makes one filesystem change mean one compile. Each environment still resolves the resulting files against its own module graph, so this deduplicates the work rather than skipping the non-client environments, and SSR keeps behaving as it should. Documentation had drifted from the code: - `how.md` claimed config resolution does the initial compile and that `handleHotUpdate` alerts the browser. Neither is true. It now covers why the daemon starts in `configureServer` without being awaited, why `vite build` blocks instead, and what happens on an edit, including the batching and the per-environment fan-out above. - The `<script type="module" src>` limitation still holds, but for a reason worth stating: `isJSRequest` tests a fixed extension list that excludes `.fs`, so a bare request never reaches the transform pipeline, while an `import` gets `?import` appended and does. - "Transpiled F# files are not written to disk" was only half true. Your own sources are held in memory; compiled `fable_modules` output is cached under `obj/`. - `getting-started.md` still showed the ten-line startup log the plugin no longer prints, and `debug.md` did not mention the `debug` option.
Deduplicating the compile for a file change kept a single entry, on the assumption that Vite finishes handing a change to every environment before the next change arrives. It does not: the watcher calls onFileChange without awaiting it, so saving two files at once interleaves their fan-outs and the second change overwrites the first before its remaining environments ask for it. Those then queued a compile of their own. Two files saved together compiled three times instead of once. Keep the in-flight changes in a map, capped so it stays a dedup window rather than a cache. Entries cannot be dropped when their compile settles: an environment only asks after the previous one's hotUpdate returned, which is already after the compile it awaited.
The plugin answered for a .fs file from transform, which meant Vite read the whole file off disk first so that transform could throw the contents away. A load answers from the compiled output directly, and states what the module is: rolldown otherwise infers a module type from the .fs extension, since vite:oxc only declares it for ids plugin-react claims. An F# file Fable did not compile is now an error in dev as well as in a build. Warning and returning nothing left Vite to read the file and hand the F# to the JavaScript parser, so the page broke anyway, just with a syntax error pointing at "module Foo" rather than at the file missing from the fsproj. Fable's File remembers the hash of the source it read so a later read can be skipped. The daemon built a fresh set for every compile and threw that away, so every file in the project, fable_modules included, was read and hashed again on every edit. It now holds on to them and forgets only the files the plugin reports as changed, which is safe because Vite watches the whole root and the plugin adds the sources outside it to the watcher itself. A re-crack forgets everything, since which files the project has is decided there. The cache is concurrent because the checker type-checks in parallel and calls the reader from several threads. The Inspect report no longer has a __load__ step holding the F# source, so the notes that describe reading it were updated.
The design time build cache key took its list of dependent files from MSBuildAllProjects. Since MSBuild 16.9 an import no longer adds itself to that property, so the list was the fsproj plus a handful of SDK targets and nothing else: editing a Directory.Build.props neither invalidated the cache nor re-cracked the project, because nothing knew the file existed. The convention imports are now asked for by name instead, and the packages props only when central package management is actually on, so watching it cannot re-crack for an edit that changes nothing. The keys themselves were built once per daemon lifetime. Which files an evaluation depends on is decided by the evaluation, so a key kept across cracks describes the project as it was, and a file added to the build was never noticed. They are now forgotten at the start of every crack, which still leaves them memoised for the length of one, where the same project can be visited more than once. A dev server calls watchChange on top of calling hotUpdate for every environment, so one touch of an fsproj arrived three times and cracked the project three times over, each one a full design time build. Deduplicating a change across environments already existed for source files; re-cracks now go through the same window, and watchChange defers to hotUpdate in dev, where it is the better of the two because it can reload the browser afterwards. Reading msbuild's stdout to the end before touching stderr can deadlock: a child that fills the other pipe blocks writing while this side blocks reading, and nothing between the daemon and the plugin times out. Both pipes are now drained together. A non-empty stderr also no longer fails the call, since msbuild and NuGet write warnings there on runs that succeed, and a real failure is reported on stdout with the exit code set, which is why the old message quoted stderr and named no reason at all.
The roadmap had grown to include descriptions of work that has already landed: the crossws patch and why it is needed, the Inspect setup, the reasons an item could not be fixed locally before it was filed upstream. That reads as a plan but is really a history, and history belongs in the changelog. What remains is open work, one unanswered question, and the decisions recorded so they do not get re-litigated. Items are renumbered so the list no longer has holes where finished ones used to be. Nothing outside the file points at it any more, because it will be deleted once its items are gone. A comment that says "see roadmap item N" is worse than no comment once that is true, and the numbers shift on every renumber anyway, so daemon.ts now states the hazard it was pointing at. The NoCache question is answered rather than left open. Upstream the flag only decides whether Fable's own CacheInfo is read and written, and whether fable_modules is deleted wholesale, and that delete is guarded by the evaluateOnly argument the daemon passes. Nothing else reads it, so turning Fable's caching off in favour of the daemon's own costs no correctness. That argument is now named at the call site rather than passed as a bare true, since deleting a user's fable_modules is a lot to hang on an unlabelled boolean.
Fable restores the sources of a project's package dependencies into fable_modules and compiles them alongside the project's own files, so their diagnostics arrived mixed in with the ones the user can act on. Nobody using the plugin wrote that code or can edit it, which makes the noise unactionable. The new fableModulesDiagnostics option reports them again, as a debugging aid for when a package itself looks broken. It covers errors too, so with the option off a package that fails to compile no longer fails the build: nothing is printed and vite build exits 0. That is a deliberate trade, documented on the option, in the recipes and in the test that pins it. The filter sits where diagnostics enter the plugin, so the crack's response, the compile's response, the build failure and the HMR overlay all see the same set.
The daemon holds the only copy of what it cracked, compiled and cached, and none of it could be read. Emitted JavaScript meant running a build and reading .vite-inspect, diagnostics existed only as terminal lines, and the design time build cache decision was logged and thrown away. The one thing that did serve it, the log viewer, serves HTML. The debug server now answers JSON under /api: status, project, files (including the JavaScript emitted for one file), diagnostics unfiltered, the cache decision with the input that invalidated it, the last hundred JSON-RPC requests, and the log with a resume cursor. The log viewer page is unchanged. It is read-only on purpose. The daemon only knows which files changed because the plugin tells it, so a second writer would break the promise that keeps SourceFileCache correct. It also never asks the message loop: that loop publishes an immutable snapshot after each message it serves and the endpoints read that, so a request cannot queue behind a compile. Every response carries a revision that increments per served message, which is how a caller tells whether it is looking at its own edit. The debug plugin option starts it too. It used to be plugin-side only, so reaching the daemon's own output needed VITE_PLUGIN_FABLE_DEBUG as well. VITE_PLUGIN_FABLE_DEBUG_PORT moves it off 9014, and a running daemon announces itself in a temp file that is swept of daemons whose process is gone. Roadmap item 2 goes on hold rather than proceeding: it proposed deleting this server in favour of a DevTools panel, and a browser panel cannot serve any of the above. A panel is still wanted, but it now has to earn its keep on its own terms.
postinstall ran dotnet publish on the consumer's machine, and package managers increasingly refuse to run lifecycle scripts by default. When it was skipped the install still succeeded, no bin/ was produced, and the failure surfaced much later as a confusing buildStart error. The package now carries a framework-dependent publish of Fable.Daemon, which is portable IL and runs anywhere the .NET runtime does, so there is nothing to trust and nothing to compile at install time. The .NET SDK is still required: reading an fsproj means asking dotnet msbuild about it. What ships is no longer ReadyToRun, which costs roughly 0.7s of JIT per dev server start and per build; roadmap item 4 keeps the measurements and the per-RID option that would win it back. Two things had to move for prebuilt bits to work at all. The debug assets were never copied to the publish output, and Debug.fs resolved them through __SOURCE_DIRECTORY__, which is the folder the daemon was compiled in. Both were correct only while every consumer compiled it themselves; shipping prebuilt would have baked in a stranger's home directory. They now travel with the assembly and are found next to it. Verified by packing the tarball and installing it with --ignore-scripts into a project with nothing from this repo in it: the build succeeds and the debug server serves the installed copy of its assets. cracking.fsx keeps shipping, and works against the bundled bin/ without anything being built first. Its hard-coded fable-library path had been wrong in this repo since the move to bun isolated installs, unnoticed because cracking is an MSBuild evaluation that never reads it. Note for whoever publishes: the tarball has to be built by bun. The manifest depends on the workspace catalog, npm pack leaves "catalog:" in place, and nothing can install the result.
The plugin spawns a child process, normalises paths and shells out to
MSBuild, none of which behaves the same everywhere, and all of it was
only ever exercised on Linux. The tests now run on all three, with
fail-fast off so a Windows-only failure is not hidden by whichever job
went red first. Formatting, linting and the docs build stay on one
runner: they say the same thing on every OS, and fmt:check has line
endings to lose.
Two tests could not have passed on Windows. The debug server test got a
pid that is not running by spawning /bin/sh to exit, which needs a shell
that differs per OS; it now reads the process table and picks a number
that is not in it, which also avoids pid 0 reporting itself alive on
macOS. The plugin tests built every path as `${sampleProject}/Math.fs`,
and on a Windows path.resolve that is a mixed-separator string matching
nothing the plugin had normalised. Normalising the root is also more
faithful, since Vite hands the plugin posix ids.
nojaf
marked this pull request as ready for review
August 29, 2026 19:37
This was referenced Aug 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rewrites the plugin in TypeScript, then fixes what the type checker and a read of Vite's own source turned up. 27 commits; each one stands alone.
TypeScript
index.jsandtypes.d.tsbecomeindex.tsandtypes.ts, compiled todist/bytsc, withstricton. The package ships type declarations, so avite.config.tscan namePluginOptions..d.tsmeans a hook shape change fails the build instead of failing at runtime.Hooks
hotUpdate.handleHotUpdateis deprecated and never fires for created or deleted files.hotUpdateonce per environment, so a default dev server compiled every edit twice.load, where a failure reaches the browser overlay instead of a terminal with no URL.load, so Vite no longer reads the.fsoff disk to throw it away.loadmatches ids carrying a query, and leaves?rawand?urlalone.Correctness
vite buildfails on an F# error instead of exiting 0 with broken output.vite build --mode stagingcompiles Release. It used to readenv.MODEand put Debug F# in a production bundle.noReflectionorexcludeinvalidates the caches. Neither was part of the cache key, so stale JavaScript was served silently.transformreportsmap: { mappings: "" }rather thannull, which had devtools showing an F# filename containing JavaScript.Packaging
postinstall. The daemon ships prebuilt as portable IL, so--ignore-scriptsinstalls work and nothing is compiled on the consumer's machine. The .NET 10 SDK is still required, because cracking shells out todotnet msbuild.npm packleaves the workspacecatalog:protocol in the manifest and nothing can install the result.Debugging
/api: what it cracked, compiled, cached and served, readable by anything that is not a browser. Read-only, and served from a snapshot so it never queues behind a compile.debugplugin option now starts it too, rather than needingVITE_PLUGIN_FABLE_DEBUGas well.fable_modulesare dropped by default;fableModulesDiagnosticsbrings them back.Tests and CI
Known issue, not fixed here
vite build --watch, a failed re-crack throws out of the batch runner and rejects the promise the queue chains on. That surfaces as an unhandled rejection, and any batch queued after it never runs. Dev is unaffected, because there the failure is recorded rather than rethrown.