@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.
- package/LICENSE +21 -0
- package/README.md +57 -1
- package/dist/cjs/ast.cjs +95 -0
- package/dist/cjs/contracts.cjs +62 -0
- package/dist/cjs/diagnostics.cjs +69 -0
- package/dist/cjs/evaluate.cjs +351 -0
- package/dist/cjs/graph.cjs +279 -0
- package/dist/cjs/hash.cjs +49 -0
- package/dist/cjs/index.cjs +44 -0
- package/dist/cjs/ir.cjs +33 -0
- package/dist/cjs/lower.cjs +225 -0
- package/dist/cjs/materialize.cjs +200 -0
- package/dist/cjs/normalize.cjs +622 -0
- package/dist/cjs/output.cjs +149 -0
- package/dist/cjs/planCache.cjs +224 -0
- package/dist/cjs/session.cjs +119 -0
- package/dist/cjs/yuku.cjs +159 -0
- package/dist/cjs/zero.cjs +387 -0
- package/dist/esm/ast.mjs +65 -0
- package/dist/esm/ast.mjs.map +1 -0
- package/dist/esm/contracts.mjs +34 -0
- package/dist/esm/contracts.mjs.map +1 -0
- package/dist/esm/diagnostics.mjs +44 -0
- package/dist/esm/diagnostics.mjs.map +1 -0
- package/dist/esm/evaluate.mjs +327 -0
- package/dist/esm/evaluate.mjs.map +1 -0
- package/dist/esm/graph.mjs +256 -0
- package/dist/esm/graph.mjs.map +1 -0
- package/dist/esm/hash.mjs +26 -0
- package/dist/esm/hash.mjs.map +1 -0
- package/dist/esm/index.mjs +30 -0
- package/dist/esm/ir.mjs +12 -0
- package/dist/esm/ir.mjs.map +1 -0
- package/dist/esm/lower.mjs +202 -0
- package/dist/esm/lower.mjs.map +1 -0
- package/dist/esm/materialize.mjs +179 -0
- package/dist/esm/materialize.mjs.map +1 -0
- package/dist/esm/normalize.mjs +596 -0
- package/dist/esm/normalize.mjs.map +1 -0
- package/dist/esm/output.mjs +119 -0
- package/dist/esm/output.mjs.map +1 -0
- package/dist/esm/planCache.mjs +194 -0
- package/dist/esm/planCache.mjs.map +1 -0
- package/dist/esm/session.mjs +99 -0
- package/dist/esm/session.mjs.map +1 -0
- package/dist/esm/yuku.mjs +136 -0
- package/dist/esm/yuku.mjs.map +1 -0
- package/dist/esm/zero.mjs +352 -0
- package/dist/esm/zero.mjs.map +1 -0
- package/package.json +42 -4
- package/src/ast.ts +86 -0
- package/src/contracts.ts +148 -0
- package/src/diagnostics.ts +96 -0
- package/src/evaluate.ts +497 -0
- package/src/graph.ts +347 -0
- package/src/hash.ts +36 -0
- package/src/index.ts +15 -0
- package/src/ir.ts +154 -0
- package/src/lower.ts +418 -0
- package/src/materialize.ts +330 -0
- package/src/normalize.ts +846 -0
- package/src/output.ts +175 -0
- package/src/planCache.ts +327 -0
- package/src/session.ts +172 -0
- package/src/yuku.ts +189 -0
- package/src/zero.ts +598 -0
- package/types/ast.d.ts +10 -0
- package/types/ast.d.ts.map +1 -0
- package/types/contracts.d.ts +97 -0
- package/types/contracts.d.ts.map +1 -0
- package/types/diagnostics.d.ts +31 -0
- package/types/diagnostics.d.ts.map +1 -0
- package/types/evaluate.d.ts +36 -0
- package/types/evaluate.d.ts.map +1 -0
- package/types/graph.d.ts +37 -0
- package/types/graph.d.ts.map +1 -0
- package/types/hash.d.ts +8 -0
- package/types/hash.d.ts.map +1 -0
- package/types/index.d.ts +16 -0
- package/types/index.d.ts.map +1 -0
- package/types/ir.d.ts +118 -0
- package/types/ir.d.ts.map +1 -0
- package/types/lower.d.ts +99 -0
- package/types/lower.d.ts.map +1 -0
- package/types/materialize.d.ts +104 -0
- package/types/materialize.d.ts.map +1 -0
- package/types/normalize.d.ts +8 -0
- package/types/normalize.d.ts.map +1 -0
- package/types/output.d.ts +32 -0
- package/types/output.d.ts.map +1 -0
- package/types/planCache.d.ts +113 -0
- package/types/planCache.d.ts.map +1 -0
- package/types/session.d.ts +44 -0
- package/types/session.d.ts.map +1 -0
- package/types/yuku.d.ts +4 -0
- package/types/yuku.d.ts.map +1 -0
- package/types/zero.d.ts +98 -0
- 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
|
+
}
|
package/src/planCache.ts
ADDED
|
@@ -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
|
+
}
|