@jk2908/cxx 0.3.1 → 0.3.4

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.
@@ -5,7 +5,14 @@ const loader = function (source) {
5
5
  const { pluginConfig } = this.getOptions()
6
6
  const logger = new Logger()
7
7
 
8
- const { template } = processFile(source, this.resourcePath, pluginConfig, logger)
8
+ const { template, map } = processFile(source, this.resourcePath, pluginConfig, logger)
9
+
10
+ // hand the transform's source map back to webpack/turbopack when they support it
11
+ if (map && typeof this.callback === 'function') {
12
+ this.callback(null, template, map)
13
+
14
+ return
15
+ }
9
16
 
10
17
  return template
11
18
  }
package/src/next/index.ts CHANGED
@@ -1,14 +1,15 @@
1
1
  import type { AsyncSubscription, Event } from '@parcel/watcher'
2
2
  import type { NextConfig } from 'next'
3
3
 
4
- import fs from 'node:fs/promises'
5
4
  import path from 'node:path'
6
5
  import { fileURLToPath } from 'node:url'
7
6
 
8
- import type { Logger } from '../logger.js'
9
7
  import type { PluginConfig } from '../types.js'
10
- import { collect, isENOENT, writeTypesForFile } from '../build.js'
8
+ import { collect, refreshFile, seedTypes, writeTypesForFile } from '../build.js'
11
9
  import { GENERATED_DIR } from '../config.js'
10
+ import { Logger } from '../logger.js'
11
+
12
+ const typeLogger = new Logger('cxx')
12
13
 
13
14
  const nextDir = path.dirname(fileURLToPath(import.meta.url))
14
15
  const loader = path.join(nextDir, 'cxx-loader.cjs')
@@ -38,27 +39,10 @@ async function onEvent(event: Event['type'], filePath: string) {
38
39
 
39
40
  if (event === 'delete') {
40
41
  await writeTypesForFile(filePath, new Map())
41
- logger.info(`updated types from ${filePath}`)
42
-
43
- return
44
- }
45
-
46
- let source: string
47
-
48
- try {
49
- source = await fs.readFile(filePath, 'utf-8')
50
- } catch (err) {
51
- if (!isENOENT(err)) throw err
52
-
53
- await writeTypesForFile(filePath, new Map())
54
- logger.info(`updated types from ${filePath}`)
55
-
56
- return
42
+ } else {
43
+ await refreshFile(filePath, pluginConfig)
57
44
  }
58
45
 
59
- const { tags } = collect(source, filePath, pluginConfig)
60
-
61
- await writeTypesForFile(filePath, tags)
62
46
  logger.info(`updated types from ${filePath}`)
63
47
  }
64
48
 
@@ -72,7 +56,6 @@ function ensureWatcher(pluginConfig: PluginConfig, logger: Logger) {
72
56
  if (subscriptionPromise) return
73
57
 
74
58
  const root = pluginConfig.watch?.root ?? process.cwd()
75
- const ignore = [...DEFAULT_IGNORE, ...(pluginConfig.watch?.ignore ?? [])]
76
59
 
77
60
  subscriptionPromise = (async () => {
78
61
  // lazily imported so the native module is only loaded when watching is needed
@@ -96,7 +79,7 @@ function ensureWatcher(pluginConfig: PluginConfig, logger: Logger) {
96
79
  })
97
80
  }
98
81
  },
99
- { ignore },
82
+ { ignore: DEFAULT_IGNORE },
100
83
  )
101
84
 
102
85
  registerCleanup()
