zyzz 0.0.7 → 0.0.9

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.
Files changed (78) hide show
  1. package/README.md +7 -7
  2. package/dist/Style.d.ts +2 -2
  3. package/dist/Style.d.ts.map +1 -1
  4. package/dist/Style.js +4 -4
  5. package/dist/Style.js.map +1 -1
  6. package/dist/babel/index.d.ts +4 -1
  7. package/dist/babel/index.d.ts.map +1 -1
  8. package/dist/babel/index.js +6 -3
  9. package/dist/babel/index.js.map +1 -1
  10. package/dist/babel/internal/Compilation.d.ts +12 -0
  11. package/dist/babel/internal/Compilation.d.ts.map +1 -0
  12. package/dist/babel/internal/Compilation.js +3 -0
  13. package/dist/babel/internal/Compilation.js.map +1 -0
  14. package/dist/compiler/Graph.d.ts +6 -1
  15. package/dist/compiler/Graph.d.ts.map +1 -1
  16. package/dist/compiler/Graph.js +160 -36
  17. package/dist/compiler/Graph.js.map +1 -1
  18. package/dist/compiler/Transform.d.ts.map +1 -1
  19. package/dist/compiler/Transform.js +2 -1
  20. package/dist/compiler/Transform.js.map +1 -1
  21. package/dist/compiler/internal/Stylesheets.d.ts +8 -0
  22. package/dist/compiler/internal/Stylesheets.d.ts.map +1 -1
  23. package/dist/compiler/internal/Stylesheets.js +4 -0
  24. package/dist/compiler/internal/Stylesheets.js.map +1 -1
  25. package/dist/internal/Value.d.ts +5 -5
  26. package/dist/internal/Value.d.ts.map +1 -1
  27. package/dist/internal/Value.js +18 -64
  28. package/dist/internal/Value.js.map +1 -1
  29. package/dist/metro/Graph.d.ts +9 -1
  30. package/dist/metro/Graph.d.ts.map +1 -1
  31. package/dist/metro/Graph.js +5 -4
  32. package/dist/metro/Graph.js.map +1 -1
  33. package/dist/metro/transformer.d.ts +0 -1
  34. package/dist/metro/transformer.d.ts.map +1 -1
  35. package/dist/metro/transformer.js +19 -1
  36. package/dist/metro/transformer.js.map +1 -1
  37. package/dist/next/index.d.ts +3 -3
  38. package/dist/next/index.d.ts.map +1 -1
  39. package/dist/next/index.js +25 -30
  40. package/dist/next/index.js.map +1 -1
  41. package/dist/next/internal/Project.d.ts +4 -4
  42. package/dist/next/internal/Project.d.ts.map +1 -1
  43. package/dist/next/internal/Project.js +153 -107
  44. package/dist/next/internal/Project.js.map +1 -1
  45. package/dist/next/loader.d.ts +3 -1
  46. package/dist/next/loader.d.ts.map +1 -1
  47. package/dist/next/loader.js +152 -99
  48. package/dist/next/loader.js.map +1 -1
  49. package/dist/node/Host.d.ts.map +1 -1
  50. package/dist/node/Host.js +11 -3
  51. package/dist/node/Host.js.map +1 -1
  52. package/dist/node/internal/Snapshot.d.ts +12 -0
  53. package/dist/node/internal/Snapshot.d.ts.map +1 -0
  54. package/dist/node/internal/Snapshot.js +74 -0
  55. package/dist/node/internal/Snapshot.js.map +1 -0
  56. package/dist/unplugin/index.d.ts.map +1 -1
  57. package/dist/unplugin/index.js +13 -9
  58. package/dist/unplugin/index.js.map +1 -1
  59. package/dist/vite/index.d.ts.map +1 -1
  60. package/dist/vite/index.js +130 -27
  61. package/dist/vite/index.js.map +1 -1
  62. package/package.json +1 -1
  63. package/src/Style.ts +6 -6
  64. package/src/babel/index.ts +10 -4
  65. package/src/babel/internal/Compilation.ts +13 -0
  66. package/src/compiler/Graph.ts +213 -47
  67. package/src/compiler/Transform.ts +2 -1
  68. package/src/compiler/internal/Stylesheets.ts +9 -0
  69. package/src/internal/Value.ts +40 -88
  70. package/src/metro/Graph.ts +5 -3
  71. package/src/metro/transformer.ts +26 -1
  72. package/src/next/index.ts +26 -31
  73. package/src/next/internal/Project.ts +196 -155
  74. package/src/next/loader.ts +181 -124
  75. package/src/node/Host.ts +15 -3
  76. package/src/node/internal/Snapshot.ts +79 -0
  77. package/src/unplugin/index.ts +12 -8
  78. package/src/vite/index.ts +161 -35
