Skip to content

Commit ec73f4e

Browse files
committed
Ship the daemon prebuilt instead of building it on install
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.
1 parent e32e0f8 commit ec73f4e

10 files changed

Lines changed: 74 additions & 56 deletions

File tree

.github/workflows/main.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ jobs:
4646
run: bun run test:daemon
4747

4848
- name: Build daemon
49-
run: bun run --cwd packages/vite-plugin-fable postinstall
49+
run: bun run build:daemon
5050

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

.github/workflows/pr.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ jobs:
3939
run: bun run test:daemon
4040

4141
- name: Build daemon
42-
run: bun run --cwd packages/vite-plugin-fable postinstall
42+
run: bun run build:daemon
4343

4444
- name: Build sample
4545
run: bun run build

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2222
- 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.
2323
- 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).
2424
- 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:`.
25+
- `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.
2526
- 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.
2627
- 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`.
2728
- 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.

ROADMAP.md

Lines changed: 24 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Nothing here is done. Items are deleted as they land, so what remains is open work, a question nobody has answered, or a decision recorded so it does not get re-litigated. Finished work belongs in `CHANGELOG.md`, not here. Each item has enough context to be picked up independently.
44

5-
Item 1 is blocked upstream and can only be tracked and item 2 is on hold. Items 3 and 4 are projects, item 5 is a smaller one, item 6 records a rejected decision and item 7 is loose ends.
5+
Item 1 is blocked upstream and can only be tracked and item 2 is on hold. Item 3 is a project, items 4 and 5 are smaller ones, item 6 records a rejected decision and item 7 is loose ends.
66

77
References into `~/Projects/Fable` are against Fable 5.14, the version in the workspace catalog.
88

@@ -30,6 +30,17 @@ Only `Fable.Daemon/debug/index.html` and the WebSocket feed that fills it. `Debu
3030

3131
The plugin already receives some of this over JSON-RPC, so a panel could be fed from the JavaScript side: the current `compilableFiles` map, the last set of diagnostics, cracking versus compile timings, and which files each hot-update batch recompiled. The rest never crosses JSON-RPC — project options, the watched MSBuild inputs, the design-time cache hit or miss with its reason — so a panel wanting those reads the daemon's `/api` rather than duplicating the state.
3232

33+
**The shape to try first: a panel that is a view over `/api`**
34+
35+
Rather than feeding a panel from the JavaScript side, have it fetch the endpoints directly. The panel then holds no state of its own, there is one source of truth for what the daemon did, and `/api` gets a second consumer keeping it honest. It also makes the panel small enough to be worth building, which the "delete the HTML page" argument on its own no longer is.
36+
37+
Two things in the way, neither investigated:
38+
39+
- **Cross-origin.** DevTools serves its UI from its own port (9999 by default), so a panel fetching `http://127.0.0.1:9014/api` is a cross-origin request, and `Debug.fs` sets no CORS headers. Either the endpoints grow `Access-Control-Allow-Origin` (cheap, and the server is loopback-only and already gated behind `debug`), or the DevTools node side proxies them, which also solves the next point.
40+
- **Finding the port.** A browser cannot read `$TMPDIR/vite-plugin-fable/daemon-<pid>.json`, and the port is whatever `VITE_PLUGIN_FABLE_DEBUG_PORT` said. Something node-side has to hand the panel its URL, and the plugin is the only thing that knows.
41+
42+
What this shape would still not show is the plugin's own view: which files a hot-update batch recompiled, and the coalescing behaviour around it. That is plugin-side state that never reaches the daemon, so a panel wanting it needs both sources after all.
43+
3344
**How it actually runs**, which is not shaped like `vite-plugin-inspect`
3445

3546
DevTools is a **separate CLI on its own port**, not a route on your dev server. `vite-devtools` (shipped as a bin by `@vitejs/devtools`) reads the Vite config, starts on port 9999 by default and opens a browser. Adding `DevTools()` to `plugins` injects nothing into the app page and mounts no `/__devtools/` route. The embedded panel does work on Bun, but only because of `patches/crossws@0.4.12.patch`; `CLAUDE.md` covers what that patch does and why it is pinned.
@@ -84,60 +95,41 @@ Deciding whether a module is safe to re-run needs to know whether its top-level
8495
- [ ] Decide whether this is worth pursuing at all. A reload on a non-component edit is not obviously bad, and the component path — the one people iterate on — already works.
8596
- [ ] If yes, raise the "does this module have top-level effects" question with Fable before building anything on this side.
8697

