alpha
Login
or
Join now
danielroe.dev
/
cross-origin-storage
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
[READ-ONLY] Mirror of https://github.com/danielroe/cross-origin-storage. Load shared dependencies from Cross-Origin Storage (COS).
cross-origin-storage
experimental
nuxt
vite
vite-plugin
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
Overview
Issues
Pulls
Pipelines
refactor: extract vite plugin
author
Daniel Roe
date
9 hours ago
(Jun 25, 2026, 12:48 PM +0100)
commit
377c9f58
377c9f58817228232bd9b2669e3cf4cef2ec23e8
parent
69af8a04
69af8a04bd98c49869265d51dab0f194b2773c36
+338
-156
6 changed files
Expand all
Collapse all
Unified
Split
.gitignore
package.json
pnpm-lock.yaml
src
module.ts
vite.ts
test
plugin.test.ts
+1
.gitignore
Reviewed
···
55
55
Temporary Items
56
56
.apdisk
57
57
test/.cos-extension
58
58
+
test/.plugin-scratch
+10
-1
package.json
Reviewed
···
50
50
"playwright-core": "^1.61.1",
51
51
"typescript": "~6.0.3",
52
52
"vitest": "^4.1.8",
53
53
-
"vue-tsc": "^3.3.3"
53
53
+
"vue-tsc": "^3.3.3",
54
54
+
"vite": "^7.3.5"
55
55
+
},
56
56
+
"peerDependencies": {
57
57
+
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
58
58
+
},
59
59
+
"peerDependenciesMeta": {
60
60
+
"vite": {
61
61
+
"optional": true
62
62
+
}
54
63
}
55
64
}
+3
pnpm-lock.yaml
Reviewed
···
51
51
typescript:
52
52
specifier: ~6.0.3
53
53
version: 6.0.3
54
54
+
vite:
55
55
+
specifier: ^7.3.5
56
56
+
version: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)
54
57
vitest:
55
58
specifier: ^4.1.8
56
59
version: 4.1.8(@types/node@26.0.0)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))
+7
-155
src/module.ts
Reviewed
···
1
1
-
import { createHash } from 'node:crypto'
2
1
import { defineNuxtModule, addServerPlugin, addVitePlugin, createResolver } from '@nuxt/kit'
3
3
-
import { rolldown } from 'rolldown'
4
4
-
import type { CosManifest } from './runtime/loader'
5
5
-
6
6
-
const MANIFEST_PLACEHOLDER = '__COS_MANIFEST__'
7
7
-
8
8
-
/**
9
9
-
* Bundle the runtime loader into a self-contained IIFE with rolldown, leaving
10
10
-
* `__COS_MANIFEST__` as a literal token for the caller to substitute.
11
11
-
*/
12
12
-
async function bundleLoader(entry: string): Promise<string> {
13
13
-
const builder = await rolldown({ input: entry, platform: 'browser', treeshake: true })
14
14
-
const { output } = await builder.generate({ format: 'iife', minify: true })
15
15
-
await builder.close()
16
16
-
return output[0].code
17
17
-
}
2
2
+
import { cosPlugin } from './vite'
18
3
19
4
export interface ModuleOptions {
20
5
/**
···
25
10
packages: Array<string | RegExp>
26
11
}
27
12
28
28
-
/**
29
29
-
* Recipe version embedded in every content-addressed specifier, meant to be bumped
30
30
-
* whenever the build recipe (bundler version, options, define replacements)
31
31
-
* changes in a way that alters emitted bytes, so chunks built under different
32
32
-
* recipes cannot silently collide on the same SHA-256.
33
33
-
*/
34
34
-
const RECIPE = 'cos1'
35
35
-
36
36
-
function contentSpecifier(hash: string): string {
37
37
-
return `${RECIPE}:${hash}`
38
38
-
}
39
39
-
40
13
export default defineNuxtModule<ModuleOptions>({
41
14
meta: {
42
15
name: 'nuxt-cos',
···
51
24
}
52
25
53
26
const resolver = createResolver(import.meta.url)
54
54
-
const packages = options.packages.map(p => typeof p === 'string' ? new RegExp(`^${p}$`) : p)
55
27
56
56
-
const loaderEntry = resolver.resolve('./runtime/loader.entry')
57
57
-
let loaderTemplate: Promise<string> | undefined
58
28
let scriptContent = ''
59
29
60
30
nuxt.options.nitro.virtual ||= {}
···
62
32
63
33
addServerPlugin(resolver.resolve('./runtime/server/plugins/inject'))
64
34
65
65
-
const collected = new Set<string>()
66
66
-
67
67
-
addVitePlugin(() => ({
68
68
-
name: 'nuxt-cos',
69
69
-
enforce: 'pre',
70
70
-
resolveId: {
71
71
-
order: 'pre',
72
72
-
async handler(id, importer, resolveOptions) {
73
73
-
if (!packages.some(p => p.test(id))) {
74
74
-
return
75
75
-
}
76
76
-
77
77
-
const resolved = await this.resolve(id, importer, { ...resolveOptions, skipSelf: true })
78
78
-
if (!resolved) {
79
79
-
return
80
80
-
}
81
81
-
82
82
-
collected.add(resolved.id)
83
83
-
84
84
-
// Externalise under a synthetic specifier so it never clashes with the
85
85
-
// real module id elsewhere in the app graph. It is rewritten to a
86
86
-
// content-addressed specifier in `generateBundle`, once every managed
87
87
-
// chunk has been hashed bottom-up.
88
88
-
return { id: `cos-ext:${resolved.id}`, external: true }
89
89
-
},
90
90
-
},
91
91
-
async generateBundle(_outputOptions, bundle) {
92
92
-
const ids = [...collected]
93
93
-
const idSet = new Set(ids)
94
94
-
95
95
-
// Build each managed package once, externalising its siblings. The raw
96
96
-
// output keeps sibling imports as their resolved absolute ids, which
97
97
-
// double as the dependency edges between managed chunks.
98
98
-
const raw = new Map<string, { code: string, deps: string[] }>()
99
99
-
for (const input of ids) {
100
100
-
const builder = await rolldown({
101
101
-
input,
102
102
-
platform: 'browser',
103
103
-
treeshake: false,
104
104
-
external: ids.filter(id => id !== input),
105
105
-
})
106
106
-
const { output } = await builder.generate({ file: 'chunk.js', codeSplitting: false, minify: true })
107
107
-
await builder.close()
108
108
-
109
109
-
const code = output[0].code
110
110
-
const deps = [...new Set([...code.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)].map(m => m[1]!))]
111
111
-
.filter(spec => idSet.has(spec))
112
112
-
raw.set(input, { code, deps })
113
113
-
}
114
114
-
115
115
-
// Hash bottom-up: a chunk's specifier for a dependency is that
116
116
-
// dependency's content hash, so a chunk can only be hashed once all of
117
117
-
// its dependencies have been. The npm graph for these packages is a DAG.
118
118
-
const hashes = new Map<string, string>()
119
119
-
const managed: CosManifest['chunks'] = {}
120
120
-
121
121
-
const visit = (id: string, stack: string[]): string => {
122
122
-
const existing = hashes.get(id)
123
123
-
if (existing) {
124
124
-
return existing
125
125
-
}
126
126
-
if (stack.includes(id)) {
127
127
-
throw new Error(`[nuxt-cos] dependency cycle between managed packages: ${[...stack, id].join(' -> ')}`)
128
128
-
}
129
129
-
130
130
-
const { code, deps } = raw.get(id)!
131
131
-
let resolved = code
132
132
-
for (const dep of deps) {
133
133
-
resolved = rewriteSpecifier(resolved, dep, contentSpecifier(visit(dep, [...stack, id])))
134
134
-
}
135
135
-
136
136
-
const hash = createHash('sha256').update(resolved).digest('hex')
137
137
-
const fileName = `_nuxt/${hash}.js`
138
138
-
hashes.set(id, hash)
139
139
-
managed[contentSpecifier(hash)] = { file: `${hash}.js`, hash }
140
140
-
bundle[fileName] = {
141
141
-
type: 'asset',
142
142
-
fileName,
143
143
-
name: hash,
144
144
-
names: [hash],
145
145
-
originalFileName: null,
146
146
-
originalFileNames: [],
147
147
-
needsCodeReference: false,
148
148
-
source: resolved,
149
149
-
}
150
150
-
return hash
151
151
-
}
152
152
-
153
153
-
for (const id of ids) {
154
154
-
visit(id, [])
155
155
-
}
156
156
-
157
157
-
let entry: CosManifest['entry'] | undefined
158
158
-
for (const file of Object.values(bundle)) {
159
159
-
if (file.type !== 'chunk') {
160
160
-
continue
161
161
-
}
162
162
-
for (const id of ids) {
163
163
-
file.code = rewriteSpecifier(file.code, `cos-ext:${id}`, contentSpecifier(hashes.get(id)!))
164
164
-
}
165
165
-
if (file.isEntry) {
166
166
-
// the entry is app-specific and should not be content-addressed
167
167
-
entry = { specifier: `${RECIPE}:entry`, file: file.fileName.replace(/^_nuxt\//, '') }
168
168
-
}
169
169
-
}
170
170
-
171
171
-
if (!entry) {
172
172
-
return
173
173
-
}
174
174
-
175
175
-
const manifest: CosManifest = { base: '/_nuxt/', entry, chunks: managed }
176
176
-
loaderTemplate ??= bundleLoader(loaderEntry)
177
177
-
scriptContent = (await loaderTemplate).replace(MANIFEST_PLACEHOLDER, JSON.stringify(manifest))
35
35
+
addVitePlugin(() => cosPlugin({
36
36
+
packages: options.packages,
37
37
+
base: '/_nuxt/',
38
38
+
loaderEntry: resolver.resolve('./runtime/loader.entry'),
39
39
+
onGenerated: (content) => {
40
40
+
scriptContent = content
178
41
},
179
42
}), { client: true, server: false })
180
43
},
181
44
})
182
182
-
183
183
-
function rewriteSpecifier(code: string, from: string, to: string): string {
184
184
-
const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
185
185
-
const fromImport = new RegExp(`((?:import|export)\\b[^;'"\\n]*?from\\s*|import\\s*|export\\s*\\*\\s*from\\s*)(["'])${escaped}\\2`, 'g')
186
186
-
const bareImport = new RegExp(`(\\bimport\\s*)(["'])${escaped}\\2`, 'g')
187
187
-
const dynamic = new RegExp(`(\\bimport\\s*\\(\\s*)(["'])${escaped}\\2(\\s*\\))`, 'g')
188
188
-
return code
189
189
-
.replace(dynamic, `$1$2${to}$2$3`)
190
190
-
.replace(fromImport, `$1$2${to}$2`)
191
191
-
.replace(bareImport, `$1$2${to}$2`)
192
192
-
}
+238
src/vite.ts
Reviewed
···
1
1
+
import { createHash } from 'node:crypto'
2
2
+
import { fileURLToPath } from 'node:url'
3
3
+
import { rolldown } from 'rolldown'
4
4
+
import type { Plugin } from 'vite'
5
5
+
import type { CosManifest } from './runtime/loader'
6
6
+
7
7
+
export type { CosManifest }
8
8
+
9
9
+
const MANIFEST_PLACEHOLDER = '__COS_MANIFEST__'
10
10
+
11
11
+
/**
12
12
+
* Recipe version embedded in every content-addressed specifier. Bump this
13
13
+
* whenever the build recipe (bundler version, options, define replacements)
14
14
+
* changes in a way that alters emitted bytes, so chunks built under different
15
15
+
* recipes cannot silently collide on the same SHA-256.
16
16
+
*/
17
17
+
const RECIPE = 'cos1'
18
18
+
19
19
+
const DEFAULT_LOADER_ENTRY = fileURLToPath(new URL('./runtime/loader.entry.js', import.meta.url))
20
20
+
21
21
+
export interface CosPluginOptions {
22
22
+
/**
23
23
+
* Packages to extract into standalone Cross-Origin Storage chunks. Each entry
24
24
+
* is matched against the imported module specifier; a plain string is treated
25
25
+
* as an exact match.
26
26
+
*/
27
27
+
packages: Array<string | RegExp>
28
28
+
/**
29
29
+
* Public base path the managed chunks are served from. Defaults to Vite's
30
30
+
* resolved `base` joined with `build.assetsDir`.
31
31
+
*/
32
32
+
base?: string
33
33
+
/**
34
34
+
* Path to the runtime loader entry to bundle into the injected `<script>`.
35
35
+
* Defaults to the bundled loader. Override only to swap the loader runtime.
36
36
+
*/
37
37
+
loaderEntry?: string
38
38
+
/**
39
39
+
* Called once the managed chunks are emitted, with the loader `<script>` body
40
40
+
* (loader IIFE + inlined manifest). SSR frameworks should inject this into
41
41
+
* their rendered HTML themselves. When omitted, the plugin injects it into
42
42
+
* `index.html` via `transformIndexHtml` for plain client builds.
43
43
+
*/
44
44
+
onGenerated?: (scriptContent: string) => void
45
45
+
}
46
46
+
47
47
+
function contentSpecifier(hash: string): string {
48
48
+
return `${RECIPE}:${hash}`
49
49
+
}
50
50
+
51
51
+
function toMatchers(packages: Array<string | RegExp>): RegExp[] {
52
52
+
return packages.map(p => typeof p === 'string' ? new RegExp(`^${p}$`) : p)
53
53
+
}
54
54
+
55
55
+
/**
56
56
+
* Bundle the runtime loader into a self-contained IIFE with rolldown, leaving
57
57
+
* `__COS_MANIFEST__` as a literal token for the caller to substitute. Bundling
58
58
+
* from source keeps the loader correct regardless of how the host build loaded
59
59
+
* this plugin.
60
60
+
*/
61
61
+
async function bundleLoader(entry: string): Promise<string> {
62
62
+
const builder = await rolldown({ input: entry, platform: 'browser', treeshake: true })
63
63
+
const { output } = await builder.generate({ format: 'iife', minify: true })
64
64
+
await builder.close()
65
65
+
return output[0].code
66
66
+
}
67
67
+
68
68
+
function rewriteSpecifier(code: string, from: string, to: string): string {
69
69
+
const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
70
70
+
const fromImport = new RegExp(`((?:import|export)\\b[^;'"\\n]*?from\\s*|import\\s*|export\\s*\\*\\s*from\\s*)(["'])${escaped}\\2`, 'g')
71
71
+
const bareImport = new RegExp(`(\\bimport\\s*)(["'])${escaped}\\2`, 'g')
72
72
+
const dynamic = new RegExp(`(\\bimport\\s*\\(\\s*)(["'])${escaped}\\2(\\s*\\))`, 'g')
73
73
+
return code
74
74
+
.replace(dynamic, `$1$2${to}$2$3`)
75
75
+
.replace(fromImport, `$1$2${to}$2`)
76
76
+
.replace(bareImport, `$1$2${to}$2`)
77
77
+
}
78
78
+
79
79
+
function joinBase(base: string, assetsDir: string): string {
80
80
+
const prefix = base.endsWith('/') ? base : `${base}/`
81
81
+
const dir = assetsDir.replace(/^\/+|\/+$/g, '')
82
82
+
return dir ? `${prefix}${dir}/` : prefix
83
83
+
}
84
84
+
85
85
+
export function cosPlugin(options: CosPluginOptions): Plugin {
86
86
+
const packages = toMatchers(options.packages)
87
87
+
const loaderEntry = options.loaderEntry ?? DEFAULT_LOADER_ENTRY
88
88
+
89
89
+
const collected = new Set<string>()
90
90
+
let assetsDir = 'assets'
91
91
+
let resolvedBase = '/'
92
92
+
let loaderTemplate: Promise<string> | undefined
93
93
+
let scriptContent = ''
94
94
+
95
95
+
return {
96
96
+
name: 'vite-plugin-cos',
97
97
+
enforce: 'pre',
98
98
+
apply: 'build',
99
99
+
configResolved(config) {
100
100
+
assetsDir = config.build.assetsDir
101
101
+
resolvedBase = config.base
102
102
+
},
103
103
+
resolveId: {
104
104
+
order: 'pre',
105
105
+
async handler(id, importer, resolveOptions) {
106
106
+
if (!packages.some(p => p.test(id))) {
107
107
+
return
108
108
+
}
109
109
+
110
110
+
const resolved = await this.resolve(id, importer, { ...resolveOptions, skipSelf: true })
111
111
+
if (!resolved) {
112
112
+
return
113
113
+
}
114
114
+
115
115
+
collected.add(resolved.id)
116
116
+
117
117
+
// Externalise under a synthetic specifier so it never clashes with the
118
118
+
// real module id elsewhere in the app graph. It is rewritten to a
119
119
+
// content-addressed specifier in `generateBundle`, once every managed
120
120
+
// chunk has been hashed bottom-up.
121
121
+
return { id: `cos-ext:${resolved.id}`, external: true }
122
122
+
},
123
123
+
},
124
124
+
async generateBundle(_outputOptions, bundle) {
125
125
+
const ids = [...collected]
126
126
+
if (!ids.length) {
127
127
+
return
128
128
+
}
129
129
+
const idSet = new Set(ids)
130
130
+
const base = options.base ?? joinBase(resolvedBase, assetsDir)
131
131
+
const assetPrefix = assetsDir ? `${assetsDir.replace(/^\/+|\/+$/g, '')}/` : ''
132
132
+
133
133
+
// Build each managed package once, externalising its siblings. The raw
134
134
+
// output keeps sibling imports as their resolved absolute ids, which
135
135
+
// double as the dependency edges between managed chunks.
136
136
+
const raw = new Map<string, { code: string, deps: string[] }>()
137
137
+
for (const input of ids) {
138
138
+
const builder = await rolldown({
139
139
+
input,
140
140
+
platform: 'browser',
141
141
+
treeshake: false,
142
142
+
external: ids.filter(id => id !== input),
143
143
+
})
144
144
+
// `minify` is part of the pinned recipe (see RECIPE): it both shrinks
145
145
+
// the chunk and strips rolldown's `//#region <path>` debug comments,
146
146
+
// which embed cwd-relative paths and would otherwise make the hash
147
147
+
// depend on the build location.
148
148
+
const { output } = await builder.generate({ file: 'chunk.js', codeSplitting: false, minify: true })
149
149
+
await builder.close()
150
150
+
151
151
+
const code = output[0].code
152
152
+
const deps = [...new Set([...code.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)].map(m => m[1]!))]
153
153
+
.filter(spec => idSet.has(spec))
154
154
+
raw.set(input, { code, deps })
155
155
+
}
156
156
+
157
157
+
// Hash bottom-up: a chunk's specifier for a dependency is that
158
158
+
// dependency's content hash, so a chunk can only be hashed once all of
159
159
+
// its dependencies have been. The npm graph for these packages is a DAG.
160
160
+
const hashes = new Map<string, string>()
161
161
+
const managed: CosManifest['chunks'] = {}
162
162
+
163
163
+
const visit = (id: string, stack: string[]): string => {
164
164
+
const existing = hashes.get(id)
165
165
+
if (existing) {
166
166
+
return existing
167
167
+
}
168
168
+
if (stack.includes(id)) {
169
169
+
throw new Error(`[cos] dependency cycle between managed packages: ${[...stack, id].join(' -> ')}`)
170
170
+
}
171
171
+
172
172
+
const { code, deps } = raw.get(id)!
173
173
+
let resolved = code
174
174
+
for (const dep of deps) {
175
175
+
resolved = rewriteSpecifier(resolved, dep, contentSpecifier(visit(dep, [...stack, id])))
176
176
+
}
177
177
+
178
178
+
const hash = createHash('sha256').update(resolved).digest('hex')
179
179
+
const fileName = `${assetPrefix}${hash}.js`
180
180
+
hashes.set(id, hash)
181
181
+
managed[contentSpecifier(hash)] = { file: `${hash}.js`, hash }
182
182
+
bundle[fileName] = {
183
183
+
type: 'asset',
184
184
+
fileName,
185
185
+
name: hash,
186
186
+
names: [hash],
187
187
+
originalFileName: null,
188
188
+
originalFileNames: [],
189
189
+
needsCodeReference: false,
190
190
+
source: resolved,
191
191
+
}
192
192
+
return hash
193
193
+
}
194
194
+
195
195
+
for (const id of ids) {
196
196
+
visit(id, [])
197
197
+
}
198
198
+
199
199
+
let entry: CosManifest['entry'] | undefined
200
200
+
for (const file of Object.values(bundle)) {
201
201
+
if (file.type !== 'chunk') {
202
202
+
continue
203
203
+
}
204
204
+
for (const id of ids) {
205
205
+
file.code = rewriteSpecifier(file.code, `cos-ext:${id}`, contentSpecifier(hashes.get(id)!))
206
206
+
}
207
207
+
if (file.isEntry) {
208
208
+
// The entry is app-specific and is re-rendered by Vite after this
209
209
+
// hook, so it cannot be content-addressed here; it loads from the
210
210
+
// network under a stable specifier instead.
211
211
+
entry = { specifier: `${RECIPE}:entry`, file: file.fileName.replace(new RegExp(`^${assetPrefix}`), '') }
212
212
+
}
213
213
+
}
214
214
+
215
215
+
if (!entry) {
216
216
+
return
217
217
+
}
218
218
+
219
219
+
const manifest: CosManifest = { base, entry, chunks: managed }
220
220
+
loaderTemplate ??= bundleLoader(loaderEntry)
221
221
+
scriptContent = (await loaderTemplate).replace(MANIFEST_PLACEHOLDER, JSON.stringify(manifest))
222
222
+
options.onGenerated?.(scriptContent)
223
223
+
},
224
224
+
transformIndexHtml: {
225
225
+
order: 'post',
226
226
+
handler(html) {
227
227
+
if (options.onGenerated || !scriptContent) {
228
228
+
return html
229
229
+
}
230
230
+
return html
231
231
+
.replace(/<script type="module"[^>]*src="[^"]*"[^>]*><\/script>/g, '')
232
232
+
.replace('</head>', `<script id="cos-loader">${scriptContent}</script></head>`)
233
233
+
},
234
234
+
},
235
235
+
}
236
236
+
}
237
237
+
238
238
+
export default cosPlugin
+79
test/plugin.test.ts
Reviewed
···
1
1
+
import { createHash } from 'node:crypto'
2
2
+
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, mkdirSync, globSync } from 'node:fs'
3
3
+
import { fileURLToPath } from 'node:url'
4
4
+
import { join } from 'node:path'
5
5
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
6
6
+
import { build } from 'vite'
7
7
+
import { cosPlugin } from '../src/vite'
8
8
+
9
9
+
// Build inside the project tree so the fixture resolves `vue` from the project
10
10
+
// node_modules rather than a detached temp dir.
11
11
+
const scratchRoot = fileURLToPath(new URL('./.plugin-scratch', import.meta.url))
12
12
+
const nodeModules = fileURLToPath(new URL('../node_modules', import.meta.url))
13
13
+
const vueEntry = globSync('.pnpm/vue@*/node_modules/vue/dist/vue.runtime.esm-bundler.js', { cwd: nodeModules })[0]!
14
14
+
15
15
+
describe('cosPlugin (standalone vite build)', () => {
16
16
+
let root: string
17
17
+
let outDir: string
18
18
+
let assetsDir: string
19
19
+
20
20
+
beforeAll(async () => {
21
21
+
mkdirSync(scratchRoot, { recursive: true })
22
22
+
root = mkdtempSync(join(scratchRoot, 'app-'))
23
23
+
outDir = join(root, 'dist')
24
24
+
assetsDir = join(outDir, 'assets')
25
25
+
mkdirSync(join(root, 'src'), { recursive: true })
26
26
+
writeFileSync(
27
27
+
join(root, 'index.html'),
28
28
+
'<!doctype html><html><head></head><body><script type="module" src="/src/main.js"></script></body></html>',
29
29
+
)
30
30
+
writeFileSync(join(root, 'src/main.js'), 'import { ref } from "vue"\ndocument.body.dataset.count = String(ref(0).value)\n')
31
31
+
32
32
+
await build({
33
33
+
root,
34
34
+
logLevel: 'error',
35
35
+
// The fixture lives in a scratch dir; point bare `vue` at the project copy.
36
36
+
resolve: { alias: { vue: join(nodeModules, vueEntry) } },
37
37
+
plugins: [cosPlugin({ packages: [/^(?:vue$|@vue\/)/] })],
38
38
+
build: { outDir, emptyOutDir: true, rollupOptions: { input: join(root, 'index.html') } },
39
39
+
})
40
40
+
}, 120_000)
41
41
+
42
42
+
afterAll(() => {
43
43
+
rmSync(scratchRoot, { recursive: true, force: true })
44
44
+
})
45
45
+
46
46
+
function cosChunks(): string[] {
47
47
+
return readdirSync(assetsDir).filter(f => /^[a-f0-9]{64}\.js$/.test(f))
48
48
+
}
49
49
+
50
50
+
it('emits content-addressed chunks whose names match their bytes', () => {
51
51
+
expect(cosChunks().length).toBeGreaterThanOrEqual(1)
52
52
+
for (const file of cosChunks()) {
53
53
+
const hash = createHash('sha256').update(readFileSync(join(assetsDir, file))).digest('hex')
54
54
+
expect(hash).toBe(file.replace('.js', ''))
55
55
+
}
56
56
+
})
57
57
+
58
58
+
it('rewrites managed imports to content-addressed specifiers', () => {
59
59
+
for (const file of cosChunks()) {
60
60
+
const code = readFileSync(join(assetsDir, file), 'utf8')
61
61
+
const specifiers = [...code.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)].map(m => m[1]!)
62
62
+
for (const specifier of specifiers) {
63
63
+
expect(specifier).toMatch(/^cos1:[a-f0-9]{64}$/)
64
64
+
}
65
65
+
}
66
66
+
})
67
67
+
68
68
+
it('injects the loader into index.html and removes the default entry script', () => {
69
69
+
const html = readFileSync(join(outDir, 'index.html'), 'utf8')
70
70
+
expect(html).toContain('<script id="cos-loader">')
71
71
+
expect(html).toMatch(/cos1:[a-f0-9]{64}/)
72
72
+
expect(html).not.toMatch(/<script type="module"[^>]*src="[^"]*\.js"/)
73
73
+
})
74
74
+
75
75
+
it('derives the base path from the vite config', () => {
76
76
+
const html = readFileSync(join(outDir, 'index.html'), 'utf8')
77
77
+
expect(html).toMatch(/"base":"\/assets\/"/)
78
78
+
})
79
79
+
})