@@ -3,16 +3,19 @@ import * as AtRules from '../compiler/internal/AtRules.js'
3
3
  import * as Crypto from 'node:crypto'
4
4
  import * as Fs from 'node:fs/promises'
5
5
  import * as Path from 'node:path'
6
+ import * as Stylesheets from '../compiler/internal/Stylesheets.js'
6
7
  import * as Project from './internal/Project.js'
7
8
 
8
9
  type Context = Project.Context & {
9
10
  async(): (error: Error | null, code?: string, map?: object) => void
10
11
  getOptions(): {
11
12
  bundler: string
12
- mode: 'shared' | 'source' | 'style'
13
+ development?: boolean | undefined
14
+ mode?: 'style' | undefined
13
15
  reset?: boolean | undefined
14
16
  root: string
15
17
  }
18
+ resourceQuery?: string | undefined
16
19
  }
17
20
 
18
21
  // Next owns loader worker lifetimes. Each project retains only its latest graph in that worker.
@@ -32,6 +35,15 @@ async function compile(context: Context, source: string) {
32
35
  const options = context.getOptions()
33
36
  const root = options.root
34
37
  const directory = Path.resolve(root, '.zyzz', 'next')
38
+ if (options.mode) {
39
+ const hash = new URLSearchParams(context.resourceQuery).get('zyzz')
40
+ if (!hash || !/^[a-f0-9]{64}$/.test(hash))
41
+ throw new Error('Invalid generated stylesheet request.')
42
+ const file = Path.join(directory, `${hash}.css`)
43
+ context.addDependency(file)
44
+ return { code: await Fs.readFile(file, 'utf8'), map: undefined }
45
+ }
46
+
35
47
  const eligible = (file: string) =>
36
48
  /\.[cm]?[jt]sx?$/.test(file) &&
37
49
  !/\.(?:d|test|test-d|bench|bench-d)\.[cm]?[jt]sx?$/.test(file) &&
@@ -50,97 +62,25 @@ async function compile(context: Context, source: string) {
50
62
  )
51
63
  return { code: source, map: undefined }
52
64
 
53
- if (options.mode !== 'shared' && !eligible(context.resourcePath))
54
- return { code: source, map: undefined }
65
+ if (!eligible(context.resourcePath)) return { code: source, map: undefined }
55
66
 
56
- const key = JSON.stringify([root, options.bundler, options.reset ?? false])
67
+ const key = JSON.stringify([
68
+ root,
69
+ options.bundler,
70
+ options.reset ?? false,
71
+ options.development ?? false,
72
+ ])
57
73
  let project = projects.get(key)
58
74
  if (!project) {
59
75
  project = Project.create(options)
60
76
  projects.set(key, project)
61
77
  }
62
- const graph = await project.compile(
63
- context,
64
- options.mode === 'shared' ? undefined : source,
65
- )
78
+ const graph = await project.compile(context, source)
66
79
  const output =
67
80
  graph.modules[
68
81
  `app/${Path.relative(root, context.resourcePath).split(Path.sep).join('/')}`
69
82
  ]
70
83
 
71
- const assets = new Map<string, string>()
72
- for (const [placeholder, target] of Object.entries(
73
- graph.sharedAssets ?? {},
74
- )) {
75
- const raw = target.split(/[?#]/)[0]!
76
- const file = await Fs.realpath(
77
- target.startsWith('app/')
78
- ? Path.join(root, decodeURIComponent(raw.slice(4)))
79
- : decodeURIComponent(raw),
80
- )
81
- const identity = graph.sharedAssetOwners?.[placeholder]
82
- let owner = root
83
- if (identity && Path.isAbsolute(identity)) {
84
- owner = Path.dirname(identity)
85
- for (;;) {
86
- try {
87
- await Fs.access(Path.join(owner, 'package.json'))
88
- break
89
- } catch (error) {
90
- if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
91
- const parent = Path.dirname(owner)
92
- if (parent === owner)
93
- throw new Error('Packed assets require an owning package.json.')
94
- owner = parent
95
- }
96
- }
97
- }
98
-
99
- const relative = Path.relative(await Fs.realpath(owner), file)
100
- if (
101
- !identity ||
102
- relative === '..' ||
103
- relative.startsWith(`..${Path.sep}`) ||
104
- Path.isAbsolute(relative)
105
- )
106
- throw new Error('Asset path escapes its owning package.')
107
-
108
- context.addDependency(file)
109
- assets.set(
110
- placeholder,
111
- Path.relative(
112
- options.bundler === 'turbopack'
113
- ? Path.dirname(context.resourcePath)
114
- : directory,
115
- file,
116
- )
117
- .split(Path.sep)
118
- .map(encodeURIComponent)
119
- .join('/') + target.slice(raw.length),
120
- )
121
- }
122
-
123
- const shared = graph.sharedCss
124
- ? AtRules.transform({
125
- code: Buffer.from(graph.sharedCss),
126
- filename: 'shared.css',
127
- inputSourceMap: JSON.stringify(graph.sharedCssMap),
128
- sourceMap: true,
129
- visitor: {
130
- Url: (url) =>
131
- assets.has(url.url)
132
- ? { ...url, url: assets.get(url.url)! }
133
- : undefined,
134
- },
135
- })
136
- : undefined
137
- if (options.mode === 'shared')
138
- return {
139
- code: shared?.code.toString() ?? '',
140
- map: shared?.map
141
- ? (JSON.parse(shared.map.toString()) as object)
142
- : undefined,
143
- }
144
84
  if (!output) throw new Error('Missing Next.js source compilation.')
145
85
  // Bundlers resolve map sources relative to the loader resource, not graph identities.
146
86
  const sources = (names: readonly (string | null)[]) =>
@@ -153,63 +93,180 @@ async function compile(context: Context, source: string) {
153
93
  sources: sources(output.map.sources),
154
94
  }
155
95
  const cssMap = { ...output.cssMap, sources: sources(output.cssMap.sources) }
156
- if (options.mode === 'style') return { code: output.css, map: cssMap }
157
96
 
158
- if (options.bundler === 'turbopack') {
159
- const sharedFile = Path.relative(
160
- Path.dirname(context.resourcePath),
161
- Path.join(directory, 'shared.css'),
162
- )
163
- .split(Path.sep)
164
- .join('/')
165
- // Turbopack appends the output extension to loader-transformed resource paths.
166
- const resource = await Fs.stat(context.resourcePath).then(
167
- () => context.resourcePath,
168
- async (error: NodeJS.ErrnoException) => {
169
- if (error.code !== 'ENOENT') throw error
170
- const original = context.resourcePath.slice(
171
- 0,
172
- -Path.extname(context.resourcePath).length,
173
- )
174
- await Fs.access(original)
175
- return original
176
- },
177
- )
178
- const requests = [
179
- ...(graph.sharedCss
180
- ? [
181
- `import ${JSON.stringify(sharedFile.startsWith('../') ? sharedFile : `./${sharedFile}`)};`,
182
- ]
183
- : []),
184
- ...(output.css
185
- ? [
186
- `import ${JSON.stringify(`./${Path.basename(resource)}?zyzz-style`)};`,
187
- ]
188
- : []),
189
- ]
190
- return { code: `${output.code}\n${requests.join('\n')}`, map }
97
+ if (
98
+ output.code === source &&
99
+ !output.css &&
100
+ !/(?:^|[/\\])(?:layout|_app)\.[cm]?[jt]sx?$/.test(context.resourcePath) &&
101
+ !graph.sharedCssMap?.sources.some((name) => name !== 'zyzz/reset.css') &&
102
+ !graph[Stylesheets.packed]?.length
103
+ )
104
+ return { code: source, map }
105
+
106
+ const shared = await compileShared({
107
+ css: graph.sharedCss ?? '',
108
+ map: graph.sharedCssMap!,
109
+ assets: graph.sharedAssets ?? {},
110
+ owners: graph.sharedAssetOwners ?? {},
111
+ })
112
+
113
+ async function compileShared(
114
+ stylesheet: ReturnType<typeof Stylesheets.render>,
115
+ ) {
116
+ const assets = new Map<string, string>()
117
+ for (const [placeholder, target] of Object.entries(stylesheet.assets)) {
118
+ const raw = target.split(/[?#]/)[0]!
119
+ const file = await Fs.realpath(
120
+ target.startsWith('app/')
121
+ ? Path.join(root, decodeURIComponent(raw.slice(4)))
122
+ : decodeURIComponent(raw),
123
+ )
124
+ const identity = stylesheet.owners[placeholder]
125
+ let owner = root
126
+ if (identity && Path.isAbsolute(identity)) {
127
+ owner = Path.dirname(identity)
128
+ for (;;) {
129
+ try {
130
+ await Fs.access(Path.join(owner, 'package.json'))
131
+ break
132
+ } catch (error) {
133
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
134
+ const parent = Path.dirname(owner)
135
+ if (parent === owner)
136
+ throw new Error('Packed assets require an owning package.json.')
137
+ owner = parent
138
+ }
139
+ }
140
+ }
141
+
142
+ const relative = Path.relative(await Fs.realpath(owner), file)
143
+ if (
144
+ !identity ||
145
+ relative === '..' ||
146
+ relative.startsWith(`..${Path.sep}`) ||
147
+ Path.isAbsolute(relative)
148
+ )
149
+ throw new Error('Asset path escapes its owning package.')
150
+
151
+ context.addDependency(file)
152
+ assets.set(
153
+ placeholder,
154
+ Path.relative(directory, file)
155
+ .split(Path.sep)
156
+ .map(encodeURIComponent)
157
+ .join('/') + target.slice(raw.length),
158
+ )
159
+ }
160
+
161
+ return stylesheet.css
162
+ ? AtRules.transform({
163
+ code: Buffer.from(stylesheet.css),
164
+ filename: 'shared.css',
165
+ inputSourceMap: JSON.stringify({
166
+ ...stylesheet.map,
167
+ sources: sources(stylesheet.map.sources),
168
+ }),
169
+ sourceMap: true,
170
+ visitor: {
171
+ Url: (url) =>
172
+ assets.has(url.url)
173
+ ? { ...url, url: assets.get(url.url)! }
174
+ : undefined,
175
+ },
176
+ })
177
+ : undefined
191
178
  }
179
+
192
180
  const styles = [
193
- { css: shared?.code.toString(), map: shared?.map?.toString() },
194
- { css: output.css, map: JSON.stringify(cssMap) },
181
+ ...(await Promise.all(
182
+ (graph[Stylesheets.packed] ?? []).map(async (resource) => {
183
+ const output = await compileShared(resource)
184
+ return {
185
+ css: output?.code.toString(),
186
+ map: output?.map?.toString(),
187
+ id: resource.id,
188
+ }
189
+ }),
190
+ )),
191
+ {
192
+ id: undefined,
193
+ css: shared?.code.toString(),
194
+ map: shared?.map?.toString(),
195
+ },
196
+ { id: undefined, css: output.css, map: JSON.stringify(cssMap) },
195
197
  ]
196
198
  const requests: string[] = []
197
199
  await Fs.mkdir(directory, { recursive: true })
198
- for (const stylesheet of styles) {
200
+ try {
201
+ await Fs.writeFile(
202
+ Path.join(directory, 'package.json'),
203
+ JSON.stringify({ sideEffects: true, type: 'module' }),
204
+ { flag: 'wx' },
205
+ )
206
+ } catch (error) {
207
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
208
+ }
209
+ for (const [index, stylesheet] of styles.entries()) {
199
210
  if (!stylesheet.css) continue
200
211
  const css =
201
212
  stylesheet.css +
202
213
  (stylesheet.map
203
214
  ? `\n/*# sourceMappingURL=data:application/json;base64,${Buffer.from(stylesheet.map).toString('base64')} */`
204
215
  : '')
205
- const hash = Crypto.createHash('sha256').update(css).digest('hex')
216
+ const hash = Crypto.createHash('sha256')
217
+ .update(
218
+ options.development &&
219
+ !(
220
+ index === (graph[Stylesheets.packed]?.length ?? 0) &&
221
+ graph.sharedCssMap?.sources.every(
222
+ (name) => name === 'zyzz/reset.css',
223
+ )
224
+ )
225
+ ? (stylesheet.id ??
226
+ JSON.stringify([context.resourcePath, index, graph.dependencies]))
227
+ : css,
228
+ )
229
+ .digest('hex')
206
230
  const file = Path.join(directory, `${hash}.css`)
207
- try {
208
- await Fs.writeFile(file, css, { flag: 'wx' })
209
- } catch (error) {
210
- if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
231
+ if (options.development) {
232
+ const previous = await Fs.readFile(file, 'utf8').catch(
233
+ (error: NodeJS.ErrnoException) => {
234
+ if (error.code !== 'ENOENT') throw error
235
+ return undefined
236
+ },
237
+ )
238
+ if (previous !== css) {
239
+ const temporary = `${file}.${Crypto.randomUUID()}.tmp`
240
+ await Fs.writeFile(temporary, css)
241
+ await Fs.rename(temporary, file)
242
+ }
243
+ } else {
244
+ try {
245
+ await Fs.writeFile(file, css, { flag: 'wx' })
246
+ } catch (error) {
247
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
248
+ }
249
+ }
250
+ let request = file
251
+ if (options.development) {
252
+ // Server-component CSS needs a client graph edge for Next's native CSS HMR.
253
+ request = `${file}.js`
254
+ const stylesheet =
255
+ options.bundler === 'turbopack'
256
+ ? `./style.css?zyzz=${hash}`
257
+ : `./${hash}.css`
258
+ const code = `'use client';import ${JSON.stringify(stylesheet)};`
259
+ const existing = await Fs.readFile(request, 'utf8').catch(
260
+ (error: NodeJS.ErrnoException) => {
261
+ if (error.code !== 'ENOENT') throw error
262
+ return undefined
263
+ },
264
+ )
265
+ if (existing !== code) await Fs.writeFile(request, code)
266
+ } else if (options.bundler === 'turbopack') {
267
+ request = Path.join(directory, 'style.css') + `?zyzz=${hash}`
211
268
  }
212
- const relative = Path.relative(Path.dirname(context.resourcePath), file)
269
+ const relative = Path.relative(Path.dirname(context.resourcePath), request)
213
270
  .split(Path.sep)
214
271
  .join('/')
215
272
  requests.push(
package/src/node/Host.ts CHANGED
@@ -13,6 +13,7 @@ import * as Graph from '../compiler/Graph.js'
13
13
  import * as AtRules from '../compiler/internal/AtRules.js'
14
14
  import * as Catalogs from '../compiler/internal/Catalogs.js'
15
15
  import * as Relative from '../compiler/internal/Relative.js'
16
+ import * as Snapshot from './internal/Snapshot.js'
16
17
  import * as Syntax from '../compiler/internal/Syntax.js'
17
18
  import * as Source from '../compiler/Source.js'
18
19
  import * as Transform from '../compiler/Transform.js'
@@ -124,6 +125,7 @@ export async function create(options: create.Options): Promise<Runtime> {
124
125
  const manifestPath = Path.join(outDir, '.zyzz.json')
125
126
 
126
127
  const compiler = Graph.create()
128
+ const snapshot = Snapshot.create()
127
129
  const stylesheets = new WeakMap<Transform.compile.ReturnType, Stylesheet>()
128
130
  let closed = false
129
131
  let closing: Promise<void> | undefined
@@ -250,7 +252,7 @@ export async function create(options: create.Options): Promise<Runtime> {
250
252
  `Source path conflicts with host control files: ${name}`,
251
253
  )
252
254
 
253
- sources[name] = await Fs.readFile(input, 'utf8')
255
+ sources[name] = await snapshot.read(input)
254
256
  }
255
257
 
256
258
  const modules = Object.fromEntries(
@@ -306,7 +308,7 @@ export async function create(options: create.Options): Promise<Runtime> {
306
308
 
307
309
  observe(`${file}.zyzz.json`)
308
310
  try {
309
- contracts[file] = await Fs.readFile(`${file}.zyzz.json`, 'utf8')
311
+ contracts[file] = await snapshot.read(`${file}.zyzz.json`)
310
312
  } catch (error) {
311
313
  if (
312
314
  !required &&
@@ -363,7 +365,7 @@ export async function create(options: create.Options): Promise<Runtime> {
363
365
  const file = Path.join(root, name)
364
366
  const resolutions = (imports[moduleId] = Object.create(null))
365
367
 
366
- for (const node of Syntax.parse({ moduleId, source }).program.body) {
368
+ for (const node of snapshot.parse({ moduleId, source }).program.body) {
367
369
  if (
368
370
  (node.type !== 'ImportDeclaration' &&
369
371
  node.type !== 'ExportNamedDeclaration' &&
@@ -439,6 +441,7 @@ export async function create(options: create.Options): Promise<Runtime> {
439
441
  }
440
442
 
441
443
  const graph = compiler.compile({
444
+ [Syntax.cache]: snapshot.programs(modules),
442
445
  compiler: options.compiler,
443
446
  native,
444
447
  modules,
@@ -446,6 +449,14 @@ export async function create(options: create.Options): Promise<Runtime> {
446
449
  imports,
447
450
  })
448
451
 
452
+ snapshot.retain(
453
+ new Set([
454
+ ...inputs,
455
+ ...Object.keys(contracts).map((file) => `${file}.zyzz.json`),
456
+ ]),
457
+ modules,
458
+ )
459
+
449
460
  const artifactNames = [
450
461
  'zyzz.css',
451
462
  'zyzz.css.map',
@@ -818,6 +829,7 @@ export async function create(options: create.Options): Promise<Runtime> {
818
829
 
819
830
  closing = (async () => {
820
831
  await tail
832
+ snapshot.clear()
821
833
  await lock.close()
822
834
  await Fs.rm(lockPath)
823
835
  })()
@@ -0,0 +1,79 @@
1
+ /** Retains unchanged source text and syntax within an adapter instance. @module */
2
+ import * as Fs from 'node:fs'
3
+ import * as Syntax from '../../compiler/internal/Syntax.js'
4
+
5
+ /** Creates a source cache; resolution and directory discovery remain owned by the host. */
6
+ export function create() {
7
+ const files = new Map<string, { source: string; version: string }>()
8
+ const syntax = new Map<
9
+ string,
10
+ { parsed: ReturnType<typeof Syntax.parse>; source: string }
11
+ >()
12
+
13
+ function parse(options: Syntax.parse.Options) {
14
+ let entry = syntax.get(options.moduleId)
15
+ if (entry?.source !== options.source) {
16
+ entry = { parsed: Syntax.parse(options), source: options.source }
17
+ syntax.set(options.moduleId, entry)
18
+ }
19
+ return entry.parsed
20
+ }
21
+
22
+ return {
23
+ clear() {
24
+ files.clear()
25
+ syntax.clear()
26
+ },
27
+ delete(file: string, moduleId: string) {
28
+ files.delete(file)
29
+ syntax.delete(moduleId)
30
+ },
31
+ parse,
32
+ programs(modules: Readonly<Record<string, string>>) {
33
+ return new Map(
34
+ Object.entries(modules).map(([moduleId, source]) => [
35
+ moduleId,
36
+ parse({ moduleId, source }),
37
+ ]),
38
+ )
39
+ },
40
+ async read(file: string) {
41
+ try {
42
+ const stat = await Fs.promises.stat(file, { bigint: true })
43
+ const version = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`
44
+ let entry = files.get(file)
45
+ if (entry?.version !== version) {
46
+ entry = { source: await Fs.promises.readFile(file, 'utf8'), version }
47
+ files.set(file, entry)
48
+ }
49
+ return entry.source
50
+ } catch (error) {
51
+ files.delete(file)
52
+ throw error
53
+ }
54
+ },
55
+ readSync(file: string) {
56
+ try {
57
+ const stat = Fs.statSync(file, { bigint: true })
58
+ const version = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`
59
+ let entry = files.get(file)
60
+ if (entry?.version !== version) {
61
+ entry = { source: Fs.readFileSync(file, 'utf8'), version }
62
+ files.set(file, entry)
63
+ }
64
+ return entry.source
65
+ } catch (error) {
66
+ files.delete(file)
67
+ throw error
68
+ }
69
+ },
70
+ retain(
71
+ paths: ReadonlySet<string>,
72
+ modules: Readonly<Record<string, string>>,
73
+ ) {
74
+ for (const file of files.keys()) if (!paths.has(file)) files.delete(file)
75
+ for (const id of syntax.keys())
76
+ if (!Object.hasOwn(modules, id)) syntax.delete(id)
77
+ },
78
+ }
79
+ }
@@ -4,7 +4,8 @@ import * as Crypto from 'node:crypto'
4
4
  import * as Fs from 'node:fs/promises'
5
5
  import * as Path from 'node:path'
6
6
  import * as Reset from '../node/Reset.js'
7
- import * as Parser from 'oxc-parser'
7
+ import * as Snapshot from '../node/internal/Snapshot.js'
8
+ import * as Syntax from '../compiler/internal/Syntax.js'
8
9
  import { createUnplugin } from 'unplugin'
9
10
  import type { UnpluginBuildContext } from 'unplugin'
10
11
  import * as Graph from '../compiler/Graph.js'
@@ -30,6 +31,7 @@ const portable = createUnplugin<Options | undefined, false>(
30
31
  (options = {}, meta) => {
31
32
  const root = Path.resolve(options.root ?? process.cwd())
32
33
  const compiler = Graph.create()
34
+ const snapshot = Snapshot.create()
33
35
  let graph: Graph.compile.ReturnType | undefined
34
36
  let pending: Promise<void> | undefined
35
37
  let files = new Set<string>()
@@ -138,7 +140,7 @@ const portable = createUnplugin<Options | undefined, false>(
138
140
  sourcePattern.test(file) &&
139
141
  !excludedPattern.test(file)
140
142
  ) {
141
- const source = await Fs.readFile(file, 'utf8')
143
+ const source = await snapshot.read(file)
142
144
 
143
145
  files.add(file)
144
146
  sources.set(file, source)
@@ -205,7 +207,7 @@ const portable = createUnplugin<Options | undefined, false>(
205
207
 
206
208
  files.add(sidecar)
207
209
  try {
208
- contracts[file] = await Fs.readFile(sidecar, 'utf8')
210
+ contracts[file] = await snapshot.read(sidecar)
209
211
  } catch (error) {
210
212
  if (
211
213
  !required &&
@@ -235,8 +237,9 @@ const portable = createUnplugin<Options | undefined, false>(
235
237
  await scan(root)
236
238
  for (const [file, source] of sources) {
237
239
  const links = (imports[identity(file)] = Object.create(null))
238
- for (const node of Parser.parseSync(file, source, {
239
- sourceType: 'module',
240
+ for (const node of snapshot.parse({
241
+ moduleId: identity(file),
242
+ source,
240
243
  }).program.body) {
241
244
  if (
242
245
  (node.type !== 'ImportDeclaration' &&
@@ -282,6 +285,7 @@ const portable = createUnplugin<Options | undefined, false>(
282
285
  }
283
286
 
284
287
  const result = compiler.compile({
288
+ [Syntax.cache]: snapshot.programs(modules),
285
289
  compiler: options.compiler,
286
290
  reset: options.reset ? Reset.read() : undefined,
287
291
  contracts,
@@ -500,6 +504,7 @@ const portable = createUnplugin<Options | undefined, false>(
500
504
  Catalogs.read,
501
505
  ),
502
506
  ).join('\n')
507
+ snapshot.retain(files, modules)
503
508
  graph = result
504
509
  if (meta.framework === 'webpack') emit(this)
505
510
  if (meta.framework === 'rollup')
@@ -572,9 +577,8 @@ const portable = createUnplugin<Options | undefined, false>(
572
577
  },
573
578
  webpack(compiler) {
574
579
  compiler.hooks.watchRun.tap('zyzz', () => {
575
- // Directory invalidations must also discard cached source reads before the graph and loaders rebuild.
576
- for (const file of compiler.modifiedFiles ?? [])
577
- if (directories.has(file)) compiler.inputFileSystem?.purge?.(file)
580
+ // The graph rereads every source, including files absent from Webpack's invalidation batch.
581
+ for (const file of files) compiler.inputFileSystem?.purge?.(file)
578
582
  })
579
583
  },
580
584
  }