Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,15 @@ jobs:
# stale. The same script proves no Node regression under `npm test`.
- name: webjs dev hot reload on Bun
run: bun test/bun/dev-hot-reload.mjs
# Dev live-reload of a webjs.dev.watch dir OUTSIDE the appDir (#894) and
# the SSE retry hint on the Bun.serve shell (#893). Plain scripts (no
# per-test timeout) since each boots the real CLI + polls for readiness;
# the node:test integration versions are node:http-listener-specific and
# denylisted in run-bun-tests.js.
- name: webjs dev extra-watch + reload-retry on Bun
run: |
bun test/bun/dev-extra-watch.mjs
bun test/bun/dev-reload-retry.mjs
# SSR action-result seeding on Bun (#529): seeding rode Node's
# module.registerHooks, which Bun lacks; it now installs via a Bun.plugin
# onLoad, so a shipping async component seeds during SSR (the __webjs-seeds
Expand Down
4 changes: 3 additions & 1 deletion agent-docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,8 @@ identically. This replaces the old `predev` / `prestart` npm hooks +
"webjs": {
"dev": {
"before": ["webjs db migrate"], // one-shot, runs to completion first
"parallel": ["tailwindcss -i ./public/input.css -o ./public/tailwind.css --watch"] // long-lived, runs alongside the server
"parallel": ["tailwindcss -i ./public/input.css -o ./public/tailwind.css --watch"], // long-lived, runs alongside the server
"watch": ["../blog"] // extra dirs to live-reload on, OUTSIDE the appDir
},
"start": {
"before": ["webjs db migrate"] // one-shot, before serving
Expand All @@ -286,6 +287,7 @@ identically. This replaces the old `predev` / `prestart` npm hooks +

- **`before`** (dev and start): commands run sequentially to completion BEFORE the server boots (the old `predev` / `prestart`: `webjs db migrate`, a registry copy). A non-zero exit ABORTS the boot with a clear message, so a failed migration never serves stale code/schema.
- **`parallel`** (dev only): long-lived child processes that run ALONGSIDE the server (the old `concurrently` watchers: the Tailwind CLI `--watch`). They are spawned once in the parent (not on every hot-reload restart) and TORN DOWN on exit (SIGINT / SIGTERM / server exit), so a watcher cannot leak past the dev server.
- **`watch`** (dev only, #894): extra directories the dev live-reload watcher follows IN ADDITION to the appDir. The dev server watches its appDir recursively, but an app that reads content from OUTSIDE its tree (the in-repo `website` renders posts from a repo-root `blog/` dir, a sibling of the app) sees no reload when that outside content changes. Each entry is resolved relative to the app root and MAY escape it (`"../blog"`); a change under one runs the same rebuild + browser reload as an in-tree edit. Opt-in: a missing/empty `watch` means the appDir is the only watched root, unchanged. A configured path that does not exist is skipped, an entry that overlaps the appDir (an ancestor or descendant) is dropped as redundant, and the usual `node_modules` / `.git` / `.webjs` / `db` noise is ignored under each extra root too.
- Each command runs through a shell, so a normal command line works. An empty / absent block means `webjs dev` / `start` run unchanged, so a plain app with no Tailwind/DB needs no config.
- The scaffold uses the Tailwind browser runtime (no CSS build step), so it ships only `dev.before` / `start.before` (the Drizzle migration apply); an app that adds the Tailwind CLI puts its `--watch` under `webjs.dev.parallel`. The in-repo apps (`examples/blog`, `website`, `docs`, ui-website) show the Tailwind `parallel` watcher pattern.
- **Prod note:** `before` runs at boot, so `webjs start` runs `webjs db migrate` in-process to apply pending migrations. Drizzle has no client-codegen step (the schema IS the types, inferred at compile time), so there is nothing to run at image-BUILD time. Authoring a new migration from a schema change is a dev-time `webjs db generate`, committed to source control, not a boot step. So `CMD ["npm", "start"]` and `CMD ["webjs", "start"]` are equivalent.
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/webjs-config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,17 @@ export interface WebjsDevTasks {
* the parent and torn down on exit, so a watcher cannot leak past the server.
*/
parallel?: string[];
/**
* Extra directories the dev live-reload watcher follows IN ADDITION to the
* appDir (#894). The dev server watches its appDir recursively, but an app
* that reads content from OUTSIDE its tree (e.g. blog markdown in a repo-root
* `blog/` dir, a sibling of the app) sees no reload when that content changes.
* Each entry is resolved relative to the app root and MAY escape it
* (`"../blog"`); a change under one triggers the same rebuild + browser reload
* as an in-tree edit. Missing / overlapping (ancestor or descendant of the
* appDir) entries are skipped. Read by the server (`readDevWatchPathsFromApp`).
*/
watch?: string[];
}

/** Start task orchestration in `webjs.start` (#550). Read by the CLI, not the server. */
Expand Down
2 changes: 1 addition & 1 deletion packages/server/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ with metadata, Suspense, streaming) for HTML, or `api.js` /

| File | What it owns |
|---|---|
| `dev.js` | The request handler. File serving, TypeScript stripping (Node 24+ built-in `module.stripTypeScriptTypes`, backed by the `amaro` package; non-erasable syntax fails at strip time with a 500), **server-file guardrail**, live reload via SSE. Also the observability seam (#239): `handle()` mints / honors the per-request id (`X-Request-Id` + `setRequestId`), emits the one-line structured access log via `logger.info` after the response (suppressing `/__webjs/*` probe traffic), and routes unhandled errors to the app's `onError` sink (best-effort, threaded into the SSR error path, the action endpoint, middleware, metadata, and the top-level catch); applies conditional GET (#240, via `applyConditionalGet`) as the final funnel step so every cacheable response gets an ETag + honors If-None-Match -> 304; commits the server HTML cache (#241, via `commitHtmlCache`) just before conditional-GET so the store decision sees the final post-middleware response (threads `cspEnabled` into the page `ssrOpts` so a CSP page is never HTML-cached); `produce()` answers the `/__webjs/version` build-info probe. Dev error overlay (#264): `reportDevError(error, info)` builds a frame (via `dev-error.js`) and pushes it to the open tab over the SSE channel as a `webjs-error` event, fed by three sources (the SSR render catch via `ssrOpts.onDevError`, the `tsResponse` strip-failure, and the `rebuild` catch); a successful rebuild clears `state.lastDevError`; `startServer`'s SSE replays the current frame to a freshly-connected tab; `reloadClientJs` renders the dev-only plain-DOM overlay (`textContent` only). Dev-only: `reportDevError` early-returns in prod and `/__webjs/reload.js` 404s. **Listener shell (#511):** `startServer` builds a shared `SseHub` + `ListenerContext`, then selects a shell by `serverRuntime()`: `startNodeListener` (in this file, the node:http path: `toWebRequest` -> `app.handle` -> `sendWebResponse`, 103 Early Hints, node WS via `attachWebSocket`, node:http timeouts) on Node, or the dynamically-imported `startBunListener` (`listener-bun.js`) on Bun. The SSE registry/fanout, the live-reload predicate, the WS module loader, and the lifecycle wiring live in `listener-core.js` so the two shells share them. `isCompressible` (used by `sendWebResponse`) also moved there |
| `dev.js` | The request handler. File serving, TypeScript stripping (Node 24+ built-in `module.stripTypeScriptTypes`, backed by the `amaro` package; non-erasable syntax fails at strip time with a 500), **server-file guardrail**, live reload via SSE. Also the observability seam (#239): `handle()` mints / honors the per-request id (`X-Request-Id` + `setRequestId`), emits the one-line structured access log via `logger.info` after the response (suppressing `/__webjs/*` probe traffic), and routes unhandled errors to the app's `onError` sink (best-effort, threaded into the SSR error path, the action endpoint, middleware, metadata, and the top-level catch); applies conditional GET (#240, via `applyConditionalGet`) as the final funnel step so every cacheable response gets an ETag + honors If-None-Match -> 304; commits the server HTML cache (#241, via `commitHtmlCache`) just before conditional-GET so the store decision sees the final post-middleware response (threads `cspEnabled` into the page `ssrOpts` so a CSP page is never HTML-cached); `produce()` answers the `/__webjs/version` build-info probe. Dev error overlay (#264): `reportDevError(error, info)` builds a frame (via `dev-error.js`) and pushes it to the open tab over the SSE channel as a `webjs-error` event, fed by three sources (the SSR render catch via `ssrOpts.onDevError`, the `tsResponse` strip-failure, and the `rebuild` catch); a successful rebuild clears `state.lastDevError`; `startServer`'s SSE replays the current frame to a freshly-connected tab; `reloadClientJs` renders the dev-only plain-DOM overlay (`textContent` only). **Graceful reload (#893):** a reload never paints into a half-restarted server (Node's `node --watch` briefly kills the process on an app edit), so the client gates every reload on a `/__webjs/version` readiness probe (probe-then-reload, instant under an in-process reload, waits out a restart), and a reconnect after a drop is itself treated as an edit signal so an app edit whose in-process reload frame was killed with the old process still reloads without a manual refresh (the SSE `hello` carries a short `retry: 300` so the reconnect is prompt). The reconnect-reload lives in the `dev-reload-worker.js` relay (shared connection) and the per-tab `__webjsDirectEvents` fallback. **Extra watch roots (#894):** `startServer`'s recursive `fs.watch` also follows the dirs in `webjs.dev.watch` (`readDevWatchPathsFromApp`), for content the app reads from OUTSIDE its appDir (blog markdown in a repo-root `blog/`). Dev-only: `reportDevError` early-returns in prod and `/__webjs/reload.js` 404s. **Listener shell (#511):** `startServer` builds a shared `SseHub` + `ListenerContext`, then selects a shell by `serverRuntime()`: `startNodeListener` (in this file, the node:http path: `toWebRequest` -> `app.handle` -> `sendWebResponse`, 103 Early Hints, node WS via `attachWebSocket`, node:http timeouts) on Node, or the dynamically-imported `startBunListener` (`listener-bun.js`) on Bun. The SSE registry/fanout, the live-reload predicate, the WS module loader, and the lifecycle wiring live in `listener-core.js` so the two shells share them. `isCompressible` (used by `sendWebResponse`) also moved there |
| `router.js` | Scans `app/` once, builds the route table, matches pages + APIs (`buildRouteTable`, `matchPage`, `matchApi`). Page order is set by `compareSpecificity` (#750): POSITIONAL specificity (per URL segment, static `0` < dynamic `1` < catch-all `2` via `segKind`, lexicographic on the kind arrays with shorter-prefix-first), so the catch-all kind is lowest AT ITS POSITION (a literal-prefixed catch-all like `docs/[[...slug]]` outranks an all-dynamic `[org]/[repo]`), NOT a global catch-all-last bucket, then a stable alphabetical `routeDir` tiebreak. This replaces the old coarse 3-bucket `dynScore` whose same-bucket ties resolved by fs-walk order (so `/[org]/[repo]` vs `/[user]/settings` could match the wrong page). `matchPage` returns the first pattern that matches in that deterministic order |
| `route-types.js` | Route-types generator (#258). `generateRouteTypes(appDir)` reuses `buildRouteTable` to emit the `.d.ts` text that augments `@webjsdev/core` (the `WebjsRoutes` href union + `RouteParamMap` per-route params), backing `webjs types` and the dev-startup emit. Pages-only (a `route.{js,ts}` API path is not a navigable href); strips route groups, excludes `_private`; an optional catch-all `[[...x]]` emits both the without-segment and a normalized `[...x]` href key while keeping the doubled literal as the param-map key. Deterministic (sorted keys). Helpers `routeKeyFromDir` / `dynamicSegments` / `paramTypeForKey` / `webjsRoutesKeysForKey` are exported for unit tests |
| `ssr.js` | SSR pipeline: nested layouts, metadata → `<head>`, Suspense streaming, error boundaries. `ssrPage` accepts `actionData` (put on `ctx.actionData` for the page + layouts) and `status` (default 200; the page-action re-render passes 422). Server HTML cache (#241): on a plain GET render it loads the page module once to read `export const revalidate`, serves a cache HIT via `cachedHtmlResponse` (re-minting the build id), and on a miss stamps the `HTML_CACHE_MARKER` so the funnel writes the final body. Skipped for the page-action re-render and partial-nav (`X-Webjs-Have`) requests. **Frame subtree render (#253):** after `renderChain`, when the request carries `x-webjs-frame: <id>` (a `<webjs-frame src>` self-load or a click-driven frame nav) AND the render is non-streamed, it extracts the matching `<webjs-frame id>` subtree from the rendered body via `frame-render.js` and returns ONLY that (byte-equivalent to the client's extraction from a full page, but far fewer bytes); an absent frame id falls through to the full page (the client's `webjs:frame-missing` handles it), and a request with no `x-webjs-frame` header is byte-identical to before. **Vendor modulepreload (#754):** `reachedVendorSpecifiers(graph, shippedEntryFiles, componentUrls, appDir, elidable, serverFiles)` collects the bare specifiers (`bareImports(graph)`) reached by the page's SHIPPED modules. Its walk ROOTS are the boot's actually-shipped set: the caller passes the absolute paths of `moduleUrls` (which already drops INERT page/layout modules and substitutes an IMPORT-ONLY page with its components) plus the rendered `componentUrls`, then it walks the non-elided transitive closure and collects each reached file's bare imports, skipping server files (the `.server.*` suffix AND the `serverFiles` action index). Because the roots are the shipped set, a vendor reached ONLY through a dropped module, a dropped page's SSR-only DIRECT vendor import OR its SSR-only RELATIVE HELPER's vendor, is never collected (pages/layouts are not importable, so nothing that ships reaches them), so there is no over-fetch. `vendorPreloadTargets` (importmap.js) maps that set to `{ href, integrity }`, and `wrapHead` emits one `<link rel="modulepreload">` per target (no `fp()` rewrite, deduped against the app module/component preloads, with `crossorigin` + `integrity`), flattening the cross-origin CDN waterfall one level. Only reached + non-elided + pinned vendors are hinted |
Expand Down
18 changes: 18 additions & 0 deletions packages/server/src/dev-reload-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl) {
const ports = new Set();
/** @type {string | null} the last error frame, cached for late-joining tabs */
let lastError = null;
/** @type {string | null} the last-seen per-process boot id (#893) */
let lastBoot = null;

// A MessagePort has no reliable close event, so prune a port when a post to it
// throws (a closed tab). Some browsers silently no-op instead of throwing,
Expand All @@ -33,6 +35,22 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl) {
}

const es = new EventSourceCtor(eventsUrl);

// The `hello` frame fires on every (re)connect and carries the server's
// per-process boot id (#893). A full server restart (Node's `node --watch`)
// drops this connection, and if the in-process rebuild's `reload` frame was
// killed with the old process no reload was delivered, so the edit would need
// a MANUAL refresh. The browser auto-reconnects to the FRESH process, whose
// boot id differs, so a CHANGED id is the edit signal: broadcast a reload (the
// tab still gates it on a readiness probe). A transient reconnect (sleep/wake,
// a network blip, a tab evicted at the HTTP/1.1 cap) reconnects to the SAME
// process with the SAME id, so it never reloads. The first `hello` only
// records the baseline.
es.addEventListener('hello', (e) => {
Comment thread
vivek7405 marked this conversation as resolved.
if (lastBoot !== null && e.data !== lastBoot) fanout({ type: 'reload' });
lastBoot = e.data;
});

es.addEventListener('reload', () => { lastError = null; fanout({ type: 'reload' }); });
es.addEventListener('webjs-error', (e) => { lastError = e.data; fanout({ type: 'webjs-error', data: e.data }); });

Expand Down
Loading
Loading