@webjsdev/cli 0.10.39 → 0.10.40

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/lib/app-tasks.js CHANGED
@@ -4,15 +4,20 @@ import { join } from 'node:path';
4
4
  /**
5
5
  * Read the dev/start task orchestration from an app's `package.json` `"webjs"`
6
6
  * block (#550). This is what lets `webjs dev` / `webjs start` behave identically
7
- * to `npm run dev` / `npm run start`: the orchestration (Tailwind's watcher, a
8
- * `db migrate` before prod boot) moves OUT of `concurrently` + `pre*` npm hooks
7
+ * to `npm run dev` / `npm run start`: the orchestration (a Tailwind compile, a
8
+ * `db migrate` before boot) moves OUT of `concurrently` + `pre*` npm hooks
9
9
  * and INTO the framework primitive, so a bare `webjs dev` is not a degraded run.
10
10
  *
11
+ * Reads only `before` / `parallel` here (the CLI-run tasks). The dev-only
12
+ * `regenerate` key (#967, on-request rebuilds of a stale served output like
13
+ * `public/tailwind.css`) is read by the SERVER, not this reader, so the scaffold
14
+ * keeps its static CSS fresh WITHOUT a `--watch` under `parallel`.
15
+ *
11
16
  * Shape:
12
17
  * "webjs": {
13
18
  * "dev": {
14
- * "before": ["webjs db migrate"],
15
- * "parallel": ["tailwindcss -i ./public/input.css -o ./public/tailwind.css --watch"]
19
+ * "before": ["webjs db migrate", "tailwindcss -i ./public/input.css -o ./public/tailwind.css --minify"]
20
+ * // dev.regenerate keeps the CSS fresh on request (server-read, see dev-regenerate.js)
16
21
  * },
17
22
  * "start": { "before": ["webjs db migrate"] }
18
23
  * }
@@ -20,9 +25,10 @@ import { join } from 'node:path';
20
25
  * `before` commands run sequentially to completion BEFORE the server boots (the
21
26
  * old `predev` / `prestart` hooks: a one-shot `webjs db migrate`).
22
27
  * `parallel` (dev only) commands run as long-lived child processes ALONGSIDE the
23
- * server (the old `concurrently` watchers: Tailwind). Returns normalized arrays
24
- * (never undefined) so callers iterate without guards, and a missing/empty
25
- * config yields empty arrays so a plain app runs `webjs dev`/`start` unchanged.
28
+ * server (the old `concurrently` watchers), for a genuinely long-lived side
29
+ * process. Returns normalized arrays (never undefined) so callers iterate
30
+ * without guards, and a missing/empty config yields empty arrays so a plain app
31
+ * runs `webjs dev`/`start` unchanged.
26
32
  *
27
33
  * Pure (reads one file, never spawns / prints / exits) so it is unit-testable
28
34
  * without a process, matching `lib/port.js` and `lib/dev-supervisor.js`.
package/lib/create.js CHANGED
@@ -348,12 +348,11 @@ export async function scaffoldApp(name, cwd, opts = {}) {
348
348
  // (not the browser runtime), so it renders styled with JavaScript off. The
349
349
  // compiler is a node-shebang CLI, so a Bun app (whose Dockerfile is a node-less
350
350
  // `oven/bun:1` image, #595) must run it under Bun via `bun --bun`; a Node app
351
- // runs it directly (the before / parallel steps get node_modules/.bin on PATH
351
+ // runs it directly (the before / regenerate steps get node_modules/.bin on PATH
352
352
  // via envWithLocalBin). Deliberately NOT `npm run css:build` in the hooks: the
353
353
  // Bun image has no npm, so that step would exit 127 and abort the boot.
354
354
  const twBin = isBun ? 'bun --bun tailwindcss' : 'tailwindcss';
355
355
  const cssBuildCmd = `${twBin} -i ./public/input.css -o ./public/tailwind.css --minify`;
356
- const cssWatchCmd = `${twBin} -i ./public/input.css -o ./public/tailwind.css --watch`;
357
356
  const appDir = join(cwd, name);
358
357
  if (existsSync(appDir)) {
359
358
  console.error(`Error: directory '${name}' already exists.`);
@@ -489,15 +488,31 @@ export async function scaffoldApp(name, cwd, opts = {}) {
489
488
  // migrate` (idempotent, a no-op when the db is current), so a freshly
490
489
  // generated migration is applied without a manual step (#725). For a UI
491
490
  // template it ALSO compiles Tailwind in `before` so a freshly cloned app is
492
- // styled on the very first boot with no manual step, and runs the Tailwind
493
- // `--watch` under `parallel` for live recompiles in dev. The compile command
494
- // is the runtime-aware `cssBuildCmd` (a Bun app runs it under Bun, since its
491
+ // styled on the very first boot with no manual step. The compile command is
492
+ // the runtime-aware `cssBuildCmd` (a Bun app runs it under Bun, since its
495
493
  // image has no npm or Node), NOT `npm run css:build`. The api template has no
496
494
  // CSS, so it gets neither.
495
+ //
496
+ // In dev the static public/tailwind.css is kept fresh by `dev.regenerate`
497
+ // (#967), NOT a background `tailwindcss --watch`. A watch that dies mid-
498
+ // session or never starts serves stale/missing CSS with no error (a newly
499
+ // added utility class has no backing rule, so the app renders unstyled
500
+ // locally while prod is fine). `regenerate` instead recompiles ON REQUEST
501
+ // when the output is older than a source (or missing): the framework rebuilds
502
+ // it before serving `/public/tailwind.css`, so there is no watch process to
503
+ // die and no staleness window. Same `cssBuildCmd` as prod, so dev and prod
504
+ // resolve classes identically (nothing to diverge). `inputs` mirrors the
505
+ // input.css @source globs (the dirs Tailwind scans for classes).
497
506
  webjs: {
498
507
  dev: {
499
508
  before: isApi ? ['webjs db migrate'] : ['webjs db migrate', cssBuildCmd],
500
- ...(isApi ? {} : { parallel: [cssWatchCmd] }),
509
+ ...(isApi ? {} : {
510
+ regenerate: [{
511
+ output: 'public/tailwind.css',
512
+ command: cssBuildCmd,
513
+ inputs: ['app', 'components', 'modules', 'lib', 'public/input.css'],
514
+ }],
515
+ }),
501
516
  },
502
517
  start: { before: isApi ? ['webjs db migrate'] : ['webjs db migrate', cssBuildCmd] },
503
518
  },
@@ -1226,8 +1241,9 @@ export default function RootLayout({ children }: { children: unknown }) {
1226
1241
  })();
1227
1242
  </script>
1228
1243
  <!-- Tailwind: a STATIC stylesheet compiled from public/input.css to
1229
- public/tailwind.css by css:build / css:watch (run automatically by the
1230
- dev and start tasks). A real stylesheet, so the app is fully styled with
1244
+ public/tailwind.css by css:build (run automatically by the dev and start
1245
+ tasks; in dev it is also recompiled on request when a source changes, so
1246
+ it never goes stale). A real stylesheet, so the app is fully styled with
1231
1247
  JavaScript DISABLED (no in-browser compile). -->
1232
1248
  <link rel="stylesheet" href="/public/tailwind.css">
1233
1249
  <style>
package/lib/doctor.js CHANGED
@@ -36,7 +36,7 @@
36
36
  * fails CI because npm was briefly unreachable is worse than useless.
37
37
  */
