@jk2908/cxx 0.2.4 → 0.3.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/src/vite/index.ts CHANGED
@@ -2,16 +2,18 @@ import { realpathSync } from 'node:fs'
2
2
  import fs from 'node:fs/promises'
3
3
  import path from 'node:path'
4
4
 
5
- import { type Plugin, type ViteDevServer } from 'vite'
5
+ import { type Plugin, type ResolvedConfig, type ViteDevServer } from 'vite'
6
6
 
7
7
  import type { BuildContext, PluginConfig } from '../types.js'
8
+ import { collect, flattenTags, isENOENT, writeTypes } from '../build.js'
8
9
  import { GENERATED_DIR } from '../config.js'
9
- import { collect, flattenTags, isENOENT, writeTypes } from '../index.js'
10
10
  import { Logger } from '../logger.js'
11
- import { debounce } from '../utils.js'
11
+ import { createBatcher } from '../utils.js'
12
12
 
13
13
  const ACCEPTED_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx'])
14
14
 
15
+ type WatchEvent = 'add' | 'change' | 'unlink'
16
+
15
17
  function normaliseWatchPath(p: string) {
16
18
  return p.replace(/\\/g, '/')
17
19
  }
@@ -21,23 +23,11 @@ export default function cxx(pluginConfig: PluginConfig = {}) {
21
23
  tagsByFile: new Map(),
22
24
  } satisfies BuildContext
23
25
 
24
- const logger = new Logger(
25
- (pluginConfig?.logger?.level ?? process.env.NODE_ENV === 'production')
26
- ? 'error'
27
- : 'debug',
28
- )
29
-
30
- // write generated files into a hidden folder at the project root
31
- // keep the generated surface out of src
32
- const watchRoot = normaliseWatchPath(realpathSync.native(process.cwd()))
33
- const watchedRoots = [`${watchRoot}/`]
26
+ const logger = new Logger()
34
27
 
35
- let rebuildRunning = false
36
- let rebuildQueued = false
28
+ // resolved from Vite's root in `configResolved`, not from `process.cwd()`
29
+ let watchRoot = normaliseWatchPath(process.cwd())
37
30
 
38
- // watcher events can arrive through symlinked paths like /var while cwd has
39
- // already resolved to /private/var, so canonicalise the parent dir once and
40
- // reattach the file name for stable prefix checks
41
31
  function resolveWatchFile(filePath: string) {
42
32
  const absolutePath = path.resolve(watchRoot, filePath)
43
33
  const parentPath = path.dirname(absolutePath)
@@ -53,101 +43,142 @@ export default function cxx(pluginConfig: PluginConfig = {}) {
53
43
  }
54
44
  }
55
45
 