87-
## 4. Replace `postinstall` with a prebuilt daemon package
98+
## 4. Shipping the daemon: what is left
8899

89-
**Why this matters most**
100+
The `postinstall` half of this landed: `vite-plugin-fable` ships a framework-dependent publish of `Fable.Daemon` in `bin/`, there is no lifecycle script, and `--ignore-scripts` installs work. The .NET SDK is still required, because cracking shells out to `dotnet msbuild` (`Fable.Daemon/MSBuild.fs:18`, and Fable's own `MSBuildCrackerResolver` does the same). Roughly 14 MB compressed, 35 MB unpacked.
90101

91-
Not performance — reliability. `packages/vite-plugin-fable/package.json` `postinstall` runs `dotnet publish` on the consumer's machine, and package managers increasingly refuse to run lifecycle scripts by default: bun only runs them for `trustedDependencies`, pnpm gates them behind `onlyBuiltDependencies`, and `npm --ignore-scripts` is common in CI and locked-down environments. When the script is skipped the install still _succeeds_, no `bin/` is produced, and the failure surfaces much later as a confusing `buildStart` error. Getting off `postinstall` removes a whole class of "it doesn't work on my machine" reports. The compile-on-install cost (full NuGet restore, needs network, needs the right SDK) is a bonus.
102+
Two things were considered and are worth not re-deriving.
92103

93-
**Decision: one prebuilt npm package containing portable IL**
104+
**A separate `@fable-org/fable-daemon` package: mostly moot now**
94105

95-
A framework-dependent publish of `Fable.Daemon` is portable IL that runs anywhere the .NET 10 runtime does, so this is a single **plain dependency** — not `optionalDependencies`, no `os`/`cpu` fields, no per-platform publish matrix:
96-
97-
```
98-
vite-plugin-fable → dependencies: { "<daemon package>": "<exact plugin version>" }
99-
```
100-
101-
The bits then arrive at `npm install` like everything else, the lockfile pins them so plugin/daemon skew is structurally impossible, and the existing `node_modules` CI cache covers them. The .NET SDK is still required — project cracking shells out to `dotnet msbuild` (`Fable.Daemon/MSBuild.fs:18`, and Fable's own `MSBuildCrackerResolver` does the same) — and that is accepted; say so plainly in the README.
102-
103-
**Naming: leave room for a future split**
104-
105-
Whatever the single package is called now becomes awkward if platform packages arrive later, so pick a name that does not encode the current choice (nothing like `-portable`). The ecosystem convention is a scope for the platform packages — `esbuild` + `@esbuild/darwin-arm64`, `rolldown` + `@rolldown/binding-darwin-arm64`, `oxfmt` + `@oxfmt/binding-darwin-arm64`.
106-
107-
The `@fable-org` scope already exists on npm (`@fable-org/fable-library-js`), so something like `@fable-org/fable-daemon` leaves `@fable-org/fable-daemon-<rid>` free later. The daemon is also not really Vite-specific — it is a Fable compilation daemon that this plugin happens to drive — so a name that does not mention Vite is both more accurate and more reusable. Coordinate with the Fable org before claiming it.
106+
The plan was a prebuilt daemon as its own package, a plain dependency pinned to the exact plugin version, so the lockfile made plugin/daemon skew structurally impossible. Bundling gives that for free, since they are one package, so the remaining argument is only about not re-downloading the bits on every plugin patch release, which npm's tarball-per-version model does not avoid anyway. Revisit only if the per-RID split below happens, where a scope for platform packages (`esbuild` + `@esbuild/darwin-arm64`, `rolldown` + `@rolldown/binding-darwin-arm64`) is the ecosystem convention. `@fable-org` already exists on npm, so `@fable-org/fable-daemon-<rid>` is available; coordinate with the Fable org before claiming it, and pick a name that does not mention Vite, since the daemon is not Vite-specific.
108107

109108
**Deferred: per-RID ReadyToRun packages**
110109

111-
Measured on this repo, `sample-project`, warm caches, four `vite build` runs each:
110+
What ships is portable IL, so the JIT cost the ReadyToRun build avoided is now paid on every `vite dev` start and every `vite build`. Measured on `sample-project`, warm caches:
112111

113112
| variant | size | runs |
114113
| ------------------------------------------------------- | ----- | -------------------------- |
115114
| ReadyToRun (`--ucr -p:PublishReadyToRun=true`, per-RID) | 77 MB | 1.41s, 1.41s, 1.40s, 1.42s |
116-
| portable IL (framework-dependent) | 33 MB | 2.15s, 2.17s, 2.15s, 2.13s |
117-
118-
About **0.74s, roughly 35% faster** with ReadyToRun, very low variance. Caches were warm, so that is JIT across the type-check and compile path, not process startup, and it is paid on every `vite dev` start and every `vite build`.
115+
| portable IL (framework-dependent, what ships) | 33 MB | 2.15s, 2.17s, 2.15s, 2.13s |
119116

120-
Not acted on yet, for two reasons:
117+
Not acted on, for two reasons that still hold:
121118

122119
- **The 35% flatters ReadyToRun.** `sample-project` is five files. JIT cost is roughly fixed — the same FCS code paths get compiled regardless of project size — so on a real project the absolute delta stays near 0.7s while the percentage collapses. Re-measure against something like the telplin or fantomas-tools projects in `DebugTests.fs` before treating 35% as real.
123-
- **Its value has already shrunk.** The dev server no longer blocks on the first compile, so the 0.74s is spent behind a server that is already listening rather than in front of a terminal with no URL. That makes a five-package matrix much harder to justify.
120+
- **Its value has already shrunk.** The dev server no longer blocks on the first compile, so the 0.74s is spent behind a server that is already listening rather than in front of a terminal with no URL.
124121

125122
Cost if it is ever taken up: five or so packages at 77 MB each to publish (a consumer downloads one), a per-RID CI matrix, `os`/`cpu` platform packages, a resolution shim on the JS side, and a fallback when no platform package matches.
126123

127124
**Also considered: a multi-RID .NET tool**
128125

129-
.NET 10 can publish a tool carrying several RuntimeIdentifiers with the CLI selecting one at run time, invoked via `dotnet tool exec` / `dnx` without a permanent install (verified working on SDK 10.0.400). It fits, since the SDK is required anyway, and it would move RID selection out of npm entirely — see `baronfel/multi-rid-tool` for `PackAsTool` / `ToolCommandName` / `dotnet pack -p ToolType=<agnostic|specific|self-contained|trimmed|aot>`.
126+
.NET 10 can publish a tool carrying several RuntimeIdentifiers with the CLI selecting one at run time, invoked via `dotnet tool exec` / `dnx` without a permanent install (verified working on SDK 10.0.400). See `baronfel/multi-rid-tool` for `PackAsTool` / `ToolCommandName` / `dotnet pack -p ToolType=<agnostic|specific|self-contained|trimmed|aot>`.
130127

131-
Passed over because the bits would then arrive on first `vite dev` rather than at install, putting a NuGet download in front of a dev server that already blocks on startup, and shifting the failure mode for restricted networks from install time to first run. Worth revisiting only if the per-RID split happens, where the CLI doing RID selection beats maintaining `os`/`cpu` packages. Self-contained, trimmed and AOT are ruled out regardless: the SDK is present anyway, and FSharp.Compiler.Service is reflection-heavy enough that trimming would break things (`Caching.fableCompilerVersion` reads the Fable.Compiler version via reflection).
128+
Passed over because the bits would then arrive on first `vite dev` rather than at install, shifting the failure mode for restricted networks from install time to first run. Worth revisiting only if the per-RID split happens, where the CLI doing RID selection beats maintaining `os`/`cpu` packages. Self-contained, trimmed and AOT are ruled out regardless: the SDK is present anyway, and FSharp.Compiler.Service is reflection-heavy enough that trimming would break things (`Caching.fableCompilerVersion` reads the Fable.Compiler version via reflection).
132129

133130
**To do**
134131

135-
- [ ] Agree the package name with the Fable org, then publish the portable-IL daemon from CI, version-locked to the plugin release.
136-
- [ ] Resolve the daemon through the module system (`import.meta.resolve`), the way `getFableLibrary` already resolves `fable-library`, replacing the hard-coded `path.join(currentDir, "..", "bin", "Fable.Daemon.dll")`.
137-
- [ ] Drop `postinstall`, the F# sources and `Directory.*.props` from the published plugin's `files`.
138-
- [ ] Keep `bun run build:daemon` at the repo root for local work so the sample project runs against the working tree, not the published package.
139-
- [ ] Give a clear error when the daemon package cannot be resolved. A missing .NET SDK is already detected spawn-side and fails fast.
140-
- [ ] README: state that the .NET 10 SDK is required, and why.
132+
- [ ] README: state that the .NET 10 SDK is required, and why. `docs/getting-started.md` says it; the README does not.
141133

142134
## 5. Cache invalidation: an explicit `<Import>` is invisible
143135

docs/getting-started.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,14 @@ First you need a new Vite project:
1616

1717
Next, you need to install the `vite-plugin-fable` package:
1818

19-
<vpf-command npm="npm install -D vite-plugin-fable" bun="bun install -D --trust vite-plugin-fable"></vpf-command>
19+
<vpf-command npm="npm install -D vite-plugin-fable" bun="bun install -D vite-plugin-fable"></vpf-command>
2020

21-
It is important that the _post-install script_ of the plugin did run. The first time this runs, it can take some time.
21+
That is the whole install. The compiler ships inside the package, so there is no post-install step
22+
to trust, no lifecycle script to run, and nothing to compile on your machine. Installing with
23+
`--ignore-scripts` is fine.
2224

23-
If for some reason it didn't run, please manually invoke:
24-
25-
<vpf-command npm="npm --prefix ./node_modules/vite-plugin-fable/ run postinstall" bun="bun run --cwd ./node_modules/vite-plugin-fable/ postinstall"></vpf-command>
25+
You do still need the **.NET 10 SDK** on your `PATH`: reading your `.fsproj` means asking MSBuild
26+
about it, and that is `dotnet msbuild`. Check with `dotnet --version`.
2627

2728
_Note: you don't need to install Fable as a dotnet tool when using this plugin._
2829

packages/vite-plugin-fable/Fable.Daemon/Debug.fs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@ let zeroSuaveLogger : Logger =
2828
member x.name = [| "vite-plugin-fable" |]
2929
}
3030

31-
let homeFolder = Path.Combine (__SOURCE_DIRECTORY__, "debug")
31+
/// Next to the assembly rather than `__SOURCE_DIRECTORY__`, which bakes in the path the daemon was
32+
/// compiled in. That was the same folder while the daemon was built on the machine that ran it, and
33+
/// is a stranger's home directory now that it ships prebuilt.
34+
let homeFolder = Path.Combine (AppContext.BaseDirectory, "debug")
3235

3336
type LogEntry =
3437
{

packages/vite-plugin-fable/Fable.Daemon/Fable.Daemon.fsproj

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
<Compile Include="MSBuild.fs" />
1212
<Compile Include="Caching.fs" />
1313
<Compile Include="ProjectCracking.fs" />
14-
<Content Include="debug\index.html" />
15-
<Content Include="debug\favicon.ico" />
14+
<!-- Copied next to the assembly, because the daemon now ships prebuilt: it is no longer
15+
compiled in the folder it is served from. -->
16+
<Content Include="debug\index.html" CopyToOutputDirectory="PreserveNewest" />
17+
<Content Include="debug\favicon.ico" CopyToOutputDirectory="PreserveNewest" />
1618
<Compile Include="Debug.fsi" />
1719
<Compile Include="Debug.fs" />
1820
<Compile Include="Program.fs" />

0 commit comments

Comments
 (0)