@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/README.md +18 -2
- package/dist/build.d.ts +46 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +314 -0
- package/dist/build.js.map +13 -0
- package/dist/index.d.ts +3 -36
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -188
- package/dist/index.js.map +4 -7
- package/dist/logger.d.ts +9 -11
- package/dist/logger.d.ts.map +1 -1
- package/dist/lru.d.ts +2 -2
- package/dist/next/cxx-loader.cjs +249 -152
- package/dist/next/cxx-loader.cjs.map +9 -9
- package/dist/next/index.d.ts +32 -11
- package/dist/next/index.d.ts.map +1 -1
- package/dist/next/index.js +278 -135
- package/dist/next/index.js.map +7 -7
- package/dist/types.d.ts +3 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +16 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/vite/index.d.ts +14 -6
- package/dist/vite/index.d.ts.map +1 -1
- package/dist/vite/index.js +281 -129
- package/dist/vite/index.js.map +8 -8
- package/package.json +83 -75
- package/src/build.ts +373 -0
- package/src/index.ts +24 -210
- package/src/logger.ts +14 -22
- package/src/lru.ts +2 -2
- package/src/next/cxx-loader.js +1 -5
- package/src/next/index.ts +143 -100
- package/src/types.ts +5 -4
- package/src/utils.ts +72 -7
- package/src/vite/index.ts +110 -79
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
|
-
|
|
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
|
|
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
|
package/src/next/cxx-loader.js
CHANGED
|
@@ -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
|
|
12
|
-
import { collect,
|
|
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
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
74
|
-
|
|
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 (
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
98
|
-
if (!watchedFiles.has(
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Close all active Parcel subscriptions.
|
|
111
|
+
*/
|
|
112
|
+
async function cleanup() {
|
|
113
|
+
const active = subscription
|
|
111
114
|
|
|
112
|
-
|
|
115
|
+
subscription = null
|
|
116
|
+
subscriptionPromise = null
|
|
117
|
+
watchedFiles.clear()
|
|
113
118
|
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
134
|
+
function registerCleanup() {
|
|
135
|
+
if (cleanupRegistered) return
|
|
121
136
|
|
|
122
|
-
|
|
137
|
+
cleanupRegistered = true
|
|
138
|
+
process.once('SIGINT', () => void shutdown('SIGINT'))
|
|
139
|
+
process.once('SIGTERM', () => void shutdown('SIGTERM'))
|
|
123
140
|
}
|
|
124
141
|
|
|
125
142
|
/**
|
|
126
|
-
*
|
|
143
|
+
* Transform one module and, in development, register it with the shared watcher state.
|
|
127
144
|
*/
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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.
|
|
140
|
-
|
|
141
|
-
|
|
153
|
+
if (process.env['NODE_ENV'] !== 'production') {
|
|
154
|
+
watchedFiles.set(resolvedFilePath, { pluginConfig, logger })
|
|
155
|
+
ensureWatcher(pluginConfig, logger)
|
|
156
|
+
}
|
|
142
157
|
|
|
143
|
-
|
|
144
|
-
cleanup().finally(() => process.exit(143))
|
|
145
|
-
})
|
|
158
|
+
const result = collect(source, resolvedFilePath, pluginConfig)
|
|
146
159
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
}
|