@jk2908/cxx 0.2.6 → 0.3.3

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/next/index.ts CHANGED
@@ -5,159 +5,192 @@ import fs from 'node:fs/promises'
5
5
  import path from 'node:path'
6
6
  import { fileURLToPath } from 'node:url'
7
7
 
8
- import watcher from '@parcel/watcher'
9
-
10
8
  import type { Logger } from '../logger.js'
11
- import type { PluginConfig, Tags } from '../types.js'
12
- import { collect, flattenTags, isENOENT, writeTypes } from '../build.js'
9
+ import type { PluginConfig } from '../types.js'
10
+ import { collect, isENOENT, takeRebuildError, writeTypesForFile } from '../build.js'
11
+ import { GENERATED_DIR } from '../config.js'
13
12
 
14
13
  const nextDir = path.dirname(fileURLToPath(import.meta.url))
15
14
  const loader = path.join(nextDir, 'cxx-loader.cjs')
16
15
 
17
- // keep the latest extracted tags per file so one edit can rebuild the full type surface
18
- const tagsByFile: Map<string, Tags> = new Map()
19
-
20
- // directory watches are shared, but rebuilds still need the file's config and logger
16
+ // keep the exact file inputs and their config so a single recursive watcher can filter events
21
17
  const watchedFiles = new Map<string, { pluginConfig: PluginConfig; logger: Logger }>()
22
- const subscriptionsByDir = new Map<string, Promise<AsyncSubscription>>()
23
18
 
