@pitlane/assets 0.1.0 → 0.2.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @pitlane/assets
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - f14d251: `assets()` accepts `allowFiles`, `allowPackages`, and `denyFiles`, the browser boundary options of `remix/assets`, and reads them the same way. Setting `allowFiles` turns the boundary on: `vite build` fails, listing each file and its importer, when a file outside it would reach the client output, including files that server code links and the build copies there. `vite dev` fails the browser transform of such a module and refuses to serve such a file. Without `allowFiles` nothing changes. With `remix()`, pass the options as `remix({ assets: { allowFiles, … } })`. Upgrade if you deploy the same app to the `remix/assets` Node server and to a bundled host such as Oxygen or Cloudflare, so a file the Node server refuses no longer ships silently from the build.
8
+ - f14d251: `@pitlane/assets/vite-plugin` exports `ASSETS_MANIFEST_FILE`, the name of the manifest module `vite build` writes at the root of each server environment's output directory. Server bundles import that file by a relative path, so a host that deploys a single module, such as Shopify Oxygen, has to fold it into the bundle in a later build. Use the constant there instead of hardcoding `__pitlane_assets_manifest.js`.
9
+
10
+ ### Patch Changes
11
+
12
+ - The API reference and installed declarations now document `assets()` and every `AssetsPluginOptions` member, and the default `@pitlane/assets/manifest` export. No behavior changed.
13
+ - d7fcdd4: The development manifest no longer rebuilds from scratch. `assets()` under `vite dev` now keeps the import edges it recorded for every module and, after an edit, transforms only the changed file before answering the next request; modules the server never executed, such as lazy routes, are no longer transformed again on every invalidation. Browser modules it discovers are transformed through Vite's own request pipeline, so the browser's first load reads the cache instead of transforming them a second time.
14
+
15
+ On an app with about 1,900 server modules and 400 browser modules, an edit that previously held the next request for 20–45 seconds now answers in under 2 seconds, the same as the previous `@pitlane/dev` integration, and the first manifest costs about a third less. The dev manifest's contents are unchanged.
16
+
17
+ - 92ce0a2: `vite build` no longer publishes server-only files into the client output. The copy from each server build into the client output directory took every file the server bundle emitted, so server sourcemaps, `.dev.vars` and `wrangler.json` from `@cloudflare/vite-plugin`, `oxygen.json` from MiniOxygen, and anything else a plugin wrote beside the server bundle ended up in the publicly served directory. Only the stylesheets the server code imports, the fonts and images they reference, and the files the server code imports are copied now, and the server's half of the asset manifest names only those files. Upgrade if you deploy the client output of an app built with server sourcemaps or a platform plugin.
18
+ - 8e38080: The development manifest no longer stays out of date after a build that changes kept interrupting. Under `vite dev`, `assets()` builds the manifest again when a module it reads is invalidated mid-build, and gives up after three builds so a plugin that invalidates modules on every transform cannot hold the request forever. Vite then cached the manifest it gave up on, and it stayed cached, missing the last change's stylesheets and browser entries, until another edit invalidated it. That was most likely on a cold start, where Vite's dependency optimizer re-runs and invalidates the browser module graph. The next import of `@pitlane/assets/manifest` now builds it again, and a build that completes uninterrupted is cached as before.
19
+ - d8670f8: The development manifest no longer keeps what a module imported, or which browser entries it named, before an edit. Under `vite dev`, `assets()` can transform one module more than once at the same time: the manifest's discovery and the module runner each ask for it, or an edit invalidates the module mid-transform. If a transform of the old code finished last, the manifest kept that code's stylesheets and literal `getScriptEntry()` and `getHref()` inputs until the module changed again, including in the first manifest a dev server built. A transform whose module was invalidated after its request began now records nothing, and a manifest being built when that happens is built again.
20
+
3
21
  ## 0.1.0
4
22
 
5
23
  ### Minor Changes
@@ -12,6 +12,7 @@ import { o as AssetsManifest } from "./types-DlTsImzF.mjs";
12
12
  *
13
13
  * @module @pitlane/assets/manifest
14
14
  */
15
+ /** `{ mode: "unavailable" }`, until `assets()` or an alias replaces this module. */
15
16
  declare let manifest: AssetsManifest;
16
17
  //#endregion
17
18
  export { manifest as default };
package/dist/manifest.mjs CHANGED
@@ -11,6 +11,7 @@
11
11
  *
12
12
  * @module @pitlane/assets/manifest
13
13
  */
14
+ /** `{ mode: "unavailable" }`, until `assets()` or an alias replaces this module. */
14
15
  let manifest = { mode: "unavailable" };
15
16
  //#endregion
16
17
  export { manifest as default };
@@ -1,5 +1,14 @@
1
1
  import { PluginOption } from "vite";
2
+ //#region src/vite/state.d.ts
3
+ /**
4
+ * The manifest module a build writes at the root of each server environment's
5
+ * output directory. The server chunks that read the manifest import this file
6
+ * by a relative path, so a host that deploys a single module must bundle it in.
7
+ */
8
+ export declare const ASSETS_MANIFEST_FILE = "__pitlane_assets_manifest.js";
9
+ //#endregion
2
10
  //#region src/vite-plugin.d.ts
