@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 +1 -1
- package/src/action-seed-bun.js +11 -45
- package/src/action-seed.js +12 -60
- package/src/dev.js +1 -31
- package/src/vendor.js +21 -31
- package/webjs-config.schema.json +0 -5
- package/src/bun-pin-rewrite.js +0 -192
package/package.json
CHANGED
package/src/action-seed-bun.js
CHANGED
|
@@ -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
|
|
34
|
-
*
|
|
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
|
|
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-
|
|
41
|
+
name: 'webjs-action-seed',
|
|
64
42
|
setup(build) {
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
89
|
-
if (
|
|
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
|
|
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
|
});
|
package/src/action-seed.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
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(
|
|
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
|
|
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
|
-
|
|
358
|
-
|
|
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
|
-
|
|
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)
|
|
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);
|
package/webjs-config.schema.json
CHANGED
|
@@ -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",
|
package/src/bun-pin-rewrite.js
DELETED
|
@@ -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
|
-
}
|