Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
03d1637
Convert the Vite plugin to TypeScript
nojaf Aug 29, 2026
753ba40
Harden the daemon and move it behind an interface
nojaf Aug 29, 2026
50e6d53
Test the Vite hooks against a stub daemon
nojaf Aug 29, 2026
ed31f94
Lint with oxlint and require explicit type annotations
nojaf Aug 29, 2026
b4db861
fmt:check on main
nojaf Aug 29, 2026
11a0288
Fail vite build when F# compilation fails
nojaf Aug 29, 2026
f9bf19b
Migrate to hotUpdate and fix the HMR pipeline
nojaf Aug 29, 2026
7f5fa80
Enable strict mode and set up plugin inspection tooling
nojaf Aug 29, 2026
0a46297
Fix the transform sourcemap signal and path normalisation
nojaf Aug 29, 2026
9a65340
Compile Release for every build, not just --mode production
nojaf Aug 29, 2026
e3bbf4b
Invalidate caches on option changes and validate the options
nojaf Aug 29, 2026
34051cd
Match transform ids that carry a query string
nojaf Aug 29, 2026
0b2ee6e
Answer every message the daemon fails to serve
nojaf Aug 29, 2026
c667555
Start the dev server before the first F# compile
nojaf Aug 29, 2026
d407310
Make the JSX and Fast Refresh handoff to plugin-react explicit
nojaf Aug 29, 2026
86687b8
Print one line per compile, with a debug option for the rest
nojaf Aug 29, 2026
1d5c6b8
Compile once per change, not once per environment
nojaf Aug 29, 2026
625de54
Vite and Fable might be present
nojaf Aug 29, 2026
2e62153
Remember every change being fanned out, not just the last one
nojaf Aug 29, 2026
50f2265
Serve the compiled F# from load, and keep what the daemon read
nojaf Aug 29, 2026
7bac35d
Update ROADMAP
nojaf Aug 29, 2026
ad02400
Crack the project when its MSBuild inputs change, and only once
nojaf Aug 29, 2026
5e13b91
Strip the roadmap to what is left, and stop pointing at it
nojaf Aug 29, 2026
dc6b605
Add test for what we send over the wire.
nojaf Aug 29, 2026
800bae6
Stop reporting diagnostics for files in fable_modules
nojaf Aug 29, 2026
e32e0f8
Let the daemon be asked what it is doing, in JSON
nojaf Aug 29, 2026
ec73f4e
Ship the daemon prebuilt instead of building it on install
nojaf Aug 29, 2026
a3a914e
Run the PR tests on Linux, macOS and Windows
nojaf Aug 29, 2026
ab39abe
Make test cross plat
nojaf Aug 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,19 @@ jobs:
run: bun install --frozen-lockfile

- name: Check formatting
run: bun run format:check
run: bun run fmt:check

- name: TypeScript check
- name: Lint
run: bun run lint

- name: Test plugin
run: bun run test:plugin

- name: Test daemon
run: bun run test:daemon

- name: Build daemon
run: bun run --cwd packages/vite-plugin-fable postinstall
run: bun run build:daemon

- name: Build docs
run: dotnet fsdocs build --noapidocs --projects "$(pwd)/packages/vite-plugin-fable/Fable.Daemon/Fable.Daemon.fsproj"
Expand Down
48 changes: 42 additions & 6 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ name: PR
on: [pull_request]

jobs:
build:
# Formatting, linting and the docs build say the same thing on every OS, so they run once.
# `fmt:check` in particular has nothing to gain from a second opinion and something to lose to
# line endings.
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
Expand All @@ -29,15 +32,48 @@ jobs:
- name: Check formatting
run: bun run fmt:check

- name: TypeScript check
- name: Lint
run: bun run lint

- name: Build docs
run: dotnet fsdocs build --noapidocs --projects "$(pwd)/packages/vite-plugin-fable/Fable.Daemon/Fable.Daemon.fsproj"

