@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/bin/voxgig-model +4 -4
- package/dist/model.js +29 -1
- package/dist/model.js.map +1 -1
- package/dist/producer/msg.d.ts +4 -0
- package/dist/producer/msg.js +170 -0
- package/dist/producer/msg.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -4
- package/src/build.ts +254 -0
- package/src/config.ts +49 -0
- package/src/init.ts +65 -0
- package/src/model.ts +330 -0
- package/src/producer/local.ts +116 -0
- package/src/producer/model.ts +122 -0
- package/src/producer/msg.ts +207 -0
- package/src/tsconfig.json +19 -0
- package/src/types.ts +162 -0
- package/src/watch.ts +335 -0
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
|
+
}
|