@m4l-jweb/build 1.3.1 → 1.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/build",
3
- "version": "1.3.1",
3
+ "version": "1.6.0",
4
4
  "description": "m4l-jweb: the CLI that builds and packages a device repo into installable Max for Live devices.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,6 +18,8 @@
18
18
  "exports": {
19
19
  ".": "./src/index.mjs",
20
20
  "./chains": "./src/chains.mjs",
21
+ "./controls": "./src/controls.mjs",
22
+ "./target": "./src/target.mjs",
21
23
  "./surface": "./src/surface.mjs",
22
24
  "./watch": "./src/watch.mjs",
23
25
  "./files": "./src/files.mjs",
@@ -35,6 +37,6 @@
35
37
  "archiver": "^7.0.1",
36
38
  "esbuild": "^0.25.0",
37
39
  "typescript": "^5.7.0",
38
- "@m4l-jweb/wrapper": "1.3.1"
40
+ "@m4l-jweb/wrapper": "1.6.0"
39
41
  }
40
42
  }
package/src/chains.mjs CHANGED
@@ -100,7 +100,10 @@ export function claimAppMessages(ctx, routeId, unmatchedOutlet) {
100
100
 
101
101
  removeLine(ctx.lines, srcId, ctx.unmatchedId);
102
102
  ctx.lines.push(line(srcId, srcOutlet, routeId, 0));
103
- ctx.lines.push(line(routeId, unmatchedOutlet, ctx.unmatchedId, 0));
103
+ // A HEADLESS device has nowhere to pass the tail on TO: `[js]` is already the far
104
+ // end of this stream, so the last route's unmatched outlet is left unconnected
105
+ // rather than wired back into the box the messages came from.
106
+ if (ctx.unmatchedId) ctx.lines.push(line(routeId, unmatchedOutlet, ctx.unmatchedId, 0));
104
107
  ctx.appOut = [routeId, unmatchedOutlet];
105
108
  }
106
109
 
