vite-plugin-cross-origin-storage 1.8.0 → 2.0.1

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/dist/index.mjs ADDED
@@ -0,0 +1,242 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import MagicString from "magic-string";
5
+ import { rolldown } from "rolldown";
6
+ import { parseAst } from "rolldown/parseAst";
7
+
8
+ //#region src/index.ts
9
+ const MANIFEST_PLACEHOLDER = "__COS_MANIFEST__";
10
+ /**
11
+ * Recipe version embedded in every content-addressed specifier. Bump this
12
+ * whenever the build recipe (bundler version, options, define replacements)
13
+ * changes in a way that alters emitted bytes, so chunks built under different
14
+ * recipes cannot silently collide on the same SHA-256.
15
+ */
16
+ const RECIPE = "cos1";
17
+ function defaultLoaderEntry() {
18
+ for (const ext of ["mjs", "ts"]) {
19
+ const candidate = fileURLToPath(new URL(`./loader.entry.${ext}`, import.meta.url));
20
+ if (existsSync(candidate)) return candidate;
21
+ }
22
+ throw new Error("[cos] could not locate the runtime loader entry");
23
+ }
24
+ function contentSpecifier(hash) {
25
+ return `${RECIPE}:${hash}`;
26
+ }
27
+ function toMatchers(packages) {
28
+ return packages.map((p) => typeof p === "string" ? new RegExp(`^${p}$`) : p);
29
+ }
30
+ /**
31
+ * Bundle the runtime loader into a self-contained IIFE with rolldown, leaving
32
+ * `__COS_MANIFEST__` as a literal token for the caller to substitute. Bundling
33
+ * from source keeps the loader correct regardless of how the host build loaded
34
+ * this plugin.
35
+ */
36
+ async function bundleLoader(entry) {
37
+ const builder = await rolldown({
38
+ input: entry,
39
+ platform: "browser",
40
+ treeshake: true
41
+ });
42
+ const { output } = await builder.generate({
43
+ format: "iife",
44
+ minify: true
45
+ });
46
+ await builder.close();
47
+ return output[0].code;
48
+ }
49
+ /** Collect every static and dynamic import/export source string literal. */
50
+ function collectImportSources(code) {
51
+ const sources = [];
52
+ const visit = (node) => {
53
+ if (!node || typeof node !== "object") return;
54
+ if (Array.isArray(node)) {
55
+ for (const child of node) visit(child);
56
+ return;
57
+ }
58
+ const record = node;
59
+ if (record.type === "ImportDeclaration" || record.type === "ExportNamedDeclaration" || record.type === "ExportAllDeclaration" || record.type === "ImportExpression") {
60
+ const source = record.source;
61
+ if (source?.type === "Literal" && typeof source.value === "string" && typeof source.start === "number" && typeof source.end === "number") sources.push({
62
+ value: source.value,
63
+ start: source.start,
64
+ end: source.end
65
+ });
66
+ }
67
+ for (const key in record) if (key !== "type") visit(record[key]);
68
+ };
69
+ visit(parseAst(code));
70
+ return sources;
71
+ }
72
+ /**
73
+ * Rewrite import/export specifiers by AST position rather than by pattern, so a
74
+ * managed specifier appearing in an ordinary string literal is never touched
75
+ * and dynamic imports are handled the same as static ones. Returns a sourcemap
76
+ * only when `withMap` is set (i.e. the source chunk already had one to keep
77
+ * valid); the standalone cos chunks have no downstream map and skip it.
78
+ */
79
+ function rewriteSpecifiers(code, rewrites, fileName, withMap) {
80
+ const edits = collectImportSources(code).filter((s) => rewrites.has(s.value));
81
+ if (!edits.length) return { code };
82
+ const magic = new MagicString(code);
83
+ for (const { value, start, end } of edits) {
84
+ const quote = code[start];
85
+ magic.overwrite(start, end, `${quote}${rewrites.get(value)}${quote}`);
86
+ }
87
+ return {
88
+ code: magic.toString(),
89
+ map: withMap ? magic.generateMap({
90
+ source: fileName,
91
+ hires: "boundary"
92
+ }) : void 0
93
+ };
94
+ }
95
+ function joinBase(base, assetsDir) {
96
+ const prefix = base.endsWith("/") ? base : `${base}/`;
97
+ const dir = assetsDir.replace(/^\/+|\/+$/g, "");
98
+ return dir ? `${prefix}${dir}/` : prefix;
99
+ }
100
+ function cosPlugin(options) {
101
+ const packages = toMatchers(options.packages);
102
+ const loaderEntry = options.loaderEntry ?? defaultLoaderEntry();
103
+ const collected = /* @__PURE__ */ new Set();
104
+ let assetsDir = "assets";
105
+ let resolvedBase = "/";
106
+ let loaderTemplate;
107
+ let scriptContent = "";
108
+ return {
109
+ name: "vite-plugin-cos",
110
+ enforce: "pre",
111
+ apply: "build",
112
+ configResolved(config) {
113
+ assetsDir = config.build.assetsDir;
114
+ resolvedBase = config.base;
115
+ },
116
+ resolveId: {
117
+ order: "pre",
118
+ filter: { id: packages },
119
+ async handler(id, importer, resolveOptions) {
120
+ const resolved = await this.resolve(id, importer, {
121
+ ...resolveOptions,
122
+ skipSelf: true
123
+ });
124
+ if (!resolved) return;
125
+ collected.add(resolved.id);
126
+ return {
127
+ id: `cos-ext:${resolved.id}`,
128
+ external: true
129
+ };
130
+ }
131
+ },
132
+ async generateBundle(_outputOptions, bundle) {
133
+ if (!collected.size) return;
134
+ const base = options.base ?? joinBase(resolvedBase, assetsDir);
135
+ const assetPrefix = assetsDir ? `${assetsDir.replace(/^\/+|\/+$/g, "")}/` : "";
136
+ const raw = /* @__PURE__ */ new Map();
137
+ const queue = [...collected];
138
+ while (queue.length) {
139
+ const input = queue.shift();
140
+ if (raw.has(input)) continue;
141
+ const deps = /* @__PURE__ */ new Set();
142
+ let code;
143
+ try {
144
+ const builder = await rolldown({
145
+ input,
146
+ platform: "browser",
147
+ treeshake: false,
148
+ plugins: [{
149
+ name: "cos-externalise-deps",
150
+ async resolveId(id, importer) {
151
+ if (!importer) return null;
152
+ const dep = await this.resolve(id, importer, { skipSelf: true });
153
+ if (!dep) return null;
154
+ deps.add(dep.id);
155
+ return {
156
+ id: `cos-dep:${dep.id}`,
157
+ external: true
158
+ };
159
+ }
160
+ }]
161
+ });
162
+ const { output } = await builder.generate({
163
+ file: "chunk.js",
164
+ codeSplitting: false,
165
+ minify: true
166
+ });
167
+ await builder.close();
168
+ code = output[0].code;
169
+ } catch (error) {
170
+ throw new Error(`[cos] cannot bundle managed package as a standalone chunk:\n ${input}\nIt likely imports build-time virtuals (e.g. \`#build/*\`, \`#imports\`) that only resolve inside the host build, so it is not a self-contained, shareable artifact. Only depend on packages whose source resolves from disk on its own.\n\nUnderlying error: ${error.message}`, { cause: error });
171
+ }
172
+ raw.set(input, {
173
+ code,
174
+ deps: [...deps]
175
+ });
176
+ queue.push(...deps);
177
+ }
178
+ const hashes = /* @__PURE__ */ new Map();
179
+ const managed = {};
180
+ const visit = (id, stack) => {
181
+ const existing = hashes.get(id);
182
+ if (existing) return existing;
183
+ if (stack.includes(id)) throw new Error(`[cos] dependency cycle between managed packages: ${[...stack, id].join(" -> ")}`);
184
+ const { code, deps } = raw.get(id);
185
+ const rewrites = /* @__PURE__ */ new Map();
186
+ for (const dep of deps) rewrites.set(`cos-dep:${dep}`, contentSpecifier(visit(dep, [...stack, id])));
187
+ const { code: resolved } = rewriteSpecifiers(code, rewrites, "", false);
188
+ const hash = createHash("sha256").update(resolved).digest("hex");
189
+ const fileName = `${assetPrefix}${hash}.js`;
190
+ hashes.set(id, hash);
191
+ managed[contentSpecifier(hash)] = {
192
+ file: `${hash}.js`,
193
+ hash
194
+ };
195
+ bundle[fileName] = {
196
+ type: "asset",
197
+ fileName,
198
+ name: hash,
199
+ names: [hash],
200
+ originalFileName: null,
201
+ originalFileNames: [],
202
+ needsCodeReference: false,
203
+ source: resolved
204
+ };
205
+ return hash;
206
+ };
207
+ for (const id of raw.keys()) visit(id, []);
208
+ const appRewrites = /* @__PURE__ */ new Map();
209
+ for (const id of collected) appRewrites.set(`cos-ext:${id}`, contentSpecifier(hashes.get(id)));
210
+ let entry;
211
+ for (const file of Object.values(bundle)) {
212
+ if (file.type !== "chunk") continue;
213
+ const { code, map } = rewriteSpecifiers(file.code, appRewrites, file.fileName, !!file.map);
214
+ file.code = code;
215
+ if (map) file.map = map;
216
+ if (file.isEntry) entry = {
217
+ specifier: `${RECIPE}:entry`,
218
+ file: file.fileName.replace(new RegExp(`^${assetPrefix}`), "")
219
+ };
220
+ }
221
+ if (!entry) return;
222
+ const manifest = {
223
+ base,
224
+ entry,
225
+ chunks: managed
226
+ };
227
+ loaderTemplate ??= bundleLoader(loaderEntry);
228
+ scriptContent = (await loaderTemplate).replace(MANIFEST_PLACEHOLDER, JSON.stringify(manifest));
229
+ options.onGenerated?.(scriptContent);
230
+ },
231
+ transformIndexHtml: {
232
+ order: "post",
233
+ handler(html) {
234
+ if (options.onGenerated || !scriptContent) return html;
235
+ return html.replace(/<script type="module"[^>]*src="[^"]*"[^>]*><\/script>/g, "").replace("</head>", `<script id="cos-loader">${scriptContent}<\/script></head>`);
236
+ }
237
+ }
238
+ };
239
+ }
240
+
241
+ //#endregion
242
+ export { cosPlugin, cosPlugin as default };
@@ -0,0 +1,38 @@
1
+ //#region src/loader.d.ts
2
+ declare global {
3
+ interface Navigator {
4
+ crossOriginStorage?: {
5
+ requestFileHandles: (descriptors: Array<{
6
+ algorithm: string;
7
+ value: string;
8
+ }>, options?: {
9
+ create?: boolean;
10
+ }) => Promise<Array<{
11
+ getFile: () => Promise<File>;
12
+ createWritable: () => Promise<{
13
+ write: (data: Blob) => Promise<void>;
14
+ close: () => Promise<void>;
15
+ }>;
16
+ }>>;
17
+ };
18
+ }
19
+ }
20
+ interface CosManifest {
21
+ /** Public base path that managed chunks are served from, e.g. `/_nuxt/`. */
22
+ base: string;
23
+ /**
24
+ * The entry chunk to import once the import map is ready. It is app-specific, so it is
25
+ * loaded straight from the network rather than stored in COS by a content hash.
26
+ */
27
+ entry: {
28
+ specifier: string;
29
+ file: string;
30
+ };
31
+ /** Map of content-addressed specifier to `{ file, hash }` for every COS-managed chunk. */
32
+ chunks: Record<string, {
33
+ file: string;
34
+ hash: string;
35
+ }>;
36
+ }
37
+ //#endregion
38
+ export { CosManifest };
@@ -0,0 +1 @@
1
+ export { };
@@ -0,0 +1,7 @@
1
+ import { runCosLoader } from "./loader.mjs";
2
+
3
+ //#region src/loader.entry.ts
4
+ runCosLoader(__COS_MANIFEST__);
5
+
6
+ //#endregion
7
+ export { };
@@ -0,0 +1,51 @@
1
+ //#region src/loader.ts
2
+ async function runCosLoader(manifest) {
3
+ const cos = navigator.crossOriginStorage;
4
+ const imports = {};
5
+ async function resolveChunk(hash, file) {
6
+ if (cos) try {
7
+ const [handle] = await cos.requestFileHandles([{
8
+ algorithm: "SHA-256",
9
+ value: hash
10
+ }]);
11
+ if (handle) {
12
+ const blob = await handle.getFile();
13
+ return URL.createObjectURL(new Blob([blob], { type: "text/javascript" }));
14
+ }
15
+ } catch (error) {
16
+ if (error?.name !== "NotFoundError") console.error("[cos] lookup failed", error);
17
+ }
18
+ const response = await fetch(file);
19
+ const blob = new Blob([await response.blob()], { type: "text/javascript" });
20
+ if (cos) try {
21
+ const [handle] = await cos.requestFileHandles([{
22
+ algorithm: "SHA-256",
23
+ value: hash
24
+ }], { create: true });
25
+ if (handle) {
26
+ const writable = await handle.createWritable();
27
+ await writable.write(blob);
28
+ await writable.close();
29
+ }
30
+ } catch (error) {
31
+ console.error("[cos] store failed", error);
32
+ }
33
+ return URL.createObjectURL(blob);
34
+ }
35
+ await Promise.all(Object.entries(manifest.chunks).map(async ([specifier, { file, hash }]) => {
36
+ imports[specifier] = await resolveChunk(hash, manifest.base + file);
37
+ }));
38
+ imports[manifest.entry.specifier] = new URL(manifest.base + manifest.entry.file, location.origin).href;
39
+ const script = document.createElement("script");
40
+ script.type = "importmap";
41
+ script.textContent = JSON.stringify({ imports });
42
+ document.head.appendChild(script);
43
+ await new Promise((resolve) => setTimeout(resolve, 0));
44
+ await import(
45
+ /* @vite-ignore */
46
+ manifest.entry.specifier
47
+ );
48
+ }
49
+
50
+ //#endregion
51
+ export { runCosLoader };
package/package.json CHANGED
@@ -1,70 +1,72 @@
1
1
  {
2
2
  "name": "vite-plugin-cross-origin-storage",
3
- "version": "1.8.0",
4
- "description": "Vite plugin to load chunks from Cross-Origin Storage",
3
+ "type": "module",
4
+ "version": "2.0.1",
5
+ "description": "Vite plugin to extract shared dependencies into content-addressed chunks loaded from Cross-Origin Storage",
6
+ "author": {
7
+ "name": "Daniel Roe",
8
+ "email": "daniel@roe.dev",
9
+ "url": "https://github.com/danielroe"
10
+ },
11
+ "contributors": [
12
+ {
13
+ "name": "Thomas Steiner",
14
+ "url": "https://github.com/tomayac"
15
+ }
16
+ ],
17
+ "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/danielroe/cross-origin-storage.git",
21
+ "directory": "packages/vite-plugin-cross-origin-storage"
22
+ },
5
23
  "keywords": [
6
- "vite",
7
- "plugin",
24
+ "vite-plugin",
8
25
  "cross-origin-storage",
9
26
  "cos",
10
- "loader",
27
+ "content-addressed",
11
28
  "performance"
12
29
  ],