@@ -150,6 +133,11 @@ export function processFile(
150
133
  ) {
151
134
  const resolvedFilePath = path.resolve(filePath)
152
135
 
136
+ // skip files that cannot contain a template, including node_modules
137
+ if (!source.includes('cxx') || /[\\/]node_modules[\\/]/.test(resolvedFilePath)) {
138
+ return { template: source, tags: new Map(), map: null }
139
+ }
140
+
153
141
  if (process.env['NODE_ENV'] !== 'production') {
154
142
  watchedFiles.set(resolvedFilePath, { pluginConfig, logger })
155
143
  ensureWatcher(pluginConfig, logger)
@@ -157,9 +145,7 @@ export function processFile(
157
145
 
158
146
  const result = collect(source, resolvedFilePath, pluginConfig)
159
147
 
160
- void writeTypesForFile(resolvedFilePath, result.tags).catch(err => {
161
- logger.error(`failed to write types for ${resolvedFilePath}`, err)
162
- })
148
+ void writeTypesForFile(resolvedFilePath, result.tags)
163
149
 
164
150
  return result
165
151
  }
@@ -170,6 +156,18 @@ export function withCxx(nextConfig: NextConfig = {}, pluginConfig: PluginConfig
170
156
  const watchRoot =
171
157
  pluginConfig.watch?.root ?? nextConfig.turbopack?.root ?? process.cwd()
172
158
 
159
+ // Seed on dev startup only, matching Vite. A production build is covered by the loader, and
160
+ // `next start` must not scan or write at runtime.
161
+ if (process.env['NODE_ENV'] !== 'production') {
162
+ // the app directory, not the whole Turbopack root, so sibling apps in a monorepo do not
163
+ // leak into this app's types
164
+ const seedRoot = pluginConfig.watch?.root ?? process.cwd()
165
+
166
+ void seedTypes(seedRoot, pluginConfig).catch(err =>
167
+ typeLogger.error('failed to seed cxx types', err),
168
+ )
169
+ }
170
+
173
171
  const loaderItem = {
174
172
  loader,
175
173
  options: {
package/src/types.ts CHANGED
@@ -9,17 +9,11 @@ export type Tags = Map<string, string[]>
9
9
  export type PluginConfig = {
10
10
  typeSuffix?: string | false
11
11
  watch?: {
12
- // root used for type-regeneration watching; defaults to the Vite root / Next turbopack root
12
+ /** Root used for type-regeneration watching. Defaults to the Vite root or Next Turbopack root. */
13
13
  root?: string
14
- // extra globs to ignore on top of the defaults
15
- ignore?: string[]
16
14
  }
17
15
  }
18
16
 
19
- export type BuildContext = {
20
- tagsByFile: Map<string, Tags>
21
- }
22
-
23
17
  export type Tag = <ClassName extends string = string>(
24
18
  name: string,
25
19
  ) => (_: TemplateStringsArray) => Result<ClassName>
package/src/utils.ts CHANGED
@@ -8,82 +8,3 @@ export function pascalise(str: string) {
8
8
  .map(capitalise)
9
9
  .join('')
10
10
  }
11
-
12
- export type Batcher<K, V> = {
13
- /**
14
- * Queue a value for a key. Queueing the same key again replaces the previous value.
15
- */
16
- add: (key: K, value: V) => void
17
- /**
18
- * Run any waiting work now and resolve once it has finished.
19
- */
20
- flush: () => Promise<void>
21
- }
22
-
23
- /**
24
- * Group bursts of work and run them together. Every key queued within `wait` is
25
- * included once, and a key queued while a batch is running is handled in the next
26
- * batch instead of replacing the one in progress.
27
- */
28
- export function createBatcher<K, V>(
29
- wait: number,
30
- flush: (batch: Map<K, V>) => void | Promise<void>,
31
- ): Batcher<K, V> {
32
- // work waiting to run, keyed so each key is only queued once
33
- const pending = new Map<K, V>()
34
- // waits for a burst of changes to settle before running
35
- let timer: ReturnType<typeof setTimeout> | null = null
36
- // true while a batch is running, so we never start a second one
37
- let running = false
38
-
39
- async function drain() {
40
- // a run is already going, so let it pick up the new work
41
- if (running) return
42
- running = true
43
-
44
- try {
45
- // keep looping while there is work, including work added during a flush
46
- while (pending.size > 0) {
47
- // snapshot the queued work
48
- const batch = new Map(pending)
49
- // empty the queue right away, so anything added during flush waits for the next loop
50
- pending.clear()
51
-
52
- await flush(batch)
53
- }
54
- } finally {
55
- // always allow the next run, even if flush threw
56
- running = false
57
- }
58
- }
59
-
60
- function schedule() {
61
- // cancel the previous wait, so the timer restarts from now
62
- if (timer) clearTimeout(timer)
63
-
64
- // run once the calls stop coming for `wait` ms
65
- timer = setTimeout(() => {
66
- // the timer has fired, so drop the stale handle
67
- timer = null
68
- // start the run without waiting for it (this is a timer callback)
69
- void drain()
70
- }, wait)
71
- }
72
-
73
- return {
74
- add(key, value) {
75
- // the newest value for this key wins
76
- pending.set(key, value)
77
- schedule()
78
- },
79
- flush() {
80
- // run now instead of waiting out the timer
81
- if (timer) {
82
- clearTimeout(timer)
83
- timer = null
84
- }
85
-
86
- return drain()
87
- },
88
- }
89
- }
package/src/vite/index.ts CHANGED
@@ -1,16 +1,19 @@
1
1
  import { realpathSync } from 'node:fs'
2
- import fs from 'node:fs/promises'
3
2
  import path from 'node:path'
4
3
 
5
4
  import { type Plugin, type ResolvedConfig, type ViteDevServer } from 'vite'
6
5
 
7
- import type { BuildContext, PluginConfig } from '../types.js'
8
- import { collect, flattenTags, isENOENT, writeTypes } from '../build.js'
6
+ import type { PluginConfig } from '../types.js'
7
+ import {
8
+ collect,
9
+ flushTypes,
10
+ refreshFile,
11
+ seedTypes,
12
+ SOURCE_EXTENSIONS,
13
+ writeTypesForFile,
14
+ } from '../build.js'
9
15
  import { GENERATED_DIR } from '../config.js'
10
16
  import { Logger } from '../logger.js'
11
- import { createBatcher } from '../utils.js'
12
-
13
- const ACCEPTED_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx'])
14
17
 
15
18
  type WatchEvent = 'add' | 'change' | 'unlink'
16
19
 
@@ -19,38 +22,44 @@ function normaliseWatchPath(p: string) {
19
22
  }
20
23
 
21
24
  export default function cxx(pluginConfig: PluginConfig = {}) {
22
- const buildContext = {
23
- tagsByFile: new Map(),
24
- } satisfies BuildContext
25
-
26
25
  const logger = new Logger()
27
26
 
28
27
  // resolved from Vite's root in `configResolved`, not from `process.cwd()`
29
28
  let watchRoot = normaliseWatchPath(process.cwd())
29
+ let seeded = false
30
+
31
+ // realpath is synchronous, so cache it per directory to keep it off the transform hot path
32
+ const realpaths = new Map<string, string>()
30
33
 
31
34
  function resolveWatchFile(filePath: string) {
32
35
  const absolutePath = path.resolve(watchRoot, filePath)
33
36
  const parentPath = path.dirname(absolutePath)
34
37
 
35
- try {
36
- const resolvedParentPath = normaliseWatchPath(realpathSync.native(parentPath))
38
+ let resolvedParentPath = realpaths.get(parentPath)
39
+
40
+ if (resolvedParentPath === undefined) {
41
+ try {
42
+ resolvedParentPath = normaliseWatchPath(realpathSync.native(parentPath))
43
+ } catch {
44
+ resolvedParentPath = normaliseWatchPath(parentPath)
45
+ }
37
46
 
38
- return normaliseWatchPath(
39
- path.join(resolvedParentPath, path.basename(absolutePath)),
40
- )
41
- } catch {
42
- return normaliseWatchPath(absolutePath)
47
+ realpaths.set(parentPath, resolvedParentPath)
43
48
  }
49
+
50
+ return normaliseWatchPath(path.join(resolvedParentPath, path.basename(absolutePath)))
44
51
  }
45
52
 
46
- // returns the canonical, watchable path or null when the file is irrelevant
47
- function watchedFile(filePath: string): string | null {
53
+ /**
54
+ * Returns the canonical, watchable path, or null when the file is irrelevant.
55
+ */
56
+ function watchedFile(filePath: string) {
48
57
  const resolvedPath = resolveWatchFile(filePath)
49
58
 
50
59
  if (
51
60
  resolvedPath.startsWith(`${watchRoot}/`) &&
52
61
  !resolvedPath.includes(`/${GENERATED_DIR}/`) &&
53
- ACCEPTED_EXTENSIONS.has(path.extname(resolvedPath))
62
+ SOURCE_EXTENSIONS.has(path.extname(resolvedPath))
54
63
  ) {
55
64
  return resolvedPath
56
65
  }
@@ -58,78 +67,37 @@ export default function cxx(pluginConfig: PluginConfig = {}) {
58
67
  return null
59
68
  }
60
69
 
61
- async function onEvent(event: WatchEvent, file: string) {
62
- const hadTags = buildContext.tagsByFile.has(file)
63
-
70
+ async function handleEvent(event: WatchEvent, file: string) {
64
71
  if (event === 'unlink') {
65
- if (!hadTags) return
66
-
67
- buildContext.tagsByFile.delete(file)
68
- await writeTypes(flattenTags(buildContext.tagsByFile))
69
- logger.info(`updated types from ${file}`)
70
-
71
- return
72
- }
73
-
74
- let source: string
75
-
76
- try {
77
- source = await fs.readFile(file, 'utf-8')
78
- } catch (err) {
79
- if (!isENOENT(err)) throw err
80
- if (!hadTags) return
81
-
82
- buildContext.tagsByFile.delete(file)
83
- await writeTypes(flattenTags(buildContext.tagsByFile))
84
- logger.info(`updated types from ${file}`)
85
-
86
- return
87
- }
88
-
89
- const { tags } = collect(source, file, pluginConfig)
90
- const hasTags = tags.size > 0
91
-
92
- if (hasTags) {
93
- buildContext.tagsByFile.set(file, tags)
72
+ await writeTypesForFile(file, new Map())
94
73
  } else {
95
- buildContext.tagsByFile.delete(file)
74
+ await refreshFile(file, pluginConfig)
96
75
  }
97
76
 
98
- if (!hadTags && !hasTags) return
99
-
100
- await writeTypes(flattenTags(buildContext.tagsByFile))
101
77
  logger.info(`updated types from ${file}`)
102
78
  }
103
79
 
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) {
80
+ function onWatch(event: WatchEvent) {
81
+ return (p: string) => {
82
+ const file = watchedFile(p)
83
+ if (!file) return
84
+
85
+ void handleEvent(event, file).catch(err => {
112
86
  logger.error(`failed to update types from ${file}`, err)
113
- }
87
+ })
114
88
  }
115
- })
89
+ }
90
+
91
+ async function seed() {
92
+ if (seeded) return
93
+ seeded = true
94
+
95
+ await seedTypes(watchRoot, pluginConfig)
96
+ }
116
97
 
117
98
  return {
118
99
  name: 'cxx',
119
100
  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
101
  configResolved(config: ResolvedConfig) {
134
102
  const root = pluginConfig.watch?.root ?? config.root
135
103
 
@@ -138,6 +106,11 @@ export default function cxx(pluginConfig: PluginConfig = {}) {
138
106
  } catch {
139
107
  watchRoot = normaliseWatchPath(root)
140
108
  }
109
+
110
+ realpaths.clear()
111
+ },
112
+ async buildStart() {
113
+ await seed()
141
114
  },
142
115
  transform(code, id) {
143
116
  if (id.startsWith('\0') || id.includes('/node_modules/')) return null
@@ -145,18 +118,14 @@ export default function cxx(pluginConfig: PluginConfig = {}) {
145
118
  if (!code.includes('cxx')) return null
146
119
 
147
120
  const cleanId = id.split('?')[0]
148
- if (!ACCEPTED_EXTENSIONS.has(path.extname(cleanId))) return null
121
+
122
+ if (!SOURCE_EXTENSIONS.has(path.extname(cleanId))) return null
149
123
 
150
124
  const { template, tags, map } = collect(code, cleanId, pluginConfig)
151
- if (template === code) return null
152
125
 
153
- const resolved = resolveWatchFile(cleanId)
126
+ if (template === code) return null
154
127
 
155
- if (tags.size) {
156
- buildContext.tagsByFile.set(resolved, tags)
157
- } else {
158
- buildContext.tagsByFile.delete(resolved)
159
- }
128
+ void writeTypesForFile(resolveWatchFile(cleanId), tags)
160
129
 
161
130
  return {
162
131
  code: template,
@@ -167,21 +136,12 @@ export default function cxx(pluginConfig: PluginConfig = {}) {
167
136
  logger.info('Watching for changes...')
168
137
 
169
138
  server.watcher
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
- })
139
+ .on('add', onWatch('add'))
140
+ .on('change', onWatch('change'))
141
+ .on('unlink', onWatch('unlink'))
182
142
  },
183
143
  async writeBundle() {
184
- await writeTypes(flattenTags(buildContext.tagsByFile))
144
+ await flushTypes()
185
145
  },
186
146
  } satisfies Plugin
187
147
  }