# The plugin spawns a child process, normalises paths and shells out to MSBuild, so what it does
# is not the same on every OS. `fail-fast: false` because "it broke on Windows only" is the
# answer worth having, and it is lost when the first failure cancels the rest.
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0

- uses: oven-sh/setup-bun@v2

- name: Setup dotnet
uses: actions/setup-dotnet@v6
with:
global-json-file: global.json

- name: Restore solution
run: dotnet restore

- name: Install node_modules
run: bun install --frozen-lockfile

- name: Test plugin
run: bun run test:plugin

- name: Test daemon
run: bun run test:daemon

- name: Build daemon
run: bun run --cwd packages/vite-plugin-fable postinstall
run: bun run build:daemon

- name: Build sample
run: bun run build
working-directory: ./sample-project

- name: Build docs
run: dotnet fsdocs build --noapidocs --projects "$(pwd)/packages/vite-plugin-fable/Fable.Daemon/Fable.Daemon.fsproj"
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -495,3 +495,9 @@ output/
# copied from the repo root by prepublishOnly
/packages/vite-plugin-fable/Directory.Build.props
/packages/vite-plugin-fable/Directory.Packages.props

# TypeScript build output for the Vite plugin
packages/vite-plugin-fable/dist/

# Generated by vite-plugin-inspect (build: true)
sample-project/.vite-inspect/
22 changes: 22 additions & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"jsPlugins": ["@nojaf/oxlint-plugin-annotate-non-primitives"],
"env": { "builtin": true, "node": true },
"ignorePatterns": [
"**/dist/**",
"**/bin/**",
"**/obj/**",
"**/artifacts/**",
"**/fable_modules/**"
],
"overrides": [
{
"files": ["**/*.ts"],
"rules": {
"nojaf/annotate-non-primitives": "error",
"typescript/explicit-function-return-type": "error"
}
}
]
}
6 changes: 6 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Read by oxfmt, which looks for .gitignore and .prettierignore when no --ignore-path is given.
#
# These are golden files: the exact JSON the daemon serialises, compared byte for byte by the
# contract tests on both sides. A formatter rewriting them would break that comparison and hide the
# thing they exist to catch.
packages/vite-plugin-fable/tests/fixtures/
1 change: 1 addition & 0 deletions AGENTS.md
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- The plugin is written in TypeScript. `index.js` and `types.d.ts` became `index.ts` and `types.ts`, compiled to `dist/` by `tsc` during `prepublishOnly`; the package now ships type declarations. Behaviour is unchanged.
- `bun install` at the repo root now builds the daemon and the plugin (`bun run build`), so a fresh clone works without a manual build step. The plugin's own `postinstall` is now only the consumer hook and delegates to `build:daemon`.
- Fable.Daemon now targets `net10.0`; the .NET 10 runtime is required.
- Updated Fable.Compiler to 5.14.1 and `@fable-org/fable-library-js` to 2.5.1.
- Project cracking now delegates to Fable's own `MSBuildCrackerResolver`. The design time build cache and watched MSBuild files are kept in `ProjectCracking.fs`; `CoolCatCracking.fs` was removed.
Expand All @@ -20,11 +22,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Vite 8 is the peer dependency. The JSX transform after Fable compilation uses Vite's `transformWithOxc` (rolldown/oxc); `esbuild` is no longer a peer dependency.
- All JavaScript dependencies updated (`@babel/code-frame` 8, `ts-lsp-client` 1.1.1, TypeScript 7, React 19 and `@vitejs/plugin-react` 6 in the sample).
- The repository is a Bun workspace with isolated installs: the plugin lives in `packages/vite-plugin-fable` and `sample-project` depends on it via `workspace:*`. `Directory.*.props` stay at the repo root and are copied into the package by `prepublishOnly`; the changelog updater lives in `scripts/`. Shared versions (`vite`, `vite-plugin-inspect`, `@fable-org/fable-library-js`) are declared once in the root workspace catalog and referenced as `catalog:`.
- `vite-plugin-fable` ships the daemon prebuilt and no longer has a `postinstall` script. It used to run `dotnet publish` on the consumer's machine, which package managers increasingly refuse to run by default (bun only for `trustedDependencies`, pnpm behind `onlyBuiltDependencies`, `npm --ignore-scripts` in CI); 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` that runs anywhere the .NET runtime does, so `--ignore-scripts` installs work and nothing is compiled at install time. The .NET 10 SDK is still required, because reading a `.fsproj` means asking `dotnet msbuild` about it. The published bits are portable IL rather than the ReadyToRun build the old `postinstall` produced, which costs roughly 0.7s of JIT per `vite dev` start and per `vite build`. A missing daemon assembly now fails with a message naming it instead of `dotnet`'s own error arriving behind "the daemon stopped unexpectedly". `cracking.fsx` still ships, and works against the bundled `bin/` without anything being built first.
- The daemon's debug server answers JSON under `/api`, so what it cracked, compiled, cached and served can be read by anything that is not a browser: `/api/status`, `/api/project`, `/api/files` (including the JavaScript emitted for one file), `/api/diagnostics` (unfiltered), `/api/cache` (whether the design time build was reused and which input invalidated it), `/api/requests` and `/api/logs`. Read-only, served from a snapshot the message loop publishes so it never queues behind a compile, and every response carries a revision that increments per served request. The log viewer page is unchanged. A running daemon writes `$TMPDIR/vite-plugin-fable/daemon-<pid>.json`, and `VITE_PLUGIN_FABLE_DEBUG_PORT` moves it off 9014 when two dev servers would collide.
- The `debug` plugin option starts the daemon's debug server too. It used to be plugin-side only, so the daemon's own output could only be reached by also setting `VITE_PLUGIN_FABLE_DEBUG`.
- Diagnostics for files under `fable_modules` are no longer reported. They are about the sources Fable restored for the packages a project depends on, which nobody using the plugin wrote or can edit. The new `fableModulesDiagnostics` option reports them again, including errors, which is worth turning on when a package itself is what looks broken.
- `fable-library` is located with `import.meta.resolve` instead of guessing `node_modules` paths.
- Prettier replaced by oxfmt (`bun run format`, `bun run format:check`).