24
- /**
25
- * Rebuild the generated type surface for one watched file after a Parcel event
26
- */
27
- async function rebuildFile(filePath: string, event: Event['type']) {
28
- const watchedFile = watchedFiles.get(filePath)
29
- if (!watchedFile) return
19
+ let subscription: AsyncSubscription | null = null
20
+ let subscriptionPromise: Promise<void> | null = null
21
+ let cleanupRegistered = false
22
+ let watcherLogger: Logger | null = null
30
23
 
31
- const { pluginConfig, logger } = watchedFile
32
- const hadTags = tagsByFile.has(filePath)
24
+ // a single recursive watch avoids overlapping per-directory watchers; ignore the parts of the
25
+ // tree that never contain source we transform
26
+ const DEFAULT_IGNORE = [
27
+ '**/node_modules/**',
28
+ '**/.next/**',
29
+ `**/${GENERATED_DIR}/**`,
30
+ '**/.git/**',
31
+ ]
33
32
 
34
- if (event === 'delete') {
35
- tagsByFile.delete(filePath)
36
- if (!hadTags) return
33
+ async function onEvent(event: Event['type'], filePath: string) {
34
+ const watched = watchedFiles.get(filePath)
35
+ if (!watched) return
37
36
 
38
- await writeTypes(flattenTags(tagsByFile))
37
+ const { pluginConfig, logger } = watched
38
+
39
+ if (event === 'delete') {
40
+ await writeTypesForFile(filePath, new Map())
39
41
  logger.info(`updated types from ${filePath}`)
40
42
 
41
43
  return
42
44
  }
43
45
 
44
- try {
45
- const source = await fs.readFile(filePath, 'utf-8')
46
- const { tags } = collect(source, filePath, pluginConfig)
47
- const hasTags = tags.size > 0
48
-
49
- if (hasTags) {
50
- tagsByFile.set(filePath, tags)
51
- } else {
52
- tagsByFile.delete(filePath)
53
- }
46
+ let source: string
54
47
 
55
- if (!hadTags && !hasTags) return
56
-
57
- await writeTypes(flattenTags(tagsByFile))
58
- logger.info(`updated types from ${filePath}`)
48
+ try {
49
+ source = await fs.readFile(filePath, 'utf-8')
59
50
  } catch (err) {
60
51
  if (!isENOENT(err)) throw err
61
- if (!hadTags) return
62
-
63
- tagsByFile.delete(filePath)
64
- await writeTypes(flattenTags(tagsByFile))
65
52
 
53
+ await writeTypesForFile(filePath, new Map())
66
54
  logger.info(`updated types from ${filePath}`)
55
+
56
+ return
67
57
  }
58
+
59
+ const tags = source.includes('cxx')
60
+ ? collect(source, filePath, pluginConfig).tags
61
+ : new Map()
62
+
63
+ await writeTypesForFile(filePath, tags)
64
+ logger.info(`updated types from ${filePath}`)
68
65
  }
69
66
 
70
67
  /**
71
- * Transform one module and, in development, register it with the shared watcher state
68
+ * Start the watcher lazily, on the first file we actually process, so merely importing this module
69
+ * has no side effects.
72
70
  */
73
- export function processFile(
74
- source: string,
75
- filePath: string,
76
- pluginConfig: PluginConfig,
77
- logger: Logger,
78
- ) {
79
- const resolvedFilePath = path.resolve(filePath)
80
- const directoryPath = path.dirname(resolvedFilePath)
81
- let subscription = subscriptionsByDir.get(directoryPath)
71
+ function ensureWatcher(pluginConfig: PluginConfig, logger: Logger) {
72
+ watcherLogger = logger
82
73
 
83
- if (process.env.NODE_ENV !== 'production') {
84
- // Parcel watches directories, so remember the exact file inputs we care about
85
- watchedFiles.set(resolvedFilePath, { pluginConfig, logger })
74
+ if (subscriptionPromise) return
75
+
76
+ const root = pluginConfig.watch?.root ?? process.cwd()
77
+ const ignore = [...DEFAULT_IGNORE, ...(pluginConfig.watch?.ignore ?? [])]
78
+
79
+ subscriptionPromise = (async () => {
80
+ // lazily imported so the native module is only loaded when watching is needed
81
+ const { subscribe } = await import('@parcel/watcher')
82
+
83
+ subscription = await subscribe(
84
+ root,
85
+ (watchErr, events) => {
86
+ if (watchErr) {
87
+ watcherLogger?.error(`failed to watch ${root}`, watchErr)
86
88
 
87
- if (!subscription) {
88
- // one subscription per directory is enough; the callback filters back down to watched files
89
- subscription = watcher.subscribe(directoryPath, async (err, events) => {
90
- if (err) {
91
- logger.error(`failed to update types from ${directoryPath}`, err)
92
89
  return
93
90
  }
94
91
 
95
- // Parcel can report unrelated files from the same directory, so ignore anything unregistered
96
92
  for (const event of events) {
97
- const eventFilePath = path.resolve(event.path)
98
- if (!watchedFiles.has(eventFilePath)) continue
99
-
100
- try {
101
- await rebuildFile(eventFilePath, event.type)
102
- } catch (watchErr) {
103
- logger.error(`failed to update types from ${eventFilePath}`, watchErr)
104
- }
93
+ const filePath = path.resolve(event.path)
94
+ if (!watchedFiles.has(filePath)) continue
95
+
96
+ void onEvent(event.type, filePath).catch(err => {
97
+ watcherLogger?.error(`failed to update types from ${filePath}`, err)
98
+ })
105
99
  }
106
- })
100
+ },
101
+ { ignore },
102
+ )
103
+
104
+ registerCleanup()
105
+ })().catch(err => {
106
+ watcherLogger?.error('failed to start cxx watcher', err)
107
+ subscriptionPromise = null
108
+ })
109
+ }
107
110
 
108
- subscriptionsByDir.set(directoryPath, subscription)
109
- }
110
- }
111
+ /**
112
+ * Close all active Parcel subscriptions.
113
+ */
114
+ async function cleanup() {
115
+ const active = subscription
111
116
 
112
- const result = collect(source, filePath, pluginConfig)
117
+ subscription = null
118
+ subscriptionPromise = null
119
+ watchedFiles.clear()
113
120
 
114
- if (result.tags.size > 0) {
115
- tagsByFile.set(filePath, result.tags)
116
- } else {
117
- tagsByFile.delete(filePath)
121
+ if (active) await active.unsubscribe()
122
+ }
123
+
124
+ /**
125
+ * Restore the host process's default signal behaviour instead of forcing an exit from a library.
126
+ */
127
+ async function shutdown(signal: NodeJS.Signals) {
128
+ try {
129
+ await cleanup()
130
+ } finally {
131
+ // our `once` handlers have already been removed, so re-raising lets Node terminate as usual
132
+ process.kill(process.pid, signal)
118
133
  }
134
+ }
119
135
 
120
- void writeTypes(flattenTags(tagsByFile))
136
+ function registerCleanup() {
137
+ if (cleanupRegistered) return
121
138
 
122
- return result
139
+ cleanupRegistered = true
140
+ process.once('SIGINT', () => void shutdown('SIGINT'))
141
+ process.once('SIGTERM', () => void shutdown('SIGTERM'))
123
142
  }
124
143
 
125
144
  /**
126
- * Close all active Parcel subscriptions before the process exits
145
+ * Transform one module and, in development, register it with the shared watcher state.
127
146
  */
128
- async function cleanup() {
129
- await Promise.all(
130
- [...subscriptionsByDir].map(async ([directoryPath, subPromise]) => {
131
- subscriptionsByDir.delete(directoryPath)
132
- await (await subPromise).unsubscribe()
133
- }),
134
- )
135
-
136
- subscriptionsByDir.clear()
137
- }
147
+ export function processFile(
148
+ source: string,
149
+ filePath: string,
150
+ pluginConfig: PluginConfig,
151
+ logger: Logger,
152
+ ) {
153
+ const resolvedFilePath = path.resolve(filePath)
138
154
 
139
- process.once('SIGINT', () => {
140
- cleanup().finally(() => process.exit(130))
141
- })
155
+ // skip files that cannot contain a template, including node_modules
156
+ if (!source.includes('cxx') || /[\\/]node_modules[\\/]/.test(resolvedFilePath)) {
157
+ return { template: source, tags: new Map(), map: null }
158
+ }
142
159
 
143
- process.once('SIGTERM', () => {
144
- cleanup().finally(() => process.exit(143))
145
- })
160
+ // surface a duplicate-tag error from a previous background rebuild
161
+ const priorError = takeRebuildError()
162
+ if (priorError) throw priorError
146
163
 
147
- export async function withCxx(
148
- nextConfig: NextConfig = {},
149
- pluginConfig: PluginConfig = {},
150
- ) {
151
- const TURBOPACK_GLOB = '*.{tsx,jsx,ts,js}'
164
+ if (process.env['NODE_ENV'] !== 'production') {
165
+ watchedFiles.set(resolvedFilePath, { pluginConfig, logger })
166
+ ensureWatcher(pluginConfig, logger)
167
+ }
168
+
169
+ const result = collect(source, resolvedFilePath, pluginConfig)
170
+
171
+ void writeTypesForFile(resolvedFilePath, result.tags)
172
+
173
+ return result
174
+ }
175
+
176
+ const TURBOPACK_GLOB = '*.{tsx,jsx,ts,js}'
177
+
178
+ export function withCxx(nextConfig: NextConfig = {}, pluginConfig: PluginConfig = {}) {
179
+ const watchRoot =
180
+ pluginConfig.watch?.root ?? nextConfig.turbopack?.root ?? process.cwd()
152
181
 
153
182
  const loaderItem = {
154
183
  loader,
155
184
  options: {
156
- pluginConfig,
185
+ pluginConfig: {
186
+ ...pluginConfig,
187
+ watch: { ...pluginConfig.watch, root: watchRoot },
188
+ },
157
189
  },
158
190
  }
159
191
 
160
192
  const currentRule = nextConfig.turbopack?.rules?.[TURBOPACK_GLOB]
193
+ const userWebpack = nextConfig.webpack
161
194
 
162
195
  return {
163
196
  ...nextConfig,
@@ -175,5 +208,24 @@ export async function withCxx(
175
208
  : { loaders: [loaderItem] },
176
209
  },
177
210
  },
211
+ // webpack builds (Next without `--turbopack`) would otherwise silently skip the transform
212
+ webpack(config: unknown, context: unknown) {
213
+ const next = (
214
+ userWebpack ? userWebpack(config as never, context as never) : config
215
+ ) as {
216
+ module?: { rules?: unknown[] }
217
+ }
218
+
219
+ next.module ??= {}
220
+ next.module.rules ??= []
221
+ next.module.rules.push({
222
+ test: /\.[jt]sx?$/,
223
+ exclude: /node_modules/,
224
+ enforce: 'pre',
225
+ use: [loaderItem],
226
+ })
227
+
228
+ return next
229
+ },
178
230
  }
179
231
  }
package/src/types.ts CHANGED
@@ -1,5 +1,3 @@
1
- import type { LogLevel } from './logger.js'
2
-
3
1
  export type Result<ClassName extends string = string> = readonly [
4
2
  css: string,
5
3
  classes: Readonly<Record<ClassName, string>>,
@@ -10,15 +8,14 @@ export type Tags = Map<string, string[]>
10
8
 
11
9
  export type PluginConfig = {
12
10
  typeSuffix?: string | false
13
- logger?: {
14
- level?: LogLevel
11
+ watch?: {
12
+ /** Root used for type-regeneration watching. Defaults to the Vite root or Next Turbopack root. */
13
+ root?: string
14
+ /** Extra globs to ignore on top of the defaults. */
15
+ ignore?: string[]
15
16
  }
16
17
  }
17
18
 
18
- export type BuildContext = {
19
- tagsByFile: Map<string, Tags>
20
- }
21
-
22
19
  export type Tag = <ClassName extends string = string>(
23
20
  name: string,
24
21
  ) => (_: TemplateStringsArray) => Result<ClassName>
package/src/utils.ts CHANGED
@@ -9,16 +9,89 @@ export function pascalise(str: string) {
9
9
  .join('')
10
10
  }
11
11
 
12
- export function debounce<T extends unknown[]>(fn: (...args: T) => void, wait: number) {
13
- let timeoutId: ReturnType<typeof setTimeout> | null = null
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
+ // the run in progress, so `flush` can wait for it and we never start a second
37
+ let current: Promise<void> | null = null
38
+
39
+ function drain() {
40
+ // a run is already going, so let it pick up the new work
41
+ if (current) return current
14
42
 
15
- return (...args: T) => {
16
- if (timeoutId) {
17
- clearTimeout(timeoutId)
18
- }
43
+ const run = (async () => {
44
+ // keep looping while there is work, including work added during a flush
45
+ while (pending.size > 0) {
46
+ // snapshot the queued work
47
+ const batch = new Map(pending)
48
+ // empty the queue right away, so anything added during flush waits for the next loop
49
+ pending.clear()
19
50
 
20
- timeoutId = setTimeout(() => {
21
- fn.apply(null, args)
51
+ await flush(batch)
52
+ }
53
+ })()
54
+
55
+ current = run
56
+ run.then(
57
+ () => {
58
+ if (current === run) current = null
59
+ },
60
+ () => {
61
+ if (current === run) current = null
62
+ },
63
+ )
64
+
65
+ return run
66
+ }
67
+
68
+ function schedule() {
69
+ // cancel the previous wait, so the timer restarts from now
70
+ if (timer) clearTimeout(timer)
71
+
72
+ // run once the calls stop coming for `wait` ms
73
+ timer = setTimeout(() => {
74
+ // the timer has fired, so drop the stale handle
75
+ timer = null
76
+ // the flush callback handles its own errors, so just start the run
77
+ void drain().catch(() => {})
22
78
  }, wait)
23
79
  }
80
+
81
+ return {
82
+ add(key, value) {
83
+ // the newest value for this key wins
84
+ pending.set(key, value)
85
+ schedule()
86
+ },
87
+ flush() {
88
+ // run now instead of waiting out the timer
89
+ if (timer) {
90
+ clearTimeout(timer)
91
+ timer = null
92
+ }
93
+
94
+ return drain()
95
+ },
96
+ }
24
97
  }