@@ -232,7 +235,7 @@ export function assertUniqueBoxIds(boxes, deviceName, scope = "the patcher") {
232
235
  * Also CUTS the template's direct midiin -> midiout thru cord: a device that
233
236
  * transforms notes must not also leak the untransformed ones.
234
237
  */
235
- function midiInChain({ boxes, lines, jwebId }) {
238
+ function midiInChain({ boxes, lines, appIn }) {
236
239
  removeLine(lines, "obj-midiin", "obj-midiout");
237
240
  boxes.push(
238
241
  box("obj-midiparse", "midiparse", {
@@ -244,7 +247,10 @@ function midiInChain({ boxes, lines, jwebId }) {
244
247
  boxes.push(box("obj-noteinmsg", "prepend notein"));
245
248
  lines.push(line("obj-midiin", 0, "obj-midiparse", 0));
246
249
  lines.push(line("obj-midiparse", 0, "obj-noteinmsg", 0)); // outlet 0 = note: pitch, velocity
247
- lines.push(line("obj-noteinmsg", 0, jwebId, 0));
250
+ // The APP endpoint - `[jweb]` under the default target, `[js]` under headless. A
251
+ // played note reaches a React `onNote()` or a `function notein()` in [js], and this
252
+ // chain does not know or care which.
253
+ lines.push(line("obj-noteinmsg", 0, appIn, 0));
248
254
  }
249
255
 
250
256
  /**
@@ -854,6 +860,15 @@ function reverbChain(ctx) {
854
860
  */
855
861
  function webaudioChain(ctx) {
856
862
  const { boxes, lines, jwebId } = ctx;
863
+ // The signal comes out of the PAGE's own outlets. There is no page in a headless
864
+ // device, so this is not a stage that can be claimed - say so here rather than
865
+ // emit a cord from a box that is not in the patcher.
866
+ if (!jwebId) {
867
+ throw new Error(
868
+ `chain "webaudio" on device "${ctx.device?.name}" takes its signal from the page's [jweb~] outlets, ` +
869
+ `and target "headless" has no page. Write the audio in MSP (a chain), or drop \`target: "headless"\`.`,
870
+ );
871
+ }
857
872
 
858
873
  for (const ch of [0, 1]) {
859
874
  const [srcId, srcOut] = ctx.audioIn(ch);
@@ -0,0 +1,209 @@
1
+ /**
2
+ * controls.mjs - the build side of defineControls().
3
+ *
4
+ * The fourth of the same pipeline (surface.mjs, watch.mjs, files.mjs), and like
5
+ * files.mjs it produces BOTH kinds of output: a patcher CHAIN and a data banner.
6
+ * The declaration itself rides on the Surface - `defineSurface({ controls })` - so
7
+ * there is nothing to import here that `loadSurface()` has not already loaded.
8
+ *
9
+ * ------------------------------------------------------------------------------
10
+ * WHERE THE WORK LIVES, and why it is split where it is.
11
+ *
12
+ * doc/TODO.md asked for discovery, grab, release and the value observer in
13
+ * the chain, with the frame diff and `send_value` in the wrapper. The line moved,
14
+ * and it moved for one reason: EVERY MEASURED FACT ABOUT THIS API WAS MEASURED
15
+ * THROUGH `[js]`. `push-probe` resolved the surface, grabbed by name, painted and
16
+ * observed from LiveAPI in the wrapper, and doc/MAX-FACTS.md's numbers are that
17
+ * path's numbers. A patcher rewrite of the same steps is unmeasured code in an API
18
+ * that reports NOTHING when it is wrong - so the parts that only [js] can do at all
19
+ * stayed in [js], and the part the constraint is actually about moved out of it:
20
+ *
21
+ * the chain THE INPUT PATH. One `[live.observer value]` per declared control,
22
+ * straight into `[jweb]`. A press crosses no [js] at all. Plus the
23
+ * two takeover parameters, tapped into [js] so the grab follows the
24
+ * dial even with the page closed or dead.
25
+ *
26
+ * the wrapper discovery (walking `live_app`'s `control_surfaces` is a LOOP, and
27
+ * a loop in a patcher is [uzi] + [zl] that nothing can test without
28
+ * the hardware), resolving each role against `get_control_names`,
29
+ * grab, release, the focus policy, the frame buffer and its per-cell
30
+ * diff, and `send_value`.
31
+ *
32
+ * The constraint that carries a failure mode - a grabbed pad must not wait on
33
+ * anything the page is doing - is met. The constraint that was a preference is not,
34
+ * and this comment is the record of the trade rather than a silent divergence.
35
+ * ------------------------------------------------------------------------------
36
+ */
37
+ import { box, fanParamInto, line } from "./chains.mjs";
38
+
39
+ /** Must match FOCUS_OPTIONS in @m4l-jweb/surface - the menu's value is its index. */
40
+ const FOCUS_OPTIONS = ["Device", "Track", "Always"];
41
+
42
+ /** The chain that owns the observers. Every device that declares controls needs it. */
43
+ export const CONTROLS_CHAIN = "takeover";
44
+
45
+ /** The parameters defineControls() contributes. Must match TAKEOVER_PARAM / FOCUS_PARAM in @m4l-jweb/surface. */
46
+ export const TAKEOVER_PARAM = "takeover";
47
+ export const FOCUS_PARAM = "focus";
48
+
49
+ /**
50
+ * The chain list this device is actually built with.
51
+ *
52
+ * A device that declares controls gets `takeover` whether or not the manifest asked
53
+ * for it, appended LAST for the same reason `download` is: it claims no stage of the
54
+ * signal path, but chain order IS the signal path and inserting anywhere else would
55
+ * silently re-route a device that merely started using the pads.
56
+ *
57
+ * Idempotent - a manifest that lists it keeps exactly one, since running the chain
58
+ * twice emits the same box ids twice and assertUniqueBoxIds rejects that.
59
+ */
60
+ export function withControlsChain(chains, surface) {
61
+ const declared = chains ?? [];
62
+ if (!surface?.controls) return declared;
63
+ return declared.includes(CONTROLS_CHAIN) ? declared : [...declared, CONTROLS_CHAIN];
64
+ }
65
+
66
+ /**
67
+ * The `var CONTROLS_SPEC = {...}` banner prepended to a device's wrapper.js.
68
+ *
69
+ * Only what the wrapper ACTS on travels: the key (which is the selector suffix and
70
+ * the argument the chain routes on), the kind, the size of the frame buffer, and the
71
+ * CANDIDATE NAMES to resolve the role against. The role name itself rides along
72
+ * because it is what a console line has to say when a role does not resolve, and
73
+ * "matrix" is what the device author wrote.
74
+ *
75
+ * A device with no declaration gets no banner ("") - `typeof CONTROLS_SPEC ===
76
+ * "undefined"` is exactly the guard the wrapper checks.
77
+ */
78
+ export function controlsSpecBanner(surface) {
79
+ const controls = surface?.controls;
80
+ if (!controls) return "";
81
+ const spec = {
82
+ surface: controls.surface,
83
+ /**
84
+ * The declared `focus` default, as the menu INDEX Max stores it as.
85
+ *
86
+ * The wrapper needs it because a `live.menu` does not necessarily announce its
87
+ * value at load - and a wrapper that assumed 0 would treat a device declared
88
+ * `Track` as `Device` until somebody touched the menu, which is a takeover that
89
+ * silently does not happen on a track the user has selected.
90
+ */
91
+ focus: FOCUS_OPTIONS.indexOf(controls.defaultFocus),
92
+ controls: controls.keys.map((key) => {
93
+ const c = controls.controls[key];
94
+ return {
95
+ key,
96
+ kind: c.kind,
97
+ role: c.role,
98
+ rows: c.kind === "grid" ? c.rows : 1,
99
+ cols: c.kind === "grid" ? c.cols : 1,
100
+ // Stamped onto the spec by defineControls() from its own ROLE_NAMES table,
101
+ // so this file never reimplements it. See ControlBase in controls.ts.
102
+ names: c.names ?? [],
103
+ };
104
+ }),
105
+ };
106
+ return `var CONTROLS_SPEC = ${JSON.stringify(spec)};\n`;
107
+ }
108
+
109
+ /** The per-key selector the wrapper addresses the chain with: `tk_<key> id <n>`. */
110
+ export const controlIdSelector = (key) => `tk_${key}`;
111
+
112
+ /**
113
+ * "takeover" - the pads' INPUT PATH, and the two parameters that switch it on.
114
+ *
115
+ * ```
116
+ * [js] outlet 1 -> [route tk_pads ...] -> id <n> -> right inlet of
117
+ * [live.observer value]
118
+ * |
119
+ * [prepend pad_pads] -+
120
+ * |
121
+ * [jweb]
122
+ * ```
123
+ *
124
+ * THE OBSERVER IS THE WHOLE POINT OF THE CHAIN. A press reaches the page through
125
+ * `[live.observer]` -> `[prepend]` -> `[jweb]` and touches no [js] on the way, so the
126
+ * one thing that must never queue behind anything else does not.
127
+ *
128
+ * `live.observer`'s LEFT OUTLET CARRIES THE VALUE AND NOTHING ELSE - read off Max's
129
+ * own reference on disk (`refpages/m4l-ref/live.observer.maxref.xml`: "The left
130
+ * outlet is reserved for value messages, all other output is sent to the right
131
+ * outlet"), which is NOT the shape [js] sees. A [js] callback is handed
132
+ * `["value", ...]`; this is the atoms alone. The page is written against the atoms.
133
+ *
134
+ * ...and it fires ONCE ON ATTACH, with the property's current value, which for a
135
+ * control nobody has touched is not a press. That reaches the page as a `pad_<key>`
136
+ * with no arguments and `padStore` drops it on arity - the same trap the probe hit
137
+ * as a press at (undefined, undefined).
138
+ *
139
+ * THE TWO PARAMETERS ARE TAPPED INTO `[js]`, not read out of the page. `takeover`
140
+ * decides whether this device holds the hardware, and a device whose grid dies
141
+ * because its Chromium view is closed is a device that fails exactly when the user
142
+ * is looking at the Push instead of the screen. `fanParamInto` wires BOTH of a
143
+ * parameter's sources - the object's own outlet (a knob turn, an automation lane,
144
+ * Push) and the route outlet carrying what the app wrote - because the app's write
145
+ * reaches the object as `set`, which updates it without producing output.
146
+ */
147
+ export function takeoverChain(ctx) {
148
+ const { boxes, lines, appIn, surface, device } = ctx;
149
+ // The wrapper's own box, not `ctx.unmatchedId` - which is null under the headless
150
+ // target, where there is no separate far end to pass a tail on to. The id for the
151
+ // observer comes off [js]'s AUX outlet either way.
152
+ const jsId = "obj-js";
153
+ const controls = surface?.controls;
154
+ if (!controls) {
155
+ throw new Error(
156
+ `chain "${CONTROLS_CHAIN}" on device "${device?.name}" needs a defineControls() declaration - ` +
157
+ `pass it to defineSurface({ ..., controls }) in src/app/${device?.ui ?? device?.name}/surface.ts.`,
158
+ );
159
+ }
160
+ for (const id of [TAKEOVER_PARAM, FOCUS_PARAM]) {
161
+ if (!surface.params?.[id]) {
162
+ throw new Error(
163
+ `chain "${CONTROLS_CHAIN}" on device "${device?.name}" expected the generated parameter "${id}". ` +
164
+ `defineSurface() adds it when a \`controls\` declaration is present - this surface has ${surface.ids.join(", ") || "none"}.`,
165
+ );
166
+ }
167
+ }
168
+
169
+ const keys = controls.keys;
170
+ const routeId = "obj-tk-route";
171
+
172
+ // [js]'s AUX outlet, the same one the `download` chain takes [maxurl]'s requests
173
+ // from. Two routes hang off it in parallel and that is safe here where it is not
174
+ // safe on [jweb]'s outlet: neither of them forwards what it did not match, so a
175
+ // message is delivered once or dropped, never twice.
176
+ boxes.push(
177
+ box(routeId, `route ${keys.map(controlIdSelector).join(" ")}`, {
178
+ numoutlets: keys.length + 1,
179
+ outlettype: keys.map(() => "").concat(""),
180
+ }),
181
+ );
182
+ lines.push(line(jsId, 1, routeId, 0));
183
+
184
+ keys.forEach((key, i) => {
185
+ const obs = `obj-tk-obs-${key}`;
186
+ const tag = `obj-tk-pad-${key}`;
187
+ boxes.push(box(obs, "live.observer value", { numinlets: 2, numoutlets: 2, outlettype: ["", ""] }));
188
+ // The RIGHT inlet takes `id <n>` - the reference is explicit that the id goes
189
+ // there, and [route] has already stripped the `tk_<key>` word by the time it
190
+ // arrives, leaving exactly that message.
191
+ lines.push(line(routeId, i, obs, 1));
192
+ boxes.push(box(tag, `prepend pad_${key}`));
193
+ lines.push(line(obs, 0, tag, 0));
194
+ // The APP endpoint. Under the default target a press lands in the page with no
195
+ // [js] in the way; under headless the page is not there and it lands in [js],
196
+ // which is where the game already was.
197
+ lines.push(line(tag, 0, appIn, 0));
198
+ });
199
+
200
+ for (const [param, selector] of [
201
+ [TAKEOVER_PARAM, "controls_takeover"],
202
+ [FOCUS_PARAM, "controls_focus"],
203
+ ]) {
204
+ const id = `obj-tk-${param}`;
205
+ boxes.push(box(id, `prepend ${selector}`));
206
+ lines.push(line(id, 0, jsId, 0));
207
+ fanParamInto(ctx, param, id, 0);
208
+ }
209
+ }
package/src/index.mjs CHANGED
@@ -16,11 +16,18 @@ import path from "node:path";
16
16
  import { fileURLToPath, pathToFileURL } from "node:url";
17
17
 
18
18
  import { AMXD_TYPES, assertES5, buildAmxd, extraPayloadsJs, payloadJs } from "./amxd.mjs";
19
- import { CHAINS, assertUniqueBoxIds, closeAudio, openAudio, resetLayout } from "./chains.mjs";
19
+ import { CHAINS, assertUniqueBoxIds, closeAudio, openAudio, registerChain, resetLayout } from "./chains.mjs";
20
+ import { CONTROLS_CHAIN, controlsSpecBanner, takeoverChain, withControlsChain } from "./controls.mjs";
21
+ import { deviceTarget, isHeadless, openApp } from "./target.mjs";
20
22
  import { applySurface, applyWindows, applyPersistence, loadSurface, parameterRegistry, surfaceContext } from "./surface.mjs";
21
23
  import { effectiveChains, filesSpecBanner, loadFiles } from "./files.mjs";
22
24
  import { loadWatch, watchSpecsBanner } from "./watch.mjs";
23
25
 
26
+ // The takeover chain lives in controls.mjs beside the rest of defineControls()'s
27
+ // build half, and joins the vocabulary here rather than in chains.mjs - which it
28
+ // imports for box()/line()/fanParamInto(), and which must not import it back.
29
+ registerChain(CONTROLS_CHAIN, takeoverChain);
30
+
24
31
  const require = createRequire(import.meta.url);
25
32
  const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
26
33
  const templates = path.join(pkgDir, "templates");
@@ -113,6 +120,76 @@ export function buildWrapper(root) {
113
120
  return out;
114
121
  }
115
122
 
123
+ /**
124
+ * Compile ONE headless device's own `[js]` logic to ES5.
125
+ *
126
+ * `src/app/<device>/headless.ts` is what `App.tsx` is for a jweb device: the thing the
127
+ * device actually does. It is concatenated after the packaged wrapper, so it sees the
128
+ * wrapper's globals (`post`, `outlet`, `Task`, `LiveAPI`, `MODE`) and typechecks
129
+ * against them - which is why the wrapper sources are in the program even though only
130
+ * this file's output is kept.
131
+ *
132
+ * ONE PROGRAM PER DEVICE, deliberately. Compiling every device's headless source
133
+ * together would put them all in one global scope, where two devices could not both
134
+ * declare a `var step` - a constraint invented by the build, paid by the author, for
135
+ * nothing. A tsc run is about a second.
136
+ *
137
+ * The ES5 gate is the same one the wrapper passes and for the same reason: Max's [js]
138
+ * is an ES5-era interpreter, and this file runs inside it.
139
+ */
140
+ export function buildHeadless(root, uiDir) {
141
+ const src = path.join(root, "src", "app", uiDir, "headless.ts");
142
+ if (!existsSync(src)) return null;
143
+
144
+ const { sources, types } = require("@m4l-jweb/wrapper/sources");
145
+ const deviceExt = path.join(root, "wrapper", "device.ts");
146
+ const context = [...sources, ...(existsSync(deviceExt) ? [deviceExt] : [])];
147
+
148
+ const tmp = path.join(root, "dist", `.headless-${uiDir}`);
149
+ rmSync(tmp, { recursive: true, force: true });
150
+ mkdirSync(tmp, { recursive: true });
151
+
152
+ // Staged flat, like buildWrapper: tsc derives its output layout from the common root
153
+ // of its inputs, and the packaged sources live in node_modules while this one lives
154
+ // in the repo.
155
+ const staged = context.map((f, i) => {
156
+ const dest = path.join(tmp, `${String(i).padStart(2, "0")}-${path.basename(f)}`);
157
+ writeFileSync(dest, readFileSync(f, "utf8"));
158
+ return dest;
159
+ });
160
+ const mine = path.join(tmp, "device-headless.ts");
161
+ writeFileSync(mine, readFileSync(src, "utf8"));
162
+ const stagedTypes = path.join(tmp, path.basename(types));
163
+ writeFileSync(stagedTypes, readFileSync(types, "utf8"));
164
+
165
+ const tsconfig = path.join(tmp, "tsconfig.json");
166
+ writeFileSync(
167
+ tsconfig,
168
+ JSON.stringify({
169
+ compilerOptions: {
170
+ target: "ES5",
171
+ lib: ["ES5"],
172
+ module: "none",
173
+ outDir: tmp,
174
+ strict: true,
175
+ noImplicitThis: false,
176
+ alwaysStrict: false,
177
+ noImplicitAny: false,
178
+ skipLibCheck: true,
179
+ types: [],
180
+ },
181
+ files: [stagedTypes, ...staged, mine],
182
+ }),
183
+ );
184
+ execFileSync(process.execPath, [require.resolve("typescript/bin/tsc"), "-p", tsconfig], { stdio: "inherit" });
185
+
186
+ const js = readFileSync(mine.replace(/\.ts$/, ".js"), "utf8");
187
+ assertES5(js, `headless logic for ${uiDir}`);
188
+ rmSync(tmp, { recursive: true, force: true });
189
+ console.log(`m4l-jweb: headless logic for ${uiDir} (${js.length} bytes, ES5 verified)`);
190
+ return js;
191
+ }
192
+
116
193
  /* ------------------------------------------------------------------ *
117
194
  * Step 2: the patchers
118
195
  * ------------------------------------------------------------------ */
@@ -143,6 +220,32 @@ async function readDocs(root) {
143
220
  return Array.isArray(mod.docs) ? mod.docs : [];
144
221
  }
145
222
 
223
+ /**
224
+ * Release BUNDLES: a zip for one device, or a few, on its own.
225
+ *
226
+ * The repo zip is for somebody installing the whole library. A bundle is for somebody who
227
+ * wants ONE device and has never heard of the library - a game, an instrument, a thing
228
+ * with its own name and its own audience. They get a zip with that device in it and
229
+ * nothing else.
230
+ *
231
+ * Declared as a named export beside the manifest:
232
+ *
233
+ * export const bundles = [
234
+ * { name: "push-snake", title: "Snake for Push", devices: ["push-snake"], readme: "doc/SNAKE.md" }
235
+ * ];
236
+ *
237
+ * `readme` is written into the zip as `README.md`, because that is the file a person
238
+ * opens. Everything the named devices need travels with them: their `looseFiles`, and the
239
+ * sidecar folder of any `site:` window. An `.amxd` that embeds its own assets - which is
240
+ * every device that does not declare those two things - needs nothing else at all.
241
+ */
242
+ async function readBundles(root) {
243
+ const p = path.join(root, "patcher", "devices.mjs");
244
+ if (!existsSync(p)) return [];
245
+ const mod = await import(pathToFileURL(p).href);
246
+ return Array.isArray(mod.bundles) ? mod.bundles : [];
247
+ }
248
+
146
249
  /** patcher/base.json in the device repo wins; otherwise the packaged template. */
147
250
  function readBase(root) {
148
251
  const local = path.join(root, "patcher", "base.json");
@@ -217,7 +320,29 @@ export function composePatcher(base, d, surface, files = null) {
217
320
  *
218
321
  * Unset keeps the object's default. jweb~ clamps to 3x the minimum.
219
322
  */
220
- if (d.latency != null) boxes.find((b) => b.box.id === "obj-jweb").box.latency = d.latency;
323
+ if (d.latency != null) {
324
+ // A headless device has no [jweb~] to buffer, and a `latency` on one is a setting
325
+ // for a thing that is not there - worth saying, not ignoring.
326
+ if (isHeadless(d)) {
327
+ throw new Error(`device "${d.name}" is target "headless" and sets \`latency\` - there is no [jweb~] to give a ring buffer to.`);
328
+ }
329
+ boxes.find((b) => b.box.id === "obj-jweb").box.latency = d.latency;
330
+ }
331
+
332
+ /**
333
+ * `mpe: true` - ask Live to send this device MPE.
334
+ *
335
+ * `is_mpe` is a PATCHER attribute, not a box one, and it is a declaration rather
336
+ * than machinery: Max's own reference says "If enabled, a Max for Live device will
337
+ * receive MPE data from Live", and a shipping device that carries the MPE badge
338
+ * sets it to 1 while parsing the result with ordinary `midiin` / `midiparse` (see
339
+ * doc/MAX-FACTS.md). The template writes 0, so a device that wants it says so here
340
+ * and something still has to READ the stream - the `mpein` chain, or `midiin`.
341
+ *
342
+ * Off by default. A device declaring MPE it does not handle is a device Live sends
343
+ * per-note channels to for no reason, and the badge tells the user a lie.
344
+ */
345
+ if (d.mpe) p.patcher.is_mpe = 1;
221
346
 
222
347
  const unmatchedId = d.unmatchedTo === "js" ? "obj-js" : (d.unmatchedTo ?? "obj-js");
223
348
 
@@ -238,11 +363,21 @@ export function composePatcher(base, d, surface, files = null) {
238
363
  // voice abstraction). A chain pushes { name, data } here; generatePatchers writes
239
364
  // each next to the device patcher and packageDevices freezes it into the container.
240
365
  const extras = [];
241
- const ctx = { boxes, lines, jwebId: "obj-jweb", unmatchedId, device: d, extras, ...surfaceContext(surface) };
242
-
366
+ const ctx = { boxes, lines, unmatchedId, device: d, extras, ...surfaceContext(surface) };
367
+
368
+ // WHERE THE LOGIC RUNS, decided once and before anything else touches the graph.
369
+ // It sets `ctx.appIn` / `ctx.appOut` - the endpoint every chain reaches "the app"
370
+ // through - and, for a headless device, deletes [jweb] and both of its cords. A
371
+ // chain claims a stage in a stream it did not create and must not know which target
372
+ // created it (target.mjs).
373
+ openApp(ctx);
243
374
  openAudio(ctx);
244
375
 
245
- for (const name of effectiveChains(d.chains, files)) {
376
+ // A declared takeover contributes its chain the way a declared files.ts
377
+ // contributes `download`: derived from the declaration, never from the manifest
378
+ // remembering to ask. A device whose observers were missing would load, grab, and
379
+ // report every press to nobody.
380
+ for (const name of withControlsChain(effectiveChains(d.chains, files), surface)) {
246
381
  const chain = CHAINS[name];
247
382
  if (!chain) throw new Error(`unknown chain "${name}" for device "${d.name}" (known: ${Object.keys(CHAINS).join(", ")})`);
248
383
  chain(ctx);
@@ -305,8 +440,9 @@ export async function generatePatchers(root) {
305
440
  // The chains it was BUILT with, not the ones the manifest listed - a derived
306
441
  // `download` that never appeared in the log would be the same invisible wiring
307
442
  // this feature exists to end.
308
- const chains = effectiveChains(d.chains, files).join(", ") || "none";
309
- console.log(`m4l-jweb: ${d.name}.json (${d.type}, chains: ${chains}, params: ${params || "none"})`);
443
+ const chains = withControlsChain(effectiveChains(d.chains, files), surface).join(", ") || "none";
444
+ const target = deviceTarget(d);
445
+ console.log(`m4l-jweb: ${d.name}.json (${d.type}/${target}, chains: ${chains}, params: ${params || "none"})`);
310
446
  }
311
447
  return devices;
312
448
  }
@@ -391,15 +527,17 @@ export async function packageDevices(root) {
391
527
  * disk was stale. The symptom would be a device showing its sibling's
392
528
  * interface.
393
529
  */
530
+ const headless = isHeadless(d);
394
531
  const uiName = `${d.name}.html`;
395
- const uiSrc = path.join(dist, "ui", d.ui ?? d.name, "index.html");
396
- const legacy = path.join(dist, "index.html"); // single-UI repos (the starter template)
397
- const uiFrom = existsSync(uiSrc) ? uiSrc : legacy;
398
- if (!existsSync(uiFrom)) {
399
- throw new Error(`no UI for "${d.name}" at ${uiSrc} - run \`pnpm build\` (scripts/build-ui.mjs) first`);
532
+ if (!headless) {
533
+ const uiSrc = path.join(dist, "ui", d.ui ?? d.name, "index.html");
534
+ const legacy = path.join(dist, "index.html"); // single-UI repos (the starter template)
535
+ const uiFrom = existsSync(uiSrc) ? uiSrc : legacy;
536
+ if (!existsSync(uiFrom)) {
537
+ throw new Error(`no UI for "${d.name}" at ${uiSrc} - run \`pnpm build\` (scripts/build-ui.mjs) first`);
538
+ }
539
+ await copyFile(uiFrom, path.join(outDir, uiName));
400
540
  }
401
- const uiHtml = readFileSync(uiFrom);
402
- await copyFile(uiFrom, path.join(outDir, uiName));
403
541
 
404
542
  /**
405
543
  * Payloads ride inside wrapper.js as base64 and are written to real files
@@ -413,19 +551,40 @@ export async function packageDevices(root) {
413
551
  // ...and so do its declared files: FILES_SPEC is what tells the packaged wrapper
414
552
  // this device writes to disk, and therefore to hand the page its device folder.
415
553
  const files = await loadFiles(root, d.ui ?? d.name);
554
+ // ...and its declared CONTROLS: CONTROLS_SPEC is the role table the packaged
555
+ // wrapper resolves against the connected hardware's own get_control_names.
556
+ const deviceSurface = await loadSurface(root, d.ui ?? d.name);
416
557
  let wrapperData =
417
- banner + watchSpecsBanner(watch) + filesSpecBanner(files) + siteWindowsBanner(root, outDir, d, await loadSurface(root, d.ui ?? d.name)) + wrapperJs;
418
- const uiDirContent = readdirSync(path.join(dist, "ui", d.ui ?? d.name)).filter((f) => f.endsWith(".html"));
558
+ banner +
559
+ // The one thing that tells the packaged wrapper there is no page: no [jweb] to
560
+ // point at a URL, no payload to extract, no window sizes to follow. Everything
561
+ // else about the wrapper is unchanged, which is the point of the target being a
562
+ // seam rather than a second wrapper.
563
+ (headless ? "var HEADLESS = 1;\n" : "") +
564
+ watchSpecsBanner(watch) +
565
+ filesSpecBanner(files) +
566
+ controlsSpecBanner(deviceSurface) +
567
+ siteWindowsBanner(root, outDir, d, deviceSurface) +
568
+ wrapperJs;
419
569
 
420
- // Main UI payload
421
- wrapperData += payloadJs("UI_PAYLOAD", uiName, readFileSync(path.join(dist, "ui", d.ui ?? d.name, "index.html")));
422
-
423
- // Additional window payloads
424
- const extraWindows = uiDirContent.filter((f) => f !== "index.html");
425
570
  const payloads = (d.payloads ?? []).map((f) => ({ name: path.basename(f), data: readFileSync(path.join(root, f)) }));
426
571
 
427
- for (const winHtml of extraWindows) {
428
- payloads.push({ name: `${d.name}_${winHtml}`, data: readFileSync(path.join(dist, "ui", d.ui ?? d.name, winHtml)) });
572
+ if (headless) {
573
+ // The device's OWN [js], where a jweb device would have had a bundle. Nothing
574
+ // else is appended: a headless .amxd contains a patcher and one script, and that
575
+ // is the whole claim the target makes.
576
+ const own = buildHeadless(root, d.ui ?? d.name);
577
+ if (own) wrapperData += "\n" + own;
578
+ } else {
579
+ const uiDirContent = readdirSync(path.join(dist, "ui", d.ui ?? d.name)).filter((f) => f.endsWith(".html"));
580
+
581
+ // Main UI payload
582
+ wrapperData += payloadJs("UI_PAYLOAD", uiName, readFileSync(path.join(dist, "ui", d.ui ?? d.name, "index.html")));
583
+
584
+ // Additional window payloads
585
+ for (const winHtml of uiDirContent.filter((f) => f !== "index.html")) {
586
+ payloads.push({ name: `${d.name}_${winHtml}`, data: readFileSync(path.join(dist, "ui", d.ui ?? d.name, winHtml)) });
587
+ }
429
588
  }
430
589
 
431
590
  if (payloads.length) wrapperData += extraPayloadsJs(payloads);
@@ -446,7 +605,7 @@ export async function packageDevices(root) {
446
605
  extras: [...(d.extraFiles ?? []).map((f) => ({ name: path.basename(f), data: readFileSync(path.join(root, f)) })), ...chainExtras],
447
606
  });
448
607
  writeFileSync(path.join(outDir, deviceName), amxd);
449
- console.log(`m4l-jweb: ${deviceName} (${d.type}, ${amxd.length} bytes)`);
608
+ console.log(`m4l-jweb: ${deviceName} (${d.type}/${deviceTarget(d)}, ${amxd.length} bytes)`);
450
609
  }
451
610
 
452
611
  await copyFile(path.join(dist, "wrapper", "wrapper.js"), path.join(outDir, "wrapper.js"));
@@ -503,7 +662,10 @@ export async function packageDevices(root) {
503
662
  archive.pipe(output);
504
663
  const files = [
505
664
  ...devices.map((d) => `${d.name}.amxd`),
506
- ...devices.map((d) => `${d.name}.html`), // each device's own UI, for inspection
665
+ // Each device's own UI, for inspection - a HEADLESS device has none, and there
666
+ // is nothing missing: the .amxd holds the patcher and one script, which is the
667
+ // whole of it.
668
+ ...devices.filter((d) => !isHeadless(d)).map((d) => `${d.name}.html`),
507
669
  ...loose.map((f) => path.basename(f)),
508
670
  ...presets,
509
671
  ...docs,
@@ -528,6 +690,79 @@ export async function packageDevices(root) {
528
690
 
529
691
  const { size } = await stat(zipPath);
530
692
  console.log(`m4l-jweb: dist/${name}.zip (${size} bytes)`);
693
+
694
+ await packageBundles(root, dist, outDir, devices);
695
+ }
696
+
697
+ /**
698
+ * One zip per declared bundle, beside the repo zip.
699
+ *
700
+ * A bundle names devices that must exist - a typo here would otherwise produce a zip that
701
+ * is missing the very thing it is named after, and nothing would say so until somebody
702
+ * downloaded it.
703
+ */
704
+ async function packageBundles(root, dist, outDir, devices) {
705
+ const bundles = await readBundles(root);
706
+ const known = new Set(devices.map((d) => d.name));
707
+
708
+ for (const bundle of bundles) {
709
+ const named = bundle.devices ?? [];
710
+ for (const d of named) {
711
+ if (!known.has(d)) {
712
+ throw new Error(`bundle "${bundle.name}" lists device "${d}", which patcher/devices.mjs does not declare (${[...known].join(", ")}).`);
713
+ }
714
+ }
715
+ if (!named.length) throw new Error(`bundle "${bundle.name}" names no devices - a zip of nothing is not a release`);
716
+
717
+ const zipPath = path.join(dist, `${bundle.name}.zip`);
718
+ const entries = [];
719
+
720
+ await new Promise((resolve, reject) => {
721
+ const output = createWriteStream(zipPath);
722
+ const archive = archiver("zip", { zlib: { level: 9 } });
723
+ output.on("close", resolve);
724
+ archive.on("error", reject);
725
+ archive.pipe(output);
726
+
727
+ for (const d of named) {
728
+ const device = devices.find((x) => x.name === d);
729
+ archive.file(path.join(outDir, `${d}.amxd`), { name: `${bundle.name}/${d}.amxd` });
730
+ entries.push(`${d}.amxd`);
731
+
732
+ // Whatever that device cannot embed. A loose file is one a Max object resolves
733
+ // when it INSTANTIATES, before any code has run, so it has to be a real file next
734
+ // to the .amxd; a `site:` window's folder is too big to be a payload.
735
+ for (const f of device.looseFiles ?? []) {
736
+ archive.file(path.join(outDir, path.basename(f)), { name: `${bundle.name}/${path.basename(f)}` });
737
+ entries.push(path.basename(f));
738
+ }
739
+ const site = path.join(outDir, `${d}-site`);
740
+ if (existsSync(site)) {
741
+ archive.directory(site, `${bundle.name}/${d}-site`);
742
+ entries.push(`${d}-site/`);
743
+ }
744
+ }
745
+
746
+ // The file a person opens, under the name they will look for.
747
+ if (bundle.readme && existsSync(path.join(root, bundle.readme))) {
748
+ archive.file(path.join(root, bundle.readme), { name: `${bundle.name}/README.md` });
749
+ entries.push("README.md");
750
+ }
751
+ for (const f of bundle.docs ?? []) {
752
+ const from = path.join(root, f);
753
+ if (!existsSync(from)) {
754
+ console.warn(`m4l-jweb: bundle "${bundle.name}" doc ${f} is not there - skipped`);
755
+ continue;
756
+ }
757
+ archive.file(from, { name: `${bundle.name}/${path.basename(f)}` });
758
+ entries.push(path.basename(f));
759
+ }
760
+ archive.finalize();
761
+ });
762
+
763
+ const { size } = await stat(zipPath);
764
+ console.log(`m4l-jweb: dist/${bundle.name}.zip (${size} bytes: ${entries.join(", ")})`);
765
+ }
531
766
  }
532
767
 
533
768
  export async function buildAll(root) {
package/src/surface.mjs CHANGED
@@ -389,7 +389,7 @@ function kindBoxAttrs(spec) {
389
389
  * wrapper. Doing it last means no chain has to know the Surface exists.
390
390
  */
391
391
  export function applySurface(ctx) {
392
- const { boxes, lines, surface, jwebId } = ctx;
392
+ const { boxes, lines, surface, jwebId, appIn } = ctx;
393
393
  if (!surface || surface.ids.length === 0) return;
394
394
 
395
395
  // Where the native dials go, if any were declared. `slots` is empty for a
@@ -403,7 +403,8 @@ export function applySurface(ctx) {
403
403
  // stays in one place when the app flips between the two views. It is excluded from
404
404
  // `computeNativeSlots` (it is not in `native.params`), so give it a rect here.
405
405
  const switchId = native?.switch;
406
- const jwebBox = boxes.find((b) => b.box.id === jwebId)?.box;
406
+ // Null under the headless target: there is no page to lay out around.
407
+ const jwebBox = jwebId ? boxes.find((b) => b.box.id === jwebId)?.box : null;
407
408
  const [, , jpw] = jwebBox?.presentation_rect ?? [0, 0, 420, DEVICE_H];
408
409
  let switchRect = null;
409
410
  if (switchId) {
@@ -435,16 +436,19 @@ export function applySurface(ctx) {
435
436
  },
436
437
  });
437
438
  // Read direction: a knob turn reaches the app as `<id> <value>`. A parameter
438
- // is just another inlet message.
439
+ // is just another inlet message - and under the headless target the app IS the
440
+ // wrapper, so the same cord lands on a `function <id>()` in [js] instead of on
441
+ // a `useParam()` in React.
439
442
  boxes.push(box(`obj-prepend-${id}`, `prepend ${id}`));
440
443
  lines.push(line(paramObject(id), 0, `obj-prepend-${id}`, 0));
441
- lines.push(line(`obj-prepend-${id}`, 0, jwebId, 0));
444
+ lines.push(line(`obj-prepend-${id}`, 0, appIn, 0));
442
445
  x += 56;
443
446
  }
444
447
 
445
448
  // Position [jweb] for the native layout. WIDTH is preserved (React layouts were
446
449
  // built for 420 px). A surface with no native content leaves [jweb] where the
447
- // template put it.
450
+ // template put it - and a HEADLESS device has no [jweb] at all, so the dials are
451
+ // the whole device view and nothing needs shifting out of their way.
448
452
  if ((nativeW > 0 || switchId) && jwebBox) {
449
453
  const [, py, pw, ph] = jwebBox.presentation_rect ?? [0, 0, 420, DEVICE_H];
450
454
  if (native.panel) {
@@ -724,6 +728,18 @@ export function applyWindows(ctx) {
724
728
  const windowIds = surface?.windows ? Object.keys(surface.windows) : [];
725
729
  if (windowIds.length === 0) return;
726
730
 
731
+ // A window IS a second page - its own [jweb] in a subpatcher, its own bundle. There
732
+ // is no page under the headless target, so this is not a thing that can be built
733
+ // quietly smaller: say so, at the declaration, rather than emit a subpatcher whose
734
+ // whole contents are a box that target does not have.
735
+ if (ctx.target === "headless") {
736
+ throw new Error(
737
+ `device "${ctx.device?.name}" is target "headless" and declares window(s) ${windowIds.join(", ")} in its surface.ts. ` +
738
+ `A window is a second [jweb] page, which that target has no browser for. ` +
739
+ `Use layout.native for a device view, or drop \`target: "headless"\`.`,
740
+ );
741
+ }
742
+
727
743
  const selectors = windowIds.flatMap((id) => [`window_${id}_open`, `window_${id}_close`]);
728
744
 
729
745
  const routeId = "obj-windows-route";
package/src/target.mjs ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * target.mjs - WHERE a device's logic runs, and what the patcher therefore contains.
3
+ *
4
+ * Until now there was one answer and it was wired into everything: the logic is a
5
+ * React app inside `[jweb]`, so the patcher has a `[jweb]`, the `.amxd` carries a
6
+ * base64 HTML payload, and every chain that wants to reach "the app" sends to that
7
+ * box. A device could not opt out, and the library was named after the assumption.
8
+ *
9
+ * A TARGET is that assumption, made explicit and given a second value:
10
+ *
11
+ * jweb the device page runs in Max's embedded Chromium. React, Web Audio,
12
+ * Workers, a UI you can look at. What every device shipped before this.
13
+ * headless there is no browser at all. The device declares its interface in
14
+ * TypeScript exactly as it does today - `defineSurface`, `defineControls`,
15
+ * `defineWatch` - and the build emits ONLY the `[js]` wrapper and the
16
+ * patcher. The device's own logic is `src/app/<device>/headless.ts`,
17
+ * compiled to ES5 and concatenated after the wrapper.
18
+ *
19
+ * ------------------------------------------------------------------------------
20
+ * WHY HEADLESS IS WORTH A SEAM, and none of it is speculation (doc/TODO.md item 3):
21
+ *
22
+ * - THE PAD TAKEOVER NEEDS NO BROWSER. The grab, the paint and the value observer
23
+ * are `live.object` / `live.observer` and `[js]`; the page was never in that path.
24
+ * A grid device's logic is a control plane, and `[js]` plus `Task` is a BETTER
25
+ * clock than a Chromium page nobody is looking at - the Worker in `push-snake`
26
+ * exists to dodge throttling that only a hidden page suffers.
27
+ * - AUDIO WITHOUT `[jweb~]` is a path this repo has already walked: bytes to disk,
28
+ * played through `[buffer~]`/`[groove~]`. Retired for ergonomics, not because it
29
+ * failed - and for a device holding two decoded FLACs the trade inverts, because
30
+ * half a gigabyte inside Chromium inside Live is not a thing `[buffer~]` suffers.
31
+ *
32
+ * ------------------------------------------------------------------------------
33
+ * THE ONE THING THE SEAM IS: `ctx.appIn` and `ctx.appOut`.
34
+ *
35
+ * Every chain that reaches "the app" used to name `[jweb]`. Now it names the APP
36
+ * ENDPOINT, which is `[jweb]` under one target and `[js]` under the other - and that
37
+ * substitution is the whole port. A `[prepend notein]` feeds `ctx.appIn` either way; a
38
+ * `[route midinote]` claims from `ctx.appOut` either way. Nothing else in the chain
39
+ * vocabulary had to know.
40
+ *
41
+ * What headless GIVES UP is real and belongs here rather than in a surprise: no React
42
+ * device view (native `live.*` objects only, which `defineSurface` already generates),
43
+ * no Web Audio, no Workers, no floating windows, and ES5 in the emitted output.
44
+ */
45
+ import { removeBox } from "./chains.mjs";
46
+
47
+ /** The `[jweb]` box the template ships. Only a jweb-target device has one. */
48
+ export const JWEB_ID = "obj-jweb";
49
+ /** The `[js]` box, which EVERY device has - it is the wrapper. */
50
+ export const JS_ID = "obj-js";
51
+
52
+ export const TARGETS = ["jweb", "headless"];
53
+
54
+ /** A device's target, defaulting to the one every device had before there was a choice. */
55
+ export function deviceTarget(d) {
56
+ const target = d?.target ?? "jweb";
57
+ if (!TARGETS.includes(target)) {
58
+ throw new Error(`device "${d?.name}" declares target "${target}" - known targets are ${TARGETS.join(", ")}`);
59
+ }
60
+ return target;
61
+ }
62
+
63
+ export const isHeadless = (d) => deviceTarget(d) === "headless";
64
+
65
+ /**
66
+ * Point the context's app endpoint at whatever this device's target is, and delete
67
+ * what the other one would have needed.
68
+ *
69
+ * Called by `composePatcher` BEFORE any chain, for the same reason `openAudio` is:
70
+ * a chain claims a stage in a stream it did not create, and it must not have to know
71
+ * which target created it.
72
+ *
73
+ * THE HEADLESS CASE REMOVES `[jweb]` OUTRIGHT, both cords with it - the template's
74
+ * `[js] -> [jweb]` and `[jweb] -> [js]`. A box left in place with nothing wired to it
75
+ * would still load Chromium, which is the entire cost the target exists to avoid.
76
+ *
77
+ * `appOut` is seeded rather than left null for headless, because `claimAppMessages`
78
+ * falls back to `[jweb]`'s outlet 2 when it is null - the one place the old assumption
79
+ * was a literal.
80
+ */
81
+ export function openApp(ctx) {
82
+ const target = deviceTarget(ctx.device);
83
+
84
+ if (target === "jweb") {
85
+ ctx.target = "jweb";
86
+ ctx.jwebId = JWEB_ID;
87
+ ctx.appIn = JWEB_ID;
88
+ // Left null on purpose: claimAppMessages reads [jweb]'s outlet 2 as the head of
89
+ // the unclaimed stream, and asserts the template's cord to [js] is still there.
90
+ ctx.appOut = null;
91
+ return;
92
+ }
93
+
94
+ ctx.target = "headless";
95
+ removeBox(ctx.boxes, ctx.lines, JWEB_ID);
96
+ // Nothing may reach for `[jweb]` from here on. It is null rather than absent so the
97
+ // few genuinely jweb-only things (the `webaudio` chain, a declared window, the
98
+ // presentation rect applySurface gives the page) fail on a name rather than wire a
99
+ // cord to a box that is not in the patcher.
100
+ ctx.jwebId = null;
101
+ ctx.appIn = JS_ID;
102
+ // The wrapper's outlet 0 IS the app's outlet here: what a page would have sent
103
+ // across the bridge, `[js]` sends out of the same port, into the same routes.
104
+ ctx.appOut = [JS_ID, 0];
105
+ // ...and there is nowhere to pass the unmatched tail ON to, because [js] is already
106
+ // the far end. A route's unmatched outlet is simply left unconnected.
107
+ ctx.unmatchedId = null;
108
+ }
109
+
110
+ /** The message a jweb-only feature should refuse a headless device with. */
111
+ export function refuseHeadless(ctx, what, instead) {
112
+ return new Error(
113
+ `device "${ctx.device?.name}" is target "headless", and ${what} needs the browser. ` +
114
+ `${instead} Drop \`target: "headless"\` from patcher/devices.mjs, or drop ${what}.`,
115
+ );
116
+ }
@@ -17,13 +17,13 @@
17
17
  "format": "prettier --write \"src/**/*.{ts,tsx,css}\" \"scripts/*.mjs\" \"patcher/*.mjs\""
18
18
  },
19
19
  "dependencies": {
20
- "@m4l-jweb/bridge": "^1.3.0",
21
- "@m4l-jweb/surface": "^1.3.0",
20
+ "@m4l-jweb/bridge": "^1.6.0",
21
+ "@m4l-jweb/surface": "^1.6.0",
22
22
  "react": "^19.0.0",
23
23
  "react-dom": "^19.0.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@m4l-jweb/build": "^1.3.0",
26
+ "@m4l-jweb/build": "^1.6.0",
27
27
  "@types/node": "^22.0.0",
28
28
  "@types/react": "^19.0.0",
29
29
  "@types/react-dom": "^19.0.0",
@@ -17,8 +17,15 @@ export const devices = (await import(pathToFileURL(path.join(root, "patcher/devi
17
17
  /** The folder under src/app/ holding a device's UI. */
18
18
  export const uiDir = (d) => d.ui ?? d.name;
19
19
 
20
- /** Every distinct UI that has to be built (two devices may share one). */
21
- export const uiDirs = [...new Set(devices.map(uiDir))];
20
+ /**
21
+ * Every distinct UI that has to be built (two devices may share one).
22
+ *
23
+ * A `target: "headless"` device has none - no [jweb], no page, no bundle - so it is not
24
+ * in this list and `pnpm dev:<it>` has nothing to serve. Its interface is its
25
+ * `surface.ts` and its logic is `headless.ts`, both of which the .amxd build reads
26
+ * directly.
27
+ */
28
+ export const uiDirs = [...new Set(devices.filter((d) => d.target !== "headless").map(uiDir))];
22
29
 
23
30
  /** Validate a device name from the command line, with a useful error. */
24
31
  export function resolveDevice(arg) {
@@ -27,7 +34,17 @@ export function resolveDevice(arg) {
27
34
  if (!known.includes(name)) {
28
35
  throw new Error(`unknown device "${name}" - patcher/devices.mjs declares: ${known.join(", ")}`);
29
36
  }
30
- const dir = uiDir(devices.find((d) => d.name === name));
37
+ const d = devices.find((x) => x.name === name);
38
+ // A HEADLESS device has no page to serve, and the harness exists to run one beside a
39
+ // mocked Live. Say that, rather than start a dev server on a folder with no App.tsx
40
+ // in it and fail somewhere inside vite.
41
+ if (d.target === "headless") {
42
+ throw new Error(
43
+ `device "${name}" is target "headless" - it has no page, so there is nothing for the dev harness to serve. ` +
44
+ `Its logic is src/app/${uiDir(d)}/headless.ts, which runs in Max's [js]: build it and load the .amxd.`,
45
+ );
46
+ }
47
+ const dir = uiDir(d);
31
48
  if (!existsSync(path.join(root, "src/app", dir))) {
32
49
  throw new Error(`device "${name}" has no UI at src/app/${dir}/`);
33
50
  }
@@ -24,5 +24,13 @@
24
24
  "noUnusedParameters": true,
25
25
  "noFallthroughCasesInSwitch": true
26
26
  },
27
- "include": ["src"]
27
+ "include": ["src"],
28
+ /*
29
+ * A HEADLESS device's `headless.ts` is NOT app code. It is a Max `[js]` script -
30
+ * ES5, no modules, and its globals are `post`/`outlet`/`Task`/`LiveAPI` from the
31
+ * wrapper's max.d.ts, none of which exist here. The build compiles it in its own
32
+ * program (buildHeadless in @m4l-jweb/build), which is where it is typechecked;
33
+ * leaving it in this one reports every Max global as an undefined name.
34
+ */
35
+ "exclude": ["src/app/*/headless.ts"]
28
36
  }
@@ -19,11 +19,22 @@
19
19
  "isolatedModules": true,
20
20
  "moduleDetection": "force",
21
21
  "noEmit": true,
22
+ // A test that RENDERS a component reaches a .tsx module, and tsc refuses to
23
+ // resolve one without a jsx setting even when the test itself is plain .ts.
24
+ // Matches tsconfig.app.json, and vitest.config.ts sets the same automatic
25
+ // runtime for its own esbuild pass.
26
+ "jsx": "react-jsx",
22
27
  "strict": true
23
28
  },
24
29
  // `vitest.config.ts` is listed alongside the tests, and not only to check it: a
25
30
  // project whose include matches nothing at all is a tsc ERROR (TS18003), and a
26
31
  // freshly scaffolded repo has no tests yet. One file that always exists keeps the
27
32
  // project valid from the first minute.
28
- "include": ["tests/**/*.ts", "vitest.config.ts"]
33
+ // `src/vite-env.d.ts` is here for its `/// <reference types="vite/client" />`, not
34
+ // to be checked. A test that imports app code pulls that module into THIS program,
35
+ // and app code imports assets - `import track from "./x.ogg"` - whose ambient
36
+ // declarations live in vite's client types. Without them the test project reports
37
+ // "cannot find module ./x.ogg" for a file the app compiles fine, and only under
38
+ // `tsc -b`, which is the one that runs in CI.
39
+ "include": ["tests/**/*.ts", "vitest.config.ts", "src/vite-env.d.ts"]
29
40
  }
@@ -5,6 +5,12 @@ export default defineConfig({
5
5
  resolve: {
6
6
  alias: [{ find: "@", replacement: fileURLToPath(new URL("./src", import.meta.url)) }],
7
7
  },
8
+ // The app and the packages are compiled with tsconfig's "jsx": "react-jsx" - the
9
+ // AUTOMATIC runtime, where a component file imports no React at all. vitest's own
10
+ // esbuild pass defaults to the classic runtime, so a test that renders any component
11
+ // dies on "React is not defined" inside a file that is perfectly correct. Match the
12
+ // app.
13
+ esbuild: { jsx: "automatic" },
8
14
  test: {
9
15
  include: ["tests/**/*.test.{ts,mjs}", "packages/*/tests/**/*.test.{ts,mjs}", "src/**/*.test.{ts,tsx}"],
10
16
  environment: "node",