@voxgig/model 10.0.1 → 10.1.0

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/watch.ts ADDED
@@ -0,0 +1,335 @@
1
+ /* Copyright © 2021-2025 Voxgig Ltd, MIT License. */
2
+
3
+ import Path from 'node:path'
4
+
5
+
6
+ import type {
7
+ Build,
8
+ BuildResult,
9
+ Log,
10
+ Run,
11
+ Canon,
12
+ ChangeItem,
13
+ BuildSpec,
14
+ RunSpec,
15
+ } from './types'
16
+
17
+ import { makeBuild } from './build'
18
+ import { FSWatcher } from 'chokidar'
19
+
20
+ import { stat } from 'fs/promises'
21
+
22
+
23
+
24
+ class Watch {
25
+ fsw: FSWatcher | undefined
26
+ wspec: any
27
+ last?: BuildResult
28
+ lastChangeTime: number
29
+ build: Build | undefined
30
+ runq: Run[]
31
+ doneq: Run[]
32
+ canons: Canon[]
33
+ canonPaths: Set<string>
34
+ lastrun: Run | undefined
35
+ idle: number
36
+ intervalId: ReturnType<typeof setInterval> | undefined
37
+ startTime: number
38
+ running: boolean
39
+ lastChange: ChangeItem
40
+ lastTrigger: ChangeItem
41
+ log: Log
42
+ name: string
43
+ mode: {
44
+ mod: boolean // file modification
45
+ add: boolean // file addition
46
+ rem: boolean // file deletion
47
+ }
48
+
49
+ constructor(bspec: BuildSpec, log: Log) {
50
+ this.wspec = bspec
51
+ this.log = log
52
+
53
+ this.name = bspec.name || 'model'
54
+ this.lastChangeTime = 0
55
+ this.runq = []
56
+ this.doneq = []
57
+ this.canons = []
58
+ this.canonPaths = new Set()
59
+ this.intervalId = undefined
60
+ this.startTime = 0
61
+ this.lastChange = { path: '', when: 0 }
62
+ this.lastTrigger = { path: '', when: 0 }
63
+ this.running = false
64
+ this.lastrun = undefined
65
+
66
+ this.idle = bspec.idle || 111
67
+
68
+ this.mode = {
69
+ mod: null == bspec.watch?.mod ? true : true == bspec.watch?.mod,
70
+ add: true === bspec.watch?.add,
71
+ rem: true === bspec.watch?.rem,
72
+ }
73
+ }
74
+
75
+
76
+ ensureFSW(): FSWatcher {
77
+ if (!this.fsw) {
78
+ this.fsw = new FSWatcher()
79
+
80
+ const handleChange = this.handleChange.bind(this)
81
+
82
+ if (this.mode.mod) {
83
+ this.fsw.on('change', handleChange)
84
+ }
85
+
86
+ if (this.mode.add) {
87
+ this.fsw.on('add', handleChange)
88
+ }
89
+
90
+ if (this.mode.rem) {
91
+ this.fsw.on('unlink', handleChange)
92
+ }
93
+ }
94
+ return this.fsw
95
+ }
96
+
97
+
98
+ // Begin watching. The initial build, and every subsequent rebuild, is
99
+ // enqueued asynchronously once the watcher settles, so nothing is returned.
100
+ // Pass initial=false to start watching without forcing that first build
101
+ // (e.g. when the caller has already produced one).
102
+ start(initial: boolean = true) {
103
+ this.ensureFSW()
104
+ this.startTime = Date.now()
105
+ if (initial) {
106
+ this.handleChange('<start>')
107
+ }
108
+
109
+ // Check if there have been no recent changes, if so, run build.
110
+ this.intervalId = setInterval(() => {
111
+ // const start = this.startTime
112
+ const now = Date.now()
113
+ const idleDuration = now - this.lastChange.when
114
+
115
+ // Only trigger a build if there was an actual change
116
+ const trigger = this.lastChange.when !== this.lastTrigger.when // &&
117
+ // this.lastChange.path !== this.lastTrigger.path
118
+
119
+ if (trigger) {
120
+ // Only add to build queue if we've been idle.
121
+ // This allows external compilation outputting multiple files to complete fully.
122
+ // IMPORTANT: always trigger a new build if there were changes *inside* a build period
123
+ if (this.idle < idleDuration) {
124
+ this.lastTrigger.path = this.lastChange.path
125
+ this.lastTrigger.when = this.lastChange.when
126
+
127
+ const path = this.lastChange.path
128
+ const canon = this.canon(path)
129
+
130
+ const entry = {
131
+ canon,
132
+ path,
133
+ start: now,
134
+ end: -1,
135
+ }
136
+ this.runq.push(entry)
137
+
138
+ // Defer builds to the event loop to keep idle checking separate.
139
+ setImmediate(this.drain.bind(this))
140
+ }
141
+ }
142
+
143
+ }, (this.idle * 1.1 / 2) | 0)
144
+ }
145
+
146
+
147
+ // If path is inside a watched folder, return folder as canonical reference.
148
+ canon(path: string) {
149
+ for (const canon of this.canons) {
150
+ if (canon.isFolder && path.startsWith(canon.path)) {
151
+ return canon.path
152
+ }
153
+ }
154
+ return path
155
+ }
156
+
157
+
158
+ handleChange(path: string) {
159
+ // Record most recent (last) changed path and time
160
+ this.lastChange.path = path
161
+ this.lastChange.when = Date.now()
162
+ }
163
+
164
+
165
+ async drain() {
166
+ // If already running, all items in queue will be drained from this.runq in the while loop
167
+ if (this.running) {
168
+ return
169
+ }
170
+
171
+ this.running = true
172
+ let r: Run | undefined
173
+
174
+ // While there are queued runs, run them sequentially
175
+ while (r = this.runq.shift()) {
176
+ let br = await this.run(this.name, true, r.canon)
177
+ r.result = br
178
+ r.end = Date.now()
179
+ this.doneq.push(r)
180
+ this.lastrun = r
181
+ }
182
+ this.running = false
183
+ }
184
+
185
+
186
+ async add(path: string) {
187
+ if (!Path.isAbsolute(path)) {
188
+ path = Path.join(this.wspec.require || process.cwd(), path)
189
+ }
190
+
191
+ // Ignore if already added
192
+ if (this.canonPaths.has(path)) {
193
+ return
194
+ }
195
+
196
+ const fileStat = await stat(path)
197
+ const canon: Canon = {
198
+ path: path,
199
+ isFolder: fileStat.isDirectory(),
200
+ when: Date.now()
201
+ }
202
+
203
+ this.canons.push(canon)
204
+ this.canonPaths.add(path)
205
+
206
+ this.ensureFSW().add(path)
207
+ }
208
+
209
+
210
+ async update(br: BuildResult) {
211
+ let build = br.build ? br.build() : undefined
212
+
213
+ if (build?.deps) {
214
+ let files: string[] = [build.path]
215
+ for (const target of Object.keys(build.deps)) {
216
+ files.push(...Object.keys(build.deps[target]))
217
+ }
218
+
219
+ // TODO: remove deleted files
220
+ for (const file of files) {
221
+ if ('string' === typeof file && '' !== file && build.opts.base !== file) {
222
+ await this.add(file)
223
+ }
224
+ }
225
+ }
226
+ }
227
+
228
+
229
+ async run(name: string, watch?: boolean, trigger?: string): Promise<BuildResult> {
230
+ try {
231
+ this.lastChangeTime = Date.now()
232
+
233
+ this.log.info({
234
+ point: 'build-start', last: this.lastChangeTime, watch: name,
235
+ note: 'watch:' + name + ' last:' + new Date(this.lastChangeTime).toISOString()
236
+ })
237
+ this.log.info({
238
+ point: 'build-trigger', trigger,
239
+ note: 'watch:' + name + ' trigger:' + ('' + trigger).replace(process.cwd() + '/', '')
240
+ })
241
+
242
+ this.build = this.build || makeBuild(this.wspec, this.log)
243
+
244
+ let rspec: RunSpec = { watch: true === watch }
245
+ let br: BuildResult = await this.build.run(rspec)
246
+
247
+ if (br.ok) {
248
+ const deps = this.descDeps(br.build ? br.build().deps : undefined)
249
+ this.log.debug({
250
+ point: 'deps', deps,
251
+ note: 'watch:' + name + ' deps:\n' + deps
252
+ })
253
+
254
+ const rootkeys = Object.keys(this.build.model).join(';')
255
+ this.log.info({
256
+ point: 'root-keys', keys: rootkeys,
257
+ note: 'watch:' + name + ' keys: ' + rootkeys
258
+ })
259
+
260
+ if (watch) {
261
+ // There may be new files.
262
+ await this.update(br)
263
+ }
264
+
265
+ this.log.info({
266
+ point: 'build-end', watch: name,
267
+ note: 'watch:' + name + '\n',
268
+ })
269
+ }
270
+ else {
271
+ let errs = br.errs || [new Error('Unknown build error')]
272
+ errs.filter(err => !err.__logged__).forEach((err: any) => {
273
+ this.log.error({
274
+ fail: 'build', point: 'run-build', build: this, err
275
+ })
276
+ err.__logged__ = true
277
+ })
278
+ }
279
+
280
+ this.last = br
281
+
282
+ return br
283
+ }
284
+ catch (err: any) {
285
+ if (!err.__logged__) {
286
+ this.log.error({
287
+ fail: 'build', point: 'run-build', build: this, err
288
+ })
289
+ err.__logged__ = true
290
+ }
291
+
292
+ let br = {
293
+ ok: false,
294
+ errs: [err],
295
+ runlog: []
296
+ }
297
+
298
+ return br
299
+ }
300
+ }
301
+
302
+
303
+ async stop() {
304
+ if (this.intervalId) {
305
+ clearInterval(this.intervalId)
306
+ this.intervalId = undefined
307
+ }
308
+ if (this.fsw) {
309
+ await this.fsw.close()
310
+ }
311
+ }
312
+
313
+
314
+ descDeps(deps: Record<string, Record<string, { tar: string }>>) {
315
+ if (null == deps) {
316
+ return ''
317
+ }
318
+
319
+ let cwd = process.cwd()
320
+ let desc = []
321
+ for (let entryPath of Object.keys(deps)) {
322
+ desc.push(' ' + entryPath)
323
+ for (let depPath of Object.keys(deps[entryPath])) {
324
+ depPath = depPath.replace(cwd, '.')
325
+ desc.push(' ' + depPath)
326
+ }
327
+ }
328
+ return desc.join('\n')
329
+ }
330
+ }
331
+
332
+
333
+ export {
334
+ Watch
335
+ }