11
+ /** Options for {@link assets}. `remix()` from `@pitlane/vite-plugin-remix` accepts them as `remix({ assets })`. */
3
12
  export interface AssetsPluginOptions {
4
13
  /** Extra source keys to make available as client entries or assets. */
5
14
  include?: string[];
@@ -7,6 +16,21 @@ export interface AssetsPluginOptions {
7
16
  serverEnvironments?: string[];
8
17
  /** Override Vite's native setting. With neither setting present, maps are disabled. */
9
18
  chunkImportMap?: boolean;
19
+ /**
20
+ * Files and globs, relative to the Vite root, that may reach the browser.
21
+ * Setting it turns on the browser boundary: a build that would publish any
22
+ * other file fails, and `vite dev` refuses to serve one. Read as
23
+ * `remix/assets` reads it; a directory path matches every file below it.
24
+ */
25
+ allowFiles?: string[];
26
+ /**
27
+ * Packages that may reach the browser, with their `dependencies` and
28
+ * installed `optionalDependencies`. Peer dependencies are not included.
29
+ * Requires `allowFiles`.
30
+ */
31
+ allowPackages?: string[];
32
+ /** Files and globs refused even when `allowFiles` or `allowPackages` allows them. Requires `allowFiles`. */
33
+ denyFiles?: string[];
10
34
  }
11
35
  /** Browser entries declared by one server module's development transform. */
12
36
  export interface AssetEntryRegistration {
@@ -22,5 +46,13 @@ export interface AssetsPluginApi {
22
46
  /** Replace an owner's declarations; an empty list removes them. Builds use `emitFile` instead. */
23
47
  setBrowserEntries(registration: AssetEntryRegistration): void;
24
48
  }
49
+ /**
50
+ * The Vite plugins that give `@pitlane/assets` its manifest: they serve
51
+ * browser entries during `vite dev`, emit them in a build, and replace
52
+ * `@pitlane/assets/manifest` with the manifest of the current dev server or
53
+ * build. Setting `allowFiles` also enforces the browser boundary.
54
+ *
55
+ * @throws When `allowPackages` or `denyFiles` is set without `allowFiles`.
56
+ */
25
57
  export declare function assets(options?: AssetsPluginOptions): PluginOption;
26
58
  //#endregion
@@ -1,11 +1,90 @@
1
1
  import { n as quotedList, t as assetsSpecifier } from "./specifier-DLC81sXY.mjs";
2
2
  import { t as createAssetManifest } from "./build-BbA-Zq1m.mjs";
3
- import { mkdir, readFile, writeFile } from "node:fs/promises";
4
- import { basename, dirname, isAbsolute, posix, relative, resolve } from "node:path";
5
- import { isCSSRequest, normalizePath, parseSync } from "vite";
3
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
4
+ import { basename, dirname, isAbsolute, join, posix, relative, resolve } from "node:path";
6
5
  import { fileURLToPath } from "node:url";
6
+ import { createFilter, isCSSRequest, normalizePath, parseSync } from "vite";
7
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
7
8
  import MagicString from "magic-string";
8
- import { existsSync } from "node:fs";
9
+ import { AsyncLocalStorage } from "node:async_hooks";
10
+ //#region src/vite/boundary-policy.ts
11
+ /**
12
+ * Builds the access check `remix/assets` applies to the files it serves.
13
+ * `root` is a real path; files passed to the check are real absolute paths.
14
+ */
15
+ function createBrowserBoundary(root, options) {
16
+ let allowMatchers = options.allowFiles.map((pattern) => fileMatcher(root, pattern));
17
+ let denyMatchers = (options.denyFiles ?? []).map((pattern) => ({
18
+ pattern,
19
+ matches: fileMatcher(root, pattern)
20
+ }));
21
+ let packageRoots = allowedPackageRoots(root, (options.allowPackages ?? []).map((name) => name.trim()));
22
+ return (file) => {
23
+ if (file.includes("/node_modules/@oxc-project/runtime/")) return { allowed: true };
24
+ if (!(allowMatchers.some((matches) => matches(file)) || packageRoots.some((directory) => within(directory, file)))) return { allowed: false };
25
+ let denial = denyMatchers.find(({ matches }) => matches(file));
26
+ return denial ? {
27
+ allowed: false,
28
+ deniedBy: denial.pattern
29
+ } : { allowed: true };
30
+ };
31
+ }
32
+ /** Whether `file` is `directory` or below it. */
33
+ function within(directory, file) {
34
+ return file === directory || file.startsWith(`${directory}/`);
35
+ }
36
+ function fileMatcher(root, pattern) {
37
+ let path = normalizePath(isAbsolute(pattern) ? pattern : join(root, pattern));
38
+ if (/[*?[\]{}()!+@]/.test(pattern)) return createFilter(path, null, { resolve: false });
39
+ if (!existsSync(path)) return (file) => file === path;
40
+ let real = normalizePath(realpathSync(path));
41
+ return statSync(real).isDirectory() ? (file) => within(real, file) : (file) => file === real;
42
+ }
43
+ /** Whether `name` is a bare or scoped package name, not a path, as `remix/assets` requires. */
44
+ function isPackageName(name) {
45
+ let scoped = name.startsWith("@");
46
+ let parts = (scoped ? name.slice(1) : name).split("/");
47
+ return parts.length === (scoped ? 2 : 1) && parts.every((part) => /^[\w.~-]+$/.test(part) && part !== "." && part !== "..");
48
+ }
49
+ /**
50
+ * The real directories of each named package and of everything its
51
+ * `dependencies` and installed `optionalDependencies` pull in, transitively.
52
+ * Peer dependencies are left out, as `remix/assets` leaves them out.
53
+ */
54
+ function allowedPackageRoots(root, names) {
55
+ let roots = /* @__PURE__ */ new Set();
56
+ let pending = names.map((name) => {
57
+ if (!isPackageName(name)) throw new Error(`[assets] allowPackages values must be package names. Received ${JSON.stringify(name)}.`);
58
+ let directory = packageDirectory(name, root);
59
+ if (!directory) throw new Error(`[assets] Could not resolve allowed package "${name}".`);
60
+ return directory;
61
+ });
62
+ while (pending.length > 0) {
63
+ let directory = pending.pop();
64
+ if (roots.has(directory)) continue;
65
+ roots.add(directory);
66
+ let manifest = JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
67
+ for (let name of Object.keys(manifest.dependencies ?? {})) {
68
+ let dependency = packageDirectory(name, directory);
69
+ if (!dependency) throw new Error(`[assets] Could not resolve dependency "${name}" of allowed package ${directory}.`);
70
+ pending.push(dependency);
71
+ }
72
+ for (let name of Object.keys(manifest.optionalDependencies ?? {})) {
73
+ let dependency = packageDirectory(name, directory);
74
+ if (dependency) pending.push(dependency);
75
+ }
76
+ }
77
+ return [...roots];
78
+ }
79
+ /** The real directory Node's lookup finds for package `name` from `from`, or `undefined`. */
80
+ function packageDirectory(name, from) {
81
+ for (let directory = from;; directory = dirname(directory)) {
82
+ let candidate = join(directory, "node_modules", name);
83
+ if (existsSync(join(candidate, "package.json"))) return normalizePath(realpathSync(candidate));
84
+ if (dirname(directory) === directory) return void 0;
85
+ }
86
+ }
87
+ //#endregion
9
88
  //#region src/vite/entries.ts
10
89
  function sourceKey(root, id) {
11
90
  let file = id.split("?")[0].split("#")[0];
@@ -22,6 +101,217 @@ function fileModule(id) {
22
101
  return !id.startsWith("\0") && isAbsolute(id.split("?")[0]);
23
102
  }
24
103
  //#endregion
104
+ //#region src/vite/folded-files.ts
105
+ /** `context`, with `addWatchFile` reporting each file to `observe` before Vite records it. */
106
+ function watching(context, observe) {
107
+ return new Proxy(context, { get(target, property) {
108
+ if (property === "addWatchFile") return (file) => {
109
+ observe(file);
110
+ target.addWatchFile(file);
111
+ };
112
+ let value = Reflect.get(target, property, target);
113
+ return typeof value === "function" ? value.bind(target) : value;
114
+ } });
115
+ }
116
+ function unsupported(name) {
117
+ return /* @__PURE__ */ new Error(`[assets] This Vite version does not expose the ${name} hook the browser boundary reads. Use a supported Vite version.`);
118
+ }
119
+ /** Calls `observe` with every file the folding plugins report, by wrapping their hooks' contexts. */
120
+ function observeFoldedFiles(config, observe) {
121
+ let plugin = (name) => config.plugins.find((candidate) => candidate.name === name);
122
+ for (let name of ["vite:css", "vite:worker-import-meta-url"]) {
123
+ let hook = plugin(name)?.transform;
124
+ if (typeof hook !== "object") throw unsupported(name);
125
+ let handler = hook.handler;
126
+ hook.handler = function(code, id, options) {
127
+ let context = watching(this, (file) => observe(this.environment, name, id, file));
128
+ return handler.call(context, code, id, options);
129
+ };
130
+ }
131
+ let hook = plugin("vite:worker")?.load;
132
+ if (typeof hook !== "object") throw unsupported("vite:worker");
133
+ let handler = hook.handler;
134
+ hook.handler = function(id, options) {
135
+ let context = watching(this, (file) => observe(this.environment, "vite:worker", id, file));
136
+ return handler.call(context, id, options);
137
+ };
138
+ }
139
+ //#endregion
140
+ //#region src/vite/boundary.ts
141
+ function describe({ file, importer, deniedBy }) {
142
+ let reason = deniedBy ? `matches denyFiles ${JSON.stringify(deniedBy)}` : "not in allowFiles or allowPackages";
143
+ return `${file}${importer ? ` (imported by ${importer})` : ""}: ${reason}`;
144
+ }
145
+ /** Query-free path of a module id or emitted file name. */
146
+ function filePath(id) {
147
+ return normalizePath(id.split(/[?#]/, 1)[0]);
148
+ }
149
+ /**
150
+ * A page, or an inline script Vite proxies out of one: the boundary governs
151
+ * what a page loads, not the page. An HTML file a module imports is data.
152
+ */
153
+ function htmlDocument(id) {
154
+ return filePath(id).endsWith(".html") && (!id.includes("?") || /[?&]html-proxy\b/.test(id));
155
+ }
156
+ /**
157
+ * The server files a build copies into the client output: every stylesheet
158
+ * the server graph imports, except as a string or URL, and the assets its
159
+ * chunks link.
160
+ */
161
+ function publishedServerFiles(bundle) {
162
+ let files = [];
163
+ for (let output of Object.values(bundle)) {
164
+ if (output.type !== "chunk" || !output.viteMetadata) continue;
165
+ for (let id of output.moduleIds) if (isCSSRequest(id) && !/[?&](?:inline|raw|url)\b/.test(id)) files.push(id);
166
+ for (let fileName of output.viteMetadata.importedAssets) {
167
+ let asset = bundle[fileName];
168
+ if (asset?.type === "asset") files.push(...asset.originalFileNames);
169
+ }
170
+ }
171
+ return files;
172
+ }
173
+ /**
174
+ * Holds the client graph and every file the build publishes for the browser
175
+ * to `allowFiles`, `allowPackages`, and `denyFiles`, the way `remix/assets`
176
+ * holds the files its asset server answers.
177
+ */
178
+ function browserBoundary(options, serverEnvironments) {
179
+ let root = "";
180
+ let inspect;
181
+ let viteClient = normalizePath(dirname(fileURLToPath(import.meta.resolve("vite/dist/client/client.mjs"))));
182
+ /** Build module id → the files a folding plugin read into it. */
183
+ let foldedFiles = /* @__PURE__ */ new Map();
184
+ function refusal(file, importer) {
185
+ let verdict = inspect(file);
186
+ if (verdict.allowed) return;
187
+ return {
188
+ file: sourceKey(root, file),
189
+ importer: importer && sourceKey(root, importer),
190
+ deniedBy: verdict.deniedBy
191
+ };
192
+ }
193
+ /** The real path of the file `path` names from the root, or `undefined` when none exists. */
194
+ function existingFile(path) {
195
+ let file = resolve(root, filePath(path));
196
+ if (!existsSync(file) || !statSync(file).isFile()) return;
197
+ return normalizePath(realpathSync(file));
198
+ }
199
+ /** The source file a dev request reaches, or `undefined` when Vite itself supplies it. */
200
+ function servedSource(environment, file) {
201
+ if (within(viteClient, file)) return;
202
+ let optimizer = environment.depsOptimizer;
203
+ if (!optimizer?.isOptimizedDepFile(file)) return file;
204
+ let { optimized, discovered } = optimizer.metadata;
205
+ let dependency = [...Object.values(optimized), ...Object.values(discovered)].find((info) => normalizePath(info.file) === file);
206
+ return dependency?.src && normalizePath(dependency.src);
207
+ }
208
+ function devRefusal(environment, file, importer) {
209
+ let source = servedSource(environment, file);
210
+ if (!source) return;
211
+ importer ??= [...environment.moduleGraph.getModulesByFile(file) ?? []].flatMap((module) => [...module.importers]).find((module) => module.file)?.file ?? void 0;
212
+ return refusal(source, importer);
213
+ }
214
+ return {
215
+ name: "pitlane-assets-boundary",
216
+ sharedDuringBuild: true,
217
+ configResolved(config) {
218
+ root = config.root;
219
+ inspect = createBrowserBoundary(normalizePath(realpathSync(root)), options);
220
+ observeFoldedFiles(config, (environment, plugin, module, file) => {
221
+ if (plugin === "vite:css" && !isCSSRequest(file) && !config.assetsInclude(file)) return;
222
+ if (environment.mode === "build") {
223
+ let files = foldedFiles.get(module);
224
+ if (!files) foldedFiles.set(module, files = /* @__PURE__ */ new Set());
225
+ files.add(file);
226
+ return;
227
+ }
228
+ if (environment.name !== "client" || environment.mode !== "dev") return;
229
+ let source = existingFile(file);
230
+ let refused = source && devRefusal(environment, source, module);
231
+ if (refused) throw new Error(`[assets] Outside the browser boundary: ${describe(refused)}`);
232
+ });
233
+ },
234
+ configureServer(server) {
235
+ let client = server.environments.client;
236
+ let { publicDir, base } = server.config;
237
+ server.middlewares.use(((request, _response, next) => {
238
+ let path;
239
+ try {
240
+ path = decodeURIComponent(filePath(request.url ?? "/"));
241
+ } catch {
242
+ return next();
243
+ }
244
+ if (base !== "/" && path.startsWith(base)) path = `/${path.slice(base.length)}`;
245
+ if (path.startsWith("/@") && !path.startsWith("/@fs/")) return next();
246
+ if (publicDir && existsSync(join(publicDir, path))) return next();
247
+ if (htmlDocument(path)) return next();
248
+ let requested = join(root, path);
249
+ if (path.startsWith("/@fs/")) {
250
+ let fsPath = path.slice(5);
251
+ requested = /^[A-Za-z]:\//.test(fsPath) ? fsPath : `/${fsPath}`;
252
+ }
253
+ let file = existingFile(requested);
254
+ let refused = file && devRefusal(client, file);
255
+ if (!refused) return next();
256
+ next(/* @__PURE__ */ new Error(`[assets] Outside the browser boundary: ${describe(refused)}`));
257
+ }));
258
+ },
259
+ transform: {
260
+ order: "pre",
261
+ handler(_code, id) {
262
+ let environment = this.environment;
263
+ if (environment.mode !== "dev" || environment.name !== "client" || !fileModule(id)) return;
264
+ if (htmlDocument(id)) return;
265
+ let refused = devRefusal(environment, existingFile(id) ?? filePath(id));
266
+ if (refused) throw new Error(`[assets] Outside the browser boundary: ${describe(refused)}`);
267
+ }
268
+ },
269
+ generateBundle(_options, bundle) {
270
+ let name = this.environment.name;
271
+ let published = [];
272
+ if (name === "client") {
273
+ for (let id of this.getModuleIds()) {
274
+ if (!fileModule(id)) continue;
275
+ let info = this.getModuleInfo(id);
276
+ let importer = [...info?.importers ?? [], ...info?.dynamicImporters ?? []].find(fileModule);
277
+ published.push({
278
+ path: id,
279
+ importer
280
+ });
281
+ }
282
+ for (let output of Object.values(bundle)) {
283
+ if (output.type !== "asset" || output.names.some(isCSSRequest)) continue;
284
+ for (let path of output.originalFileNames) published.push({ path });
285
+ }
286
+ } else if (serverEnvironments.includes(name)) for (let path of publishedServerFiles(bundle)) {
287
+ let importer = this.getModuleInfo(path)?.importers.find(fileModule);
288
+ published.push({
289
+ path,
290
+ importer
291
+ });
292
+ }
293
+ let refusals = /* @__PURE__ */ new Map();
294
+ for (let { path, importer } of published) {
295
+ let entries = [{
296
+ path,
297
+ importer
298
+ }, ...[...foldedFiles.get(path) ?? []].map((file) => ({
299
+ path: file,
300
+ importer: path
301
+ }))];
302
+ for (let entry of entries) {
303
+ let file = !htmlDocument(entry.path) && existingFile(entry.path);
304
+ let refused = file && refusal(file, entry.importer);
305
+ if (refused && !refusals.get(refused.file)?.importer) refusals.set(refused.file, refused);
306
+ }
307
+ }
308
+ if (refusals.size === 0) return;
309
+ let lines = [...refusals.values()].map(describe);
310
+ this.error(`[assets] Outside the browser boundary:\n ${lines.join("\n ")}`);
311
+ }
312
+ };
313
+ }
314
+ //#endregion
25
315
  //#region src/vite/output.ts
26
316
  function captureOutput(bundle, config, role, assetFiles, entryFiles) {
27
317
  let graph = {
@@ -31,8 +321,10 @@ function captureOutput(bundle, config, role, assetFiles, entryFiles) {
31
321
  assets: Object.create(null)
32
322
  };
33
323
  let globalStylesheets = config.build.cssCodeSplit ? [] : Object.values(bundle).flatMap((output) => output.type === "asset" && output.originalFileNames.includes("style.css") ? [output.fileName] : []);
324
+ let linked = role === "server" ? linkedFiles(bundle, globalStylesheets) : void 0;
34
325
  for (let output of Object.values(bundle)) {
35
326
  if (output.type === "asset") {
327
+ if (linked && !linked.has(output.fileName)) continue;
36
328
  let stylesheet = output.names.some(isCSSRequest);
37
329
  for (let file of output.originalFileNames) {
38
330
  if (stylesheet && !isCSSRequest(file)) continue;
@@ -54,6 +346,15 @@ function captureOutput(bundle, config, role, assetFiles, entryFiles) {
54
346
  for (let [key, file] of entryFiles) if (bundle[file]?.type === "chunk") graph.entries[key] = file;
55
347
  return graph;
56
348
  }
349
+ function linkedFiles(bundle, globalStylesheets) {
350
+ let files = new Set(globalStylesheets);
351
+ for (let output of Object.values(bundle)) {
352
+ if (output.type !== "chunk" || !output.viteMetadata) continue;
353
+ for (let file of output.viteMetadata.importedCss) files.add(file);
354
+ for (let file of output.viteMetadata.importedAssets) files.add(file);
355
+ }
356
+ return files;
357
+ }
57
358
  function captureImportMap(bundle, fileName) {
58
359
  let output = bundle[fileName];
59
360
  if (output?.type !== "asset") throw new Error(`[assets] The client build did not emit its configured import map "${fileName}".`);
@@ -324,7 +625,12 @@ function literalInputs(modules, id) {
324
625
  }
325
626
  //#endregion
326
627
  //#region src/vite/state.ts
327
- const MANIFEST_FILE = "__pitlane_assets_manifest.js";
628
+ /**
629
+ * The manifest module a build writes at the root of each server environment's
630
+ * output directory. The server chunks that read the manifest import this file
631
+ * by a relative path, so a host that deploys a single module must bundle it in.
632
+ */
633
+ const ASSETS_MANIFEST_FILE = "__pitlane_assets_manifest.js";
328
634
  const MANIFEST_ID = "\0pitlane:assets-manifest";
329
635
  const MANIFEST_EXTERNAL = "pitlane:assets-build-manifest";
330
636
  const EMPTY_INPUT = "\0pitlane:assets-empty-input";
@@ -372,16 +678,26 @@ async function writeManifests(state) {
372
678
  });
373
679
  let output = state.outputs.get(name);
374
680
  await mkdir(output.outDir, { recursive: true });
375
- await writeFile(resolve(output.outDir, MANIFEST_FILE), `export default JSON.parse(${JSON.stringify(JSON.stringify({
681
+ await writeFile(resolve(output.outDir, ASSETS_MANIFEST_FILE), `export default JSON.parse(${JSON.stringify(JSON.stringify({
376
682
  ...manifest,
377
683
  serverEnvironment: name
378
684
  }))});\n`);
379
- for (let asset of Object.values(output.bundle)) {
380
- if (asset.type !== "asset" || twins.get(name).has(asset.fileName)) continue;
381
- let destination = resolve(client.outDir, asset.fileName);
382
- await mkdir(dirname(destination), { recursive: true });
383
- await writeFile(destination, asset.source);
384
- }
685
+ await publishLinkedFiles(output, twins.get(name), client.outDir);
686
+ }
687
+ }
688
+ /**
689
+ * Copies the stylesheets and assets a server graph links into the public client
690
+ * output, so every URL its manifest names is served. A stylesheet with a client
691
+ * twin already is.
692
+ */
693
+ async function publishLinkedFiles(output, twin, publicDir) {
694
+ let linked = /* @__PURE__ */ new Set([...Object.values(output.graph.chunks).flatMap((chunk) => chunk.stylesheets), ...Object.values(output.graph.assets)]);
695
+ for (let file of linked) {
696
+ let asset = output.bundle[file];
697
+ if (asset?.type !== "asset" || twin.has(file)) continue;
698
+ let destination = resolve(publicDir, file);
699
+ await mkdir(dirname(destination), { recursive: true });
700
+ await writeFile(destination, asset.source);
385
701
  }
386
702
  }
387
703
  function importMapFileName(config) {
@@ -440,7 +756,7 @@ function assetBuild(state) {
440
756
  },
441
757
  renderChunk(code, chunk) {
442
758
  if (!code.includes("pitlane:assets-build-manifest")) return;
443
- let file = posix.relative(posix.dirname(chunk.fileName), MANIFEST_FILE);
759
+ let file = posix.relative(posix.dirname(chunk.fileName), ASSETS_MANIFEST_FILE);
444
760
  if (!file.startsWith(".")) file = `./${file}`;
445
761
  return {
446
762
  code: code.replaceAll(MANIFEST_EXTERNAL, file),
@@ -566,6 +882,26 @@ function assetStyles() {
566
882
  };
567
883
  }
568
884
  //#endregion
885
+ //#region src/vite/transform-start.ts
886
+ let invalidations = 0;
887
+ let requestStart = new AsyncLocalStorage();
888
+ /** Counts a hard invalidation and returns its place on the timeline. */
889
+ function countInvalidation() {
890
+ return ++invalidations;
891
+ }
892
+ /** Runs `request`, which loads and transforms a module, as starting now. */
893
+ function startRequest(request) {
894
+ return requestStart.run(invalidations, request);
895
+ }
896
+ /**
897
+ * The place on the timeline where the calling transform's request started.
898
+ * A transform no request started, such as one a plugin runs through
899
+ * `pluginContainer.transform` itself, started where this is called.
900
+ */
901
+ function requestStarted() {
902
+ return requestStart.getStore() ?? invalidations;
903
+ }
904
+ //#endregion
569
905
  //#region src/vite/dev-graph.ts
570
906
  /** CSS requests that import a string or URL rather than apply a stylesheet. */
571
907
  const NON_STYLESHEET_QUERY = /[?&](?:inline|url|raw)\b/;
@@ -613,28 +949,55 @@ function followed(environment, id) {
613
949
  return (noExternal === void 0 ? [] : Array.isArray(noExternal) ? noExternal : [noExternal]).some((pattern) => typeof pattern === "string" ? pattern === pkg : pattern.test(pkg));
614
950
  }
615
951
  /**
952
+ * A module is analyzed once. Its edge record was written by the transform
953
+ * hook, whether the runner or discovery ran that transform, and stays
954
+ * current until the module is invalidated, which drops the record. Keying
955
+ * this on `transformResult` instead would re-transform every module the
956
+ * runner has not executed on each rebuild.
957
+ *
958
+ * Client discovery goes through `transformRequest`, so it shares Vite's
959
+ * in-flight and cached transforms with the import analysis that warms a
960
+ * browser module's dependencies, and the browser later reads the cache.
616
961
  * Server discovery must not populate `transformResult`: the module runner
617
- * would treat its stale instance as current. Client transforms may warm
618
- * and cache their dependencies after the optimizer has initialized.
962
+ * asks whether a module it already evaluated is still current by that
963
+ * field, so a transform cached between a file change and the runner's next
964
+ * import would keep its stale instance alive.
619
965
  */
620
966
  async function analyze(environment, id, edges) {
621
- if (environment.moduleGraph.getModuleById(id)?.transformResult && edges.has(id)) return;
967
+ if (edges.has(id)) return;
622
968
  let { root } = environment.config;
623
969
  try {
624
- await environment.moduleGraph.ensureEntryFromUrl(servedPath(root, id));
625
- let loaded = await environment.pluginContainer.load(id);
626
- let code = loaded == null ? null : typeof loaded === "string" ? loaded : loaded.code;
627
- if (code == null) {
628
- if (!fileModule(id)) throw new Error("No plugin loaded the module.");
629
- code = await readFile(id.split("?")[0], "utf8");
970
+ let url = servedPath(root, id);
971
+ if (environment.config.consumer === "client") {
972
+ await environment.transformRequest(url.startsWith("/@id/") ? id : url);
973
+ if (edges.has(id)) return;
630
974
  }
631
- await environment.pluginContainer.transform(code, id);
975
+ await startRequest(() => transformOutsideCache(environment, id, url));
632
976
  } catch (error) {
633
977
  let name = fileModule(id) ? sourceKey(root, id) : id;
634
978
  throw new Error(`[assets] Could not analyze "${name}" for the "${environment.name}" development asset manifest: ${error.message}`, { cause: error });
635
979
  }
636
980
  }
637
981
  /**
982
+ * Loads and transforms a module as `transformRequest` does, handing the load
983
+ * result's source map and module type to the transform, but without reading
984
+ * or populating Vite's transform cache.
985
+ */
986
+ async function transformOutsideCache(environment, id, url) {
987
+ await environment.moduleGraph.ensureEntryFromUrl(url);
988
+ let loaded = await environment.pluginContainer.load(id);
989
+ let source = typeof loaded === "string" ? { code: loaded } : loaded;
990
+ let code = source?.code;
991
+ if (code == null) {
992
+ if (!fileModule(id)) throw new Error("No plugin loaded the module.");
993
+ code = await readFile(id.split("?")[0], "utf8");
994
+ }
995
+ await environment.pluginContainer.transform(code, id, {
996
+ inMap: source?.map,
997
+ moduleType: source?.moduleType
998
+ });
999
+ }
1000
+ /**
638
1001
  * Every module reachable from `roots` through static and dynamic edges,
639
1002
  * transforming each one the graph has not analyzed yet. Stylesheets are
640
1003
  * leaves: their own `@import`s are inlined by Vite's CSS pipeline. Passing
@@ -784,12 +1147,48 @@ function assetDevelopment(state) {
784
1147
  };
785
1148
  let snapshots = /* @__PURE__ */ new Map();
786
1149
  let building = /* @__PURE__ */ new Set();
787
- /** Invalidates a server environment's manifest and, through Vite, every module importing it. */
1150
+ /** Server environments invalidated while their snapshot was being built. */
1151
+ let stale = /* @__PURE__ */ new Set();
1152
+ /** Environment name → module id → where its last hard invalidation is on the transform timeline. */
1153
+ let invalidations = /* @__PURE__ */ new Map();
1154
+ /**
1155
+ * Invalidates a server environment's manifest and, through Vite, every
1156
+ * module importing it. A manifest being built is marked instead, and
1157
+ * `load` builds it again, since Vite would cache the code `load` returns.
1158
+ */
788
1159
  function invalidate(name, timestamp) {
1160
+ if (building.has(name)) return void stale.add(name);
789
1161
  let environment = server?.environments[name];
790
1162
  let manifest = environment?.moduleGraph.getModuleById(MANIFEST_ID);
791
1163
  if (manifest) environment.moduleGraph.invalidateModule(manifest, /* @__PURE__ */ new Set(), timestamp, true);
792
1164
  }
1165
+ /** The modules of `environment` a server environment's snapshot was computed from. */
1166
+ function tracked(environment, serverName) {
1167
+ let snapshot = snapshots.get(serverName);
1168
+ if (environment === serverName) return snapshot?.server;
1169
+ if (environment === "client") return snapshot?.client;
1170
+ }
1171
+ /** Invalidates every snapshot computed, or being computed, from module `id` of `environment`. */
1172
+ function invalidateSnapshotsWith(environment, id, timestamp) {
1173
+ for (let serverName of snapshots.keys()) if (tracked(environment, serverName)?.has(id)) invalidate(serverName, timestamp);
1174
+ for (let serverName of building) if (environment === serverName || environment === "client") stale.add(serverName);
1175
+ }
1176
+ function sameEdges(previous, next) {
1177
+ return previous.static.length === next.static.length && previous.dynamic.length === next.dynamic.length && previous.static.every((edge, index) => edge === next.static[index]) && previous.dynamic.every((edge, index) => edge === next.dynamic[index]);
1178
+ }
1179
+ /**
1180
+ * Records what a transform of module `id` found: its edges and, in a
1181
+ * server environment, its resolver usage.
1182
+ */
1183
+ function recordTransform(environment, id, edges, usage) {
1184
+ let edgeRecords = environmentEdges(graph, environment);
1185
+ let previous = edgeRecords.get(id);
1186
+ edgeRecords.set(id, edges);
1187
+ if (previous && !sameEdges(previous, edges)) invalidateSnapshotsWith(environment, id);
1188
+ let usageRecords = graph.resolverUsage.get(environment);
1189
+ if (usage) usageRecords?.set(id, usage);
1190
+ else usageRecords?.delete(id);
1191
+ }
793
1192
  return {
794
1193
  name: "pitlane-assets-dev",
795
1194
  apply: "serve",
@@ -799,10 +1198,26 @@ function assetDevelopment(state) {
799
1198
  handler(devServer) {
800
1199
  server = devServer;
801
1200
  for (let environment of Object.values(devServer.environments)) {
802
- let { name } = environment;
1201
+ let { name, moduleGraph } = environment;
1202
+ let edges = environmentEdges(graph, name);
1203
+ let invalidated = /* @__PURE__ */ new Map();
1204
+ invalidations.set(name, invalidated);
1205
+ let invalidateModule = moduleGraph.invalidateModule;
1206
+ moduleGraph.invalidateModule = (mod, seen, timestamp, isHmr, softInvalidate) => {
1207
+ if (!softInvalidate && mod.id && mod.id !== "\0pitlane:assets-manifest" && !isCSSRequest(mod.id)) {
1208
+ edges.delete(mod.id);
1209
+ if (!isHmr) invalidated.set(mod.id, countInvalidation());
1210
+ invalidateSnapshotsWith(name, mod.id, timestamp);
1211
+ }
1212
+ invalidateModule.call(moduleGraph, mod, seen, timestamp, isHmr, softInvalidate);
1213
+ };
1214
+ let { transformRequest, warmupRequest } = environment;
1215
+ environment.transformRequest = (url) => startRequest(() => transformRequest.call(environment, url));
1216
+ environment.warmupRequest = (url) => startRequest(() => warmupRequest.call(environment, url));
803
1217
  if (!state.serverEnvironments.includes(name)) continue;
804
1218
  let roots = /* @__PURE__ */ new Set();
805
1219
  graph.roots.set(name, roots);
1220
+ graph.resolverUsage.set(name, /* @__PURE__ */ new Map());
806
1221
  let fetchModule = environment.fetchModule;
807
1222
  environment.fetchModule = async (id, importer, options) => {
808
1223
  let fetched = await fetchModule.call(environment, id, importer, options);
@@ -830,32 +1245,45 @@ function assetDevelopment(state) {
830
1245
  if (!state.serverEnvironments.includes(name)) throw unservedEnvironmentError(name, state.serverEnvironments);
831
1246
  building.add(name);
832
1247
  try {
833
- let snapshot = await createDevSnapshot(server, state, graph, name);
1248
+ let snapshot;
1249
+ let passes = 0;
1250
+ do {
1251
+ stale.delete(name);
1252
+ snapshot = await createDevSnapshot(server, state, graph, name);
1253
+ } while (stale.has(name) && ++passes < 3);
834
1254
  snapshots.set(name, snapshot);
1255
+ if (stale.has(name)) {
1256
+ let { moduleGraph } = server.environments[name];
1257
+ moduleGraph.invalidateModule(await moduleGraph.ensureEntryFromUrl(MANIFEST_ID));
1258
+ }
835
1259
  return `export default JSON.parse(${JSON.stringify(JSON.stringify(snapshot.manifest))});\n`;
836
1260
  } finally {
837
1261
  building.delete(name);
1262
+ stale.delete(name);
838
1263
  }
839
1264
  },
840
1265
  transform: { async handler(code, id) {
841
1266
  if (id === "\0pitlane:assets-manifest" || isCSSRequest(id)) return;
842
1267
  let name = this.environment.name;
843
- let specifiers = importSpecifiers(code);
1268
+ let invalidated = invalidations.get(name);
1269
+ let started = requestStarted();
1270
+ let resolve = (specifier) => this.resolve(specifier, id);
844
1271
  let resolveAll = async (list) => {
845
- let resolved = await Promise.all(list.map((specifier) => this.resolve(specifier, id)));
1272
+ let resolved = await Promise.all(list.map(resolve));
846
1273
  return [...new Set(resolved.flatMap((result) => result && !result.external ? [result.id] : []))];
847
1274
  };
848
- let [staticEdges, dynamicEdges] = await Promise.all([resolveAll(specifiers.static), resolveAll(specifiers.dynamic)]);
849
- environmentEdges(graph, name).set(id, {
1275
+ let specifiers = importSpecifiers(code);
1276
+ let usage = state.serverEnvironments.includes(name) ? scanResolverUsage(code, id) : void 0;
1277
+ let [staticEdges, dynamicEdges, linkedUsage] = await Promise.all([
1278
+ resolveAll(specifiers.static),
1279
+ resolveAll(specifiers.dynamic),
1280
+ usage && linkResolverUsage(usage, resolve)
1281
+ ]);
1282
+ if ((invalidated?.get(id) ?? 0) > started) return;
1283
+ recordTransform(name, id, {
850
1284
  static: staticEdges,
851
1285
  dynamic: dynamicEdges
852
- });
853
- if (!state.serverEnvironments.includes(name)) return;
854
- let modules = graph.resolverUsage.get(name);
855
- if (!modules) graph.resolverUsage.set(name, modules = /* @__PURE__ */ new Map());
856
- let usage = scanResolverUsage(code, id);
857
- if (!usage) return void modules.delete(id);
858
- modules.set(id, await linkResolverUsage(usage, (specifier) => this.resolve(specifier, id)));
1286
+ }, linkedUsage);
859
1287
  } },
860
1288
  hotUpdate: {
861
1289
  order: "post",
@@ -868,9 +1296,9 @@ function assetDevelopment(state) {
868
1296
  }
869
1297
  let name = this.environment.name;
870
1298
  let ids = modules.flatMap((module) => module.id ? [module.id] : []);
871
- for (let [serverName, snapshot] of snapshots) {
872
- let tracked = name === serverName ? snapshot.server : name === "client" ? snapshot.client : void 0;
873
- if (tracked && (tracked.has(path) || ids.some((id) => tracked.has(id)))) invalidate(serverName, timestamp);
1299
+ for (let serverName of snapshots.keys()) {
1300
+ let set = tracked(name, serverName);
1301
+ if (set && (set.has(path) || ids.some((id) => set.has(id)))) invalidate(serverName, timestamp);
874
1302
  }
875
1303
  }
876
1304
  }
@@ -878,7 +1306,17 @@ function assetDevelopment(state) {
878
1306
  }
879
1307
  //#endregion
880
1308
  //#region src/vite-plugin.ts
1309
+ /**
1310
+ * The Vite plugins that give `@pitlane/assets` its manifest: they serve
1311
+ * browser entries during `vite dev`, emit them in a build, and replace
1312
+ * `@pitlane/assets/manifest` with the manifest of the current dev server or
1313
+ * build. Setting `allowFiles` also enforces the browser boundary.
1314
+ *
1315
+ * @throws When `allowPackages` or `denyFiles` is set without `allowFiles`.
1316
+ */
881
1317
  function assets(options = {}) {
1318
+ let { allowFiles, allowPackages, denyFiles } = options;
1319
+ if (!allowFiles && (allowPackages || denyFiles)) throw new Error("[assets] allowPackages and denyFiles refine allowFiles. Set allowFiles to turn on the browser boundary.");
882
1320
  let state = {
883
1321
  serverEnvironments: options.serverEnvironments ?? ["ssr"],
884
1322
  inputs: /* @__PURE__ */ new Map(),
@@ -961,8 +1399,13 @@ function assets(options = {}) {
961
1399
  },
962
1400
  assetBuild(state),
963
1401
  assetDevelopment(state),
964
- assetStyles()
1402
+ assetStyles(),
1403
+ allowFiles && browserBoundary({
1404
+ allowFiles,
1405
+ allowPackages,
1406
+ denyFiles
1407
+ }, state.serverEnvironments)
965
1408
  ];
966
1409
  }
967
1410
  //#endregion
968
- export { assets };
1411
+ export { ASSETS_MANIFEST_FILE, assets };
package/package.json CHANGED
@@ -1,76 +1,74 @@
1
1
  {
2
- "name": "@pitlane/assets",
3
- "version": "0.1.0",
4
- "description": "Framework-neutral asset resolution for bundled applications.",
5
- "keywords": [
6
- "assets",
7
- "import-map",
8
- "manifest",
9
- "modulepreload",
10
- "pitlane",
11
- "vite",
12
- "vite-plugin"
13
- ],
14
- "homepage": "https://pitlane.tools/package/assets/",
15
- "bugs": {
16
- "url": "https://github.com/pitlane-tools/pitlane/issues"
2
+ "name": "@pitlane/assets",
3
+ "version": "0.2.0",
4
+ "description": "Framework-neutral asset resolution for bundled applications.",
5
+ "keywords": [
6
+ "assets",
7
+ "import-map",
8
+ "manifest",
9
+ "modulepreload",
10
+ "pitlane",
11
+ "vite",
12
+ "vite-plugin"
13
+ ],
14
+ "homepage": "https://pitlane.tools/package/assets/",
15
+ "bugs": {
16
+ "url": "https://github.com/pitlane-tools/pitlane/issues"
17
+ },
18
+ "license": "MIT",
19
+ "author": "Mark Malstrom <mark@malstrom.me>",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/pitlane-tools/pitlane.git",
23
+ "directory": "packages/assets"
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "CHANGELOG.md",
28
+ "LICENSE.upstream"
29
+ ],
30
+ "type": "module",
31
+ "types": "./dist/index.d.mts",
32
+ "exports": {
33
+ ".": {
34
+ "types": "./dist/index.d.mts",
35
+ "import": "./dist/index.mjs"
17
36
  },
18
- "license": "MIT",
19
- "author": "Mark Malstrom <mark@malstrom.me>",
20
- "repository": {
21
- "type": "git",
22
- "url": "git+https://github.com/pitlane-tools/pitlane.git",
23
- "directory": "packages/assets"
37
+ "./manifest": {
38
+ "types": "./dist/manifest.d.mts",
39
+ "import": "./dist/manifest.mjs"
24
40
  },
25
- "files": [
26
- "dist",
27
- "CHANGELOG.md",
28
- "LICENSE.upstream"
29
- ],
30
- "type": "module",
31
- "types": "./dist/index.d.mts",
32
- "exports": {
33
- ".": {
34
- "types": "./dist/index.d.mts",
35
- "import": "./dist/index.mjs"
36
- },
37
- "./manifest": {
38
- "types": "./dist/manifest.d.mts",
39
- "import": "./dist/manifest.mjs"
40
- },
41
- "./build": {
42
- "types": "./dist/build.d.mts",
43
- "import": "./dist/build.mjs"
44
- },
45
- "./vite-plugin": {
46
- "types": "./dist/vite-plugin.d.mts",
47
- "import": "./dist/vite-plugin.mjs"
48
- }
41
+ "./build": {
42
+ "types": "./dist/build.d.mts",
43
+ "import": "./dist/build.mjs"
49
44
  },
50
- "scripts": {
51
- "prepublishOnly": "vp run build"
52
- },
53
- "dependencies": {
54
- "magic-string": "^0.30.21"
55
- },
56
- "devDependencies": {
57
- "@types/node": "^25.5.0",
58
- "fast-check": "4.10.2",
59
- "playwright": "1.61.1",
60
- "remix": "3.0.0",
61
- "typescript": "^7.0.2",
62
- "vite": "npm:@voidzero-dev/vite-plus-core@^1.0.0",
63
- "vite-plus": "^1.0.0"
64
- },
65
- "peerDependencies": {
66
- "vite": ">=8.1.0"
67
- },
68
- "peerDependenciesMeta": {
69
- "vite": {
70
- "optional": true
71
- }
72
- },
73
- "engines": {
74
- "node": "^20.19.0 || >=22.12.0"
45
+ "./vite-plugin": {
46
+ "types": "./dist/vite-plugin.d.mts",
47
+ "import": "./dist/vite-plugin.mjs"
48
+ }
49
+ },
50
+ "dependencies": {
51
+ "magic-string": "^0.30.21"
52
+ },
53
+ "devDependencies": {
54
+ "@types/node": "^25.5.0",
55
+ "fast-check": "4.10.2",
56
+ "playwright": "1.61.1",
57
+ "remix": "3.0.0",
58
+ "typescript": "^7.0.2",
59
+ "vite": "npm:@voidzero-dev/vite-plus-core@^1.0.0",
60
+ "vite-plus": "^1.0.0"
61
+ },
62
+ "peerDependencies": {
63
+ "vite": ">=8.1.0"
64
+ },
65
+ "peerDependenciesMeta": {
66
+ "vite": {
67
+ "optional": true
75
68
  }
76
- }
69
+ },
70
+ "engines": {
71
+ "node": "^20.19.0 || >=22.12.0"
72
+ },
73
+ "scripts": {}
74
+ }