@tamagui/compiler-core 0.0.0-bootstrap.0 → 3.0.0-beta.637.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.
Files changed (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -1
  3. package/dist/cjs/ast.cjs +95 -0
  4. package/dist/cjs/contracts.cjs +62 -0
  5. package/dist/cjs/diagnostics.cjs +69 -0
  6. package/dist/cjs/evaluate.cjs +351 -0
  7. package/dist/cjs/graph.cjs +279 -0
  8. package/dist/cjs/hash.cjs +49 -0
  9. package/dist/cjs/index.cjs +44 -0
  10. package/dist/cjs/ir.cjs +33 -0
  11. package/dist/cjs/lower.cjs +225 -0
  12. package/dist/cjs/materialize.cjs +200 -0
  13. package/dist/cjs/normalize.cjs +622 -0
  14. package/dist/cjs/output.cjs +149 -0
  15. package/dist/cjs/planCache.cjs +224 -0
  16. package/dist/cjs/session.cjs +119 -0
  17. package/dist/cjs/yuku.cjs +159 -0
  18. package/dist/cjs/zero.cjs +387 -0
  19. package/dist/esm/ast.mjs +65 -0
  20. package/dist/esm/ast.mjs.map +1 -0
  21. package/dist/esm/contracts.mjs +34 -0
  22. package/dist/esm/contracts.mjs.map +1 -0
  23. package/dist/esm/diagnostics.mjs +44 -0
  24. package/dist/esm/diagnostics.mjs.map +1 -0
  25. package/dist/esm/evaluate.mjs +327 -0
  26. package/dist/esm/evaluate.mjs.map +1 -0
  27. package/dist/esm/graph.mjs +256 -0
  28. package/dist/esm/graph.mjs.map +1 -0
  29. package/dist/esm/hash.mjs +26 -0
  30. package/dist/esm/hash.mjs.map +1 -0
  31. package/dist/esm/index.mjs +30 -0
  32. package/dist/esm/ir.mjs +12 -0
  33. package/dist/esm/ir.mjs.map +1 -0
  34. package/dist/esm/lower.mjs +202 -0
  35. package/dist/esm/lower.mjs.map +1 -0
  36. package/dist/esm/materialize.mjs +179 -0
  37. package/dist/esm/materialize.mjs.map +1 -0
  38. package/dist/esm/normalize.mjs +596 -0
  39. package/dist/esm/normalize.mjs.map +1 -0
  40. package/dist/esm/output.mjs +119 -0
  41. package/dist/esm/output.mjs.map +1 -0
  42. package/dist/esm/planCache.mjs +194 -0
  43. package/dist/esm/planCache.mjs.map +1 -0
  44. package/dist/esm/session.mjs +99 -0
  45. package/dist/esm/session.mjs.map +1 -0
  46. package/dist/esm/yuku.mjs +136 -0
  47. package/dist/esm/yuku.mjs.map +1 -0
  48. package/dist/esm/zero.mjs +352 -0
  49. package/dist/esm/zero.mjs.map +1 -0
  50. package/package.json +42 -4
  51. package/src/ast.ts +86 -0
  52. package/src/contracts.ts +148 -0
  53. package/src/diagnostics.ts +96 -0
  54. package/src/evaluate.ts +497 -0
  55. package/src/graph.ts +347 -0
  56. package/src/hash.ts +36 -0
  57. package/src/index.ts +15 -0
  58. package/src/ir.ts +154 -0
  59. package/src/lower.ts +418 -0
  60. package/src/materialize.ts +330 -0
  61. package/src/normalize.ts +846 -0
  62. package/src/output.ts +175 -0
  63. package/src/planCache.ts +327 -0
  64. package/src/session.ts +172 -0
  65. package/src/yuku.ts +189 -0
  66. package/src/zero.ts +598 -0
  67. package/types/ast.d.ts +10 -0
  68. package/types/ast.d.ts.map +1 -0
  69. package/types/contracts.d.ts +97 -0
  70. package/types/contracts.d.ts.map +1 -0
  71. package/types/diagnostics.d.ts +31 -0
  72. package/types/diagnostics.d.ts.map +1 -0
  73. package/types/evaluate.d.ts +36 -0
  74. package/types/evaluate.d.ts.map +1 -0
  75. package/types/graph.d.ts +37 -0
  76. package/types/graph.d.ts.map +1 -0
  77. package/types/hash.d.ts +8 -0
  78. package/types/hash.d.ts.map +1 -0
  79. package/types/index.d.ts +16 -0
  80. package/types/index.d.ts.map +1 -0
  81. package/types/ir.d.ts +118 -0
  82. package/types/ir.d.ts.map +1 -0
  83. package/types/lower.d.ts +99 -0
  84. package/types/lower.d.ts.map +1 -0
  85. package/types/materialize.d.ts +104 -0
  86. package/types/materialize.d.ts.map +1 -0
  87. package/types/normalize.d.ts +8 -0
  88. package/types/normalize.d.ts.map +1 -0
  89. package/types/output.d.ts +32 -0
  90. package/types/output.d.ts.map +1 -0
  91. package/types/planCache.d.ts +113 -0
  92. package/types/planCache.d.ts.map +1 -0
  93. package/types/session.d.ts +44 -0
  94. package/types/session.d.ts.map +1 -0
  95. package/types/yuku.d.ts +4 -0
  96. package/types/yuku.d.ts.map +1 -0
  97. package/types/zero.d.ts +98 -0
  98. package/types/zero.d.ts.map +1 -0
package/src/output.ts ADDED
@@ -0,0 +1,175 @@
1
+ import {
2
+ GenMapping,
3
+ addSegment,
4
+ setSourceContent,
5
+ toEncodedMap,
6
+ } from '@jridgewell/gen-mapping'
7
+ import MagicString from 'magic-string'
8
+
9
+ import type { ResolvedModuleId, SourceSpan } from './contracts'
10
+ import { contentHash } from './hash'
11
+
12
+ export interface SourceEdit {
13
+ /** UTF-16 source-string index, inclusive. */
14
+ start: number
15
+ /** UTF-16 source-string index, exclusive. Equal to start for an insertion. */
16
+ end: number
17
+ content: string
18
+ origin: SourceSpan
19
+ }
20
+
21
+ export interface CompilerSourceMap {
22
+ version: 3
23
+ file?: string
24
+ names: readonly string[]
25
+ sources: readonly (string | null)[]
26
+ sourcesContent: readonly (string | null)[]
27
+ mappings: string
28
+ }
29
+
30
+ export interface AppliedLoweredModule {
31
+ changed: boolean
32
+ code: string
33
+ map: CompilerSourceMap | null
34
+ }
35
+
36
+ export interface ApplicableLoweredModulePlan {
37
+ id: ResolvedModuleId
38
+ sourceHash: string
39
+ edits: readonly SourceEdit[]
40
+ }
41
+
42
+ function compareEdits(
43
+ left: SourceEdit & { index: number },
44
+ right: SourceEdit & { index: number }
45
+ ): number {
46
+ return left.start - right.start || left.end - right.end || left.index - right.index
47
+ }
48
+
49
+ export function sourceContentHash(source: string): string {
50
+ return contentHash(source)
51
+ }
52
+
53
+ export function validateSourceEdits(source: string, edits: readonly SourceEdit[]): void {
54
+ const sorted = edits.map((edit, index) => ({ ...edit, index })).sort(compareEdits)
55
+ let previousEnd = 0
56
+ for (const edit of sorted) {
57
+ if (
58
+ !Number.isInteger(edit.start) ||
59
+ !Number.isInteger(edit.end) ||
60
+ edit.start < 0 ||
61
+ edit.end < edit.start ||
62
+ edit.end > source.length
63
+ ) {
64
+ throw new Error(`Invalid UTF-16 source edit [${edit.start}, ${edit.end})`)
65
+ }
66
+ if (edit.start < previousEnd) {
67
+ throw new Error(`Overlapping UTF-16 source edit at ${edit.start}`)
68
+ }
69
+ previousEnd = Math.max(previousEnd, edit.end)
70
+ }
71
+ }
72
+
73
+ interface Position {
74
+ line: number
75
+ column: number
76
+ }
77
+
78
+ function sourcePositions(source: string): Position[] {
79
+ const positions: Position[] = new Array(source.length + 1)
80
+ let line = 0
81
+ let column = 0
82
+ for (let index = 0; index <= source.length; index++) {
83
+ positions[index] = { line, column }
84
+ if (index === source.length) break
85
+ if (source.charCodeAt(index) === 10) {
86
+ line++
87
+ column = 0
88
+ } else {
89
+ column++
90
+ }
91
+ }
92
+ return positions
93
+ }
94
+
95
+ function mappedSourceMap(
96
+ source: string,
97
+ id: ResolvedModuleId,
98
+ edits: readonly (SourceEdit & { index: number })[]
99
+ ): CompilerSourceMap {
100
+ const map = new GenMapping()
101
+ setSourceContent(map, id, source)
102
+ const positions = sourcePositions(source)
103
+ let generatedLine = 0
104
+ let generatedColumn = 0
105
+ let sourceCursor = 0
106
+
107
+ const emit = (content: string, sourceStart: number, sourceEnd: number) => {
108
+ const available = Math.max(1, sourceEnd - sourceStart)
109
+ for (let offset = 0; offset < content.length; offset++) {
110
+ const originalIndex = Math.min(sourceStart + offset, sourceStart + available - 1)
111
+ const original = positions[originalIndex]!
112
+ addSegment(map, generatedLine, generatedColumn, id, original.line, original.column)
113
+ if (content.charCodeAt(offset) === 10) {
114
+ generatedLine++
115
+ generatedColumn = 0
116
+ } else {
117
+ generatedColumn++
118
+ }
119
+ }
120
+ }
121
+
122
+ for (const edit of edits) {
123
+ emit(source.slice(sourceCursor, edit.start), sourceCursor, edit.start)
124
+ if (edit.origin.id !== id) {
125
+ throw new Error(`Source edit for ${id} must use a local mapping origin`)
126
+ }
127
+ emit(
128
+ edit.content,
129
+ edit.origin.start,
130
+ edit.start === edit.end ? edit.origin.start + 1 : edit.origin.end
131
+ )
132
+ sourceCursor = edit.end
133
+ }
134
+ emit(source.slice(sourceCursor), sourceCursor, source.length)
135
+ const encoded = toEncodedMap(map)
136
+ return {
137
+ version: 3,
138
+ file: encoded.file ?? undefined,
139
+ names: encoded.names,
140
+ sources: encoded.sources,
141
+ sourcesContent: encoded.sourcesContent ?? [source],
142
+ mappings: encoded.mappings,
143
+ }
144
+ }
145
+
146
+ /** The only compiler-core path that applies source edits and owns their source map. */
147
+ export function applyLoweredModule(
148
+ source: string,
149
+ id: ResolvedModuleId,
150
+ plan: ApplicableLoweredModulePlan
151
+ ): AppliedLoweredModule {
152
+ if (plan.id !== id) {
153
+ throw new Error(`Lowered module plan ${plan.id} cannot be applied to ${id}`)
154
+ }
155
+ if (plan.sourceHash !== sourceContentHash(source)) {
156
+ throw new Error(`Lowered module plan for ${id} does not match the supplied source`)
157
+ }
158
+ if (plan.edits.length === 0) return { changed: false, code: source, map: null }
159
+ validateSourceEdits(source, plan.edits)
160
+
161
+ const output = new MagicString(source)
162
+ const sorted = plan.edits.map((edit, index) => ({ ...edit, index })).sort(compareEdits)
163
+ for (const edit of sorted) {
164
+ if (edit.start === edit.end) {
165
+ output.appendLeft(edit.start, edit.content)
166
+ } else {
167
+ output.overwrite(edit.start, edit.end, edit.content)
168
+ }
169
+ }
170
+ return {
171
+ changed: true,
172
+ code: output.toString(),
173
+ map: mappedSourceMap(source, id, sorted),
174
+ }
175
+ }
@@ -0,0 +1,327 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import {
3
+ mkdir,
4
+ readdir,
5
+ readFile,
6
+ rename,
7
+ stat,
8
+ unlink,
9
+ writeFile,
10
+ } from 'node:fs/promises'
11
+ import { dirname, join } from 'node:path'
12
+
13
+ import type { HostModuleInput, ResolvedModuleId } from './contracts'
14
+ import { contentHash, stableStringify } from './hash'
15
+ import { LOWERED_MODULE_PLAN_VERSION, type LoweredModulePlan } from './lower'
16
+ import { moduleContentHash } from './graph'
17
+
18
+ export const PLAN_CACHE_SCHEMA_VERSION = 1
19
+
20
+ /**
21
+ * Entries are content addressed, so every edit of a shared module writes new
22
+ * files and nothing ever replaces the old ones. Twenty edits of one module a
23
+ * hundred consumers import leaves two thousand dead entries that would live
24
+ * forever. A pruned entry is just a miss, and a miss recompiles, so dropping
25
+ * entries can never be wrong - only slower.
26
+ *
27
+ * The cap has to sit well above the module count of a real project, or an
28
+ * unchanged rebuild of a project larger than the cap would miss on every module
29
+ * the last build evicted. Twenty thousand entries is a few hundred MB at the
30
+ * high end and comfortably holds a large app plus several generations of edits.
31
+ *
32
+ * It is a soft cap: pruning runs between batches of writes, so a store can sit
33
+ * up to one batch above it. Bounding growth is the point, not an exact size.
34
+ */
35
+ export const PLAN_CACHE_MAX_ENTRIES = 20_000
36
+
37
+ /**
38
+ * The one on-disk cache location for a project. Metro's plan manifest already
39
+ * lives under it, and the build-time benchmark's "cold" state is defined as
40
+ * deleting it, so everything the compiler persists belongs here and nowhere
41
+ * else.
42
+ */
43
+ export function tamaguiCacheRoot(projectRoot: string): string {
44
+ return join(projectRoot, 'node_modules', '.cache', 'tamagui')
45
+ }
46
+
47
+ export function defaultPlanCacheRoot(projectRoot: string, target: string): string {
48
+ return join(tamaguiCacheRoot(projectRoot), 'plans', target)
49
+ }
50
+
51
+ /**
52
+ * Everything a plan depends on that is not a module: the compiler build, the
53
+ * evaluated Tamagui config and component registry, the platform, and the
54
+ * structural pass. `stamp` comes from `loadCompilerProject`'s `cacheStamp`.
55
+ */
56
+ export interface PlanCacheIdentity {
57
+ stamp: string
58
+ target: string
59
+ structuralPassHash: string
60
+ }
61
+
62
+ export interface PlanCacheEntry {
63
+ schemaVersion: typeof PLAN_CACHE_SCHEMA_VERSION
64
+ moduleId: ResolvedModuleId
65
+ /** Digest of this module plus its whole non-external import closure. */
66
+ closureDigest: string
67
+ plan: LoweredModulePlan
68
+ }
69
+
70
+ export interface ModuleClosureNode {
71
+ contentHash: string
72
+ /** Non-external dependencies only: the edges symbol resolution can cross. */
73
+ dependencies: readonly ResolvedModuleId[]
74
+ }
75
+
76
+ export type ModuleClosureLookup = (id: ResolvedModuleId) => ModuleClosureNode | null
77
+
78
+ /**
79
+ * Reads a closure node straight off host module records, for callers that have
80
+ * not built a ProjectGraph (Metro's prepass skips it entirely on a full hit).
81
+ */
82
+ export function moduleClosureNode(input: HostModuleInput): ModuleClosureNode {
83
+ return {
84
+ contentHash: moduleContentHash(input),
85
+ dependencies: input.imports
86
+ .filter(({ external }) => !external)
87
+ .map(({ resolvedId }) => resolvedId),
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Identity of a module's whole compile input: itself plus every module reachable
93
+ * from it over non-external imports, each by content hash.
94
+ *
95
+ * This is deliberately the import closure rather than the dependency set a
96
+ * successful compile recorded. The recorded set is only known after compiling,
97
+ * and it omits the module that made a value bail, so a dependency edit that
98
+ * turns a bailout into a static value would not invalidate its consumer, which
99
+ * is exactly how a stale style ships. The import closure is the same edge set
100
+ * `ProjectGraph.affectedBy` propagates over in memory, so it can never
101
+ * invalidate less than a live session would.
102
+ *
103
+ * Null when any module in the closure is unknown: an unknown input is a miss,
104
+ * never a guess.
105
+ */
106
+ export function moduleClosureDigest(
107
+ id: ResolvedModuleId,
108
+ lookup: ModuleClosureLookup,
109
+ memo?: Map<ResolvedModuleId, string | null>
110
+ ): string | null {
111
+ const cached = memo?.get(id)
112
+ if (cached !== undefined) return cached
113
+ const reached = new Map<ResolvedModuleId, string>()
114
+ const queue: ResolvedModuleId[] = [id]
115
+ let complete = true
116
+ while (queue.length) {
117
+ const current = queue.pop()!
118
+ if (reached.has(current)) continue
119
+ const node = lookup(current)
120
+ if (!node) {
121
+ complete = false
122
+ break
123
+ }
124
+ reached.set(current, node.contentHash)
125
+ for (const dependency of node.dependencies) queue.push(dependency)
126
+ }
127
+ const digest = complete
128
+ ? contentHash(
129
+ [...reached]
130
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
131
+ .map(([current, hash]) => `${current}\0${hash}`)
132
+ .join('\0')
133
+ )
134
+ : null
135
+ memo?.set(id, digest)
136
+ return digest
137
+ }
138
+
139
+ export function planCacheKey(
140
+ identity: PlanCacheIdentity,
141
+ id: ResolvedModuleId,
142
+ closureDigest: string
143
+ ): string {
144
+ return contentHash(
145
+ stableStringify({
146
+ schema: PLAN_CACHE_SCHEMA_VERSION,
147
+ plan: LOWERED_MODULE_PLAN_VERSION,
148
+ stamp: identity.stamp,
149
+ target: identity.target,
150
+ structuralPassHash: identity.structuralPassHash,
151
+ id,
152
+ closureDigest,
153
+ })
154
+ )
155
+ }
156
+
157
+ /**
158
+ * Content-addressed JSON entries on disk, one file per key. There is no
159
+ * manifest on purpose: a manifest is what makes a cache all-or-nothing, and the
160
+ * point of these caches is that editing one module leaves every other module's
161
+ * entry valid.
162
+ */
163
+ export class JsonFileCache {
164
+ #hits = 0
165
+ #misses = 0
166
+ #writes = 0
167
+ #writesSincePrune = 0
168
+ #prunedThisProcess = false
169
+ readonly #created = new Set<string>()
170
+
171
+ constructor(
172
+ readonly root: string,
173
+ readonly schemaVersion: number,
174
+ readonly maxEntries: number = PLAN_CACHE_MAX_ENTRIES
175
+ ) {}
176
+
177
+ get stats(): { hits: number; misses: number; writes: number } {
178
+ return { hits: this.#hits, misses: this.#misses, writes: this.#writes }
179
+ }
180
+
181
+ resetStats(): void {
182
+ this.#hits = 0
183
+ this.#misses = 0
184
+ this.#writes = 0
185
+ }
186
+
187
+ #path(key: string): string {
188
+ return join(this.root, `v${this.schemaVersion}`, key.slice(0, 2), `${key}.json`)
189
+ }
190
+
191
+ /**
192
+ * Null on anything short of an entry `validate` fully accepts. A miss
193
+ * recompiles; nothing here repairs or partially trusts an entry, and a later
194
+ * successful compile overwrites the bad file.
195
+ */
196
+ async read<T>(key: string, validate: (value: unknown) => T | null): Promise<T | null> {
197
+ let parsed: unknown
198
+ try {
199
+ parsed = JSON.parse(await readFile(this.#path(key), 'utf8'))
200
+ } catch {
201
+ this.#misses++
202
+ return null
203
+ }
204
+ const entry = validate(parsed)
205
+ if (entry === null) {
206
+ this.#misses++
207
+ return null
208
+ }
209
+ this.#hits++
210
+ return entry
211
+ }
212
+
213
+ async write(key: string, value: unknown): Promise<void> {
214
+ const path = this.#path(key)
215
+ const directory = dirname(path)
216
+ // a full first build writes one entry per module, so the directory syscall
217
+ // is worth remembering
218
+ if (!this.#created.has(directory)) {
219
+ await mkdir(directory, { recursive: true })
220
+ this.#created.add(directory)
221
+ }
222
+ const temporaryPath = `${path}.${process.pid}-${randomBytes(6).toString('hex')}.tmp`
223
+ await writeFile(temporaryPath, `${stableStringify(value)}\n`, 'utf8')
224
+ await rename(temporaryPath, path)
225
+ this.#writes++
226
+ // Once per process, plus once per cap-sized batch inside a long-lived one.
227
+ // Checking only the batch counter would never prune the common case, where
228
+ // every build is a fresh process writing far fewer entries than the cap.
229
+ this.#writesSincePrune++
230
+ const batch = Math.max(1, Math.floor(this.maxEntries / 4))
231
+ if (!this.#prunedThisProcess || this.#writesSincePrune >= batch) {
232
+ this.#prunedThisProcess = true
233
+ this.#writesSincePrune = 0
234
+ await this.#prune()
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Drops the oldest entries once the store exceeds its cap, oldest by the later
240
+ * of read and write time so an entry a build keeps hitting is not treated as
241
+ * dead on filesystems that maintain access times.
242
+ */
243
+ async #prune(): Promise<void> {
244
+ const versionRoot = join(this.root, `v${this.schemaVersion}`)
245
+ let shards: string[]
246
+ try {
247
+ shards = await readdir(versionRoot)
248
+ } catch {
249
+ return
250
+ }
251
+ const paths: string[] = []
252
+ for (const shard of shards) {
253
+ const directory = join(versionRoot, shard)
254
+ let files: string[]
255
+ try {
256
+ files = await readdir(directory)
257
+ } catch {
258
+ continue
259
+ }
260
+ for (const file of files) {
261
+ if (file.endsWith('.json')) paths.push(join(directory, file))
262
+ }
263
+ }
264
+ // names are enough to know whether pruning is needed, so a store under its
265
+ // cap costs one readdir per shard and no stat at all
266
+ if (paths.length <= this.maxEntries) return
267
+ const entries: { path: string; usedAt: number }[] = []
268
+ for (const path of paths) {
269
+ try {
270
+ const stats = await stat(path)
271
+ entries.push({ path, usedAt: Math.max(stats.atimeMs, stats.mtimeMs) })
272
+ } catch {
273
+ // a concurrent build may have pruned it already
274
+ }
275
+ }
276
+ entries.sort((left, right) => left.usedAt - right.usedAt)
277
+ for (const entry of entries.slice(0, Math.max(0, entries.length - this.maxEntries))) {
278
+ await unlink(entry.path).catch(() => {})
279
+ }
280
+ }
281
+ }
282
+
283
+ /** Per-module lowering plans, keyed by `planCacheKey`. */
284
+ export class ModulePlanCache {
285
+ readonly #files: JsonFileCache
286
+
287
+ constructor(readonly root: string) {
288
+ this.#files = new JsonFileCache(root, PLAN_CACHE_SCHEMA_VERSION)
289
+ }
290
+
291
+ get stats(): { hits: number; misses: number; writes: number } {
292
+ return this.#files.stats
293
+ }
294
+
295
+ resetStats(): void {
296
+ this.#files.resetStats()
297
+ }
298
+
299
+ read(
300
+ key: string,
301
+ id: ResolvedModuleId,
302
+ closureDigest: string
303
+ ): Promise<PlanCacheEntry | null> {
304
+ return this.#files.read(key, (value) => {
305
+ const entry = value as PlanCacheEntry | null
306
+ const plan = entry?.plan
307
+ return entry?.schemaVersion === PLAN_CACHE_SCHEMA_VERSION &&
308
+ entry.moduleId === id &&
309
+ entry.closureDigest === closureDigest &&
310
+ plan &&
311
+ plan.version === LOWERED_MODULE_PLAN_VERSION &&
312
+ plan.id === id &&
313
+ typeof plan.sourceHash === 'string' &&
314
+ typeof plan.css === 'string' &&
315
+ Array.isArray(plan.edits) &&
316
+ Array.isArray(plan.diagnostics) &&
317
+ Array.isArray(plan.dependencies) &&
318
+ !!plan.stats
319
+ ? entry
320
+ : null
321
+ })
322
+ }
323
+
324
+ write(key: string, entry: PlanCacheEntry): Promise<void> {
325
+ return this.#files.write(key, entry)
326
+ }
327
+ }
package/src/session.ts ADDED
@@ -0,0 +1,172 @@
1
+ import type { HostModuleInput, ResolvedModuleId } from './contracts'
2
+ import type { GraphInvalidation } from './graph'
3
+ import { ProjectGraph } from './graph'
4
+ import type {
5
+ CompilerLoweringHost,
6
+ CompilerTarget,
7
+ LoweredModulePlan,
8
+ StructuralModulePass,
9
+ } from './lower'
10
+ import { lowerModule } from './lower'
11
+ import { materializeModule } from './materialize'
12
+ import type { AppliedLoweredModule } from './output'
13
+ import { applyLoweredModule } from './output'
14
+ import type { ModulePlanCache } from './planCache'
15
+ import { PLAN_CACHE_SCHEMA_VERSION, moduleClosureDigest, planCacheKey } from './planCache'
16
+ import { yukuFactory } from './yuku'
17
+
18
+ export interface CompilerAdapter {
19
+ target: CompilerTarget
20
+ projectGeneration: string
21
+ host: CompilerLoweringHost
22
+ load(id: ResolvedModuleId): Promise<HostModuleInput | null>
23
+ /**
24
+ * Persistent per-module plan reuse across processes. Absent means the host
25
+ * could not produce a content stamp for this project, so nothing is cached
26
+ * rather than cached under a stamp that does not describe the config.
27
+ */
28
+ planCache?: { store: ModulePlanCache; stamp: string }
29
+ }
30
+
31
+ export interface CompileModuleInput {
32
+ module: HostModuleInput
33
+ adapter: CompilerAdapter
34
+ structuralPass?: StructuralModulePass
35
+ }
36
+
37
+ export interface CompilerSessionResult {
38
+ plan: LoweredModulePlan
39
+ output: AppliedLoweredModule
40
+ invalidatedIds: ResolvedModuleId[]
41
+ }
42
+
43
+ function compareIds(left: ResolvedModuleId, right: ResolvedModuleId): number {
44
+ return left < right ? -1 : left > right ? 1 : 0
45
+ }
46
+
47
+ /**
48
+ * Bundler-neutral compiler state. The adapter owns module resolution and loading;
49
+ * the session only accepts canonical host-resolved module records.
50
+ */
51
+ export class CompilerSession {
52
+ readonly #graph = new ProjectGraph(yukuFactory, { modules: [] })
53
+ #queue: Promise<unknown> = Promise.resolve()
54
+
55
+ compile(input: CompileModuleInput): Promise<CompilerSessionResult> {
56
+ const operation = this.#queue.then(() => this.#compile(input))
57
+ this.#queue = operation.catch(() => undefined)
58
+ return operation
59
+ }
60
+
61
+ update(module: HostModuleInput): Promise<ResolvedModuleId[]> {
62
+ return this.#enqueue(() => this.#graph.updateModule(module).invalidatedIds)
63
+ }
64
+
65
+ has(id: ResolvedModuleId): boolean {
66
+ return this.#graph.contentHash(id) !== null
67
+ }
68
+
69
+ dependentsOf(id: ResolvedModuleId): ResolvedModuleId[] {
70
+ return this.#graph.dependentsOf(id)
71
+ }
72
+
73
+ remove(id: ResolvedModuleId): Promise<GraphInvalidation> {
74
+ return this.#enqueue(() => this.#graph.removeModule(id))
75
+ }
76
+
77
+ parseCount(id: ResolvedModuleId): number {
78
+ return this.#graph.parseCount(id)
79
+ }
80
+
81
+ #enqueue<T>(operation: () => T | Promise<T>): Promise<T> {
82
+ const queued = this.#queue.then(operation)
83
+ this.#queue = queued.catch(() => undefined)
84
+ return queued
85
+ }
86
+
87
+ async #compile({
88
+ module,
89
+ adapter,
90
+ structuralPass,
91
+ }: CompileModuleInput): Promise<CompilerSessionResult> {
92
+ const invalidated = new Set<ResolvedModuleId>()
93
+ await this.#install(module, adapter, new Set(), invalidated)
94
+ const graph = this.#graph
95
+ // an empty stamp is not an identity: a key without one would be shared by
96
+ // every project and every config, so such a project simply does not cache
97
+ const cache = adapter.planCache?.stamp ? adapter.planCache : null
98
+ const closureDigest = cache
99
+ ? moduleClosureDigest(module.id, (id) => {
100
+ const hash = graph.contentHash(id)
101
+ return hash
102
+ ? { contentHash: hash, dependencies: graph.dependenciesOf(id) }
103
+ : null
104
+ })
105
+ : null
106
+ const entry =
107
+ cache && closureDigest
108
+ ? {
109
+ store: cache.store,
110
+ digest: closureDigest,
111
+ key: planCacheKey(
112
+ {
113
+ stamp: cache.stamp,
114
+ target: adapter.target,
115
+ structuralPassHash:
116
+ structuralPass?.versionHash ?? `${adapter.target}-noop-v1`,
117
+ },
118
+ module.id,
119
+ closureDigest
120
+ ),
121
+ }
122
+ : null
123
+
124
+ const cached = entry
125
+ ? await entry.store.read(entry.key, module.id, entry.digest)
126
+ : null
127
+ const plan =
128
+ cached?.plan ??
129
+ lowerModule({
130
+ module: materializeModule(graph, module.id),
131
+ source: module.source,
132
+ target: adapter.target,
133
+ host: adapter.host,
134
+ options: { projectGeneration: adapter.projectGeneration },
135
+ structuralPass,
136
+ })
137
+ if (entry && !cached) {
138
+ await entry.store.write(entry.key, {
139
+ schemaVersion: PLAN_CACHE_SCHEMA_VERSION,
140
+ moduleId: module.id,
141
+ closureDigest: entry.digest,
142
+ plan,
143
+ })
144
+ }
145
+ return {
146
+ plan,
147
+ output: applyLoweredModule(module.source, module.id, plan),
148
+ invalidatedIds: [...invalidated].sort(compareIds),
149
+ }
150
+ }
151
+
152
+ async #install(
153
+ module: HostModuleInput,
154
+ adapter: CompilerAdapter,
155
+ visited: Set<ResolvedModuleId>,
156
+ invalidated: Set<ResolvedModuleId>
157
+ ): Promise<void> {
158
+ if (visited.has(module.id)) return
159
+ visited.add(module.id)
160
+
161
+ for (const dependency of module.imports) {
162
+ if (dependency.external || this.has(dependency.resolvedId)) continue
163
+ const loaded = await adapter.load(dependency.resolvedId)
164
+ if (loaded) {
165
+ await this.#install(loaded, adapter, visited, invalidated)
166
+ }
167
+ }
168
+
169
+ const update = this.#graph.updateModule(module)
170
+ for (const id of update.invalidatedIds) invalidated.add(id)
171
+ }
172
+ }