Hot Module Replacement
Edit services, controllers, and models without restarting the process
HMR is on by default in development. When you run just dev, the @justscale/hmr/registerloader hooks Node's module resolution, watches the workspace, and hot-swaps modules in place on every save. Nothing to wire up in your own code.
What Survives, What Rebuilds
HMR reloads the module graph that changed and rebinds it to the running container. Services, controllers, models, and their route handlers swap to the new implementation without a process restart. Module-scoped state in a separate file keeps its value across swaps.
// Module-scoped state — survives HMR swaps of the service file below
export let counter = 0;
export const bump = () => ++counter;- Rebuilt on save:the edited file's module and anything downstream in the DI graph.
- Preserved:the process itself, open sockets, module-scoped state in files you didn't touch, in-flight requests that started before the swap.
- Not preserved:per-instance state inside a service factory — that's the point of a rebuild. Keep anything worth keeping in a separate state module, or in a real store.
Adding New Wiring Without Restart
HMR isn't just for editing existing code. Adding a fresh .add(NewService) or .add(NewController) to your app factory is picked up on the next save: the new definition lands in the live DI container, controllers get resolved against it, routes light up. The process keeps running, existing state keeps its value, in-flight requests finish against the old graph.
// src/app.ts
export default defineApp(import.meta, (env: AppEnv) =>
JustScale()
.add(env)
.add(GreetingService)
.add(GreetingController),
);Save the file after adding .add(AdminController) at the end of the chain:
[hmr] src/app.ts changed — bumping 3 url(s) in dep chain
[hmr] rebuilding — entry …/src/app.ts @ v=1776780742716
[hmr] added controller src/admin.controller.ts#AdminController (routes=1)
[hmr] rebuild complete in 9ms — replaced=0 added=1 removed=0The controller's inject list is resolved against the live container, so a newly-wired controller that depends on a service registered at boot (or in the same edit) connects to the same singleton the rest of the app already uses — no fresh instance, no split state.
- One-time stable-ID cost. The first save that introduces a module logs
added. Every subsequent save of the same file is a normalreplaced(factory swap in place) — identical performance to editing a service that existed from boot. - Services get registered only. A service with no consumers stays dormant on the live container; nothing resolves it until a controller or another service pulls it in.
- Broken grafts are refused. If the new
.add(X)introduces an unsatisfied dependency (sayXneeds a service you forgot to also add), the rebuild's.build()validator rejects the graph, logs exactly what's missing, and leaves the live app untouched. No half-wired state.
[hmr] src/app.ts changed — bumping 3 url(s) in dep chain
[hmr] rebuild failed during build(): DependencyError: Missing dependencies:
ParentService requires:
- ChildServiceTip
HMR_VERBOSE=1 before just devto trace the rebuild step-by-step: which stable IDs were collected, which were new vs. known, whether each new one was actually registered in the new build's DI graph. Useful when an edit doesn't propagate the way you expected.Limits (Honest List)
Removal is a no-op today. If you delete .add(X) from the chain, X's instance keeps running and its routes keep answering until the next process restart. Matters most for services that own timers, intervals, or open connections — those don't get shutdown-on-remove. Safe for pure-function services. Planned.
Durable process definitions (*.process.ts) have a more conservative rule: HMR swaps the service def, but any execution already suspended mid-flight keeps using its original compiled state machine. A process started before an edit completes on the old code; new executions use the new code. Intentional — yanking an opcode out from under a live workflow is worse than a slightly stale run.
Syntax Errors Are Non-Fatal
A broken save does not crash the dev process. The old handler keeps serving traffic while HMR logs the failure. Fix the typo, save again, and the next good build replaces the handler. You do not need to restart just dev.
$ just dev
[hmr] watching workspace
# ... edit a file with a syntax error ...
[hmr] rebuild failed: services/user-service.ts (1:14): unexpected token
[hmr] keeping previous handler
# ... fix and save ...
[hmr] reloaded services/user-service.tsTip
Testing HMR
The HMR package ships an e2e harness for tests that need to exercise the real reload loop — a live child process, real HTTP calls, and a per-run temporary copy of a fixture directory so mutations don't leak between tests.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { startFixture } from '@justscale/hmr/test/e2e/harness';
test('handler reloads on edit', async () => {
const app = await startFixture({ fixtureDir: './fixtures/basic' });
try {
assert.equal(await app.json('/hello'), 'hello');
// edit() writes the file and waits for HMR to settle
await app.edit('src/hello.ts', (src) =>
src.replace('hello', 'hola'),
);
assert.equal(await app.json('/hello'), 'hola');
} finally {
await app.shutdown();
}
});The harness copies fixtureDir into a temp path per run, spawns a child just dev against the copy, and gives you helpers to edit files and wait for HMR. A suite of example tests covering method-swap, route add/remove, controller and service add, missing-dep rejection, and syntax-error recovery lives in packages/feature/hmr/test/e2e/ — lift whichever pattern fits.