@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/logger.ts CHANGED
@@ -8,6 +8,8 @@ const LEVELS = {
8
8
 
9
9
  export type LogLevel = keyof typeof LEVELS
10
10
 
11
+ const defaultLevel: LogLevel = process.env['NODE_ENV'] === 'production' ? 'error' : 'info'
12
+
11
13
  type LogEntry = {
12
14
  ts: number
13
15
  level: LogLevel
@@ -22,37 +24,27 @@ type LogEntry = {
22
24
  }
23
25
 
24
26
  /**
25
- * Log messages with different severity levels
27
+ * Log messages with different severity levels.
26
28
  */
27
29
  export class Logger {
28
- static #defaultLevel: LogLevel = 'info'
29
-
30
- #level?: LogLevel
30
+ #level: LogLevel
31
31
 
32
32
  constructor(
33
33
  level?: LogLevel,
34
34
  public name?: string,
35
35
  ) {
36
- this.#level = level
37
- }
38
-
39
- static set defaultLevel(level: LogLevel) {
40
- Logger.#defaultLevel = level
41
- }
42
-
43
- static get defaultLevel() {
44
- return Logger.#defaultLevel
36
+ this.#level = level ?? defaultLevel
45
37
  }
46
38
 
47
39
  /**
48
- * Convert a value to an Error instance
40
+ * Convert a value to an Error instance.
49
41
  */
50
42
  static toError(err: unknown) {
51
43
  return err instanceof Error ? err : new Error(String(err), { cause: err })
52
44
  }
53
45
 
54
46
  /**
55
- * Stringify the error for logging
47
+ * Stringify the error for logging.
56
48
  */
57
49
  static print(err: unknown) {
58
50
  if (err instanceof Error) {
@@ -79,11 +71,11 @@ export class Logger {
79
71
  }
80
72
 
81
73
  get level() {
82
- return this.#level ?? Logger.#defaultLevel
74
+ return this.#level
83
75
  }
84
76
 
85
77
  /**
86
- * Log a message with a specific level
78
+ * Log a message with a specific level.
87
79
  */
88
80
  log(level: LogLevel, message: string, error?: Error) {
89
81
  if (LEVELS[level] < LEVELS[this.level]) return
@@ -115,35 +107,35 @@ export class Logger {
115
107
  }
116
108
 
117
109
  /**
118
- * Log a debug message
110
+ * Log a debug message.
119
111
  */
120
112
  debug(...messages: string[]) {
121
113
  this.log('debug', messages.join(' '))
122
114
  }
123
115
 
124
116
  /**
125
- * Log an info message
117
+ * Log an info message.
126
118
  */
127
119
  info(...messages: string[]) {
128
120
  this.log('info', messages.join(' '))
129
121
  }
130
122
 
131
123
  /**
132
- * Log a warning message
124
+ * Log a warning message.
133
125
  */
134
126
  warn(...messages: string[]) {
135
127
  this.log('warn', messages.join(' '))
136
128
  }
137
129
 
138
130
  /**
139
- * Log an error message
131
+ * Log an error message.
140
132
  */
141
133
  error(message: string, error?: unknown) {
142
134
  this.log('error', message, error === undefined ? undefined : Logger.toError(error))
143
135
  }
144
136
 
145
137
  /**
146
- * Log a fatal error message
138
+ * Log a fatal error message.
147
139
  */
148
140
  fatal(message: string, error?: unknown) {
149
141
  this.log('fatal', message, error === undefined ? undefined : Logger.toError(error))
package/src/lru.ts CHANGED
@@ -14,7 +14,7 @@ export class LruCache<T> {
14
14
 
15
15
  /**
16
16
  * Retrieve a cached value and promote it to most-recently-used so it won't
17
- * be evicted while still actively referenced
17
+ * be evicted while still actively referenced.
18
18
  */
19
19
  get(key: string) {
20
20
  const cached = this.#cache.get(key)
@@ -27,7 +27,7 @@ export class LruCache<T> {
27
27
  * Promote an existing cache entry to most-recently-used by deleting and
28
28
  * re-inserting it, which moves it to the end of the Map's iteration
29
29
  * order. If the cache is at capacity, evict the least recently used
30
- * entry (front) before inserting
30
+ * entry (front) before inserting.
31
31
  */
32
32
  set(key: string, value: T) {
33
33
  // drop any existing entry
@@ -3,11 +3,7 @@ import { processFile } from './index.js'
3
3
 
4
4
  const loader = function (source) {
5
5
  const { pluginConfig } = this.getOptions()
6
- const logger = new Logger(
7
- (pluginConfig?.logger?.level ?? process.env.NODE_ENV === 'production')
8
- ? 'error'
9
- : 'debug',
10
- )
6
+ const logger = new Logger()
11
7
 
12
8
  const { template } = processFile(source, this.resourcePath, pluginConfig, logger)
13
9
 
package/src/next/index.ts CHANGED
@@ -5,159 +5,183 @@ 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 '../index.js'
9
+ import type { PluginConfig } from '../types.js'
10
+ import { collect, isENOENT, 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
36
+
37
+ const { pluginConfig, logger } = watched
37
38
 
38
- await writeTypes(flattenTags(tagsByFile))
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
- }
54
-
55
- if (!hadTags && !hasTags) return
46
+ let source: string
56
47
 
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 } = collect(source, filePath, pluginConfig)
60
+
61
+ await writeTypesForFile(filePath, tags)
62
+ logger.info(`updated types from ${filePath}`)
68
63
  }
69
64
 
70
65
  /**
71
- * Transform one module and, in development, register it with the shared watcher state
66
+ * Start the watcher lazily, on the first file we actually process, so merely importing this module
67
+ * has no side effects.
72
68
  */
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)
69
+ function ensureWatcher(pluginConfig: PluginConfig, logger: Logger) {
70
+ watcherLogger = logger
82
71
 
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 })
72
+ if (subscriptionPromise) return
73
+
74
+ const root = pluginConfig.watch?.root ?? process.cwd()
75
+ const ignore = [...DEFAULT_IGNORE, ...(pluginConfig.watch?.ignore ?? [])]
76
+
77
+ subscriptionPromise = (async () => {
78
+ // lazily imported so the native module is only loaded when watching is needed
79
+ const { subscribe } = await import('@parcel/watcher')
80
+
81
+ subscription = await subscribe(
82
+ root,
83
+ (watchErr, events) => {
84
+ if (watchErr) {
85
+ watcherLogger?.error(`failed to watch ${root}`, watchErr)
86
86
 
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
87
  return
93
88
  }
94
89
 
95
- // Parcel can report unrelated files from the same directory, so ignore anything unregistered
96
90
  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
- }
91
+ const filePath = path.resolve(event.path)
92
+ if (!watchedFiles.has(filePath)) continue
93
+
94
+ void onEvent(event.type, filePath).catch(err => {
95
+ watcherLogger?.error(`failed to update types from ${filePath}`, err)
96
+ })
105
97
  }
106
- })
98
+ },
99
+ { ignore },
100
+ )
101
+
102
+ registerCleanup()
103
+ })().catch(err => {
104
+ watcherLogger?.error('failed to start cxx watcher', err)
105
+ subscriptionPromise = null
106
+ })
107
+ }
107
108
 
108
- subscriptionsByDir.set(directoryPath, subscription)
109
- }
110
- }
109
+ /**
110
+ * Close all active Parcel subscriptions.
111
+ */
112
+ async function cleanup() {
113
+ const active = subscription
111
114
 
112
- const result = collect(source, filePath, pluginConfig)
115
+ subscription = null
116
+ subscriptionPromise = null
117
+ watchedFiles.clear()
113
118
 
114
- if (result.tags.size > 0) {
115
- tagsByFile.set(filePath, result.tags)
116
- } else {
117
- tagsByFile.delete(filePath)
119
+ if (active) await active.unsubscribe()
120
+ }
121
+
122
+ /**
123
+ * Restore the host process's default signal behaviour instead of forcing an exit from a library.
124
+ */
125
+ async function shutdown(signal: NodeJS.Signals) {
126
+ try {
127
+ await cleanup()
128
+ } finally {
129
+ // our `once` handlers have already been removed, so re-raising lets Node terminate as usual
130
+ process.kill(process.pid, signal)
118
131
  }
132
+ }
119
133
 
120
- void writeTypes(flattenTags(tagsByFile))
134
+ function registerCleanup() {
135
+ if (cleanupRegistered) return
121
136
 
122
- return result
137
+ cleanupRegistered = true
138
+ process.once('SIGINT', () => void shutdown('SIGINT'))
139
+ process.once('SIGTERM', () => void shutdown('SIGTERM'))
123
140
  }
124
141
 
125
142
  /**
126
- * Close all active Parcel subscriptions before the process exits
143
+ * Transform one module and, in development, register it with the shared watcher state.
127
144
  */
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
- }
145
+ export function processFile(
146
+ source: string,
147
+ filePath: string,
148
+ pluginConfig: PluginConfig,
149
+ logger: Logger,
150
+ ) {
151
+ const resolvedFilePath = path.resolve(filePath)
138
152
 
139
- process.once('SIGINT', () => {
140
- cleanup().finally(() => process.exit(130))
141
- })
153
+ if (process.env['NODE_ENV'] !== 'production') {
154
+ watchedFiles.set(resolvedFilePath, { pluginConfig, logger })
155
+ ensureWatcher(pluginConfig, logger)
156
+ }
142
157
 
143
- process.once('SIGTERM', () => {
144
- cleanup().finally(() => process.exit(143))
145
- })
158
+ const result = collect(source, resolvedFilePath, pluginConfig)
146
159
 
147
- export async function withCxx(
148
- nextConfig: NextConfig = {},
149
- pluginConfig: PluginConfig = {},
150
- ) {
151
- const TURBOPACK_GLOB = '*.{tsx,jsx,ts,js}'
160
+ void writeTypesForFile(resolvedFilePath, result.tags).catch(err => {
161
+ logger.error(`failed to write types for ${resolvedFilePath}`, err)
162
+ })
163
+
164
+ return result
165
+ }
166
+
167
+ const TURBOPACK_GLOB = '*.{tsx,jsx,ts,js}'
168
+
169
+ export function withCxx(nextConfig: NextConfig = {}, pluginConfig: PluginConfig = {}) {
170
+ const watchRoot =
171
+ pluginConfig.watch?.root ?? nextConfig.turbopack?.root ?? process.cwd()
152
172
 
153
173
  const loaderItem = {
154
174
  loader,
155
175
  options: {
156
- pluginConfig,
176
+ pluginConfig: {
177
+ ...pluginConfig,
178
+ watch: { ...pluginConfig.watch, root: watchRoot },
179
+ },
157
180
  },
158
181
  }
159
182
 
160
183
  const currentRule = nextConfig.turbopack?.rules?.[TURBOPACK_GLOB]
184
+ const userWebpack = nextConfig.webpack
161
185
 
162
186
  return {
163
187
  ...nextConfig,
@@ -175,5 +199,24 @@ export async function withCxx(
175
199
  : { loaders: [loaderItem] },
176
200
  },
177
201
  },
202
+ // webpack builds (Next without `--turbopack`) would otherwise silently skip the transform
203
+ webpack(config: unknown, context: unknown) {
204
+ const next = (
205
+ userWebpack ? userWebpack(config as never, context as never) : config
206
+ ) as {
207
+ module?: { rules?: unknown[] }
208
+ }
209
+
210
+ next.module ??= {}
211
+ next.module.rules ??= []
212
+ next.module.rules.push({
213
+ test: /\.[jt]sx?$/,
214
+ exclude: /node_modules/,
215
+ enforce: 'pre',
216
+ use: [loaderItem],
217
+ })
218
+
219
+ return next
220
+ },
178
221
  }
179
222
  }
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,8 +8,11 @@ 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 / 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
 
package/src/utils.ts CHANGED
@@ -9,16 +9,81 @@ 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
+ // 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
14
43
 
15
- return (...args: T) => {
16
- if (timeoutId) {
17
- clearTimeout(timeoutId)
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
18
57
  }
58
+ }
19
59
 
20
- timeoutId = setTimeout(() => {
21
- fn.apply(null, args)
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()
22
70
  }, wait)
23
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
+ }
24
89
  }