@webjsdev/cli 0.10.58 → 0.10.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,29 +1,44 @@
1
1
  /**
2
- * Dev-server reload supervisor planning for `webjs dev` (issue #514).
2
+ * Dev-server reload supervisor planning for `webjs dev` (issues #514, #1521).
3
3
  *
4
- * `webjs dev` re-execs itself under the host runtime's hot-reload supervisor so
5
- * an edit to a transitively-imported module (an action, query, component, util)
6
- * takes effect without a manual restart. Both runtimes cache ES modules by
7
- * resolved URL with no public invalidation API, so the dev re-import in
8
- * `@webjsdev/server`'s `dev.js` relies on the runtime's own file-watching cache
9
- * invalidation:
4
+ * `webjs dev` runs its server in a CHILD process that a supervising parent
5
+ * restarts, so an edit to a transitively-imported module (an action, query,
6
+ * component, util) takes effect without a manual restart. Both runtimes cache
7
+ * ES modules by resolved URL with no public invalidation API, so the dev
8
+ * re-import in `@webjsdev/server`'s `dev.js` relies on a fresh process (Node)
9
+ * or the runtime's own cache invalidation (Bun):
10
10
  *
11
- * - **Node** has no in-place module-cache eviction, so it re-execs under
12
- * `node --watch`, which RESTARTS the process on a file change (a fresh ESM
13
- * cache each time). The dev re-import additionally appends a `?t=` cache-bust
14
- * query that Node honours between restarts.
15
- * - **Bun** keys its module cache by path and IGNORES that `?t=` query, so the
16
- * `node --watch` model does not transfer: without help a re-imported module
17
- * stays STALE on Bun (the #514 bug). Bun's `--hot` invalidates loaded modules
18
- * on a file change WITHOUT restarting the process, which is exactly what the
19
- * dev re-import needs; `Bun.serve` is reused across hot reloads, so the
20
- * listener is not duplicated. `--hot` auto-watches every loaded file, so the
21
- * node `--watch-path` flags do not apply (and are not Bun flags).
11
+ * - **Node** has no in-place module-cache eviction, so the parent RESTARTS the
12
+ * child on a change under the watched paths (a fresh ESM cache each time).
13
+ * This used to be `node --watch`, which dies on the first watcher error it
14
+ * cannot handle (an EACCES on a temp file another user created, a file that
15
+ * vanished mid-scan, #1521) and takes the preview down for good. WebJs's own
16
+ * supervisor (`lib/dev-reload.js`) watches the same paths with every watcher
17
+ * error handled, and restarts the child faster.
18
+ * - **Bun** keys its module cache by path and IGNORES the `?t=` cache-bust, so
19
+ * a restart-per-edit model is not needed: `bun --hot` invalidates loaded
20
+ * modules on a file change WITHOUT restarting the process, and `Bun.serve` is
21
+ * reused across hot reloads. The parent still supervises it, but only to
22
+ * bring a CRASHED child back (`restartOnChange: false`).
23
+ *
24
+ * On both runtimes a child that exits on its own (a crash) is restarted on the
25
+ * next file change, and after a short backoff even with no change.
22
26
  *
23
27
  * This pure planner returns the spawn decision so the bin stays a thin shell and
24
28
  * the branch logic is unit-testable without spawning a process.
25
29
  */
26
30
 
31
+ /** Project directories whose changes restart the dev server on Node. */
32
+ export const WATCH_DIRS = ['app', 'components', 'modules', 'lib', 'actions'];
33
+
34
+ /**
35
+ * Every extension the server's root-middleware lookup accepts, in the same
36
+ * order. If these two lists diverge, an app gets a middleware that loads but
37
+ * never restarts the dev server when edited, which is the quiet half of the
38
+ * bug where a `middleware.ts` was loaded by neither.
39
+ */
40
+ export const WATCH_FILES = ['middleware.ts', 'middleware.js', 'middleware.mts', 'middleware.mjs'];
41
+
27
42
  /**
28
43
  * Plan how `webjs dev` runs its server.
29
44
  *
@@ -31,35 +46,21 @@
31
46
  * @param {boolean} opts.isBun Whether the host runtime is Bun (`process.versions.bun`).
32
47
  * @param {string[]} opts.argv `process.argv.slice(1)` (the script path followed by its args), forwarded to the child verbatim.
33
48
  * @param {boolean} opts.noHot Whether `--no-hot` was passed (opt out of the supervisor entirely).
34
- * @param {(path: string) => boolean} opts.exists Existence check for the Node `--watch-path` targets (relative to cwd). Unused on Bun.
35
- * @returns {{ mode: 'inline' } | { mode: 'spawn', args: string[] }}
36
- * `inline` runs the server in this process (no reload watcher); `spawn`
37
- * re-execs `process.execPath` with `args` and `__WEBJS_DEV_CHILD=1`.
49
+ * @returns {{ mode: 'inline' } | { mode: 'supervise', args: string[], restartOnChange: boolean, watchDirs: string[], watchFiles: string[] }}
50
+ * `inline` runs the server in this process (no reload watcher); `supervise`
51
+ * spawns `process.execPath` with `args` and `__WEBJS_DEV_CHILD=1` under the
52
+ * supervisor, which watches `watchDirs` (recursively) and `watchFiles` (at
53
+ * the app root). The directories need not exist yet: one created later is
54
+ * picked up.
38
55
  */
