@webjsdev/server 0.8.34 → 0.8.36

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.34",
3
+ "version": "0.8.36",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
@@ -26,39 +26,73 @@
26
26
  * same module Bun would have loaded.
27
27
  */
28
28
 
29
+ /** Bun's global install cache + node_modules: a dep, never an app file to rewrite. */
30
+ const DEP_PATH_RE = /[\\/](?:node_modules|install[\\/]cache|\.bun)[\\/]/;
31
+
29
32
  /**
30
- * Install the `Bun.plugin` seed `onLoad`. Idempotency is the caller's
31
- * responsibility (`registerSeedHooks` guards on `_registered`).
33
+ * Install the webjs `Bun.plugin` `onLoad`. It carries two independent transforms
34
+ * on ONE handler (Bun `onLoad` is first-match-wins, so a second overlapping
35
+ * plugin would starve one of them):
36
+ *
37
+ * - **Pin rewrite (#685)**, when `pinTransform` is supplied: rewrite bare
38
+ * specifiers of declared deps to inline-versioned ones so Bun zero-install
39
+ * fetches the pinned version, not latest. Applies to APP files only (a dep
40
+ * in `node_modules` / the global cache is returned raw). This broadens the
41
+ * filter to all JS/TS; without it the filter stays the cheap `*.server.*`
42
+ * pre-screen.
43
+ * - **Seed facade (#472)**, when `seedEnabled`: facet a `'use server'`
44
+ * candidate into the SSR action-result seed facade, built from the
45
+ * (already pin-rewritten) source.
46
+ *
47
+ * Idempotency is the caller's responsibility (`registerSeedHooks` guards on
48
+ * `_registered`).
32
49
  *
33
50
  * @param {{
34
51
  * isSeedCandidate: (specifier: string) => boolean,
35
52
  * buildSeedFacade: (origSpec: string, absPath: string, src: string) => (string | null),
36
53
  * serverFileRe: RegExp,
54
+ * seedEnabled?: boolean,
55
+ * pinTransform?: ((src: string, loader: 'ts' | 'js') => string) | null,
37
56
  * }} helpers
38
57
  */