13
- "author": "Thomas Steiner",
14
- "license": "Apache-2.0",
15
- "homepage": "https://github.com/tomayac/vite-plugin-cross-origin-storage#readme",
16
- "repository": {
17
- "type": "git",
18
- "url": "git+https://github.com/tomayac/vite-plugin-cross-origin-storage.git"
19
- },
20
- "bugs": {
21
- "url": "https://github.com/tomayac/vite-plugin-cross-origin-storage/issues"
22
- },
23
- "type": "module",
24
- "main": "./dist/index.js",
25
- "module": "./dist/index.js",
26
- "types": "./dist/index.d.ts",
30
+ "sideEffects": false,
27
31
  "exports": {
28
32
  ".": {
29
- "types": "./dist/index.d.ts",
30
- "import": "./dist/index.js"
33
+ "import": "./dist/index.mjs"
31
34
  }
32
35
  },
36
+ "main": "./dist/index.mjs",
37
+ "module": "./dist/index.mjs",
38
+ "types": "./dist/index.d.mts",
33
39
  "files": [
34
- "dist",
35
- "loader.js"
40
+ "dist"
36
41
  ],
37
- "scripts": {
38
- "build": "tsup index.ts --format esm --dts --clean && cp loader.js dist/",
39
- "prepublishOnly": "npm run build",
40
- "test:simple:dev": "npm run build && vite testing/simple-test",
41
- "test:simple:build": "npm run build && vite build testing/simple-test",
42
- "test:simple:preview": "vite preview testing/simple-test",
43
- "test:react-hello-world:dev": "npm run build && vite testing/react-hello-world",
44
- "test:react-hello-world:build": "npm run build && vite build testing/react-hello-world",
45
- "test:react-hello-world:preview": "vite preview testing/react-hello-world",
46
- "test:react-todo:dev": "npm run build && vite testing/react-todo",
47
- "test:react-todo:build": "npm run build && vite build testing/react-todo",
48
- "test:react-todo:preview": "vite preview --port 4174 testing/react-todo"
42
+ "engines": {
43
+ "node": ">=20.19.0"
49
44
  },
50
45
  "peerDependencies": {
51
- "vite": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
46
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
47
+ },
48
+ "dependencies": {
49
+ "magic-string": "^0.30.21",
50
+ "rolldown": "1.1.0"
52
51
  },
53
52
  "devDependencies": {
54
- "@rollup/pluginutils": "^5.3.0",
55
- "@types/node": "^25.6.2",
56
- "@types/react": "^19.2.14",
57
- "@types/react-dom": "^19.2.3",
58
- "@vitejs/plugin-react": "^6.0.1",
59
- "react": "^19.2.6",
60
- "react-dom": "^19.2.6",
61
- "rollup": "^4.60.3",
62
- "three": "^0.184.0",
63
- "tsup": "^8.5.1",
64
- "typescript": "^6.0.3",
65
- "vite": "^8.0.11"
53
+ "@types/node": "latest",
54
+ "eslint": "^10.4.1",
55
+ "hookable": "^5.5.3",
56
+ "playwright-core": "^1.61.1",
57
+ "tsdown": "^0.20.3",
58
+ "typescript": "~6.0.3",
59
+ "unhead": "^2.1.0",
60
+ "vite": "^7.3.5",
61
+ "vitest": "^4.1.8",
62
+ "vue": "3.5.39"
66
63
  },
67
- "dependencies": {
68
- "esbuild": "^0.28.0"
64
+ "scripts": {
65
+ "build": "tsdown",
66
+ "dev": "vitest dev",
67
+ "lint": "eslint .",
68
+ "test": "pnpm test:unit && pnpm test:types",
69
+ "test:unit": "vitest run",
70
+ "test:types": "tsc --noEmit && vitest run --typecheck.only"
69
71
  }
70
- }
72
+ }
package/dist/index.d.ts DELETED
@@ -1,17 +0,0 @@
1
- import { Plugin } from 'vite';
2
-
3
- interface CosPluginOptions {
4
- /**
5
- * Pattern to include chunks to be managed by COS.
6
- * Matches against the output filename (e.g. `assets/vendor-*.js`).
7
- * Default: `['**\/*']` (all chunks, except the entry implementation detail)
8
- */
9
- include?: string | RegExp | (string | RegExp)[];
10
- /**
11
- * Pattern to exclude chunks from being managed by COS.
12
- */
13
- exclude?: string | RegExp | (string | RegExp)[];
14
- }
15
- declare function cosPlugin(options?: CosPluginOptions): Plugin;
16
-
17
- export { type CosPluginOptions, cosPlugin as default };