39
- export function planDevSupervisor({ isBun, argv, noHot, exists }) {
56
+ export function planDevSupervisor({ isBun, argv, noHot }) {
40
57
  // `--no-hot` opts out of the reload supervisor on either runtime: run the dev
41
58
  // server in THIS process with no watcher. Degraded dev (a deep-import edit
42
59
  // needs a manual restart) but useful under an external process manager or a
43
60
  // debugger that wants a single, un-re-exec'd process.
44
61
  if (noHot) return { mode: 'inline' };
45
62
 
46
- if (isBun) return { mode: 'spawn', args: ['--hot', ...argv] };
47
-
48
- // Node: re-exec under `node --watch`, watching the project dirs/files that
49
- // exist. `--watch-preserve-output` keeps prior logs across a restart.
50
- const watchPaths = [];
51
- for (const dir of ['app', 'components', 'modules', 'lib', 'actions']) {
52
- if (exists(dir)) watchPaths.push('--watch-path', dir);
53
- }
54
- // Every extension the server's root-middleware lookup accepts, in the same
55
- // order. If these two lists diverge, an app gets a middleware that loads but
56
- // never restarts the dev server when edited, which is the quiet half of the
57
- // bug where a `middleware.ts` was loaded by neither.
58
- for (const f of ['middleware.ts', 'middleware.js', 'middleware.mts', 'middleware.mjs']) {
59
- if (exists(f)) watchPaths.push('--watch-path', f);
60
- }
61
- return {
62
- mode: 'spawn',
63
- args: ['--watch', '--watch-preserve-output', ...watchPaths, ...argv],
64
- };
63
+ const watch = { watchDirs: [...WATCH_DIRS], watchFiles: [...WATCH_FILES] };
64
+ if (isBun) return { mode: 'supervise', args: ['--hot', ...argv], restartOnChange: false, ...watch };
65
+ return { mode: 'supervise', args: [...argv], restartOnChange: true, ...watch };
65
66
  }
@@ -49,6 +49,7 @@ export const DOCTOR_CODES = {
49
49
  'Component elision (what the browser drops)': 'ELISION_COMPONENTS',
50
50
  'Static build outputs (dev.regenerate freshness)': 'STATIC_ASSET_FRESHNESS',
51
51
  'Asset urls (unmarked stylesheet links)': 'UNMARKED_ASSET_LINKS',
52
+ 'workspace-overrides': 'WORKSPACE_OVERRIDES',
52
53
  };
53
54
 
54
55
  /**
@@ -0,0 +1,79 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname, join, relative, sep, matchesGlob } from 'node:path';
3
+
4
+ /**
5
+ * @typedef {import('../codes.js').DoctorResult} DoctorResult
6
+ */
7
+
8
+ /** @param {string} p */
9
+ function readJson(p) {
10
+ try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; }
11
+ }
12
+
13
+ /**
14
+ * The workspace globs a root package.json declares (npm / bun / yarn), in
15
+ * either the array form or yarn's `{ packages: [...] }` form, else null.
16
+ * @param {any} pkg
17
+ * @returns {string[] | null}
18
+ */
19
+ function workspaceGlobs(pkg) {
20
+ const ws = pkg?.workspaces;
21
+ if (Array.isArray(ws)) return ws;
22
+ if (ws && Array.isArray(ws.packages)) return ws.packages;
23
+ return null;
24
+ }
25
+
26
+ /**
27
+ * Find the workspace root that `appDir` is a MEMBER of: the nearest ancestor
28
+ * whose package.json `workspaces` globs match the app's relative path. A plain
29
+ * parent with a package.json but no matching glob is not a workspace for this
30
+ * app, so it does not count.
31
+ * @param {string} appDir
32
+ * @returns {string | null}
33
+ */
34
+ export function findWorkspaceRoot(appDir) {
35
+ let dir = dirname(appDir);
36
+ for (;;) {
37
+ const pkg = existsSync(join(dir, 'package.json')) ? readJson(join(dir, 'package.json')) : null;
38
+ const globs = workspaceGlobs(pkg);
39
+ if (globs) {
40
+ const rel = relative(dir, appDir).split(sep).join('/');
41
+ const included = globs.filter((g) => !g.startsWith('!')).some((g) => matchesGlob(rel, g.replace(/^\.\//, '').replace(/\/$/, '')));
42
+ const excluded = globs.filter((g) => g.startsWith('!')).some((g) => matchesGlob(rel, g.slice(1).replace(/^\.\//, '')));
43
+ if (included && !excluded) return dir;
44
+ }
45
+ const parent = dirname(dir);
46
+ if (parent === dir) return null;
47
+ dir = parent;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * CHECK (#1492), dependency overrides declared in a workspace MEMBER. npm and
53
+ * bun honour `overrides` (and yarn / bun `resolutions`) only in the workspace
54
+ * ROOT package.json, so the same block in a member is silently ignored and the
55
+ * security floor it encodes (the scaffold's puppeteer-core and basic-ftp
56
+ * floors) never applies. WARN naming the root to move it to; PASS otherwise.
57
+ * @param {string} appDir
58
+ * @returns {DoctorResult}
59
+ */
60
+ export function checkWorkspaceOverrides(appDir) {
61
+ const name = 'workspace-overrides';
62
+ const pkg = readJson(join(appDir, 'package.json'));
63
+ const keys = ['overrides', 'resolutions'].filter((k) => pkg && pkg[k] && typeof pkg[k] === 'object' && Object.keys(pkg[k]).length);
64
+ if (keys.length === 0) {
65
+ return { name, status: 'pass', message: 'No dependency overrides in this package.json.' };
66
+ }
67
+ const root = findWorkspaceRoot(appDir);
68
+ if (!root) {
69
+ return { name, status: 'pass', message: `\`${keys.join('` / `')}\` apply: this app is not a workspace member.` };
70
+ }
71
+ const rel = relative(appDir, join(root, 'package.json')) || 'package.json';
72
+ return {
73
+ name,
74
+ status: 'warn',
75
+ message: `package.json declares \`${keys.join('` / `')}\`, but this app is a member of the workspace at ${rel}, `
76
+ + 'and package managers honour overrides only at the workspace root, so these are ignored.',
77
+ fix: `Move the \`${keys.join('` / `')}\` block into ${rel} (merge it with any the root already has).`,
78
+ };
79
+ }
@@ -11,6 +11,7 @@ import { checkElisionCarriers, checkElisionComponents } from './probes/elision.j
11
11
  import { checkStaticAssetFreshness } from './probes/static-asset-freshness.js';
12
12
  import { checkUnmarkedAssetLinks } from './probes/unmarked-asset-links.js';
13
13
  import { checkFrameworkResolves, checkFrameworkLinks } from './probes/framework-resolves.js';
14
+ import { checkWorkspaceOverrides } from './probes/workspace-overrides.js';
14
15
 
15
16
  /**
16
17
  * @typedef {import('./codes.js').DoctorResult} DoctorResult
@@ -64,6 +65,7 @@ export async function runDoctorChecks(appDir, opts = {}) {
64
65
  checkElisionComponents(elision),
65
66
  checkStaticAssetFreshness(appDir),
66
67
  checkUnmarkedAssetLinks(appDir),
68
+ Promise.resolve(checkWorkspaceOverrides(appDir)),
67
69
  ]);
68
70
  // Attach the stable machine code to every result (#975). Centralized here so
69
71
  // each check function stays free of the code-contract concern.
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Package-manager detection for `webjs create` (#1494).
3
+ *
4
+ * KEEP IN SYNC with `packages/ui/src/utils/package-manager.js`, the copy
5
+ * `webjs ui add` uses. The two published
6
+ * packages carry the same small module rather than one importing the other,
7
+ * so a `@webjsdev/cli` release can never fail at import time against an older
8
+ * `@webjsdev/ui` that lacks the export. `packages/cli/test/package-manager.test.mjs`
9
+ * asserts both copies agree on every fixture.
10
+ *
11
+ * Detection order (with `prefer: 'lockfile'`, the default):
12
+ * 1. A lockfile in `cwd` or any ancestor. The walk stops at the first
13
+ * workspace root (a package.json declaring `workspaces`, or a
14
+ * `pnpm-workspace.yaml`) or at the filesystem root, so an app nested in a
15
+ * workspace finds the root's lockfile. Within one directory the order is
16
+ * pnpm, yarn, bun (`bun.lock`, the text lockfile Bun writes since 1.2,
17
+ * and the older binary `bun.lockb`), then npm.
18
+ * 2. `npm_config_user_agent`, which npm, pnpm, yarn and bun set when they
19
+ * run a script or a `dlx` / `bunx` / `npx` binary.
20
+ * 3. `npm`.
21
+ * With `prefer: 'agent'` steps 1 and 2 swap, which suits `webjs create`: the
22
+ * tool that invoked it is the strongest signal for a directory that has no
23
+ * lockfile yet.
24
+ */
25
+ import { existsSync, readFileSync } from 'node:fs';
26
+ import { dirname, join, resolve } from 'node:path';
27
+
28
+ /** @typedef {'npm'|'pnpm'|'yarn'|'bun'} PackageManager */
29
+
30
+ /** Lockfile name to manager, in per-directory precedence order. */
31
+ const LOCKFILES = /** @type {const} */ ([
32
+ ['pnpm-lock.yaml', 'pnpm'],
33
+ ['yarn.lock', 'yarn'],
34
+ ['bun.lock', 'bun'],
35
+ ['bun.lockb', 'bun'],
36
+ ['package-lock.json', 'npm'],
37
+ ['npm-shrinkwrap.json', 'npm'],
38
+ ]);
39
+
40
+ /**
41
+ * Whether `dir` is a workspace root, where the lockfile walk stops.
42
+ * @param {string} dir
43
+ * @returns {boolean}
44
+ */
45
+ function isWorkspaceRoot(dir) {
46
+ if (existsSync(join(dir, 'pnpm-workspace.yaml'))) return true;
47
+ const pkgPath = join(dir, 'package.json');
48
+ if (!existsSync(pkgPath)) return false;
49
+ try {
50
+ return Boolean(JSON.parse(readFileSync(pkgPath, 'utf8')).workspaces);
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Walk up from `cwd` looking for a lockfile.
58
+ * @param {string} cwd
59
+ * @returns {PackageManager | null}
60
+ */
61
+ export function managerFromLockfile(cwd) {
62
+ let dir = resolve(cwd);
63
+ for (;;) {
64
+ for (const [file, manager] of LOCKFILES) {
65
+ if (existsSync(join(dir, file))) return manager;
66
+ }
67
+ if (isWorkspaceRoot(dir)) return null;
68
+ const parent = dirname(dir);
69
+ if (parent === dir) return null;
70
+ dir = parent;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Read the manager from an `npm_config_user_agent` value such as
76
+ * `bun/1.3.14 npm/? node/v24.0.0 linux x64`.
77
+ * @param {string | undefined} ua
78
+ * @returns {PackageManager | null}
79
+ */
80
+ export function managerFromUserAgent(ua) {
81
+ const name = String(ua || '').split('/')[0];
82
+ return name === 'pnpm' || name === 'yarn' || name === 'bun' || name === 'npm' ? name : null;
83
+ }
84
+
85
+ /**
86
+ * @param {{ cwd?: string | null, env?: Record<string, string | undefined>, prefer?: 'lockfile' | 'agent' }} [opts]
87
+ * @returns {PackageManager}
88
+ */
89
+ export function detectPackageManager({ cwd = null, env = process.env, prefer = 'lockfile' } = {}) {
90
+ const fromLock = () => (cwd ? managerFromLockfile(cwd) : null);
91
+ const fromAgent = () => managerFromUserAgent(env.npm_config_user_agent);
92
+ return (prefer === 'agent' ? fromAgent() ?? fromLock() : fromLock() ?? fromAgent()) ?? 'npm';
93
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/cli",
3
- "version": "0.10.58",
3
+ "version": "0.10.60",
4
4
  "type": "module",
5
5
  "description": "The CLI for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Runs the dev and production servers, scaffolds apps, validates conventions, and drives the database. Node 24+ or Bun.",
6
6
  "bin": {
@@ -18,7 +18,7 @@
18
18
  ],
19
19
  "dependencies": {
20
20
  "@webjsdev/mcp": "^0.1.0",
21
- "@webjsdev/server": "^0.8.67",
21
+ "@webjsdev/server": "^0.8.68",
22
22
  "@webjsdev/ui": "^0.3.15"
23
23
  },
24
24
  "publishConfig": {
@@ -113,6 +113,17 @@ export const POST = handlers.POST;
113
113
  <form method="POST" action="/api/auth/signout"><button>Log out</button></form>
114
114
  ```
115
115
 
116
+ **OAuth sign-in returns the user where they started, with the same one form.** POST to `/api/auth/signin/github` (or `google`) with a hidden `redirectTo`, or link to `GET /api/auth/signin/github?redirectTo=/dashboard/x`; `signIn('github', undefined, { redirectTo })` does the same from an action. The target rides through the provider round trip in a short-lived signed cookie and the callback lands on it, so no wrapper around the auth route is needed:
117
+
118
+ ```html
119
+ <form method="POST" action="/api/auth/signin/github">
120
+ <input type="hidden" name="redirectTo" value="/dashboard/x">
121
+ <button>Sign in with GitHub</button>
122
+ </form>
123
+ ```
124
+
125
+ A `redirectTo` that arrives from a request (a form field or a query param, for OAuth or credentials) must be a same-origin local path: one leading `/`, not followed by `/` or `\`. An absolute URL, a protocol-relative `//host`, or a backslash variant is dropped (not repaired) and the sign-in lands on `/`, so the field is never an open redirect. A denied sign-in still goes to `pages.error`.
126
+
116
127
  For a programmatic sign-in (the auto-login-after-signup pattern), `signIn('credentials', creds, { redirectTo })` returns a `302` `Response` that a form-bound action can return directly (the framework honors a returned `Response` verbatim).
117
128
 
118
129
  Sessions are JWT by default (stateless, scales horizontally). OAuth
@@ -22,6 +22,25 @@ Read this when wiring caching or rate limiting, storing uploads, hardening heade
22
22
  | `REDIS_URL` | When set, sessions, rate limit, and cache use Redis instead of memory |
23
23
  | `SESSION_SECRET` / `AUTH_SECRET` | Session and auth signing (see `auth-and-sessions.md`) |
24
24
  | `PORT` | Listen port. Precedence `--port` flag, then `PORT` (real env or `.env`), then `8080` |
25
+ | `WEBJS_SOURCE_LOCATIONS` | `webjs dev` only. `1` stamps `data-webjs-src="<app-relative-file>:<line>"` on the elements of the app's `html` templates (see below); `0` turns off a config default. Same as `webjs.dev.sourceLocations: true`. Ignored by `webjs start` |
26
+ | `WEBJS_EMBED_ORIGINS` | `webjs dev` only. Comma-separated parent origins (`https://builder.dev,http://localhost:8080`) allowed to frame the dev server and receive the embed bridge's messages (see below). Replaces `webjs.dev.embedOrigins` when set. Ignored by `webjs start` |
27
+ | `WEBJS_DEV_RELOAD_IDLE` | `webjs dev` only. Seconds of no edit and no interaction after which the live-reload stream closes so an idle host can sleep (`webjs.dev.reloadIdle`; this wins). Off by default; see `runtime.md` |
28
+
29
+ **Source locations for tooling (`webjs.dev.sourceLocations: true` or `WEBJS_SOURCE_LOCATIONS=1`, dev only, off by default).** A tool that hosts the app (an inspector, click-to-edit in an embedding builder) can map a clicked element back to the line that wrote it. With the variable set, `webjs dev` adds `data-webjs-src="components/todo-list.ts:12"` to every element opening tag written in an `html` template inside the app, both in the SSR markup and in client renders (one source transform applied to the served module and to the module the server imports, so the two agree and hydration is unaffected). Read it with `el.closest('[data-webjs-src]')`. Not annotated: `*.server.*` modules, `node_modules`, `css` / `svg` tagged templates, `html` / `head` / `body` / head-only and raw-text elements, and the descendants of `svg` / `math`. Lines are exact; nothing reaches production. The importmap `<script>` in `<head>` carries an unrelated `data-webjs-src` (the app-source deploy id), so match the `file:line` value shape when querying the whole document.
30
+
31
+ **Embed bridge for iframe previews (`webjs.dev.embedOrigins` or `WEBJS_EMBED_ORIGINS`, dev only, off by default).** A tool that previews the app inside an iframe (an app builder, a docs playground) lists its own origin(s) in `package.json` (`"webjs": { "dev": { "embedOrigins": ["https://builder.example"] } }`) or sets `WEBJS_EMBED_ORIGINS` (comma-separated, replaces the config list for that run). `webjs dev` then (1) drops `X-Frame-Options` and adds those origins to a CSP `frame-ancestors`, so the frame loads without the app stripping headers in `webjs.headers`, and (2) inlines a small nonce-signed script into every document that, when framed by a listed origin, posts to `window.parent` (with that exact target origin, never `*`):
32
+
33
+ ```js
34
+ { source: 'webjs-embed', type: 'ready', path, title } // document parsed
35
+ { source: 'webjs-embed', type: 'navigate', path, title } // every client-router navigation + popstate
36
+ { source: 'webjs-embed', type: 'console', level: 'error' | 'warn', message, dropped? } // max 20/s
37
+ { source: 'webjs-embed', type: 'error', message, stack, file, line, column } // window error + unhandledrejection
38
+ { source: 'webjs-embed', type: 'network', method, url, status, error? } // fetch/XHR status >= 500, or status 0 on failure
39
+ { source: 'webjs-embed', type: 'server-error', kind, message, file, line, path } // the dev error overlay went up
40
+ { source: 'webjs-embed', type: 'select', src, tag, text, rect: { x, y, width, height } } // a click in inspect mode
41
+ ```
42
+
43
+ and accepts, only from `window.parent` on a listed origin: `{ source: 'webjs-embed-host', type: 'navigate', path }` (a local path; a soft navigation when the client router is on), `{ source: 'webjs-embed-host', type: 'reload' }`, `{ source: 'webjs-embed-host', type: 'resume' }` (reopens a live-reload stream closed by `webjs.dev.reloadIdle`; every host command does this too), and `{ source: 'webjs-embed-host', type: 'inspect', enabled: true | false }` (hover highlight plus click capture that posts `select`; `src` is the nearest `data-webjs-src`, so pair it with `WEBJS_SOURCE_LOCATIONS=1` for click-to-edit). Paths are app paths (`/api/x`, `/components/x.ts`). Unset adds zero bytes; `webjs start` never injects it nor relaxes a header.
25
44
 
26
45
  Defaults are single-instance memory stores. To scale horizontally, switch the store once at startup: `setStore(redisStore({ url: process.env.REDIS_URL }))`.
27
46
 
@@ -275,6 +294,25 @@ Three levels, the same scale ESLint uses: `error` fails the exit, `warn` reports
275
294
 
276
295
  Two guarantees worth knowing. A result that could not check (a network or toolchain outage) is capped at `warn` and can never be escalated, so a jspm or npm outage cannot red your CI. And a malformed gate exits 1 naming the offender rather than being ignored, so a typo cannot silently un-gate the build. That covers an unknown code, a bad severity, a wrong shape (a non-object `doctor` or `gate`), and a misspelled sibling of `gate` such as `gates`, since every one of those would otherwise leave the build un-gated while the `package.json` looks gated. Under `--json` the offenders come back as a `configErrors` array alongside an empty `results`, each entry a `{ kind }` of `malformed` / `unknown-key` / `unknown-code` / `bad-severity`. Wire it up with one workflow step, `npm run doctor`, and change what is fatal in `package.json` rather than in the workflow.
277
296
 
297
+ ### Dependency audit allowlist
298
+
299
+ `webjs audit` runs `npm audit` or `bun audit` (by the nearest lockfile, so a workspace member uses the root's) and fails on any advisory at or above `webjs.audit.level` (default `high`) that `webjs.audit.ignore` does not list. The scaffold's `Security: dependency audit` CI step runs it.
300
+
301
+ ```jsonc
302
+ { "webjs": {
303
+ "audit": {
304
+ "level": "high",
305
+ "ignore": [
306
+ { "id": "GHSA-vfj7-8cjw-p6xm", "reason": "braces has no patched release; reached only through dev tooling" }
307
+ ]
308
+ }
309
+ } }
310
+ ```
311
+
312
+ The allowlist is the ONE place an accepted advisory lives, each with its reason. Accept only an advisory with no patched release that the app's users cannot reach; upgrade anything that has a fix. Never "fix" a red audit with `npm audit fix --force`, which proposes breaking majors that often keep the same vulnerable chain. A malformed block (unknown key, bad level, an entry without an id or a reason) exits 1, and an id the audit stops reporting prints as stale, so remove it then.
313
+
314
+ Overrides apply only at a WORKSPACE ROOT. The scaffold's `overrides` block (the `puppeteer-core` and `basic-ftp` security floors) is ignored once the app is a member of an npm or bun workspace, so move it into the root `package.json`; `webjs doctor` warns with `WORKSPACE_OVERRIDES` until you do.
315
+
278
316
  ## Observability
279
317
 
280
318
  Wired at the single response funnel, covering pages, routes, actions, and assets uniformly.
@@ -206,7 +206,7 @@ export async function updateUser(id: number, patch: Partial<User>) { /* ... */ }
206
206
 
207
207
  ### Cancellation with `actionSignal()`
208
208
 
209
- Inside an action, `actionSignal()` from `@webjsdev/server` returns the request's `AbortSignal`. It fires when the client disconnects OR when a newer client render supersedes this one (the RPC stub aborts the previous in-flight fetch). Thread it into the work you start, and re-check it after an await to map an abort to a cancelled envelope:
209
+ Inside an action, `actionSignal()` from `@webjsdev/server` returns the request's `AbortSignal`. It fires when the client disconnects OR when a newer client render supersedes this one (the RPC stub aborts the previous in-flight fetch). Only an action called in the synchronous part of a component's own `render()` is tied to that render. One called from `connectedCallback`, an event handler, `firstUpdated()` / `updated()`, or a `Task` is never cancelled by a re-render, including a child element's `connectedCallback` that runs while its parent's template is being committed, so a child can start its first fetch there without the parent's next render cancelling it. Thread it into the work you start, and re-check it after an await to map an abort to a cancelled envelope:
210
210
 
211
211
  ```ts
212
212
  'use server';
@@ -87,6 +87,7 @@ export default async function User({ params }: { params: { id: string } }) {
87
87
 
88
88
  - `[param]/page.ts` dynamic segment, read via `params.param`.
89
89
  - `[...rest]/page.ts` catch-all, `[[...rest]]/page.ts` optional catch-all.
90
+ - Overlapping routes resolve by positional specificity, for pages and `route.ts` handlers alike: segment by segment, a static segment beats `[param]`, which beats a catch-all, so `api/auth/callback/github/route.ts` answers before `api/auth/[...path]/route.ts` whatever the directory order.
90
91
  - `(group)/...` route group: the folder is NOT in the URL but still scopes layout / error.
91
92
  - `_private/...` private folder: ignored by the router.
92
93
 
@@ -32,15 +32,19 @@ Three seams pick a runtime-specific implementation, all inside the framework, no
32
32
  | Listener | `node:http` shell | native `Bun.serve` (faster on the listening path only, not end-to-end, because SSR render dominates a real page) |
33
33
  | TS strip | built-in `module.stripTypeScriptTypes` | `amaro` (byte-identical, position-preserving) |
34
34
  | SQLite | built-in `node:sqlite` + `drizzle-orm/node-sqlite` | built-in `bun:sqlite` + `drizzle-orm/bun-sqlite` |
35
- | Hot reload | `node --watch` | `bun --hot` |
35
+ | Hot reload | restart on change (the `webjs dev` supervisor, #1521) | `bun --hot` |
36
36
  | WebSocket | the `ws` library | native `Bun.serve` + a bridge adapter |
37
37
  | 103 Early Hints | yes | no (`Bun.serve` has no informational-response API) |
38
- | Dev edit to a page / layout | full reload (the `node --watch` restart replaces the process) | refreshes IN PLACE, no reload (#1398) |
38
+ | Dev edit to a page / layout | full reload (the dev restart replaces the process) | refreshes IN PLACE, no reload (#1398) |
39
39
  | Reverse-proxy headers | `X-Forwarded-Proto` / `X-Forwarded-Host` honored | same |
40
40
 
41
+ **`webjs dev` lets an idle host sleep (#1507).** The live-reload stream sends nothing between edits (no keepalive) and is held open only while a tab showing the app is visible, so a sandbox or preview host that suspends on network quiet can suspend with a backgrounded dev tab open. Showing the tab reconnects, and an edit made meanwhile reloads the page on return. A host that counts an OPEN request as activity (a sandbox that suspends on idle) also needs `"webjs": { "dev": { "reloadIdle": 20 } }` (or `WEBJS_DEV_RELOAD_IDLE=20`): after that many seconds with no edit and no interaction the stream closes, and the next interaction, a tab showing, or an embed-bridge host command (`{ source: 'webjs-embed-host', type: 'resume' }`) reopens it. Off by default. Every reconnect also compares the server's state with the state the page on screen was rendered at, so an edit whose reload signal was lost while the stream was being replaced (a host that closes a held stream when it wakes) still reloads the page.
42
+
41
43
  **The in-place dev refresh (#1398) needs the server process to SURVIVE the edit,** which is the whole of the Node-versus-Bun difference in that row. A page or layout never hydrates, so a freshly rendered page is the complete truth for it and the client router can swap it in without a reload, keeping scroll and (for a page edit) the hydrated state of components outside the changed region. The server classifies the changed file and puts the verdict on the live-reload event, so this needs a process that is still alive to do the classifying.
42
44
 
43
- Bun's `bun --hot` invalidates modules in place without restarting, so it gets the refresh. Node's `bun --hot` equivalent is `node --watch`, which RESTARTS the process on a change under `app`, `components`, `modules`, `lib`, or `actions`, or to a root `middleware.{ts,js,mts,mjs}`, and a fresh process holds no record of what changed, so those edits are always a full reload. Two Node cases still refresh in place: an edit OUTSIDE that watched set (`db/schema.server.ts`, a `webjs.dev.watch` content dir), and running `npm run dev -- --no-hot`, which keeps the server in one process on either runtime. A component edit is a full reload everywhere by design, because `customElements.define` is once-per-tag and swapping fresh markup onto the old class would be worse than the reload.
45
+ Bun's `bun --hot` invalidates modules in place without restarting, so it gets the refresh. On Node the `webjs dev` supervisor (it replaced `node --watch` in #1521) RESTARTS the server process on a change under `app`, `components`, `modules`, `lib`, or `actions`, or to a root `middleware.{ts,js,mts,mjs}`, and a fresh process holds no record of what changed, so those edits are always a full reload. Two Node cases still refresh in place: an edit OUTSIDE that watched set (`db/schema.server.ts`, a `webjs.dev.watch` content dir), and running `npm run dev -- --no-hot`, which keeps the server in one process on either runtime. A component edit is a full reload everywhere by design, because `customElements.define` is once-per-tag and swapping fresh markup onto the old class would be worse than the reload.
46
+
47
+ **`webjs dev` does not stay down (#1521).** The supervisor and the server's own watcher handle every watcher error: a file in a watched dir the dev server cannot read or watch (the 0600 temp file `sed -i` creates when another user runs it, a file removed mid-scan) logs one `file watcher skipped <path> (EACCES)` warning and both keep running, where `node --watch` used to crash and leave the preview dead. A server process that crashes is started again on the next file change, or by itself after a backoff of 0.5s growing to 10s for repeated crashes. On Bun the supervisor does only the crash recovery, since `bun --hot` reloads edits in place. Stopping `webjs dev` (Ctrl-C, SIGTERM) stops the server child too, and a child whose supervisor was killed outright exits on its own, so nothing is left holding the port.
44
48
 
45
49
  The 103 Early Hints gap costs only a small first-load latency edge where an edge proxy forwards the 103, never correctness. The `modulepreload` hints still ship in the document head on both runtimes.
46
50
 
@@ -63,6 +63,8 @@ WEBJS_E2E=1 npm run test # adds the e2e layer
63
63
 
64
64
  `npm run test` dispatches on the runtime (`node --test` on Node, `bun test` on Bun). The scaffold's `web-test-runner.config.js` globs `test/**/browser/**/*.test.js` and is already wired, so you do not set it up.
65
65
 
66
+ An app with no browser tests yet (for example right after `npm run gallery:clear`) is not a failure: `webjs test --browser` sees that no file matches the config's `files` globs, prints `no browser tests yet`, and exits 0, the same way the server layer passes with zero files. So `npm run ci` stays green until you write the first browser test, and from then on the browser layer runs as normal.
67
+
66
68
  A scaffolded app has one root `test/` directory shaped the same way (feature first, kind second):
67
69
 
68
70
  ```
@@ -18,7 +18,7 @@ build
18
18
  out
19
19
  .cache
20
20
 
21
- # Local env files. The container gets its env from compose / uncloud, not a
21
+ # Local env files. The container gets its env from compose or the host, not a
22
22
  # committed file. Keep the example for reference.
23
23
  .env
24
24
  !.env.example
@@ -1,7 +1,7 @@
1
1
  # Production image for the {{APP_NAME}} webjs app.
2
2
  #
3
- # Works with a plain `docker build` / `docker compose up`, and is the same
4
- # artifact the webdeploy hosting tool (ubicloud + uncloud) builds and ships.
3
+ # Works with a plain `docker build` / `docker compose up`, and with any host
4
+ # that builds from a Dockerfile.
5
5
  #
6
6
  # webjs serves .ts directly by stripping types at the runtime layer, so there is
7
7
  # NO JavaScript build step (webjs is buildless end to end; there is no bundler or
@@ -41,7 +41,7 @@ COPY . .
41
41
  # step runs `webjs db migrate`). See the CMD note below.
42
42
 
43
43
  ENV NODE_ENV=production
44
- # webjs start reads $PORT (default 8080). compose / uncloud / Railway set it.
44
+ # webjs start reads $PORT (default 8080). compose and most hosts set it.
45
45
  ENV PORT=8080
46
46
  EXPOSE 8080
47
47
 
@@ -2,9 +2,9 @@
2
2
  #
3
3
  # docker compose up --build → http://localhost:8080
4
4
  #
5
- # In production the webdeploy tool (ubicloud + uncloud) provisions a managed
6
- # Postgres and injects DATABASE_URL + AUTH_SECRET for you. Locally this uses
7
- # the scaffold's SQLite file on a named volume so data survives `compose down`.
5
+ # In production your host provides DATABASE_URL + AUTH_SECRET. Locally this
6
+ # uses the scaffold's SQLite file on a named volume so data survives
7
+ # `compose down`.
8
8
  services:
9
9
  app:
10
10
  build: .