39
- export function installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe }) {
58
+ export function installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe, seedEnabled = true, pinTransform = null }) {
59
+ // Pinning needs to see every app module's imports, so broaden the filter when
60
+ // it is active; otherwise keep the narrow `*.server.*` seed pre-screen.
61
+ const filter = pinTransform ? /\.m?[jt]s(\?|$)/ : serverFileRe;
40
62
  Bun.plugin({
41
- name: 'webjs-action-seed',
63
+ name: 'webjs-onload',
42
64
  setup(build) {
43
- // The filter is a cheap path pre-screen (`*.server.*`, optional query).
44
- build.onLoad({ filter: serverFileRe }, async (args) => {
65
+ build.onLoad({ filter }, async (args) => {
45
66
  const absPath = args.path.split('?')[0];
46
67
  // `.ts` / `.mts` strip via the `ts` loader; `.js` / `.mjs` via `js`.
47
68
  const loader = /\.m?ts$/.test(absPath) ? 'ts' : 'js';
69
+ // A dependency (node_modules or Bun's global cache): never rewrite it
70
+ // (the app's package.json does not pin transitive deps; they follow from
71
+ // the pinned direct deps' own manifests). Return the raw source unchanged.
72
+ if (pinTransform && DEP_PATH_RE.test(absPath)) {
73
+ return { contents: await Bun.file(absPath).text(), loader };
74
+ }
48
75
  // Read the real source. A genuine read failure (missing file) propagates,
49
76
  // which Bun reports as a load error exactly as it would without the plugin.
50
- const src = await Bun.file(absPath).text();
77
+ let src = await Bun.file(absPath).text();
78
+ // Pin rewrite. For a NON-`.server` module this is where its specifiers get
79
+ // pinned. A `.server` candidate's REAL module is pinned on its
80
+ // `?webjs-seed-orig` passthrough load (which re-enters this onLoad); the
81
+ // facade itself only reads export names, unaffected by the rewrite.
82
+ if (pinTransform) {
83
+ try { src = pinTransform(src, loader); } catch { /* fail-open: raw source */ }
84
+ }
51
85
  try {
52
86
  // Facet only a `'use server'` candidate (not the `?webjs-seed-orig`
53
87
  // passthrough); a non-candidate or a passthrough falls through to the
54
- // raw source below.
55
- if (isSeedCandidate(args.path)) {
88
+ // (possibly pin-rewritten) source below.
89
+ if (seedEnabled && isSeedCandidate(args.path)) {
56
90
  const source = buildSeedFacade(args.path, absPath, src);
57
91
  if (source != null) return { contents: source, loader: 'js' };
58
92
  }
59
93
  } catch {
60
- // Fail-open: any faceting error serves the raw source (no seeding for
61
- // this module), the Bun analog of the Node hook's `nextLoad` fallback.
94
+ // Fail-open: any faceting error serves the source (no seeding for this
95
+ // module), the Bun analog of the Node hook's `nextLoad` fallback.
62
96
  }
63
97
  return { contents: src, loader };
64
98
  });
@@ -51,11 +51,13 @@
51
51
  import * as nodeModule from 'node:module';
52
52
  import { AsyncLocalStorage } from 'node:async_hooks';
53
53
  import { readFileSync } from 'node:fs';
54
+ import { join } from 'node:path';
54
55
  import { fileURLToPath } from 'node:url';
55
56
  import { stringify } from '@webjsdev/core';
56
57
  import { hashFile } from './actions.js';
57
58
  import { isStreamable } from './action-stream.js';
58
59
  import { serverRuntime } from './listener-core.js';
60
+ import { resolveDepVersions, rewriteDepSpecifiers } from './bun-pin-rewrite.js';
59
61
 
60
62
  /** Ambient per-render seed collector. `Map<key, value>` or undefined. */
61
63
  const als = new AsyncLocalStorage();
@@ -296,27 +298,66 @@ function seedLoadHook(url, context, nextLoad) {
296
298
  }
297
299
 
298
300
  /**
299
- * Install the seed load hook (idempotent). Called once at boot from `dev.js`
300
- * when seeding is enabled, BEFORE any action module is imported (a module loaded
301
- * before the hook would already be cached unwrapped). The install mechanism is
302
- * chosen by `serverRuntime()` (#529): Node's synchronous `module.registerHooks`,
303
- * or a `Bun.plugin` `onLoad` on Bun. A no-op on a second call. Async because the
304
- * Bun path dynamically imports `action-seed-bun.js` (so the `Bun.*` global is
305
- * never referenced on Node); the Node path resolves synchronously.
301
+ * Build the per-file pin transform for Bun (#685): read the app's declared dep
302
+ * versions (package.json, with `bun.lock` exact pins preferred) and return a
303
+ * function that rewrites a module's bare dep specifiers to inline-versioned ones
304
+ * so Bun zero-install fetches the pinned version, not latest. Returns null when
305
+ * there is nothing to pin (no package.json, or no declared deps). Bun-only: it
306
+ * references `Bun.Transpiler`, so it is invoked only on the Bun branch.
307
+ * @param {string} appDir
308
+ * @returns {((src: string, loader: 'ts' | 'js') => string) | null}
309
+ */
310
+ function buildBunPinTransform(appDir) {
311
+ let pkgText;
312
+ try { pkgText = readFileSync(join(appDir, 'package.json'), 'utf8'); } catch { return null; }
313
+ let lockText = null;
314
+ try { lockText = readFileSync(join(appDir, 'bun.lock'), 'utf8'); } catch { /* optional */ }
315
+ const versions = resolveDepVersions(pkgText, lockText);
316
+ if (Object.keys(versions).length === 0) return null;
317
+ return (src, loader) => {
318
+ const imports = new Bun.Transpiler({ loader }).scanImports(src);
319
+ return rewriteDepSpecifiers(src, imports, versions);
320
+ };
321
+ }
322
+
323
+ /**
324
+ * Install the boot-time load hook(s) (idempotent). Called once from `dev.js`,
325
+ * BEFORE any app module is imported (a module loaded before the hook would
326
+ * already be cached untransformed). Two concerns ride here:
327
+ *
328
+ * - **Seeding (#472)** when `seedEnabled`: facet `'use server'` modules into
329
+ * the SSR action-result seed facade. Node uses `module.registerHooks`; Bun a
330
+ * `Bun.plugin` `onLoad`.
331
+ * - **Pinning (#685)** when `pinEnabled` (Bun only): rewrite bare dep
332
+ * specifiers to the package.json/bun.lock version so zero-install fetches the
333
+ * pinned version, not latest. Independent of seeding, so the Bun plugin is
334
+ * installed when EITHER is on (they share the one first-match-wins onLoad).
335
+ *
336
+ * No-op on a second call. Async because the Bun path dynamically imports
337
+ * `action-seed-bun.js` (so the `Bun.*` global is never referenced on Node).
338
+ *
339
+ * @param {{ appDir?: string | null, seedEnabled?: boolean, pinEnabled?: boolean }} [opts]
306
340
  * @returns {Promise<void>}
307
341
  */
308
- export async function registerSeedHooks() {
342
+ export async function registerSeedHooks({ appDir = null, seedEnabled = true, pinEnabled = false } = {}) {
309
343
  if (_registered) return;
310
344
  _registered = true;
311
345
 
312
346
  if (serverRuntime() === 'bun') {
313
- // Bun has no module.registerHooks; install the same facade via Bun.plugin.
347
+ // Bun has no module.registerHooks; install the facade + pin rewrite via one
348
+ // Bun.plugin onLoad (first-match-wins, so the two transforms must share it).
314
349
  const { installBunSeedPlugin } = await import('./action-seed-bun.js');
315
- installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe: SERVER_FILE_RE });
316
- _enabled = true;
350
+ const pinTransform = (pinEnabled && appDir) ? buildBunPinTransform(appDir) : null;
351
+ installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe: SERVER_FILE_RE, seedEnabled, pinTransform });
352
+ if (seedEnabled) _enabled = true;
317
353
  return;
318
354
  }
319
355
 
356
+ // Node: seeding only (Bun-specific inline-version pinning does not apply; the
357
+ // Node zero-install resolve-hook is #669's separate concern). Nothing to
358
+ // install when seeding is off.
359
+ if (!seedEnabled) return;
360
+
320
361
  // A runtime that is neither Node-with-registerHooks nor Bun: seeding stays
321
362
  // OFF (fail-open). `seedingEnabled()` is false, ssr.js emits no seed block,
322
363
  // and the client RPC stub falls back to a normal fetch. Never wrong data.
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Pin Bun zero-install dependency versions by rewriting bare import specifiers
3
+ * to inline-versioned ones (#685).
4
+ *
5
+ * Under Bun zero-install (`bun run dev` / `start` via the `webjs-bun.mjs`
6
+ * bootstrap, no `node_modules`), Bun's auto-install fetches `latest` for a bare
7
+ * `import 'zod'`, ignoring package.json and `bun.lock` (#684). But Bun honors an
8
+ * INLINE version in the specifier (`import 'zod@1.0.0'`), and that survives the
9
+ * `onResolve` bypass because it is part of the specifier the loader receives. So
10
+ * an `onLoad` transform that rewrites `import 'zod'` to `import 'zod@<pinned>'`
11
+ * (the version from package.json / `bun.lock`) makes auto-install fetch the
12
+ * pinned version. This module is the runtime-neutral, unit-testable core; the
13
+ * Bun `onLoad` glue (which supplies `Bun.Transpiler().scanImports` and the
14
+ * resolved dep versions) lives in the Bun-side plugin.
15
+ *
16
+ * The specifier set comes from `Bun.Transpiler.scanImports`, which is
17
+ * AST-accurate (a plain string that merely looks like a specifier is NOT
18
+ * listed), so we never rewrite a non-import string. The in-source replacement is
19
+ * additionally anchored on the `from` / `import` / `require` keyword so an
20
+ * identical non-import string literal elsewhere is left alone.
21
+ */
22
+
23
+ /**
24
+ * Resolve the version to pin each DECLARED dependency to: the exact version from
25
+ * `bun.lock` when present (precise), else the package.json range/value as-is
26
+ * (Bun resolves a range in an inline specifier). Only declared deps are
27
+ * returned, so the rewrite never pins a transitive dep through an app import
28
+ * (those follow from the pinned direct deps' own manifests).
29
+ *
30
+ * Runtime-neutral: takes the two file contents (the Bun glue reads them via
31
+ * `Bun.file`), so this stays unit-testable on Node.
32
+ *
33
+ * @param {string} pkgJsonText package.json contents
34
+ * @param {string | null} [bunLockText] bun.lock contents, when present
35
+ * @returns {Record<string, string>} package name -> version
36
+ */
37
+ export function resolveDepVersions(pkgJsonText, bunLockText) {
38
+ /** @type {Record<string, string>} */
39
+ const out = {};
40
+ let pkg;
41
+ try { pkg = JSON.parse(pkgJsonText); } catch { return out; }
42
+ const declared = { ...pkg.dependencies, ...pkg.devDependencies };
43
+
44
+ // bun.lock pins each package as `"name": ["name@<exact>", ...]`. Extract the
45
+ // exact version for each DECLARED dep, anchored on its name so a substring
46
+ // match cannot cross to another package. This is the precise source.
47
+ /** @type {Record<string, string>} */
48
+ const lockExact = {};
49
+ if (bunLockText) {
50
+ for (const name of Object.keys(declared)) {
51
+ const esc = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
52
+ const m = bunLockText.match(new RegExp('"' + esc + '"\\s*:\\s*\\[\\s*"' + esc + '@([^"]+)"'));
53
+ if (m && isExactVersion(m[1])) lockExact[name] = m[1];
54
+ }
55
+ }
56
+
57
+ for (const [name, range] of Object.entries(declared)) {
58
+ // ONLY an EXACT version can be an inline specifier: Bun zero-install
59
+ // auto-install resolves `name@1.2.3` but ENOENTs on a range or dist-tag
60
+ // (`name@^1.2`, `name@latest`) and on a protocol range (`workspace:`,
61
+ // `file:`, ...). So pin to the bun.lock exact when present, else an exact
62
+ // package.json pin; a range without a lock is left BARE (it resolves to
63
+ // latest, exactly as before this feature, never to a broken specifier).
64
+ if (lockExact[name]) out[name] = lockExact[name];
65
+ else if (isExactVersion(range)) out[name] = range;
66
+ }
67
+ return out;
68
+ }
69
+
70
+ /**
71
+ * Whether a version string is an EXACT semver (the only form valid as a Bun
72
+ * inline specifier): `1.2.3`, with an optional prerelease / build suffix
73
+ * (`1.2.3-rc.1`, `1.2.3+build`). Rejects any range operator (`^ ~ > < = * x | -`
74
+ * space), a dist-tag (`latest`), and a protocol range (`workspace:` etc.).
75
+ * @param {unknown} v
76
+ * @returns {boolean}
77
+ */
78
+ function isExactVersion(v) {
79
+ return typeof v === 'string' && /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(v);
80
+ }
81
+
82
+ /**
83
+ * The npm package name a specifier belongs to: `@scope/name[/sub]` or
84
+ * `name[/sub]`. Returns null for a bare scope with no name.
85
+ * @param {string} p
86
+ * @returns {string | null}
87
+ */
88
+ export function packageNameOf(p) {
89
+ if (p.startsWith('@')) {
90
+ const parts = p.split('/');
91
+ return parts.length >= 2 && parts[0] && parts[1] ? parts[0] + '/' + parts[1] : null;
92
+ }
93
+ return p.split('/')[0] || null;
94
+ }
95
+
96
+ /**
97
+ * Whether a specifier should be left untouched: relative, the `#` app alias, a
98
+ * protocol/builtin (`node:`, `bun:`, `http:`...), or already version-pinned.
99
+ * @param {string} p
100
+ * @param {string} name
101
+ * @returns {boolean}
102
+ */
103
+ function skipSpecifier(p, name) {
104
+ if (!p || p[0] === '.' || p[0] === '#') return true;
105
+ // A protocol/builtin has a colon before any slash (node:fs, bun:sqlite, http:).
106
+ // A scope `@scope/x` has no leading colon, so it is not caught here.
107
+ const firstSlash = p.indexOf('/');
108
+ const head = firstSlash === -1 ? p : p.slice(0, firstSlash);
109
+ if (head.includes(':')) return true;
110
+ // Already versioned: the name is immediately followed by `@<something>`.
111
+ if (p.slice(name.length).startsWith('@')) return true;
112
+ return false;
113
+ }
114
+
115
+ /**
116
+ * Rewrite bare specifiers of DECLARED deps to `name@version` (keeping any
117
+ * subpath). `name` -> `name@v`, `name/sub` -> `name@v/sub`,
118
+ * `@scope/name/sub` -> `@scope/name@v/sub`.
119
+ *
120
+ * @param {string} src module source
121
+ * @param {Array<{ kind: string, path: string }>} imports Bun.Transpiler.scanImports output
122
+ * @param {Record<string, string>} depVersions package name -> version (exact preferred, e.g. from bun.lock)
123
+ * @returns {string} the rewritten source (unchanged when nothing matched)
124
+ */
125
+ export function rewriteDepSpecifiers(src, imports, depVersions) {
126
+ /** @type {Map<string, string>} specifier -> versioned specifier */
127
+ const remap = new Map();
128
+ for (const imp of imports) {
129
+ const p = imp && imp.path;
130
+ if (!p || remap.has(p)) continue;
131
+ const name = packageNameOf(p);
132
+ if (!name || skipSpecifier(p, name)) continue;
133
+ const ver = depVersions[name];
134
+ if (!ver) continue;
135
+ remap.set(p, name + '@' + ver + p.slice(name.length));
136
+ }
137
+ if (remap.size === 0) return src;
138
+
139
+ let out = src;
140
+ for (const [from, to] of remap) {
141
+ const q = from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
142
+ // Anchor on the import/export/require form so an identical non-import
143
+ // string literal is not touched: `from 'x'`, `import 'x'`, `import('x')`,
144
+ // `require('x')`. The optional `(` covers dynamic import / require. The
145
+ // leading `(^|[^\w.$])` boundary keeps a method call (`db.select().from('x')`,
146
+ // a `.import(...)` member) or a keyword-suffixed identifier (`xfrom 'x'`)
147
+ // from matching, which an unanchored keyword would wrongly rewrite.
148
+ const re = new RegExp("(^|[^\\w.$])((?:from|import|require)\\s*\\(?\\s*)(['\"])" + q + "\\3", 'g');
149
+ out = out.replace(re, (_m, pre, lead, quote) => pre + lead + quote + to + quote);
150
+ }
151
+ return out;
152
+ }
package/src/check.js CHANGED
@@ -77,12 +77,12 @@ export const RULES = [
77
77
  {
78
78
  name: 'no-static-properties',
79
79
  description:
80
- 'Reactive properties must be declared via the `extends WebComponent({ … })` factory, never a hand-written `static properties = { … }` field in the class body. The factory types each field for you (no `declare` needed) and the runtime throws on a direct `static properties`. Migrate `class X extends WebComponent { static properties = { count: { type: Number } } }` to `class X extends WebComponent({ count: Number })`; use the `prop()` helper for options (`prop(Number, { reflect: true })`) and set defaults via the `default` option or in the constructor.',
80
+ 'Reactive properties must be declared via the `extends WebComponent({ … })` factory, never a hand-written `static properties = { … }` field in the class body. The factory types each field for you (no `declare` needed) and the runtime throws on a direct `static properties`. Migrate `class X extends WebComponent { static properties = { count: { type: Number } } }` to `class X extends WebComponent({ count: Number })`; use the `prop()` helper for options (`prop(Number, { reflect: true })`) and set defaults in the constructor after super().',
81
81
  },
82
82
  {
83
83
  name: 'reactive-props-no-class-field',
84
84
  description:
85
- 'A reactive property declared via the `extends WebComponent({ … })` factory must NOT also have a plain class-field initializer (`count = 0` or `count: number = 0`) in the class body. Under modern class-field semantics that initializer runs Object.defineProperty *after* super(), clobbering the framework\'s reactive accessor and silently breaking re-renders. Set the default via the `default` property option (`prop(Number, { default: 0 })`) or by assigning in the constructor after super().',
85
+ 'A reactive property declared via the `extends WebComponent({ … })` factory must NOT also have a plain class-field initializer (`count = 0` or `count: number = 0`) in the class body. Under modern class-field semantics that initializer runs Object.defineProperty *after* super(), clobbering the framework\'s reactive accessor and silently breaking re-renders. Set the default by assigning in the constructor after super().',
86
86
  },
87
87
  {
88
88
  name: 'array-prop-uses-array-type',
@@ -451,7 +451,7 @@ export async function checkConventions(appDir) {
451
451
  file: rel,
452
452
  message:
453
453
  '`static properties = { … }` is no longer supported; declare reactive properties via the `extends WebComponent({ … })` factory instead.',
454
- fix: 'Move the properties into the factory call: `class X extends WebComponent({ count: Number })`. Use `prop(Number, { reflect: true })` for options and set defaults via the `default` option or the constructor. Delete the `static properties` block and any `declare` fields for those props.',
454
+ fix: 'Move the properties into the factory call: `class X extends WebComponent({ count: Number })`. Use `prop(Number, { reflect: true })` for options and set defaults in the constructor after super(). Delete the `static properties` block and any `declare` fields for those props.',
455
455
  });
456
456
  }
457
457
  }
@@ -471,7 +471,7 @@ export async function checkConventions(appDir) {
471
471
  rule: 'reactive-props-no-class-field',
472
472
  file: rel,
473
473
  message: `Reactive prop \`${bad}\` uses a class-field initializer; this clobbers the framework's reactive accessor under modern class-field semantics.`,
474
- fix: `Remove the class-field initializer and set the default via the \`default\` option (\`prop(<Type>, { default: <value> })\`) or by assigning \`this.${bad} = <value>\` inside \`constructor()\` after \`super()\`.`,
474
+ fix: `Remove the class-field initializer and set the default by assigning \`this.${bad} = <value>\` inside \`constructor()\` after \`super()\`.`,
475
475
  });
476
476
  }
477
477
  }
package/src/dev.js CHANGED
@@ -268,6 +268,32 @@ export async function readSeedEnabled(appDir) {
268
268
  return true;
269
269
  }
270
270
 
271
+ /**
272
+ * Read the Bun zero-install version-pinning switch (#685). The `WEBJS_PIN` env
273
+ * override wins (same grammar as `WEBJS_SEED`), otherwise
274
+ * `{ "webjs": { "pin": false } }` disables it. Default ON (opt-out). Consulted
275
+ * only on Bun: the rewrite injects inline-version specifiers that Bun's
276
+ * auto-install honors so zero-install fetches the pinned version, not latest
277
+ * (#684). Node zero-install pinning is #669's separate concern.
278
+ * @param {string} appDir
279
+ * @returns {Promise<boolean>}
280
+ */
281
+ export async function readPinEnabled(appDir) {
282
+ const raw = process.env.WEBJS_PIN;
283
+ if (raw != null && raw !== '') {
284
+ const v = String(raw).trim().toLowerCase();
285
+ if (v === '0' || v === 'false' || v === 'off' || v === 'no') return false;
286
+ if (v === '1' || v === 'true' || v === 'on' || v === 'yes') return true;
287
+ }
288
+ try {
289
+ const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
290
+ if (pkg && pkg.webjs && pkg.webjs.pin === false) return false;
291
+ } catch {
292
+ // No package.json, malformed JSON, or unreadable. Keep the default.
293
+ }
294
+ return true;
295
+ }
296
+
271
297
  /**
272
298
  * Read the client-router switch (`webjs.clientRouter`) from the app's
273
299
  * package.json (#629). Default `true`: the client router auto-enables in the
@@ -568,7 +594,11 @@ export async function createRequestHandler(opts) {
568
594
  // Read once (not per-rebuild): the hook is global and cannot be cleanly
569
595
  // un-installed, so toggling needs a restart. Disabled -> no hook, no ambient
570
596
  // collector wrap in ssr.js, and module loading stays byte-identical.
571
- if (await readSeedEnabled(appDir)) await registerSeedHooks();
597
+ // Pinning (#685) is Bun-only and independent of seeding, so install the hook
598
+ // when EITHER is on (on Bun they share one onLoad).
599
+ const seedEnabled = await readSeedEnabled(appDir);
600
+ const pinEnabled = serverRuntime() === 'bun' && await readPinEnabled(appDir);
601
+ if (seedEnabled || pinEnabled) await registerSeedHooks({ appDir, seedEnabled, pinEnabled });
572
602
 
573
603
  // When an app commits a vendor pin (.webjs/vendor/importmap.json) it carries a
574
604
  // deterministic vendor map that is cheap to read (one file, no analysis, no
package/src/vendor.js CHANGED
@@ -412,7 +412,7 @@ async function jspmCall(installs, provider) {
412
412
  // A 5xx/429 is a transient jspm problem worth retrying. A 401/4xx
413
413
  // means at least one install is genuinely unresolvable (jspm uses
414
414
  // 401 for that): a private / workspace / server-only package (e.g.
415
- // @webjsdev/server, better-sqlite3) the browser never fetches
415
+ // @webjsdev/server, pg) the browser never fetches
416
416
  // anyway. Permanent failures must NOT block readiness.
417
417
  const transient = response.status >= 500 || response.status === 429;
418
418
  return { ok: false, imports: {}, transient };
@@ -17,6 +17,11 @@
17
17
  "type": "boolean",
18
18
  "default": true
19
19
  },
20
+ "pin": {
21
+ "description": "Bun zero-install dependency version-pinning switch (#685). Default true, Bun only. When on, an onLoad transform rewrites a declared dep's bare import specifier to its package.json / bun.lock version so Bun auto-install fetches the pinned version instead of latest (#684). Set to false to leave specifiers bare (zero-install resolves to latest). Read by readPinEnabled in dev.js; the WEBJS_PIN env override wins over this. No effect on Node.",
22
+ "type": "boolean",
23
+ "default": true
24
+ },
20
25
  "clientRouter": {
21
26
  "description": "Client-router switch (#629). Default true: the client router auto-enables in the browser whenever @webjsdev/core loads, so any page with a component gets SPA-style navigation with no import. Set to false to opt the whole app out (pure MPA, full-page navigation). Read by readClientRouterEnabled in dev.js.",
22
27
  "type": "boolean",