56
- function watchedFile(filePath: string) {
46
+ // returns the canonical, watchable path or null when the file is irrelevant
47
+ function watchedFile(filePath: string): string | null {
57
48
  const resolvedPath = resolveWatchFile(filePath)
58
49
 
59
- return (
60
- watchedRoots.some(root => resolvedPath.startsWith(root)) &&
50
+ if (
51
+ resolvedPath.startsWith(`${watchRoot}/`) &&
61
52
  !resolvedPath.includes(`/${GENERATED_DIR}/`) &&
62
53
  ACCEPTED_EXTENSIONS.has(path.extname(resolvedPath))
63
- )
64
- }
54
+ ) {
55
+ return resolvedPath
56
+ }
65
57
 
66
- const rebuild = debounce((event: 'add' | 'change' | 'unlink', filePath: string) => {
67
- function queue() {
68
- void (async () => {
69
- // collapse bursts of file events into one active rebuild plus a single
70
- // queued rerun when changes land mid-build
71
- if (rebuildRunning) {
72
- rebuildQueued = true
73
- return
74
- }
58
+ return null
59
+ }
75
60
 
76
- rebuildRunning = true
61
+ async function onEvent(event: WatchEvent, file: string) {
62
+ const hadTags = buildContext.tagsByFile.has(file)
77
63
 
78
- do {
79
- rebuildQueued = false
64
+ if (event === 'unlink') {
65
+ if (!hadTags) return
80
66
 
81
- try {
82
- const hadTags = buildContext.tagsByFile.has(file)
83
- let hasTags = false
67
+ buildContext.tagsByFile.delete(file)
68
+ await writeTypes(flattenTags(buildContext.tagsByFile))
69
+ logger.info(`updated types from ${file}`)
84
70
 
85
- if (event === 'unlink') {
86
- buildContext.tagsByFile.delete(file)
87
- } else {
88
- const source = await fs.readFile(file, 'utf-8')
89
- const { tags } = collect(source, file, pluginConfig)
71
+ return
72
+ }
90
73
 
91
- hasTags = tags.size > 0
74
+ let source: string
92
75
 
93
- if (hasTags) {
94
- buildContext.tagsByFile.set(file, tags)
95
- } else {
96
- buildContext.tagsByFile.delete(file)
97
- }
98
- }
76
+ try {
77
+ source = await fs.readFile(file, 'utf-8')
78
+ } catch (err) {
79
+ if (!isENOENT(err)) throw err
80
+ if (!hadTags) return
99
81
 
100
- if (!hadTags && !hasTags) continue
82
+ buildContext.tagsByFile.delete(file)
83
+ await writeTypes(flattenTags(buildContext.tagsByFile))
84
+ logger.info(`updated types from ${file}`)
101
85
 
102
- await writeTypes(flattenTags(buildContext.tagsByFile))
103
- logger.info(`updated types from ${file}`)
104
- } catch (err) {
105
- if (isENOENT(err)) {
106
- if (!buildContext.tagsByFile.has(file)) continue
86
+ return
87
+ }
107
88
 
108
- buildContext.tagsByFile.delete(file)
109
- await writeTypes(flattenTags(buildContext.tagsByFile))
89
+ const { tags } = collect(source, file, pluginConfig)
90
+ const hasTags = tags.size > 0
110
91
 
111
- logger.info(`updated types from ${file}`)
92
+ if (hasTags) {
93
+ buildContext.tagsByFile.set(file, tags)
94
+ } else {
95
+ buildContext.tagsByFile.delete(file)
96
+ }
112
97
 
113
- continue
114
- }
98
+ if (!hadTags && !hasTags) return
115
99
 
116
- logger.error(`failed to update types from ${file}`, err)
117
- }
118
- } while (rebuildQueued)
100
+ await writeTypes(flattenTags(buildContext.tagsByFile))
101
+ logger.info(`updated types from ${file}`)
102
+ }
119
103
 
120
- rebuildRunning = false
121
- })()
104
+ // collect change events that arrive close together and handle each file once.
105
+ // The file path is the key, so changes to different files are all kept, and a
106
+ // change that arrives during a rebuild runs after that rebuild finishes.
107
+ const queue = createBatcher<string, WatchEvent>(75, async batch => {
108
+ for (const [file, event] of batch) {
109
+ try {
110
+ await onEvent(event, file)
111
+ } catch (err) {
112
+ logger.error(`failed to update types from ${file}`, err)
113
+ }
122
114
  }
123
-
124
- // ignore anything outside the watched content dirs
125
- if (!watchedFile(filePath)) return
126
-
127
- const file = resolveWatchFile(filePath)
128
- queue()
129
- }, 75)
115
+ })
130
116
 
131
117
  return {
132
118
  name: 'cxx',
133
119
  enforce: 'pre',
120
+ config() {
121
+ const ignore = pluginConfig.watch?.ignore
122
+ if (!ignore?.length) return null
123
+
124
+ // Vite concatenates `server.watch.ignored`, so only return the additions
125
+ return {
126
+ server: {
127
+ watch: {
128
+ ignored: ignore,
129
+ },
130
+ },
131
+ }
132
+ },
133
+ configResolved(config: ResolvedConfig) {
134
+ const root = pluginConfig.watch?.root ?? config.root
135
+
136
+ try {
137
+ watchRoot = normaliseWatchPath(realpathSync.native(root))
138
+ } catch {
139
+ watchRoot = normaliseWatchPath(root)
140
+ }
141
+ },
134
142
  transform(code, id) {
135
- const { template, tags } = collect(code, id, pluginConfig)
143
+ if (id.startsWith('\0') || id.includes('/node_modules/')) return null
144
+ // cheap bail-out before running the regex/lightningcss on every module
145
+ if (!code.includes('cxx')) return null
146
+
147
+ const cleanId = id.split('?')[0]
148
+ if (!ACCEPTED_EXTENSIONS.has(path.extname(cleanId))) return null
136
149
 
137
- if (tags.size) buildContext.tagsByFile.set(id, tags)
150
+ const { template, tags, map } = collect(code, cleanId, pluginConfig)
151
+ if (template === code) return null
152
+
153
+ const resolved = resolveWatchFile(cleanId)
154
+
155
+ if (tags.size) {
156
+ buildContext.tagsByFile.set(resolved, tags)
157
+ } else {
158
+ buildContext.tagsByFile.delete(resolved)
159
+ }
138
160
 
139
161
  return {
140
162
  code: template,
141
- map: null,
163
+ map,
142
164
  }
143
165
  },
144
166
  configureServer(server: ViteDevServer) {
145
167
  logger.info('Watching for changes...')
146
168
 
147
169
  server.watcher
148
- .on('add', (p: string) => rebuild('add', p))
149
- .on('change', (p: string) => rebuild('change', p))
150
- .on('unlink', (p: string) => rebuild('unlink', p))
170
+ .on('add', (p: string) => {
171
+ const file = watchedFile(p)
172
+ if (file) queue.add(file, 'add')
173
+ })
174
+ .on('change', (p: string) => {
175
+ const file = watchedFile(p)
176
+ if (file) queue.add(file, 'change')
177
+ })
178
+ .on('unlink', (p: string) => {
179
+ const file = watchedFile(p)
180
+ if (file) queue.add(file, 'unlink')
181
+ })
151
182
  },
152
183
  async writeBundle() {
153
184
  await writeTypes(flattenTags(buildContext.tagsByFile))