animader-cli 0.0.0-stage → 0.1.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/LICENSE +121 -0
- package/adapters/build-node.mjs +265 -0
- package/adapters/devserver.mjs +481 -0
- package/adapters/devsession.mjs +619 -0
- package/adapters/export-node.mjs +603 -0
- package/adapters/export-run.mjs +126 -0
- package/adapters/generations.mjs +183 -0
- package/adapters/http.mjs +294 -0
- package/adapters/launch.mjs +83 -0
- package/adapters/node-fs.mjs +80 -0
- package/adapters/pngine.mjs +87 -0
- package/adapters/snapshot.mjs +143 -0
- package/adapters/target.mjs +104 -0
- package/adapters/watch.mjs +355 -0
- package/adapters/webaudio.mjs +102 -0
- package/bin/animader.mjs +31 -0
- package/hello/join.js +46 -0
- package/hello/run.js +259 -0
- package/lib/build.mjs +126 -0
- package/lib/buildloop.mjs +254 -0
- package/lib/contract.mjs +371 -0
- package/lib/errors.mjs +51 -0
- package/lib/export.mjs +445 -0
- package/lib/new.mjs +304 -0
- package/lib/pipeline.mjs +247 -0
- package/lib/receipt.mjs +331 -0
- package/lib/rmtree.mjs +49 -0
- package/lib/routes.mjs +170 -0
- package/lib/score.mjs +434 -0
- package/lib/show.mjs +458 -0
- package/lib/soundtrack.mjs +67 -0
- package/lib/vendor.mjs +99 -0
- package/lib/wav.mjs +197 -0
- package/package.json +47 -4
- package/player/boot.mjs +792 -0
- package/player/controller.mjs +366 -0
- package/player/devchannel.mjs +306 -0
- package/player/index.html +35 -0
- package/player/page.mjs +134 -0
- package/player/player.css +393 -0
- package/player/release.mjs +76 -0
- package/src/commands/build.mjs +201 -0
- package/src/commands/dev.mjs +214 -0
- package/src/commands/doctor.mjs +348 -0
- package/src/commands/export.mjs +158 -0
- package/src/commands/new.mjs +164 -0
- package/src/commands/project.mjs +46 -0
- package/src/config.mjs +110 -0
- package/src/router.mjs +149 -0
- package/src/toolchain.mjs +691 -0
- package/starters/circle/README.md +50 -0
- package/starters/circle/animader.json +4 -0
- package/starters/circle/gitignore +9 -0
- package/starters/circle/kernels/circle.wgsl +32 -0
- package/starters/circle/program.sjon +34 -0
- package/starters/circle/timeline.piece.sjon +26 -0
- package/starters/ember/bake/spans.bin +0 -0
- package/starters/ember/kernels/curl.wgsl +10 -0
- package/starters/ember/kernels/ember.wgsl +10 -0
- package/starters/ember/kernels/hash.wgsl +12 -0
- package/starters/ember/kernels/simplex.wgsl +27 -0
- package/starters/ember/program.sjon +328 -0
- package/starters/ember/timeline.piece.sjon +34 -0
- package/toolchain/licenses/animader.txt +121 -0
- package/toolchain/licenses/esbuild.txt +21 -0
- package/toolchain/licenses/pacer.txt +121 -0
- package/toolchain/licenses/pngine.txt +121 -0
- package/toolchain/manifest.json +212 -0
- package/toolchain/prepare.mjs +446 -0
- package/toolchain/uniform-type-name.js +184 -0
- package/README.md +0 -3
package/hello/run.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// The hello player's deterministic run core: the piece of player.js that
|
|
2
|
+
// can run without a browser. The page injects pngine's `draw`/`setUniform`
|
|
3
|
+
// and pacer's compiled driver with its public routing pair
|
|
4
|
+
// (`routeWrites`/`toUniforms`); this module owns the 100-tick clock, the
|
|
5
|
+
// score's frame contract (pacer's pair, `eval(now)` in session microseconds
|
|
6
|
+
// returning write entries, split across the two buses in wire order and
|
|
7
|
+
// folded per entry into the `{ name: value }` shape the sinks take), the
|
|
8
|
+
// twelve input lanes pngine's `attachPointerTracking` holds, and the mapping
|
|
9
|
+
// from a session record back onto those lanes
|
|
10
|
+
// (npm/animader/test/pngine-normalizer.js is the same normaliser, kept for
|
|
11
|
+
// the comparison).
|
|
12
|
+
//
|
|
13
|
+
// The node test drives this module with recording fakes, so the tick
|
|
14
|
+
// arithmetic and the record-to-lane rules are pinned without WebGPU. The
|
|
15
|
+
// page's own run is the same code with the real viewer attached.
|
|
16
|
+
|
|
17
|
+
/** The piece's tick rate: a second is a hundred ticks. */
|
|
18
|
+
export const TICKS_PER_SECOND = 100;
|
|
19
|
+
|
|
20
|
+
/** One tick on the session clock, the same microseconds `:at` carries. */
|
|
21
|
+
export const MICROS_PER_TICK = 1_000_000 / TICKS_PER_SECOND;
|
|
22
|
+
|
|
23
|
+
/** The most ticks one paint may step, the page's catch-up cap. */
|
|
24
|
+
export const CATCH_UP_CAP = 12;
|
|
25
|
+
|
|
26
|
+
/** The buses the frame contract names: registry 0 visual, registry 1 audio. */
|
|
27
|
+
const REGISTRY_BUSES = 2;
|
|
28
|
+
|
|
29
|
+
const SPACE = 16;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build one run over injected siblings.
|
|
33
|
+
*
|
|
34
|
+
* @param {object} options
|
|
35
|
+
* @param {{ setControl: Function, clearControl: Function, eval: Function }} options.pacer
|
|
36
|
+
* pacer's public pair as the frame contract injects it: `setControl(ref,
|
|
37
|
+
* value)` pins before `eval`, and `eval(now)` takes the frame's now in
|
|
38
|
+
* session microseconds and answers its write entries.
|
|
39
|
+
* @param {(entries: object[], registryCount: number) => object[][]} options.routeWrites
|
|
40
|
+
* pacer's public routing split (`pacer/route`): the frame's entries
|
|
41
|
+
* grouped by their `registry` bus index.
|
|
42
|
+
* @param {(entries: object[], registry?: number) => object} options.toUniforms
|
|
43
|
+
* pacer's public fold (`pacer/route`): one bus as `{ [name]: value }`, a
|
|
44
|
+
* number for one lane and a plain array for a wider target.
|
|
45
|
+
* @param {(name: string, value: number | number[], options: object) => void} options.setUniform
|
|
46
|
+
* writes one registry-0 entry by the target's own reflected name
|
|
47
|
+
* (pngine's `setUniform`).
|
|
48
|
+
* @param {(name: string, value: number | number[], options: object) => void} [options.setParam]
|
|
49
|
+
* writes one registry-1 entry (an audio host; absent on this page).
|
|
50
|
+
* @param {(options: {time: number}) => void} options.draw draws one frame
|
|
51
|
+
* (pngine's `draw`).
|
|
52
|
+
* @param {(frame: object) => void} [options.onFrame] the page's readout.
|
|
53
|
+
*/
|
|
54
|
+
export function createPlayer({ pacer, routeWrites, toUniforms, setUniform, setParam = null, draw, onFrame = null }) {
|
|
55
|
+
const state = { tick: 0, heat: null, grain: null, held: null, draws: 0, writes: [] };
|
|
56
|
+
// pngine's twelve lanes: x, y, clickX, clickY, dx, dy, buttons,
|
|
57
|
+
// pressure, modifiers, scrollX, scrollY, pad.
|
|
58
|
+
const pointer = new Float32Array(12);
|
|
59
|
+
let lastX = 0;
|
|
60
|
+
let lastY = 0;
|
|
61
|
+
let mods = 0;
|
|
62
|
+
|
|
63
|
+
const lane = (value) => value / 65535;
|
|
64
|
+
/** The record's four modifier bits, the held Space bit kept. */
|
|
65
|
+
const modsOf = (bits) => (bits & 15) | (mods & SPACE);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* One session record lands on the input lanes, the rule pngine's
|
|
69
|
+
* `attachPointerTracking` applies to the event the record came from
|
|
70
|
+
* (pointer position, accumulated deltas, buttons, pressure, modifiers;
|
|
71
|
+
* a release negates the click point and zeroes buttons and pressure; a
|
|
72
|
+
* wheel adds the sign of its 24.8 deltas to the scroll lanes; Space
|
|
73
|
+
* toggles the sixteenth modifier bit).
|
|
74
|
+
*/
|
|
75
|
+
function apply(record) {
|
|
76
|
+
switch (record.kind) {
|
|
77
|
+
case "down": {
|
|
78
|
+
const x = lane(record.x);
|
|
79
|
+
const y = lane(record.y);
|
|
80
|
+
pointer[0] = x;
|
|
81
|
+
pointer[1] = y;
|
|
82
|
+
pointer[2] = x;
|
|
83
|
+
pointer[3] = y;
|
|
84
|
+
pointer[6] = record.buttons;
|
|
85
|
+
// pngine reads `e.pressure || 0.5` on a down, so a zero lane is a
|
|
86
|
+
// half press on the event the record rebuilds.
|
|
87
|
+
pointer[7] = lane(record.p) || 0.5;
|
|
88
|
+
pointer[8] = modsOf(record.mods);
|
|
89
|
+
mods = pointer[8];
|
|
90
|
+
lastX = x;
|
|
91
|
+
lastY = y;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
case "move": {
|
|
95
|
+
const x = lane(record.x);
|
|
96
|
+
const y = lane(record.y);
|
|
97
|
+
pointer[4] += x - lastX;
|
|
98
|
+
pointer[5] += y - lastY;
|
|
99
|
+
pointer[0] = x;
|
|
100
|
+
pointer[1] = y;
|
|
101
|
+
pointer[6] = record.buttons;
|
|
102
|
+
// `e.pressure || (e.buttons ? 0.5 : 0)` on a move.
|
|
103
|
+
pointer[7] = lane(record.p) || (record.buttons ? 0.5 : 0);
|
|
104
|
+
pointer[8] = modsOf(record.mods);
|
|
105
|
+
mods = pointer[8];
|
|
106
|
+
lastX = x;
|
|
107
|
+
lastY = y;
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
case "up":
|
|
111
|
+
case "cancel": {
|
|
112
|
+
pointer[2] = -Math.abs(pointer[2]);
|
|
113
|
+
pointer[3] = -Math.abs(pointer[3]);
|
|
114
|
+
pointer[6] = 0;
|
|
115
|
+
pointer[7] = 0;
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
case "wheel": {
|
|
119
|
+
pointer[9] += Math.sign(record.v[0]);
|
|
120
|
+
pointer[10] += Math.sign(record.v[1]);
|
|
121
|
+
pointer[8] = modsOf(record.mods);
|
|
122
|
+
mods = pointer[8];
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
case "key": {
|
|
126
|
+
if (record.str === "Space") {
|
|
127
|
+
mods = record.buttons ? mods | SPACE : mods & ~SPACE;
|
|
128
|
+
pointer[8] = mods;
|
|
129
|
+
}
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
default:
|
|
133
|
+
// resize, custom, input-write, mark, adjust and reset do not move the
|
|
134
|
+
// lanes; the page's input path is pointer, wheel and key.
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* One tick: the score's writes at the tick's own time, then one frame of
|
|
141
|
+
* the program at that time. pacer's route pair splits the frame's entries
|
|
142
|
+
* across the buses in wire order (`routeWrites`), and folding one entry at
|
|
143
|
+
* a time through `toUniforms` gives each write's value in the shape its bus
|
|
144
|
+
* sink takes: a number for one lane, a plain array otherwise. The writes
|
|
145
|
+
* reach the sinks in that order, so when two write the same name the later
|
|
146
|
+
* one wins, the frame contract's last-word rule. Every write rides
|
|
147
|
+
* `redraw: false`, and the tick's one draw carries them. Registry 0 lands
|
|
148
|
+
* by the target's own reflected name; registry 1 goes to `setParam` when
|
|
149
|
+
* the page has an audio sink. A declared-only target with no host name
|
|
150
|
+
* cannot key a by-name fold, so it is not written, exactly as the sinks
|
|
151
|
+
* could not have taken it.
|
|
152
|
+
*/
|
|
153
|
+
function step() {
|
|
154
|
+
const t = state.tick / TICKS_PER_SECOND;
|
|
155
|
+
const entries = pacer.eval(state.tick * MICROS_PER_TICK);
|
|
156
|
+
state.writes = [];
|
|
157
|
+
if (entries) {
|
|
158
|
+
const groups = routeWrites(entries, REGISTRY_BUSES);
|
|
159
|
+
for (let registry = 0; registry < groups.length; registry++) {
|
|
160
|
+
for (const entry of groups[registry]) {
|
|
161
|
+
const value = toUniforms([entry], registry)[entry.name];
|
|
162
|
+
if (value === undefined) continue;
|
|
163
|
+
state.writes.push({ name: entry.name, registry, ncomp: entry.ncomp, value });
|
|
164
|
+
if (registry === 0) {
|
|
165
|
+
setUniform(entry.name, value, { redraw: false });
|
|
166
|
+
} else if (registry === 1 && setParam) {
|
|
167
|
+
setParam(entry.name, value, { redraw: false });
|
|
168
|
+
}
|
|
169
|
+
const scalar = Array.isArray(value) ? value[0] : value;
|
|
170
|
+
if (entry.name === "heat") state.heat = scalar;
|
|
171
|
+
if (entry.name === "grain") state.grain = scalar;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
draw({ time: t });
|
|
176
|
+
state.tick += 1;
|
|
177
|
+
state.draws += 1;
|
|
178
|
+
// pngine drains dx, dy and the wheel sums after each frame
|
|
179
|
+
// (vendor/pngine.mjs, the draw path).
|
|
180
|
+
pointer[4] = 0;
|
|
181
|
+
pointer[5] = 0;
|
|
182
|
+
pointer[9] = 0;
|
|
183
|
+
pointer[10] = 0;
|
|
184
|
+
if (onFrame) onFrame(frame());
|
|
185
|
+
return state.tick;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The writes the last tick applied and the lanes it left, as plain values. */
|
|
189
|
+
function frame() {
|
|
190
|
+
return {
|
|
191
|
+
tick: state.tick,
|
|
192
|
+
heat: state.heat,
|
|
193
|
+
grain: state.grain,
|
|
194
|
+
held: state.held,
|
|
195
|
+
draws: state.draws,
|
|
196
|
+
writes: state.writes.map((w) => ({
|
|
197
|
+
name: w.name,
|
|
198
|
+
registry: w.registry,
|
|
199
|
+
ncomp: w.ncomp,
|
|
200
|
+
value: Array.isArray(w.value) ? [...w.value] : w.value,
|
|
201
|
+
})),
|
|
202
|
+
pointer: Array.from(pointer),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Step until the tick counter reaches `tick`. */
|
|
207
|
+
function stepTo(tick) {
|
|
208
|
+
while (state.tick < tick) step();
|
|
209
|
+
return state.tick;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Step the ticks strictly before session microsecond `micros`. */
|
|
213
|
+
function stepAt(micros) {
|
|
214
|
+
while (state.tick * MICROS_PER_TICK < micros) step();
|
|
215
|
+
return state.tick;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function setHeat(value) {
|
|
219
|
+
state.held = value;
|
|
220
|
+
pacer.setControl("heat", value);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function release() {
|
|
224
|
+
state.held = null;
|
|
225
|
+
pacer.clearControl("heat");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Back to tick zero, no held control, fresh lanes and no writes. */
|
|
229
|
+
function reset() {
|
|
230
|
+
state.tick = 0;
|
|
231
|
+
state.heat = null;
|
|
232
|
+
state.grain = null;
|
|
233
|
+
state.held = null;
|
|
234
|
+
state.draws = 0;
|
|
235
|
+
state.writes = [];
|
|
236
|
+
pointer.fill(0);
|
|
237
|
+
lastX = 0;
|
|
238
|
+
lastY = 0;
|
|
239
|
+
mods = 0;
|
|
240
|
+
pacer.clearControl("heat");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
state,
|
|
245
|
+
pointer,
|
|
246
|
+
apply,
|
|
247
|
+
step,
|
|
248
|
+
stepTo,
|
|
249
|
+
stepAt,
|
|
250
|
+
tick(n) {
|
|
251
|
+
for (let i = 0; i < n; i++) step();
|
|
252
|
+
return state.tick;
|
|
253
|
+
},
|
|
254
|
+
setHeat,
|
|
255
|
+
release,
|
|
256
|
+
reset,
|
|
257
|
+
frame,
|
|
258
|
+
};
|
|
259
|
+
}
|
package/lib/build.mjs
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Build orchestration, portable: the sequence and the honesty rules live
|
|
2
|
+
// here; every byte and process comes from an injected adapter. The order is
|
|
3
|
+
// fixed: verify the prepared runtime, clear stale generated output, validate
|
|
4
|
+
// the program, bake the executable payload. `preflightProject` is the
|
|
5
|
+
// legacy graphics-only spelling that adds the `/1` receipt on top of the
|
|
6
|
+
// shared bake — the score-validated full build (`lib/pipeline.mjs`) calls
|
|
7
|
+
// `bakeProject` directly and derives, packs, audits and receipts the score
|
|
8
|
+
// itself. There is deliberately no score compile, pacer invocation or
|
|
9
|
+
// target inference in the graphics bake; those semantics belong to the full
|
|
10
|
+
// pipeline and the browser's PieceHost/Pacer.load/READY chain.
|
|
11
|
+
|
|
12
|
+
import { DemoError, buildError } from './errors.mjs';
|
|
13
|
+
import { makeBuildReceipt, serializeReceipt } from './receipt.mjs';
|
|
14
|
+
|
|
15
|
+
function nativeFailure(error, phase) {
|
|
16
|
+
if (error instanceof DemoError || error?.code === 'native_pngine_failed') return error;
|
|
17
|
+
return buildError(
|
|
18
|
+
`native_${phase}`,
|
|
19
|
+
`pngine ${phase} failed: ${error?.message ?? String(error)}`,
|
|
20
|
+
'Check the program with pngine validate; the demo does not infer or retry score targets.',
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The shared graphics bake: verify the prepared runtime, ensure the output
|
|
26
|
+
* directory, clear the caller-named stale generated files, then validate
|
|
27
|
+
* and bake the program with pngine. Answers the payload bytes, their
|
|
28
|
+
* digest, the native tool identity and the verified runtime row — the
|
|
29
|
+
* inputs a receipt or an export drift check needs. Writes no receipt.
|
|
30
|
+
*
|
|
31
|
+
* @param {Object} options
|
|
32
|
+
* @param {{folder: string, files: Object}} options.project
|
|
33
|
+
* @param {{folder: string, outDir: string, payload: string}} options.paths
|
|
34
|
+
* @param {string[]} [options.staleFiles] generated files to clear first
|
|
35
|
+
* @param {Object} options.io
|
|
36
|
+
* @param {Object} options.native
|
|
37
|
+
* @param {Object} options.runtime
|
|
38
|
+
* @param {(bytes: Uint8Array) => string} options.hashBytes
|
|
39
|
+
*/
|
|
40
|
+
export async function bakeProject({ project, paths, staleFiles = [], io, native, runtime, hashBytes }) {
|
|
41
|
+
const verified = await runtime.verify();
|
|
42
|
+
|
|
43
|
+
await io.ensureDir(paths.outDir);
|
|
44
|
+
for (const stale of staleFiles) {
|
|
45
|
+
await io.removeFile(stale);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const identity = await native.identity();
|
|
49
|
+
try {
|
|
50
|
+
await native.validate({ cwd: paths.folder, program: project.files.program.name });
|
|
51
|
+
} catch (error) {
|
|
52
|
+
throw nativeFailure(error, 'validate');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
await native.bake({ cwd: paths.folder, program: project.files.program.name, output: paths.payload });
|
|
57
|
+
} catch (error) {
|
|
58
|
+
throw nativeFailure(error, 'bake');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const stats = await io.statFile(paths.payload);
|
|
62
|
+
if (stats === null || !stats.isFile || stats.size === 0) {
|
|
63
|
+
throw buildError(
|
|
64
|
+
'bake_missing_output',
|
|
65
|
+
`pngine did not leave a non-empty payload at ${paths.payloadRelative ?? paths.payload}`,
|
|
66
|
+
'Run pngine render by hand from the project folder and inspect its stderr.',
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
const bytes = await io.readFile(paths.payload);
|
|
70
|
+
return {
|
|
71
|
+
payloadPath: paths.payload,
|
|
72
|
+
bytes,
|
|
73
|
+
sha256: hashBytes(bytes),
|
|
74
|
+
identity,
|
|
75
|
+
runtime: {
|
|
76
|
+
format: verified.format,
|
|
77
|
+
digest: verified.digest,
|
|
78
|
+
files: verified.files.length,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The legacy graphics-only preflight: the shared bake plus the
|
|
85
|
+
* `animader-demo-build/1` receipt. `node demo/show.mjs --preflight-only`
|
|
86
|
+
* keeps exactly this meaning; it never validates a score.
|
|
87
|
+
*
|
|
88
|
+
* @param {Object} options
|
|
89
|
+
* @param {{folder: string, files: Object}} options.project
|
|
90
|
+
* @param {number} options.seconds
|
|
91
|
+
* @param {Object} options.paths
|
|
92
|
+
* @param {Object} options.io
|
|
93
|
+
* @param {Object} options.native
|
|
94
|
+
* @param {Object} options.runtime
|
|
95
|
+
* @param {(bytes: Uint8Array) => string} options.hashBytes
|
|
96
|
+
*/
|
|
97
|
+
export async function preflightProject({ project, seconds, paths, io, native, runtime, hashBytes }) {
|
|
98
|
+
const baked = await bakeProject({
|
|
99
|
+
project,
|
|
100
|
+
paths,
|
|
101
|
+
staleFiles: [paths.payload, paths.receipt],
|
|
102
|
+
io,
|
|
103
|
+
native,
|
|
104
|
+
runtime,
|
|
105
|
+
hashBytes,
|
|
106
|
+
});
|
|
107
|
+
const receipt = makeBuildReceipt({
|
|
108
|
+
project,
|
|
109
|
+
projectName: paths.projectName,
|
|
110
|
+
seconds,
|
|
111
|
+
output: {
|
|
112
|
+
name: paths.payloadRelative,
|
|
113
|
+
bytes: baked.bytes.byteLength,
|
|
114
|
+
sha256: baked.sha256,
|
|
115
|
+
},
|
|
116
|
+
runtime: baked.runtime,
|
|
117
|
+
tool: baked.identity,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await io.writeFile(paths.receipt, serializeReceipt(receipt));
|
|
121
|
+
return {
|
|
122
|
+
payloadPath: paths.payload,
|
|
123
|
+
receiptPath: paths.receipt,
|
|
124
|
+
receipt,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// The watch-mode build loop (plan section 5): the scheduling decisions only.
|
|
2
|
+
// Bursts of change notifications coalesce into one build (a trailing debounce
|
|
3
|
+
// while idle); changes that arrive while a build is running set one dirty
|
|
4
|
+
// flag, so however many landed, exactly one follow-up build is queued; a
|
|
5
|
+
// candidate built while any change was observed is discarded and rebuilt —
|
|
6
|
+
// it is never published — so only a candidate that observed a quiet window
|
|
7
|
+
// becomes the served generation. Builds are serialized by construction (one
|
|
8
|
+
// cycle at a time), which is what keeps two generations from interleaving.
|
|
9
|
+
//
|
|
10
|
+
// When the caller provides `isStale`, the discard decision stops depending
|
|
11
|
+
// on event delivery: after `build` resolves and before the discard/publish
|
|
12
|
+
// choice, the candidate's own inputs are checked once more, so a change
|
|
13
|
+
// whose event has not been delivered yet (a platform watcher with latency,
|
|
14
|
+
// a silent watcher) still cannot publish. `isStale` is the authority
|
|
15
|
+
// whenever it is provided — a `dirty` flag set by a prompt whose identity
|
|
16
|
+
// the candidate's own start snapshot already consumed is a late duplicate
|
|
17
|
+
// (the macOS FSEvents shape) and is dropped by that same check; callers
|
|
18
|
+
// that pass no `isStale` keep the dirty flag as the decision, exactly as
|
|
19
|
+
// before.
|
|
20
|
+
//
|
|
21
|
+
// When the caller provides `shouldBuild`, one more decision moves out of
|
|
22
|
+
// the loop's own state: a cycle entered from a prompt (the debounce timer,
|
|
23
|
+
// the initial kick, or the one follow-up owed by a dirty build) asks it
|
|
24
|
+
// whether the inputs a build would read have moved since the last
|
|
25
|
+
// attempt, and a `false` answer clears the owed work and schedules
|
|
26
|
+
// nothing — a prompt for unchanged inputs costs no build. The check runs
|
|
27
|
+
// once, before the cycle's first build only: a candidate discarded by
|
|
28
|
+
// `dirty` or `isStale` always owes its rebuild, so that path never asks.
|
|
29
|
+
// A check that cannot run cannot prove a skip either and builds, the same
|
|
30
|
+
// safe direction `isStale` takes.
|
|
31
|
+
//
|
|
32
|
+
// The loop builds nothing and owns no filesystem: `build` produces a
|
|
33
|
+
// candidate, `discard` throws one away, `publish` makes it current, and each
|
|
34
|
+
// is injected. A rejected `build` reaches `onError` and the loop keeps
|
|
35
|
+
// serving later changes — the caller reports it, the watcher keeps watching.
|
|
36
|
+
// `close` is final: a pending debounce is cancelled, an in-flight candidate
|
|
37
|
+
// is discarded when it lands, and later changes start nothing.
|
|
38
|
+
//
|
|
39
|
+
// This module is portable: no `node:` import, no process state, no DOM. The
|
|
40
|
+
// debounce uses the plain global timer pair, the same shape in a host and a
|
|
41
|
+
// test.
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {Object} options
|
|
45
|
+
* @param {() => Promise<any>} options.build one build over a fresh staging
|
|
46
|
+
* area, resolving to its candidate
|
|
47
|
+
* @param {(candidate: any) => void|Promise<void>} options.discard throw a
|
|
48
|
+
* candidate away without ever publishing it
|
|
49
|
+
* @param {(candidate: any) => void|Promise<void>} options.publish make a
|
|
50
|
+
* candidate the current generation
|
|
51
|
+
* @param {(error: unknown) => void} [options.onError] a rejected build,
|
|
52
|
+
* reported instead of thrown
|
|
53
|
+
* @param {(candidate: any) => boolean|Promise<boolean>} [options.isStale]
|
|
54
|
+
* the authority on whether the candidate's own inputs moved while it was
|
|
55
|
+
* being built, consulted after `build` resolves and before the
|
|
56
|
+
* discard/publish decision; true sends the candidate down the same
|
|
57
|
+
* discard-and-rebuild path the dirty flag does. When provided it decides
|
|
58
|
+
* even when `dirty` is set — an arrived event is not a moved input, and
|
|
59
|
+
* the platform's late duplicate delivery for a write the candidate
|
|
60
|
+
* already consumed is not a change
|
|
61
|
+
* @param {() => boolean|Promise<boolean>} [options.shouldBuild] the
|
|
62
|
+
* authority on whether the inputs a scheduled build would read have moved
|
|
63
|
+
* since the last attempt, consulted when a cycle entered from a prompt is
|
|
64
|
+
* about to start its first build; false clears the owed work and builds
|
|
65
|
+
* nothing. Never consulted for a rebuild a discard owes.
|
|
66
|
+
* @param {number} [options.debounceMs] the idle burst-coalescing window
|
|
67
|
+
*/
|
|
68
|
+
export function createBuildLoop({ build, discard, publish, onError = null, isStale = null, shouldBuild = null, debounceMs = 150 }) {
|
|
69
|
+
if (typeof build !== 'function' || typeof discard !== 'function' || typeof publish !== 'function') {
|
|
70
|
+
throw new TypeError('createBuildLoop needs build, discard and publish functions');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let closed = false;
|
|
74
|
+
let running = false;
|
|
75
|
+
let dirty = false;
|
|
76
|
+
let timer = null;
|
|
77
|
+
const idleWaiters = new Set();
|
|
78
|
+
|
|
79
|
+
function clearTimer() {
|
|
80
|
+
if (timer !== null) {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
timer = null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Resolve the idle waiters once no cycle runs and none is owed. */
|
|
87
|
+
function settleIfIdle() {
|
|
88
|
+
if (running || dirty || timer !== null) return;
|
|
89
|
+
const waiters = [...idleWaiters];
|
|
90
|
+
idleWaiters.clear();
|
|
91
|
+
for (const waiter of waiters) waiter();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A discard or publish rejection is reported like a build failure instead
|
|
96
|
+
* of leaving an unhandled rejection in the host; the cycle then settles
|
|
97
|
+
* until the next change.
|
|
98
|
+
*/
|
|
99
|
+
async function guard(step) {
|
|
100
|
+
try {
|
|
101
|
+
await step();
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (onError) onError(error);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** One serialized cycle: build until a candidate saw a quiet window. */
|
|
108
|
+
async function cycle() {
|
|
109
|
+
running = true;
|
|
110
|
+
try {
|
|
111
|
+
// A scheduled build is about to start: the caller's own authority on
|
|
112
|
+
// whether the inputs it would read have moved since the last
|
|
113
|
+
// attempt. This runs for every cycle a prompt enters — the debounce
|
|
114
|
+
// timer, the initial kick, the one follow-up a dirty build owes —
|
|
115
|
+
// and never for the rebuild a discard owes (that rebuild is owed by
|
|
116
|
+
// the discard itself, whatever the inputs now equal). A prompt that
|
|
117
|
+
// arrives while the check runs sets `dirty` here, and a dirty cycle
|
|
118
|
+
// builds regardless of the answer, so no prompt is lost to the gap
|
|
119
|
+
// between the check's stat and its verdict.
|
|
120
|
+
if (shouldBuild !== null) {
|
|
121
|
+
let wanted;
|
|
122
|
+
try {
|
|
123
|
+
wanted = await shouldBuild();
|
|
124
|
+
} catch (error) {
|
|
125
|
+
// A check that cannot run cannot prove a skip either: report it
|
|
126
|
+
// like a build failure and build — the safe direction.
|
|
127
|
+
if (onError) onError(error);
|
|
128
|
+
wanted = true;
|
|
129
|
+
}
|
|
130
|
+
if (!wanted) {
|
|
131
|
+
if (!dirty) return; // nothing moved: no build, nothing scheduled
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
for (;;) {
|
|
135
|
+
dirty = false;
|
|
136
|
+
let candidate;
|
|
137
|
+
try {
|
|
138
|
+
candidate = await build();
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (onError) onError(error);
|
|
141
|
+
// The dirty flag set during the failed build still owes its one
|
|
142
|
+
// follow-up; otherwise the loop settles until the next change.
|
|
143
|
+
if (!dirty) return;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (closed) {
|
|
147
|
+
await guard(() => discard(candidate));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
let moved = dirty;
|
|
151
|
+
if (isStale !== null) {
|
|
152
|
+
// The authority, even over a dirty flag a prompt set: the check
|
|
153
|
+
// re-stats the candidate's own start snapshot, so a prompt whose
|
|
154
|
+
// identity that snapshot already consumed (a late duplicate
|
|
155
|
+
// delivered while this candidate was in flight) answers fresh
|
|
156
|
+
// and is dropped — "an event arrived" is not "the inputs
|
|
157
|
+
// moved". Callers without isStale keep the dirty flag as
|
|
158
|
+
// before.
|
|
159
|
+
try {
|
|
160
|
+
moved = await isStale(candidate);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
// A check that cannot run cannot prove the candidate fresh
|
|
163
|
+
// either: report it like a build failure and let the rebuild
|
|
164
|
+
// decide — the same safe direction an unanswerable stat has.
|
|
165
|
+
if (onError) onError(error);
|
|
166
|
+
moved = true;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (moved) {
|
|
170
|
+
// A change was observed while this candidate was being taken or
|
|
171
|
+
// built — by an event (the fast path) or by the candidate's own
|
|
172
|
+
// inputs having moved on disk (the authority): discard it and
|
|
173
|
+
// rebuild before publishing anything.
|
|
174
|
+
await guard(() => discard(candidate));
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
await guard(() => publish(candidate));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
} finally {
|
|
181
|
+
running = false;
|
|
182
|
+
if (!closed && dirty && timer === null) {
|
|
183
|
+
// Exactly one follow-up for the changes that landed during the last
|
|
184
|
+
// build; no debounce is owed because the events already happened.
|
|
185
|
+
dirty = false;
|
|
186
|
+
cycle();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
settleIfIdle();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* A promise that resolves once no build is running and none is queued —
|
|
195
|
+
* the initial build's settle, and the test/telemetry seam for the same.
|
|
196
|
+
*/
|
|
197
|
+
function idle() {
|
|
198
|
+
if (!running && !dirty && timer === null) return Promise.resolve();
|
|
199
|
+
return new Promise((resolve) => {
|
|
200
|
+
idleWaiters.add(resolve);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
/** True while a build cycle is in flight. */
|
|
206
|
+
get running() { return running; },
|
|
207
|
+
/** True when a change arrived during the current build. */
|
|
208
|
+
get dirty() { return dirty; },
|
|
209
|
+
/** True when a debounce is scheduled or a follow-up is queued. */
|
|
210
|
+
get pending() { return timer !== null; },
|
|
211
|
+
/** True once close() has been called; the loop does nothing after it. */
|
|
212
|
+
get closed() { return closed; },
|
|
213
|
+
/** Resolves when no build runs and none is queued (or once closed). */
|
|
214
|
+
idle,
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* One filtered change arrived. Idle: schedule one coalesced cycle after
|
|
218
|
+
* the debounce window (the timer resets per event, so a burst is one
|
|
219
|
+
* build). Building: set the single dirty flag that queues one follow-up.
|
|
220
|
+
*/
|
|
221
|
+
notifyChange() {
|
|
222
|
+
if (closed) return;
|
|
223
|
+
if (running) {
|
|
224
|
+
dirty = true;
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (timer !== null) clearTimeout(timer);
|
|
228
|
+
timer = setTimeout(() => {
|
|
229
|
+
timer = null;
|
|
230
|
+
cycle();
|
|
231
|
+
}, debounceMs);
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
/** Start one cycle now (the initial build); a no-op while one runs. */
|
|
235
|
+
kick() {
|
|
236
|
+
if (closed || running) return;
|
|
237
|
+
clearTimer();
|
|
238
|
+
cycle();
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
/** Final: cancel the pending debounce, discard what is in flight. */
|
|
242
|
+
async close() {
|
|
243
|
+
if (closed) return;
|
|
244
|
+
closed = true;
|
|
245
|
+
clearTimer();
|
|
246
|
+
// An in-flight cycle observes `closed` when its build resolves and
|
|
247
|
+
// discards the candidate itself; a cycle between builds has nothing
|
|
248
|
+
// in flight. Wait for neither: close must not block on a build.
|
|
249
|
+
const waiters = [...idleWaiters];
|
|
250
|
+
idleWaiters.clear();
|
|
251
|
+
for (const waiter of waiters) waiter();
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|