@jk2908/cxx 0.3.3 → 0.3.5

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
@@ -1,154 +1,25 @@
1
- import type { AsyncSubscription, Event } from '@parcel/watcher'
2
1
  import type { NextConfig } from 'next'
3
2
 
4
- import fs from 'node:fs/promises'
5
3
  import path from 'node:path'
6
4
  import { fileURLToPath } from 'node:url'
7
5
 
8
- import type { Logger } from '../logger.js'
9
6
  import type { PluginConfig } from '../types.js'
10
- import { collect, isENOENT, takeRebuildError, writeTypesForFile } from '../build.js'
11
- import { GENERATED_DIR } from '../config.js'
7
+ import { collect, seedTypes, writeTypesForFile } from '../build.js'
8
+ import { Logger } from '../logger.js'
9
+
10
+ const typeLogger = new Logger('cxx')
12
11
 
13
12
  const nextDir = path.dirname(fileURLToPath(import.meta.url))
14
13
  const loader = path.join(nextDir, 'cxx-loader.cjs')
15
14
 
16
- // keep the exact file inputs and their config so a single recursive watcher can filter events
17
- const watchedFiles = new Map<string, { pluginConfig: PluginConfig; logger: Logger }>()
18
-
19
- let subscription: AsyncSubscription | null = null
20
- let subscriptionPromise: Promise<void> | null = null
21
- let cleanupRegistered = false
22
- let watcherLogger: Logger | null = null
23
-
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
- ]
32
-
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
38
-
39
- if (event === 'delete') {
40
- 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
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}`)
65
- }
66
-
67
- /**
68
- * Start the watcher lazily, on the first file we actually process, so merely importing this module
69
- * has no side effects.
70
- */
71
- function ensureWatcher(pluginConfig: PluginConfig, logger: Logger) {
72
- watcherLogger = logger
73
-
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)
88
-
89
- return
90
- }
91
-
92
- for (const event of events) {
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
- })
99
- }
100
- },
101
- { ignore },
102
- )
103
-
104
- registerCleanup()
105
- })().catch(err => {
106
- watcherLogger?.error('failed to start cxx watcher', err)
107
- subscriptionPromise = null
108
- })
109
- }
110
-
111
- /**
112
- * Close all active Parcel subscriptions.
113
- */
114
- async function cleanup() {
115
- const active = subscription
116
-
117
- subscription = null
118
- subscriptionPromise = null
119
- watchedFiles.clear()
120
-
121
- if (active) await active.unsubscribe()
122
- }
123
-
124
15
  /**
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)
133
- }
134
- }
135
-
136
- function registerCleanup() {
137
- if (cleanupRegistered) return
138
-
139
- cleanupRegistered = true
140
- process.once('SIGINT', () => void shutdown('SIGINT'))
141
- process.once('SIGTERM', () => void shutdown('SIGTERM'))
142
- }
143
-
144
- /**
145
- * Transform one module and, in development, register it with the shared watcher state.
16
+ * Transform one module and persist its tag types. Deleted files are pruned from the types on the
17
+ * next rebuild, since `readPartials` drops any partial whose source is gone.
146
18
  */
147
19
  export function processFile(
148
20
  source: string,
149
21
  filePath: string,
150
22
  pluginConfig: PluginConfig,
151
- logger: Logger,
152
23
  ) {
153
24
  const resolvedFilePath = path.resolve(filePath)
154
25
 
@@ -157,15 +28,6 @@ export function processFile(
157
28
  return { template: source, tags: new Map(), map: null }
158
29
  }
159
30
 
160
- // surface a duplicate-tag error from a previous background rebuild
161
- const priorError = takeRebuildError()
162
- if (priorError) throw priorError
163
-
164
- if (process.env['NODE_ENV'] !== 'production') {
165
- watchedFiles.set(resolvedFilePath, { pluginConfig, logger })
166
- ensureWatcher(pluginConfig, logger)
167
- }
168
-
169
31
  const result = collect(source, resolvedFilePath, pluginConfig)
170
32
 
171
33
  void writeTypesForFile(resolvedFilePath, result.tags)
@@ -176,16 +38,20 @@ export function processFile(
176
38
  const TURBOPACK_GLOB = '*.{tsx,jsx,ts,js}'
177
39
 
178
40
  export function withCxx(nextConfig: NextConfig = {}, pluginConfig: PluginConfig = {}) {
179
- const watchRoot =
180
- pluginConfig.watch?.root ?? nextConfig.turbopack?.root ?? process.cwd()
41
+ // Seed on dev startup only, so a fresh checkout has types before any file is compiled. A
42
+ // production build is covered by the loader, and `next start` must not write at runtime.
43
+ if (process.env['NODE_ENV'] !== 'production') {
44
+ const seedRoot = pluginConfig.watch?.root ?? process.cwd()
45
+
46
+ void seedTypes(seedRoot, pluginConfig).catch(err =>
47
+ typeLogger.error('failed to seed cxx types', err),
48
+ )
49
+ }
181
50
 
182
51
  const loaderItem = {
183
52
  loader,
184
53
  options: {
185
- pluginConfig: {
186
- ...pluginConfig,
187
- watch: { ...pluginConfig.watch, root: watchRoot },
188
- },
54
+ pluginConfig,
189
55
  },
190
56
  }
191
57
 
package/src/types.ts CHANGED
@@ -11,8 +11,6 @@ export type PluginConfig = {
11
11
  watch?: {
12
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
 
package/src/utils.ts CHANGED
@@ -8,90 +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
- // 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
42
-
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()
50
-
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(() => {})
78
- }, wait)
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
- }
97
- }
package/src/vite/index.ts CHANGED
@@ -1,5 +1,4 @@
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'
@@ -8,15 +7,13 @@ import type { PluginConfig } from '../types.js'
8
7
  import {
9
8
  collect,
10
9
  flushTypes,
11
- isENOENT,
12
- takeRebuildError,
10
+ refreshFile,
11
+ seedTypes,
12
+ SOURCE_EXTENSIONS,
13
13
  writeTypesForFile,
14
14
  } from '../build.js'
15
15
  import { GENERATED_DIR } from '../config.js'
16
16
  import { Logger } from '../logger.js'
17
- import { createBatcher } from '../utils.js'
18
-
19
- const ACCEPTED_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx'])
20
17
 
21
18
  type WatchEvent = 'add' | 'change' | 'unlink'
22
19
 
@@ -24,48 +21,45 @@ function normaliseWatchPath(p: string) {
24
21
  return p.replace(/\\/g, '/')
25
22
  }
26
23
 
27
- /** Tiny glob matcher for the ignore patterns we support (`**`, `*`, `?`). */
28
- function globToRegExp(glob: string) {
29
- const pattern = glob
30
- .replace(/[.+^${}()|[\]\\]/g, '\\$&')
31
- .replace(/\*\*(\/|$)/g, '.*$1')
32
- .replace(/\*/g, '[^/]*')
33
- .replace(/\?/g, '[^/]')
34
-
35
- return new RegExp(`^${pattern}$`)
36
- }
37
-
38
24
  export default function cxx(pluginConfig: PluginConfig = {}) {
39
25
  const logger = new Logger()
40
26
 
41
27
  // resolved from Vite's root in `configResolved`, not from `process.cwd()`
42
28
  let watchRoot = normaliseWatchPath(process.cwd())
43
- const ignored = (pluginConfig.watch?.ignore ?? []).map(globToRegExp)
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>()
44
33
 
45
34
  function resolveWatchFile(filePath: string) {
46
35
  const absolutePath = path.resolve(watchRoot, filePath)
47
36
  const parentPath = path.dirname(absolutePath)
48
37
 
49
- try {
50
- const resolvedParentPath = normaliseWatchPath(realpathSync.native(parentPath))
38
+ let resolvedParentPath = realpaths.get(parentPath)
51
39
 
52
- return normaliseWatchPath(
53
- path.join(resolvedParentPath, path.basename(absolutePath)),
54
- )
55
- } catch {
56
- return normaliseWatchPath(absolutePath)
40
+ if (resolvedParentPath === undefined) {
41
+ try {
42
+ resolvedParentPath = normaliseWatchPath(realpathSync.native(parentPath))
43
+ } catch {
44
+ resolvedParentPath = normaliseWatchPath(parentPath)
45
+ }
46
+
47
+ realpaths.set(parentPath, resolvedParentPath)
57
48
  }
49
+
50
+ return normaliseWatchPath(path.join(resolvedParentPath, path.basename(absolutePath)))
58
51
  }
59
52
 
60
- /** Returns the canonical, watchable path, or null when the file is irrelevant. */
61
- 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) {
62
57
  const resolvedPath = resolveWatchFile(filePath)
63
58
 
64
59
  if (
65
60
  resolvedPath.startsWith(`${watchRoot}/`) &&
66
61
  !resolvedPath.includes(`/${GENERATED_DIR}/`) &&
67
- ACCEPTED_EXTENSIONS.has(path.extname(resolvedPath)) &&
68
- !ignored.some(regexp => regexp.test(resolvedPath))
62
+ SOURCE_EXTENSIONS.has(path.extname(resolvedPath))
69
63
  ) {
70
64
  return resolvedPath
71
65
  }
@@ -73,76 +67,34 @@ export default function cxx(pluginConfig: PluginConfig = {}) {
73
67
  return null
74
68
  }
75
69
 
76
- async function recordFile(file: string) {
77
- let source: string
78
-
79
- try {
80
- source = await fs.readFile(file, 'utf-8')
81
- } catch (err) {
82
- if (!isENOENT(err)) throw err
70
+ async function handleEvent(event: WatchEvent, file: string) {
71
+ if (event === 'unlink') {
83
72
  await writeTypesForFile(file, new Map())
84
- return
73
+ } else {
74
+ await refreshFile(file, pluginConfig)
85
75
  }
86
76
 
87
- const tags = source.includes('cxx')
88
- ? collect(source, file, pluginConfig).tags
89
- : new Map()
90
- await writeTypesForFile(file, tags)
77
+ logger.info(`updated types from ${file}`)
91
78
  }
92
79
 
93
- /**
94
- * Seed the type surface from the source tree, so a fresh checkout has types before any file is
95
- * transformed or edited.
96
- */
97
- async function scan(dir: string) {
98
- let entries
80
+ function onWatch(event: WatchEvent) {
81
+ return (p: string) => {
82
+ const file = watchedFile(p)
83
+ if (!file) return
99
84
 
100
- try {
101
- entries = await fs.readdir(dir, { withFileTypes: true })
102
- } catch {
103
- return
85
+ void handleEvent(event, file).catch(err => {
86
+ logger.error(`failed to update types from ${file}`, err)
87
+ })
104
88
  }
89
+ }
105
90
 
106
- await Promise.all(
107
- entries.map(async entry => {
108
- if (entry.name.startsWith('.') || entry.name === 'node_modules') return
109
-
110
- const full = path.join(dir, entry.name)
111
-
112
- if (entry.isDirectory()) {
113
- await scan(full)
114
- return
115
- }
116
-
117
- if (!ACCEPTED_EXTENSIONS.has(path.extname(entry.name))) return
118
-
119
- const resolved = resolveWatchFile(full)
120
- if (ignored.some(regexp => regexp.test(resolved))) return
91
+ async function seed() {
92
+ if (seeded) return
93
+ seeded = true
121
94
 
122
- await recordFile(resolved)
123
- }),
124
- )
95
+ await seedTypes(watchRoot, pluginConfig)
125
96
  }
126
97
 
127
- // collect change events that arrive close together and handle each file once.
128
- // The file path is the key, so changes to different files are all kept, and a
129
- // change that arrives during a rebuild runs after that rebuild finishes.
130
- const queue = createBatcher<string, WatchEvent>(75, async batch => {
131
- for (const [file, event] of batch) {
132
- try {
133
- if (event === 'unlink') {
134
- await writeTypesForFile(file, new Map())
135
- } else {
136
- await recordFile(file)
137
- }
138
-
139
- logger.info(`updated types from ${file}`)
140
- } catch (err) {
141
- logger.error(`failed to update types from ${file}`, err)
142
- }
143
- }
144
- })
145
-
146
98
  return {
147
99
  name: 'cxx',
148
100
  enforce: 'pre',
@@ -154,16 +106,20 @@ export default function cxx(pluginConfig: PluginConfig = {}) {
154
106
  } catch {
155
107
  watchRoot = normaliseWatchPath(root)
156
108
  }
109
+
110
+ realpaths.clear()
111
+ },
112
+ async buildStart() {
113
+ await seed()
157
114
  },
158
115
  transform(code, id) {
159
116
  if (id.startsWith('\0') || id.includes('/node_modules/')) return null
160
-
161
117
  // cheap bail-out before running the regex/lightningcss on every module
162
118
  if (!code.includes('cxx')) return null
163
119
 
164
120
  const cleanId = id.split('?')[0]
165
121
 
166
- if (!ACCEPTED_EXTENSIONS.has(path.extname(cleanId))) return null
122
+ if (!SOURCE_EXTENSIONS.has(path.extname(cleanId))) return null
167
123
 
168
124
  const { template, tags, map } = collect(code, cleanId, pluginConfig)
169
125
 
@@ -180,26 +136,9 @@ export default function cxx(pluginConfig: PluginConfig = {}) {
180
136
  logger.info('Watching for changes...')
181
137
 
182
138
  server.watcher
183
- .on('add', (p: string) => {
184
- const file = watchedFile(p)
185
- if (file) queue.add(file, 'add')
186
- })
187
- .on('change', (p: string) => {
188
- const file = watchedFile(p)
189
- if (file) queue.add(file, 'change')
190
- })
191
- .on('unlink', (p: string) => {
192
- const file = watchedFile(p)
193
- if (file) queue.add(file, 'unlink')
194
- })
195
-
196
- void scan(watchRoot)
197
- .then(() => flushTypes())
198
- .then(() => {
199
- const err = takeRebuildError()
200
- if (err) logger.error('failed to generate types', err)
201
- })
202
- .catch(err => logger.error('failed to scan for cxx templates', err))
139
+ .on('add', onWatch('add'))
140
+ .on('change', onWatch('change'))
141
+ .on('unlink', onWatch('unlink'))
203
142
  },
204
143
  async writeBundle() {
205
144
  await flushTypes()