38
38
 
39
- import { existsSync, statSync } from 'node:fs';
39
+ import { existsSync, statSync, readdirSync } from 'node:fs';
40
40
  import { readFile } from 'node:fs/promises';
41
41
  import { join, relative } from 'node:path';
42
42
  import { createRequire } from 'node:module';
@@ -802,7 +802,7 @@ function checkGitHook(appDir) {
802
802
  * Advisory (#646): name why a page/layout SHIPS its module to the browser
803
803
  * instead of being elided. A page/layout that is a pure carrier (import-only
804
804
  * #605 / inert #179) stays out of the browser; one that ships whole is pinned
805
- * by a specific client-effecting NON-component in its closure (a util touching
805
+ * by a specific client-effecting NON-component on a component-free path from it, #963 (a util touching
806
806
  * a client global, a module-scope side effect, a bare side-effect import) or by
807
807
  * its own client work. This turns that invisible #605/#179 regression into a
808
808
  * named line. WARN only: a page legitimately MAY ship, and the analyser is
@@ -881,6 +881,89 @@ async function checkScaffoldDesign(appDir) {
881
881
  };
882
882
  }
883
883
 
884
+ // Directories never worth walking for the CSS-freshness advisory (mirrors
885
+ // dev-regenerate's IGNORE_DIRS): build output, deps, VCS + framework caches.
886
+ const FRESHNESS_IGNORE = new Set(['node_modules', '.git', '.webjs', 'dist', '.next', 'coverage']);
887
+
888
+ /**
889
+ * Newest mtime (ms) of any FILE under a path (a file's own, or the max over the
890
+ * files in a directory tree, skipping dependencies / dotfiles). Directory-node
891
+ * mtimes are NOT counted, matching dev-regenerate's walker: a content edit only
892
+ * shows through the file mtime, and a directory mtime is a flaky moving target.
893
+ * A missing path is 0. Best-effort: never throws.
894
+ * @param {string} abs
895
+ * @returns {number}
896
+ */
897
+ function newestMtimeMs(abs) {
898
+ let st;
899
+ try { st = statSync(abs); } catch { return 0; }
900
+ if (!st.isDirectory()) return st.mtimeMs;
901
+ let newest = 0;
902
+ let entries;
903
+ try { entries = readdirSync(abs, { withFileTypes: true }); } catch { return newest; }
904
+ for (const e of entries) {
905
+ if (e.name.startsWith('.') || FRESHNESS_IGNORE.has(e.name)) continue;
906
+ // Skip symlinks: following one can cycle into unbounded recursion (a stack
907
+ // overflow here) or escape into node_modules. Same tradeoff as the server
908
+ // walker in dev-regenerate.js.
909
+ if (e.isSymbolicLink()) continue;
910
+ const m = newestMtimeMs(join(abs, e.name));
911
+ if (m > newest) newest = m;
912
+ }
913
+ return newest;
914
+ }
915
+
916
+ /**
917
+ * ADVISORY: a declared `webjs.dev.regenerate` output is STALE on disk (a source
918
+ * is newer than the committed/built output). In DEV the framework recompiles it
919
+ * on request (#967), so this never bites locally, but the check is the explicit
920
+ * dev/prod PARITY backstop: it catches a stale `public/tailwind.css` that would
921
+ * be served as-is by `webjs start` (prod does NOT recompile on request) or
922
+ * committed into the repo. WARN-level: the fix is a one-line rebuild, and a
923
+ * missing output (a fresh clone before the first `css:build`) is not this app's
924
+ * bug to hard-fail on.
925
+ * @param {string} appDir
926
+ * @returns {Promise<DoctorResult>}
927
+ */
928
+ async function checkStaticAssetFreshness(appDir) {
929
+ const name = 'Static build outputs (dev.regenerate freshness)';
930
+ let pkg;
931
+ try {
932
+ pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
933
+ } catch {
934
+ return { name, status: 'pass', message: 'no package.json to analyse' };
935
+ }
936
+ const rules = pkg && pkg.webjs && pkg.webjs.dev ? pkg.webjs.dev.regenerate : null;
937
+ if (!Array.isArray(rules) || rules.length === 0) {
938
+ return { name, status: 'pass', message: 'no webjs.dev.regenerate rules declared' };
939
+ }
940
+ const stale = [];
941
+ for (const rule of rules) {
942
+ if (!rule || typeof rule.output !== 'string') continue;
943
+ const output = rule.output.replace(/^\/+/, '');
944
+ const outMtime = newestMtimeMs(join(appDir, output));
945
+ if (outMtime === 0) continue; // missing output: not a staleness fail (built on first boot)
946
+ let newestSrc = 0;
947
+ for (const inp of Array.isArray(rule.inputs) ? rule.inputs : []) {
948
+ const m = newestMtimeMs(join(appDir, inp));
949
+ if (m > newestSrc) newestSrc = m;
950
+ }
951
+ if (newestSrc > outMtime) stale.push({ output, command: rule.command });
952
+ }
953
+ if (stale.length === 0) {
954
+ return { name, status: 'pass', message: 'every declared build output is up to date with its sources' };
955
+ }
956
+ return {
957
+ name,
958
+ status: 'warn',
959
+ message:
960
+ `${stale.length} static build output(s) are older than a source file:\n` +
961
+ stale.map((s) => ` ${s.output} (rebuild: ${s.command})`).join('\n') +
962
+ '\n In dev the framework recompiles these on request, so this only bites a `webjs start` (prod) or a committed stale file.',
963
+ fix: 'Rebuild the output(s) with the command shown (e.g. `npm run css:build`) before deploying or committing. `webjs dev` regenerates them on request automatically.',
964
+ };
965
+ }
966
+
884
967
  /**
885
968
  * Probe whether `@webjsdev/core` resolves from `appDir`. Node resolution is
886
969
  * directory-relative, so this must probe FROM the app (not the CLI's own
@@ -972,6 +1055,7 @@ export async function runDoctorChecks(appDir, opts = {}) {
972
1055
  Promise.resolve(checkGitHook(appDir)),
973
1056
  checkElisionCarriers(appDir),
974
1057
  checkScaffoldDesign(appDir),
1058
+ checkStaticAssetFreshness(appDir),
975
1059
  ]);
976
1060
  return results;
977
1061
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/cli",
3
- "version": "0.10.39",
3
+ "version": "0.10.40",
4
4
  "type": "module",
5
5
  "description": "webjs CLI - dev, start, create, db",
6
6
  "bin": {
@@ -493,22 +493,32 @@ npm run dev # webjs dev, then serves
493
493
 
494
494
  `npm run dev` and `npm start` are the documented entrypoints, and they
495
495
  are thin aliases for `webjs dev` / `webjs start`. The start orchestration
496
- (applying migrations, and any parallel watcher like the Tailwind CLI)
497
- lives in the `webjs` block of `package.json` and runs INSIDE
498
- `webjs dev` / `webjs start`:
496
+ (applying migrations, and compiling Tailwind) lives in the `webjs` block
497
+ of `package.json` and runs INSIDE `webjs dev` / `webjs start`:
499
498
 
500
499
  ```jsonc
501
500
  "webjs": {
502
- "dev": { "before": ["webjs db migrate"] },
503
- "start": { "before": ["webjs db migrate"] }
501
+ "dev": {
502
+ "before": ["webjs db migrate", "tailwindcss -i ./public/input.css -o ./public/tailwind.css --minify"],
503
+ "regenerate": [
504
+ { "output": "public/tailwind.css",
505
+ "command": "tailwindcss -i ./public/input.css -o ./public/tailwind.css --minify",
506
+ "inputs": ["app", "components", "modules", "lib", "public/input.css"] }
507
+ ]
508
+ },
509
+ "start": { "before": ["webjs db migrate", "tailwindcss -i ./public/input.css -o ./public/tailwind.css --minify"] }
504
510
  }
505
511
  ```
506
512
 
507
513
  Both `dev` and `start` apply pending migrations via `webjs db migrate`
508
- (idempotent, a no-op when the db is current), so a freshly generated
509
- migration is applied without a manual step. An app that adds the Tailwind
510
- CLI puts its `--watch` command under `webjs.dev.parallel` and it runs
511
- alongside the server, torn down on exit.
514
+ (idempotent, a no-op when the db is current) and compile the static
515
+ `public/tailwind.css`, so a freshly generated migration is applied and
516
+ the app is fully styled with no manual step. In dev the stylesheet is
517
+ then kept fresh by `webjs.dev.regenerate`: the dev server recompiles it
518
+ ON REQUEST whenever a source changes, so a newly added utility class is
519
+ never served stale and there is no `tailwindcss --watch` process that can
520
+ die mid-session (`webjs.dev.parallel` still exists for a genuinely
521
+ long-lived side process, torn down on exit).
512
522
  `before` steps run to completion first; a failed `webjs db migrate`
513
523
  aborts the boot with a clear message rather than serving a stale schema.
514
524
 
@@ -830,7 +840,7 @@ Practical consequences for agents writing webjs code.
830
840
  | Fetch in `connectedCallback` / `firstUpdated` | Empty first paint (neither hook runs in SSR) | Fetch in the page function, pass as props |
831
841
  | `Task` for initial-paint data | SSR ships the pending state, flashes to resolved on hydration | Page function fetch, pass as props, OR an `async render()` in the component (`Task` is fine for client-time async) |
832
842
  | Expecting a sync `render()` only | webjs allows `async render() { const d = await getData(); ... }`; SSR bakes the data into the first paint | Use it for request-time server data; `renderFallback()` is the re-fetch loading UI (never first paint); error isolation is automatic |
833
- | Assuming an `async render()` always ships its module | A bare one (no other client signal) is ELIDED, so it costs zero JS and skips the on-hydration re-fetch, first paint unchanged | Rely on it for a fetch-and-display leaf. `static refresh = true` keeps the on-load refresh, `static shadow = true` always ships |
843
+ | Assuming an `async render()` always ships its module | A bare one (no other client signal) is ELIDED, so it costs zero JS and skips the on-hydration re-fetch, first paint unchanged | Rely on it for a fetch-and-display leaf. `static interactive = true` forces a ship the analyser would otherwise elide, `static shadow = true` always ships |
834
844
  | `window.X` / `document.X` in constructor or `render()` | SSR crash | Move to `connectedCallback` |
835
845
  | Top-level `import` of a browser-only library | SSR crash | Dynamic `import()` inside `connectedCallback` |
836
846
  | Class-field initializer for a reactive property (`student: Student = {...}`) | Silently breaks reactivity (overwrites the framework accessor) | Pass the shape to the base-class factory `WebComponent({ student: Object })` and set the default in the constructor (flagged by `reactive-props-no-class-field`) |
@@ -11,12 +11,13 @@ import { WebComponent, html } from '@webjsdev/core';
11
11
  import { serverGreeting } from '../queries/server-greeting.server.ts';
12
12
 
13
13
  export class ServerClock extends WebComponent {
14
- // A bare async-render component (no other client signal) is ELIDED, since its
15
- // SSR'd HTML is already the complete output. `static refresh = true` opts into
16
- // keeping the on-load re-fetch so the module ships (drop it for request-stable
17
- // data you are happy to leave server-rendered).
18
- static refresh = true;
19
-
14
+ // A bare async-render component (no other client signal, light DOM) is
15
+ // ELIDED: its SSR'd HTML is already the complete output, so the framework
16
+ // serves it with ZERO JavaScript and skips the redundant on-hydration
17
+ // re-fetch. This is the common fetch-and-display leaf shape. If you ever need
18
+ // to force a component to ship when the analyser would elide it (for
19
+ // interactivity static analysis cannot see, like a dynamically-computed tag
20
+ // string), declare `static interactive = true`.
20
21
  async render() {
21
22
  const info = await serverGreeting();
22
23
  return html`<p class="font-mono text-sm">server rendered this at