@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.
- package/README.md +1 -1
- package/bin/webjs.js +116 -11
- package/lib/audit.js +218 -0
- package/lib/browser-test-files.js +125 -0
- package/lib/create.js +57 -24
- package/lib/db-rewrite.js +137 -0
- package/lib/dev-reload.js +382 -0
- package/lib/dev-supervisor.js +43 -42
- package/lib/doctor/codes.js +1 -0
- package/lib/doctor/probes/workspace-overrides.js +79 -0
- package/lib/doctor/runner.js +2 -0
- package/lib/package-manager.js +93 -0
- package/package.json +2 -2
- package/templates/.agents/skills/webjs/references/auth-and-sessions.md +11 -0
- package/templates/.agents/skills/webjs/references/built-ins.md +38 -0
- package/templates/.agents/skills/webjs/references/data-and-actions.md +1 -1
- package/templates/.agents/skills/webjs/references/routing-and-pages.md +1 -0
- package/templates/.agents/skills/webjs/references/runtime.md +7 -3
- package/templates/.agents/skills/webjs/references/testing.md +2 -0
- package/templates/.dockerignore +1 -1
- package/templates/Dockerfile +3 -3
- package/templates/compose.yaml +3 -3
package/lib/dev-supervisor.js
CHANGED
|
@@ -1,29 +1,44 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Dev-server reload supervisor planning for `webjs dev` (
|
|
2
|
+
* Dev-server reload supervisor planning for `webjs dev` (issues #514, #1521).
|
|
3
3
|
*
|
|
4
|
-
* `webjs dev`
|
|
5
|
-
* an edit to a transitively-imported module (an action, query,
|
|
6
|
-
* takes effect without a manual restart. Both runtimes cache
|
|
7
|
-
* resolved URL with no public invalidation API, so the dev
|
|
8
|
-
* `@webjsdev/server`'s `dev.js` relies on
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* -
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
* @
|
|
35
|
-
*
|
|
36
|
-
* `
|
|
37
|
-
*
|
|
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
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
}
|
package/lib/doctor/codes.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/doctor/runner.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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 | `
|
|
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
|
|
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
|
|
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
|
```
|
package/templates/.dockerignore
CHANGED
|
@@ -18,7 +18,7 @@ build
|
|
|
18
18
|
out
|
|
19
19
|
.cache
|
|
20
20
|
|
|
21
|
-
# Local env files. The container gets its env from compose
|
|
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
|
package/templates/Dockerfile
CHANGED
|
@@ -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
|
|
4
|
-
#
|
|
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
|
|
44
|
+
# webjs start reads $PORT (default 8080). compose and most hosts set it.
|
|
45
45
|
ENV PORT=8080
|
|
46
46
|
EXPOSE 8080
|
|
47
47
|
|
package/templates/compose.yaml
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
#
|
|
3
3
|
# docker compose up --build → http://localhost:8080
|
|
4
4
|
#
|
|
5
|
-
# In production
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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: .
|