@webjsdev/server 0.8.37 → 0.8.39

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.37",
3
+ "version": "0.8.39",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
@@ -26,73 +26,39 @@
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
-
32
29
  /**
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`).
30
+ * Install the `Bun.plugin` seed `onLoad`. Idempotency is the caller's
31
+ * responsibility (`registerSeedHooks` guards on `_registered`).
49
32
  *
50
33
  * @param {{
51
34
  * isSeedCandidate: (specifier: string) => boolean,
52
35
  * buildSeedFacade: (origSpec: string, absPath: string, src: string) => (string | null),
53
36
  * serverFileRe: RegExp,
54
- * seedEnabled?: boolean,
55
- * pinTransform?: ((src: string, loader: 'ts' | 'js') => string) | null,
56
37
  * }} helpers
57
38
  */
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;
39
+ export function installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe }) {
62
40
  Bun.plugin({
63
- name: 'webjs-onload',
41
+ name: 'webjs-action-seed',
64
42
  setup(build) {
65
- build.onLoad({ filter }, async (args) => {
43
+ // The filter is a cheap path pre-screen (`*.server.*`, optional query).
44
+ build.onLoad({ filter: serverFileRe }, async (args) => {
66
45
  const absPath = args.path.split('?')[0];
67
46
  // `.ts` / `.mts` strip via the `ts` loader; `.js` / `.mjs` via `js`.
68
47
  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
- }
75
48
  // Read the real source. A genuine read failure (missing file) propagates,
76
49
  // which Bun reports as a load error exactly as it would without the plugin.
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
- }
50
+ const src = await Bun.file(absPath).text();
85
51
  try {
86
52
  // Facet only a `'use server'` candidate (not the `?webjs-seed-orig`
87
53
  // passthrough); a non-candidate or a passthrough falls through to the
88
- // (possibly pin-rewritten) source below.
89
- if (seedEnabled && isSeedCandidate(args.path)) {
54
+ // raw source below.
55
+ if (isSeedCandidate(args.path)) {
90
56
  const source = buildSeedFacade(args.path, absPath, src);
91
57
  if (source != null) return { contents: source, loader: 'js' };
92
58
  }
93
59
  } catch {
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.
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.
96
62
  }
97
63
  return { contents: src, loader };
98
64
  });
@@ -50,14 +50,12 @@
50
50
 
51
51
  import * as nodeModule from 'node:module';
52
52
  import { AsyncLocalStorage } from 'node:async_hooks';
53
- import { readFileSync, existsSync } from 'node:fs';
54
- import { join } from 'node:path';
53
+ import { readFileSync } from 'node:fs';
55
54
  import { fileURLToPath } from 'node:url';
56
55
  import { stringify } from '@webjsdev/core';
57
56
  import { hashFile } from './actions.js';
58
57
  import { isStreamable } from './action-stream.js';
59
58
  import { serverRuntime } from './listener-core.js';
60
- import { resolveDepVersions, rewriteDepSpecifiers } from './bun-pin-rewrite.js';
61
59
 
62
60
  /** Ambient per-render seed collector. `Map<key, value>` or undefined. */
63
61
  const als = new AsyncLocalStorage();
@@ -298,73 +296,27 @@ function seedLoadHook(url, context, nextLoad) {
298
296
  }
299
297
 
300
298
  /**
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
- export function buildBunPinTransform(appDir) {
311
- // Pinning is for TRUE zero-install only. When `node_modules` exists (an
312
- // installed app, or a workspace member like this repo's own examples), Bun
313
- // resolves bare specifiers from it, so injecting an inline version would
314
- // bypass that. Worse, for a workspace-linked dep it would swap the local
315
- // package for the published one (the #698 blog-on-Bun regression). So skip
316
- // pinning entirely when node_modules is present; Bun uses the installed copy.
317
- if (existsSync(join(appDir, 'node_modules'))) return null;
318
- let pkgText;
319
- try { pkgText = readFileSync(join(appDir, 'package.json'), 'utf8'); } catch { return null; }
320
- let lockText = null;
321
- try { lockText = readFileSync(join(appDir, 'bun.lock'), 'utf8'); } catch { /* optional */ }
322
- const versions = resolveDepVersions(pkgText, lockText);
323
- if (Object.keys(versions).length === 0) return null;
324
- return (src, loader) => {
325
- const imports = new Bun.Transpiler({ loader }).scanImports(src);
326
- return rewriteDepSpecifiers(src, imports, versions);
327
- };
328
- }
329
-
330
- /**
331
- * Install the boot-time load hook(s) (idempotent). Called once from `dev.js`,
332
- * BEFORE any app module is imported (a module loaded before the hook would
333
- * already be cached untransformed). Two concerns ride here:
334
- *
335
- * - **Seeding (#472)** when `seedEnabled`: facet `'use server'` modules into
336
- * the SSR action-result seed facade. Node uses `module.registerHooks`; Bun a
337
- * `Bun.plugin` `onLoad`.
338
- * - **Pinning (#685)** when `pinEnabled` (Bun only): rewrite bare dep
339
- * specifiers to the package.json/bun.lock version so zero-install fetches the
340
- * pinned version, not latest. Independent of seeding, so the Bun plugin is
341
- * installed when EITHER is on (they share the one first-match-wins onLoad).
342
- *
343
- * No-op on a second call. Async because the Bun path dynamically imports
344
- * `action-seed-bun.js` (so the `Bun.*` global is never referenced on Node).
345
- *
346
- * @param {{ appDir?: string | null, seedEnabled?: boolean, pinEnabled?: boolean }} [opts]
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.
347
306
  * @returns {Promise<void>}
348
307
  */
349
- export async function registerSeedHooks({ appDir = null, seedEnabled = true, pinEnabled = false } = {}) {
308
+ export async function registerSeedHooks() {
350
309
  if (_registered) return;
351
310
  _registered = true;
352
311
 
353
312
  if (serverRuntime() === 'bun') {
354
- // Bun has no module.registerHooks; install the facade + pin rewrite via one
355
- // Bun.plugin onLoad (first-match-wins, so the two transforms must share it).
313
+ // Bun has no module.registerHooks; install the same facade via Bun.plugin.
356
314
  const { installBunSeedPlugin } = await import('./action-seed-bun.js');
357
- const pinTransform = (pinEnabled && appDir) ? buildBunPinTransform(appDir) : null;
358
- installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe: SERVER_FILE_RE, seedEnabled, pinTransform });
359
- if (seedEnabled) _enabled = true;
315
+ installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe: SERVER_FILE_RE });
316
+ _enabled = true;
360
317
  return;
361
318
  }
362
319
 
363
- // Node: seeding only (Bun-specific inline-version pinning does not apply; the
364
- // Node zero-install resolve-hook is #669's separate concern). Nothing to
365
- // install when seeding is off.
366
- if (!seedEnabled) return;
367
-
368
320
  // A runtime that is neither Node-with-registerHooks nor Bun: seeding stays
369
321
  // OFF (fail-open). `seedingEnabled()` is false, ssr.js emits no seed block,
370
322
  // and the client RPC stub falls back to a normal fetch. Never wrong data.
package/src/dev.js CHANGED
@@ -268,32 +268,6 @@ 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
-
297
271
  /**
298
272
  * Read the client-router switch (`webjs.clientRouter`) from the app's
299
273
  * package.json (#629). Default `true`: the client router auto-enables in the
@@ -594,11 +568,7 @@ export async function createRequestHandler(opts) {
594
568
  // Read once (not per-rebuild): the hook is global and cannot be cleanly
595
569
  // un-installed, so toggling needs a restart. Disabled -> no hook, no ambient
596
570
  // collector wrap in ssr.js, and module loading stays byte-identical.
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 });
571
+ if (await readSeedEnabled(appDir)) await registerSeedHooks();
602
572
 
603
573
  // When an app commits a vendor pin (.webjs/vendor/importmap.json) it carries a
604
574
  // deterministic vendor map that is cheap to read (one file, no analysis, no
package/src/vendor.js CHANGED
@@ -47,7 +47,6 @@ import { createRequire } from 'node:module';
47
47
  import { spawnSync } from 'node:child_process';
48
48
  import { digestBase64 } from './crypto-utils.js';
49
49
  import { BUFFERED_MARKER } from './conditional-get.js';
50
- import { resolveDepVersions } from './bun-pin-rewrite.js';
51
50
 
52
51
  /**
53
52
  * Set of package names whose importmap entries are populated by the
@@ -67,6 +66,18 @@ import { resolveDepVersions } from './bun-pin-rewrite.js';
67
66
  */
68
67
  const BUILTIN = new Set(['@webjsdev/core', '@webjsdev/core/']);
69
68
 
69
+ /**
70
+ * Server-only framework packages that must NEVER be vendored to the browser.
71
+ * Unlike `@webjsdev/core` (browser-bound, served locally via `/__webjs/core/*`),
72
+ * these are pure server packages: the CLI, the SSR runtime, the MCP server. A
73
+ * stray browser-graph scan
74
+ * that surfaces one of them must not push it onto the jspm path (#713). Matched
75
+ * on the extracted package name, so subpaths (`@webjsdev/cli/bin/webjs.js`) are
76
+ * covered. `@webjsdev/ui` is intentionally absent: its components ARE
77
+ * browser-bound, so it stays vendorable.
78
+ */
79
+ const FRAMEWORK_SERVER_ONLY = new Set(['@webjsdev/cli', '@webjsdev/server', '@webjsdev/mcp']);
80
+
70
81
  /**
71
82
  * Scan source files under `dir` for bare import specifiers reachable
72
83
  * from the browser. Returns a Set of package names.
@@ -91,6 +102,12 @@ export async function scanBareImports(dir, skipFiles) {
91
102
  const found = new Set();
92
103
  await walk(dir, found, skipFiles);
93
104
  for (const b of BUILTIN) found.delete(b);
105
+ // Drop server-only framework packages (and their subpaths) so they never
106
+ // reach the importmap / jspm path (#713).
107
+ for (const spec of found) {
108
+ const p = extractPackageName(spec);
109
+ if (p && FRAMEWORK_SERVER_ONLY.has(p)) found.delete(spec);
110
+ }
94
111
  return found;
95
112
  }
96
113
 
@@ -262,26 +279,6 @@ export function getPackageVersion(pkgName, appDir) {
262
279
  }
263
280
  }
264
281
 
265
- /**
266
- * Declared dep versions for the zero-install importmap fallback (#699): the
267
- * SAME source the Bun server pin uses (`resolveDepVersions`), so the browser
268
- * importmap and the server resolve a vendor from one source. Returns a map of
269
- * package name to the bun.lock exact (when present) else the package.json
270
- * declared semver (an exact or an inline-safe range). Covers `dependencies` and
271
- * `devDependencies` (what `resolveDepVersions` reads), NOT `peerDependencies`.
272
- * Empty when there is no readable package.json. Used only as a fallback when
273
- * `getPackageVersion` (require.resolve against node_modules) finds nothing.
274
- * @param {string} appDir
275
- * @returns {Record<string, string>}
276
- */
277
- export function declaredVendorVersions(appDir) {
278
- let pkgText;
279
- try { pkgText = readFileSync(join(appDir, 'package.json'), 'utf8'); } catch { return {}; }
280
- let lockText = null;
281
- try { lockText = readFileSync(join(appDir, 'bun.lock'), 'utf8'); } catch { /* optional */ }
282
- return resolveDepVersions(pkgText, lockText);
283
- }
284
-
285
282
  /**
286
283
  * Read the installed package's declared `dependencies` + `peerDependencies`
287
284
  * from its `package.json`, hoist-aware (same resolution as `getPackageVersion`,
@@ -657,18 +654,11 @@ function mergePerInstall(fragments) {
657
654
  */
658
655
  export async function vendorImportMapEntries(bareImports, appDir) {
659
656
  const installs = [];
660
- // Zero-install fallback (#699). When node_modules is absent (Bun
661
- // zero-install), `getPackageVersion` (require.resolve) finds nothing, so the
662
- // entry would be dropped and the browser bare import would 404. Fall back to
663
- // the SAME source the Bun server pin uses, so the importmap and the server
664
- // resolve a vendor from one source: bun.lock exact when present, else the
665
- // package.json declared semver (jspm resolves a range, so a range works).
666
- const declared = declaredVendorVersions(appDir);
667
657
  for (const spec of bareImports) {
668
658
  if (BUILTIN.has(spec)) continue;
669
659
  const pkg = extractPackageName(spec);
670
- if (!pkg || BUILTIN.has(pkg)) continue;
671
- const version = getPackageVersion(pkg, appDir) || declared[pkg];
660
+ if (!pkg || BUILTIN.has(pkg) || FRAMEWORK_SERVER_ONLY.has(pkg)) continue;
661
+ const version = getPackageVersion(pkg, appDir);
672
662
  if (!version) continue;
673
663
  // Splice the version into the specifier: 'dayjs/plugin/utc' with
674
664
  // version 1.11.13 becomes 'dayjs@1.11.13/plugin/utc'. jspm.io's
@@ -1236,7 +1226,7 @@ export async function pinAll(appDir, opts = {}) {
1236
1226
  for (const spec of bare) {
1237
1227
  if (BUILTIN.has(spec)) continue;
1238
1228
  const pkg = extractPackageName(spec);
1239
- if (!pkg || BUILTIN.has(pkg)) continue;
1229
+ if (!pkg || BUILTIN.has(pkg) || FRAMEWORK_SERVER_ONLY.has(pkg)) continue;
1240
1230
  const version = getPackageVersion(pkg, appDir);
1241
1231
  if (!version) continue;
1242
1232
  const subpath = spec.slice(pkg.length);
@@ -17,11 +17,6 @@
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
- },
25
20
  "clientRouter": {
26
21
  "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.",
27
22
  "type": "boolean",
@@ -1,192 +0,0 @@
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 and reproducible), else the package.json
26
- * declared value passed through as-is when it is an inline-safe semver. Bun's
27
- * inline specifier resolves a range the standard way (`zod@^3.20.0` picks the
28
- * highest matching `3.x`, verified), so passing the declared range through is
29
- * the correct semver behaviour, the same a fresh `bun install` would pick. Only
30
- * declared deps are returned, so the rewrite never pins a transitive dep through
31
- * an app import (those follow from the pinned direct deps' own manifests).
32
- *
33
- * A protocol range (`workspace:`, `file:`, `link:`, git / URL) and a bare
34
- * wildcard (`*`, `x`, empty) are NOT valid inline specifiers, so they are left
35
- * BARE (resolving to latest, exactly as before this feature, never to a broken
36
- * specifier). For reproducibility across machines, commit a `bun.lock` (its
37
- * exact pin then wins over a floating range).
38
- *
39
- * Runtime-neutral: takes the two file contents (the Bun glue reads them via
40
- * `Bun.file`), so this stays unit-testable on Node.
41
- *
42
- * @param {string} pkgJsonText package.json contents
43
- * @param {string | null} [bunLockText] bun.lock contents, when present
44
- * @returns {Record<string, string>} package name -> version
45
- */
46
- export function resolveDepVersions(pkgJsonText, bunLockText) {
47
- /** @type {Record<string, string>} */
48
- const out = {};
49
- let pkg;
50
- try { pkg = JSON.parse(pkgJsonText); } catch { return out; }
51
- const declared = { ...pkg.dependencies, ...pkg.devDependencies };
52
-
53
- // bun.lock pins each package as `"name": ["name@<exact>", ...]`. Extract the
54
- // exact version for each DECLARED dep, anchored on its name so a substring
55
- // match cannot cross to another package. This is the precise source.
56
- /** @type {Record<string, string>} */
57
- const lockExact = {};
58
- if (bunLockText) {
59
- for (const name of Object.keys(declared)) {
60
- const esc = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
61
- const m = bunLockText.match(new RegExp('"' + esc + '"\\s*:\\s*\\[\\s*"' + esc + '@([^"]+)"'));
62
- if (m && isExactVersion(m[1])) lockExact[name] = m[1];
63
- }
64
- }
65
-
66
- for (const [name, range] of Object.entries(declared)) {
67
- // bun.lock exact wins (precise and reproducible). Otherwise pass the
68
- // declared semver through as an inline specifier: Bun resolves an exact,
69
- // caret, tilde, or comparator range the standard way (highest match), so
70
- // `name@^1.2.3` is correct, not broken. A protocol range (`workspace:`,
71
- // `file:`, git / URL) and a bare wildcard (`*`, `x`, empty) are NOT
72
- // inline-safe, so they are left BARE (latest, as before).
73
- if (lockExact[name]) out[name] = lockExact[name];
74
- else if (isInlineableVersion(range)) out[name] = range;
75
- }
76
- return out;
77
- }
78
-
79
- /**
80
- * Whether a version string is an EXACT semver (the only form valid as a Bun
81
- * inline specifier): `1.2.3`, with an optional prerelease / build suffix
82
- * (`1.2.3-rc.1`, `1.2.3+build`). Rejects any range operator (`^ ~ > < = * x | -`
83
- * space), a dist-tag (`latest`), and a protocol range (`workspace:` etc.).
84
- * @param {unknown} v
85
- * @returns {boolean}
86
- */
87
- function isExactVersion(v) {
88
- return typeof v === 'string' && /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(v);
89
- }
90
-
91
- /**
92
- * Whether a declared package.json version is safe to forward verbatim as a Bun
93
- * inline specifier (`name@<v>`). Accepts a single-token semver: an exact version
94
- * (with an optional prerelease / build suffix, `1.2.3`, `1.0.0-rc.3`) or a caret
95
- * / tilde / comparator range over a numeric core WITHOUT a suffix (`^1.2.3`,
96
- * `~1.2`, `>=1.2.3`, `^3`). Bun resolves these the standard way (highest match)
97
- * at auto-install time.
98
- *
99
- * Rejects, so they are left BARE: a RANGE OPERATOR combined with a prerelease /
100
- * build suffix (`^1.0.0-rc.3`, `~1.0.0-beta.1`, #703): Bun zero-install ENOENTs
101
- * on a caret-prerelease inline specifier (verified, `drizzle-orm@^1.0.0-rc.3`
102
- * errors while the exact `drizzle-orm@1.0.0-rc.3` resolves). Also a protocol
103
- * range (`workspace:`, `file:`, `link:`, `git+...`, an `http(s)://` URL, any
104
- * value with a `:`), a bare wildcard (`*`, `x`, `X`, empty), a multi-token range
105
- * (a space, a `||` union, a hyphen `1 - 2` range, which would break the
106
- * specifier string), and a dist-tag (`latest`, `next`, which auto-install
107
- * resolves unreliably). A rejected value resolves to latest, the pre-feature
108
- * behaviour, never a broken specifier.
109
- * @param {unknown} v
110
- * @returns {boolean}
111
- */
112
- function isInlineableVersion(v) {
113
- if (typeof v !== 'string') return false;
114
- const m = /^(>=|<=|>|<|=|\^|~)?(\d+(?:\.\d+){0,2})([-+][0-9A-Za-z.-]+)?$/.exec(v);
115
- if (!m) return false;
116
- // A range operator (group 1) plus a prerelease / build suffix (group 3) is not
117
- // inline-resolvable under Bun zero-install (#703). An exact version with a
118
- // suffix, or a range with no suffix, is fine.
119
- return !(m[1] && m[3]);
120
- }
121
-
122
- /**
123
- * The npm package name a specifier belongs to: `@scope/name[/sub]` or
124
- * `name[/sub]`. Returns null for a bare scope with no name.
125
- * @param {string} p
126
- * @returns {string | null}
127
- */
128
- export function packageNameOf(p) {
129
- if (p.startsWith('@')) {
130
- const parts = p.split('/');
131
- return parts.length >= 2 && parts[0] && parts[1] ? parts[0] + '/' + parts[1] : null;
132
- }
133
- return p.split('/')[0] || null;
134
- }
135
-
136
- /**
137
- * Whether a specifier should be left untouched: relative, the `#` app alias, a
138
- * protocol/builtin (`node:`, `bun:`, `http:`...), or already version-pinned.
139
- * @param {string} p
140
- * @param {string} name
141
- * @returns {boolean}
142
- */
143
- function skipSpecifier(p, name) {
144
- if (!p || p[0] === '.' || p[0] === '#') return true;
145
- // A protocol/builtin has a colon before any slash (node:fs, bun:sqlite, http:).
146
- // A scope `@scope/x` has no leading colon, so it is not caught here.
147
- const firstSlash = p.indexOf('/');
148
- const head = firstSlash === -1 ? p : p.slice(0, firstSlash);
149
- if (head.includes(':')) return true;
150
- // Already versioned: the name is immediately followed by `@<something>`.
151
- if (p.slice(name.length).startsWith('@')) return true;
152
- return false;
153
- }
154
-
155
- /**
156
- * Rewrite bare specifiers of DECLARED deps to `name@version` (keeping any
157
- * subpath). `name` -> `name@v`, `name/sub` -> `name@v/sub`,
158
- * `@scope/name/sub` -> `@scope/name@v/sub`.
159
- *
160
- * @param {string} src module source
161
- * @param {Array<{ kind: string, path: string }>} imports Bun.Transpiler.scanImports output
162
- * @param {Record<string, string>} depVersions package name -> version (exact preferred, e.g. from bun.lock)
163
- * @returns {string} the rewritten source (unchanged when nothing matched)
164
- */
165
- export function rewriteDepSpecifiers(src, imports, depVersions) {
166
- /** @type {Map<string, string>} specifier -> versioned specifier */
167
- const remap = new Map();
168
- for (const imp of imports) {
169
- const p = imp && imp.path;
170
- if (!p || remap.has(p)) continue;
171
- const name = packageNameOf(p);
172
- if (!name || skipSpecifier(p, name)) continue;
173
- const ver = depVersions[name];
174
- if (!ver) continue;
175
- remap.set(p, name + '@' + ver + p.slice(name.length));
176
- }
177
- if (remap.size === 0) return src;
178
-
179
- let out = src;
180
- for (const [from, to] of remap) {
181
- const q = from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
182
- // Anchor on the import/export/require form so an identical non-import
183
- // string literal is not touched: `from 'x'`, `import 'x'`, `import('x')`,
184
- // `require('x')`. The optional `(` covers dynamic import / require. The
185
- // leading `(^|[^\w.$])` boundary keeps a method call (`db.select().from('x')`,
186
- // a `.import(...)` member) or a keyword-suffixed identifier (`xfrom 'x'`)
187
- // from matching, which an unanchored keyword would wrongly rewrite.
188
- const re = new RegExp("(^|[^\\w.$])((?:from|import|require)\\s*\\(?\\s*)(['\"])" + q + "\\3", 'g');
189
- out = out.replace(re, (_m, pre, lead, quote) => pre + lead + quote + to + quote);
190
- }
191
- return out;
192
- }