### Fixed

- Plugin options are validated when the config loads. An unknown or badly typed option now fails with a message naming it (and suggesting the intended one) instead of being merged in and ignored, which in a `vite.config.js` was invisible.
- The package exports its `PluginOptions` and `FableConfiguration` types and declares an `exports` map, so a `vite.config.ts` can name what it passes in. The internal test seam no longer appears in the published type surface.
- All plugin options are documented, including `noReflection` and `exclude`, which were never mentioned anywhere.
- Changing the `noReflection` or `exclude` plugin options invalidates the caches. Both change what Fable emits but neither was part of the design time build cache key, so the previous build was reused and stale JavaScript was served with nothing to indicate it. The cached data now carries a format version too, so caches written before this fix are discarded rather than compared against fields they never stored.
- The MSBuild configuration follows the Vite command rather than `env.MODE`, so `vite build --mode staging` no longer compiles Debug F# into a production bundle. A new `configuration` plugin option overrides it, defaulting to `Release` for `vite build` and `Debug` for `vite dev`.
- The `transform` hook reports `map: { mappings: "" }` instead of `map: null`. `null` claims the previous source mapping still applies, which made later stages emit a map labelling the compiled JavaScript as the contents of a `.fs` file — devtools showed an F# filename containing JavaScript. Real F#-to-JS source maps remain blocked on Fable.
- Compiled output is now keyed off what the daemon returned rather than looked up per source file. The two sets differ (signature files are never compiled), and indexing the daemon's map with an already-normalised path would have yielded `undefined` for every entry had the daemon ever reported a non-POSIX path.
- CI runs the daemon test suite; previously only the plugin tests ran.
- TypeScript `strict` is on. That surfaced a real bug: `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 also the correct directory when `root` differs from the config file's location. A missing `.fsproj` is now an error rather than a `null` handed to the daemon.
- oxlint warnings fail the lint instead of being reported and ignored.
- `sample-project` runs its scripts on the Bun runtime through its own `bunfig.toml` rather than `bunx --bun` in each script, so the scripts are plain `vite`, `vite build` and `vite preview`. Bun only reads the `bunfig.toml` in the directory a command starts from, so the one at the repo root does not cover it.
- The sample project gained a `Greeting.fsi` / `Greeting.fs` pair whose output is rendered into the page heading, so signature-file behaviour can be exercised by hand: editing the implementation updates the heading in place, and breaking the signature surfaces the error against the implementation.
- Hot updates use Vite's `hotUpdate` hook instead of the deprecated `handleHotUpdate`, which also means created and deleted files now reach the plugin — `handleHotUpdate` only ever fired for updates.
- Fixed a hot-update race. Every in-flight change shared one promise, so a file edited while another was compiling was answered by the previous compile's diagnostics and pushed to the browser before it had been compiled at all; its own diagnostics were then discarded. Changes are now coalesced into batches that each carry their own result.
- Editing a signature file (`.fsi`) recompiles the implementation it describes. Previously nothing happened at all until an implementation file was touched.
- Editing an `.fsproj` or other MSBuild input now reloads the browser after the project is re-cracked, rather than re-cracking silently and leaving stale modules loaded.
- A changed F# file that nothing imports now triggers a reload instead of being silently ignored, and every module whose compiled output actually changed is invalidated rather than only the edited file. Files Fable recompiled without changing their output are left out: they are usually downstream modules that cannot accept a hot update, and one of those turns the whole update into a page reload. Editing an F# React component now hot-updates through Fast Refresh instead of reloading the page.
- `rxjs` and `promise.withresolvers` are no longer dependencies; the coalescing is a small queue.
- `vite build` now fails when F# does. A cracking or compile failure, or any error-severity diagnostic, aborts the build instead of logging and exiting 0 with broken output; an F# file Fable never compiled is reported rather than handed to the JavaScript parser as raw F#. `vite dev` is unchanged: the server stays up so the browser overlay can show the diagnostic.
- Plugin errors are logged through Vite's `logger.error` rather than `logger.warn`.
- oxlint runs over the repository, with `@nojaf/oxlint-plugin-annotate-non-primitives` requiring an explicit type annotation wherever the type is not obvious from the initializer. Every `lint` script runs oxlint before the TypeScript checks, and CI runs them on every PR.
- `scripts/changelog-updater.js` is now TypeScript and type-checked by `tsconfig.scripts.json`. `docs/scripts/command.js` stays JavaScript: the docs pages load it directly through an import map with no bundler, so porting it would mean adding a build step to a pipeline that has none.
- The plugin package is laid out as `src/` and `tests/`, with `bun test` covering the Vite hooks against a stub daemon. `bun run test:plugin` runs them; CI runs them on every PR.
- The daemon lives behind a `FableDaemon` interface in its own module. Process lifetime, the JSON-RPC endpoint and the positional wire format no longer leak into the plugin, which can now be run against a stub daemon in tests.
- The daemon process is spawned without a shell, so a `dotnet` that is not on `PATH` now fails immediately with an actionable message instead of hanging the dev server forever. Its stderr is drained (an undrained pipe would deadlock the daemon once the buffer filled), requests fail fast if the daemon exits, and the daemon is also stopped on `SIGINT`.
- Removed a leftover `console.log` that printed the whole HMR error payload to the terminal.
- `dotnet msbuild` invocations for the cache key no longer give up after 5 seconds and now fail on a non-zero exit code.

## [0.2.1] - 2025-10-23
Expand Down
81 changes: 81 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Notes for agents

Only things that are slow or impossible to work out from the repo itself. Everything derivable —
scripts, dependencies, layout — read from the files.

Use Bun for everything, including ad-hoc registry lookups (`bun pm view`, not `npm info`). The
sample deliberately runs on the Bun runtime rather than Node — that is the only place the plugin is
exercised under Bun, which matters because it spawns a child process and normalises paths.

## Check Vite and Fable behaviour against the source, not from memory

Both are often checked out as **siblings of this repo**, so `../vite` and `../Fable` resolve from
the repo root. That is a local convention rather than a guarantee — a fresh clone or CI will not
have them. Test for the directory before relying on it, and if it is missing, say so rather than
answering from memory.

When they are there, consult them freely; it is the fastest way to settle a question about someone
else's contract:

- `../vite` — the exact version in the workspace catalog. `packages/vite/src/node` answers
hook-contract questions definitively. Several plugin bugs were misdiagnosed from assumptions
about Vite that the source contradicted, and several fixes in the git history cite line numbers
from it.
- `../Fable` — Fable 5.14. Notably `src/Fable.Compiler/Library.fs`, where
`FileWriter.AddSourceMapping` is a no-op, so `CliArgs.SourceMaps` does nothing.

## Seeing what the plugin actually emitted, without a browser

`bun run build` in `sample-project` writes `.vite-inspect/reports/` (gitignored):

- `modules.json` — graph, deps, importers, which plugins transformed each module
- `transforms/*.json` — per module, the `vite-plugin-fable` step is the JavaScript it emitted. That
is its `load`, so there is no `__load__` step holding the F# source; the F# is the file on disk

Use this instead of guessing about transform output. The Vite DevTools panel shows the same thing
but is injected client-side behind a `#devframe_otp=` fragment, so it is invisible to `curl` —
the served HTML is byte-identical with and without it. Do not conclude DevTools is broken from
HTTP probes; that mistake has already been made twice.

## Asking the daemon what it is doing

With `fable({ debug: true })` or `VITE_PLUGIN_FABLE_DEBUG=1`, the daemon serves JSON on
`http://127.0.0.1:9014/api` (move it with `VITE_PLUGIN_FABLE_DEBUG_PORT`). `curl .../api` lists
every endpoint. It is read-only and answers from a snapshot the message loop publishes, so it
never blocks behind a compile and never changes what it is reporting on.

Reach for it before reconstructing anything from the outside:

- `/api/files?path=Greeting.fs` is the JavaScript Fable emitted for one file, without a build or
`.vite-inspect/`. `/api/files` lists them with sizes.
- `/api/diagnostics` is unfiltered, so it still shows the `fable_modules` ones the plugin drops.
- `/api/cache` says whether the design time build was reused and, if not, which input changed.
- `/api/project` is the crack result: source files in compile order, watched MSBuild inputs,
`?include=args,references` for the compiler arguments.
- `/api/requests` is the last 100 JSON-RPC calls with durations, `/api/logs` the daemon's log as
JSON with a `nextSince` cursor.

Every response carries a `revision` that increments per served message, which is how you tell
whether what you are reading already includes the edit you just made. A daemon you did not start
announces itself in `$TMPDIR/vite-plugin-fable/daemon-<pid>.json`.

## Traps that have cost real time

- **Vite's SPA fallback returns 200 with `index.html` for any unmatched path.** A status code
proves nothing when probing whether a route exists — compare the body against a deliberately
nonsense path.
- **`bunfig.toml` is read from the directory a command runs in and does not propagate.** That is
why `sample-project` has its own with `[run] bun = true`; a `[run]` block at the repo root looks
right and silently does nothing.
- **`bun test`: `expect(...).rejects.toThrow()` is typed `void`.** Awaiting it does nothing, and
bun asserts it without the await. Also give any plugin-context stub a real `error()` that
throws — otherwise `this.error(...)` raises a `TypeError` whose message quotes the call
expression, which can match a `toThrow` regex and pass for the wrong reason.
- **The stub daemon must return files downstream of the edited one**, because the real daemon
does. Tests that only return the requested file agree with bugs the plugin no longer has.

## `patches/`

`crossws@0.4.12.patch` drops a guard that refuses to run crossws's Node WebSocket adapter under
Bun. Without it, Vite DevTools cannot start on the Bun runtime. Pinned to that exact version, so a
bump needs it rebased; upstream fix tracked at devframes/devframe#317.
Loading