@williamzujkowski/live-coding-music-mcp 3.0.0 → 4.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/README.md +212 -780
- package/dist/AudioAnalyzer.js +1294 -91
- package/dist/PatternStore.js +276 -23
- package/dist/StrudelController.js +388 -56
- package/dist/server/resources.js +27 -20
- package/dist/server/server.js +391 -83
- package/dist/server/tools/ai.js +129 -152
- package/dist/server/tools/analysis.js +170 -91
- package/dist/server/tools/capture.js +128 -55
- package/dist/server/tools/compose.js +28 -42
- package/dist/server/tools/diagnostics.js +23 -53
- package/dist/server/tools/editor.js +63 -72
- package/dist/server/tools/generate.js +45 -167
- package/dist/server/tools/history.js +35 -66
- package/dist/server/tools/playback.js +7 -22
- package/dist/server/tools/session.js +41 -47
- package/dist/server/tools/storage.js +173 -50
- package/dist/server/tools/transform.js +136 -199
- package/dist/server/tools/types.js +209 -14
- package/dist/services/AudioCaptureService.js +302 -33
- package/dist/services/AudioExportService.js +318 -0
- package/dist/services/BrowserOnlyFunctions.js +149 -0
- package/dist/services/EventDensityProbe.js +324 -0
- package/dist/services/GeminiService.js +221 -36
- package/dist/services/IsolatedEngineRunner.js +308 -0
- package/dist/services/IsolatedStrudelEngine.js +135 -0
- package/dist/services/LocalPatternEngine.js +16 -0
- package/dist/services/MIDIExportService.js +765 -71
- package/dist/services/MIDIImportService.js +474 -0
- package/dist/services/MusicTheory.js +85 -13
- package/dist/services/PatternGenerator.js +168 -85
- package/dist/services/PatternSandbox.js +359 -0
- package/dist/services/SessionManager.js +338 -65
- package/dist/services/StrudelEngine.js +218 -12
- package/dist/services/StrudelEngineHelpers.js +24 -8
- package/dist/services/StyleRegistry.js +204 -0
- package/dist/services/Transferable.js +114 -0
- package/dist/services/ai/AiTransport.js +56 -0
- package/dist/services/ai/AudioMeasurements.js +155 -0
- package/dist/services/ai/CliTransport.js +256 -0
- package/dist/services/engineChild.js +83 -0
- package/dist/services/engineChildPath.js +28 -0
- package/dist/utils/CategorisedError.js +59 -0
- package/dist/utils/ErrorRecovery.js +130 -110
- package/dist/utils/InputValidator.js +55 -7
- package/dist/utils/PatternValidator.js +136 -17
- package/dist/utils/SafePath.js +112 -0
- package/dist/utils/ServerConfig.js +248 -0
- package/dist/utils/TableLookup.js +41 -0
- package/dist/utils/Tempo.js +143 -0
- package/package.json +17 -5
- package/dist/services/__mocks__/StrudelEngine.js +0 -157
- package/dist/types/index.js +0 -1
|
@@ -47,10 +47,81 @@ export class PatternValidator {
|
|
|
47
47
|
suggestions
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* The pattern with comments and string bodies blanked out.
|
|
52
|
+
*
|
|
53
|
+
* Both are text, not code, and scanning them as code went wrong in
|
|
54
|
+
* both directions. Measured before this:
|
|
55
|
+
*
|
|
56
|
+
* s("bd*4") // sounds sad :( -> BLOCKED, "Unclosed '('"
|
|
57
|
+
* s("bd*4") // yay :) -> BLOCKED, "Unexpected closing ')'"
|
|
58
|
+
* s("bd*4") // try gain(10) later -> BLOCKED, "Dangerous gain value"
|
|
59
|
+
* s(" eval(x)") -> BLOCKED, "eval() is not allowed"
|
|
60
|
+
*
|
|
61
|
+
* Every one of those is a valid pattern, and `edit_pattern` refuses
|
|
62
|
+
* the write on a validation error — so a smiley in a comment stopped
|
|
63
|
+
* the music. #334 taught `checkBalancedQuotes` about comments for
|
|
64
|
+
* exactly this reason and stopped there; these are the halves it did
|
|
65
|
+
* not reach (#445).
|
|
66
|
+
*
|
|
67
|
+
* Lengths and offsets are preserved, so positions reported to the user
|
|
68
|
+
* still point at the right character.
|
|
69
|
+
*
|
|
70
|
+
* @param pattern - Pattern source
|
|
71
|
+
* @returns The same string with comment and string contents replaced
|
|
72
|
+
* by spaces, delimiters kept
|
|
73
|
+
*/
|
|
74
|
+
codeOnly(pattern) {
|
|
75
|
+
const out = pattern.split('');
|
|
76
|
+
let quote = null;
|
|
77
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
78
|
+
const char = pattern[i];
|
|
79
|
+
if (quote !== null) {
|
|
80
|
+
// Count the backslash run: an escaped backslash does not escape
|
|
81
|
+
// the quote that follows it.
|
|
82
|
+
if (char === '\\') {
|
|
83
|
+
out[i] = ' ';
|
|
84
|
+
if (i + 1 < pattern.length)
|
|
85
|
+
out[i + 1] = ' ';
|
|
86
|
+
i++;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (char === quote) {
|
|
90
|
+
quote = null;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
out[i] = ' ';
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
97
|
+
quote = char;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (char === '/' && pattern[i + 1] === '/') {
|
|
101
|
+
const newline = pattern.indexOf('\n', i);
|
|
102
|
+
const end = newline === -1 ? pattern.length : newline;
|
|
103
|
+
for (let j = i; j < end; j++)
|
|
104
|
+
out[j] = ' ';
|
|
105
|
+
i = end;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (char === '/' && pattern[i + 1] === '*') {
|
|
109
|
+
const close = pattern.indexOf('*/', i + 2);
|
|
110
|
+
const end = close === -1 ? pattern.length : close + 2;
|
|
111
|
+
for (let j = i; j < end; j++)
|
|
112
|
+
out[j] = ' ';
|
|
113
|
+
i = end - 1;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return out.join('');
|
|
118
|
+
}
|
|
50
119
|
/**
|
|
51
120
|
* Checks if parentheses, brackets, and braces are balanced
|
|
52
121
|
*/
|
|
53
|
-
checkBalancedParentheses(
|
|
122
|
+
checkBalancedParentheses(source) {
|
|
123
|
+
// Brackets inside a comment or a sample name are not structure.
|
|
124
|
+
const pattern = this.codeOnly(source);
|
|
54
125
|
const stack = [];
|
|
55
126
|
const pairs = { '(': ')', '[': ']', '{': '}' };
|
|
56
127
|
const opening = Object.keys(pairs);
|
|
@@ -89,6 +160,28 @@ export class PatternValidator {
|
|
|
89
160
|
if (prevChar === '\\') {
|
|
90
161
|
continue;
|
|
91
162
|
}
|
|
163
|
+
// Comments are not code, and an apostrophe in one is not a quote.
|
|
164
|
+
// Without this, `s("bd*4") // don't stop the beat` was reported as
|
|
165
|
+
// having an unclosed single quote — and writePatternWithValidation
|
|
166
|
+
// refuses the write outright on a validation error, so a comment
|
|
167
|
+
// containing an ordinary English contraction blocked the pattern
|
|
168
|
+
// (#334).
|
|
169
|
+
if (!inSingleQuote && !inDoubleQuote && !inBacktick) {
|
|
170
|
+
if (char === '/' && pattern[i + 1] === '/') {
|
|
171
|
+
const newline = pattern.indexOf('\n', i);
|
|
172
|
+
if (newline === -1)
|
|
173
|
+
break;
|
|
174
|
+
i = newline;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (char === '/' && pattern[i + 1] === '*') {
|
|
178
|
+
const end = pattern.indexOf('*/', i + 2);
|
|
179
|
+
if (end === -1)
|
|
180
|
+
break;
|
|
181
|
+
i = end + 1;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
92
185
|
if (char === "'" && !inDoubleQuote && !inBacktick) {
|
|
93
186
|
inSingleQuote = !inSingleQuote;
|
|
94
187
|
}
|
|
@@ -156,28 +249,54 @@ export class PatternValidator {
|
|
|
156
249
|
/**
|
|
157
250
|
* Checks for potentially dangerous operations
|
|
158
251
|
*/
|
|
159
|
-
checkSafety(
|
|
252
|
+
checkSafety(source) {
|
|
160
253
|
const errors = [];
|
|
161
254
|
const warnings = [];
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
255
|
+
// A comment mentioning gain(10), or a sample called " eval(x)", is
|
|
256
|
+
// not a dangerous pattern — and both used to be refused (#445).
|
|
257
|
+
const pattern = this.codeOnly(source);
|
|
258
|
+
// Check for excessive gain that could damage speakers.
|
|
259
|
+
//
|
|
260
|
+
// This never fired for any input. The outer match used /g, which
|
|
261
|
+
// discards capture groups and returns the whole ".gain(9)"; the
|
|
262
|
+
// inner /([0-9.]+)/ then matched the LEADING DOT of ".gain", and
|
|
263
|
+
// parseFloat(".") is NaN. Both NaN > 2 and NaN > 5 are false, so
|
|
264
|
+
// neither branch was reachable — gain(99) validated clean while
|
|
265
|
+
// CLAUDE.md advertised "prevents dangerous patterns (gain > 2.0)".
|
|
266
|
+
//
|
|
267
|
+
// Four tests in PatternValidator.test.ts documented the bug in
|
|
268
|
+
// detail and asserted the broken behaviour, for coverage. Coverage
|
|
269
|
+
// measured that these lines ran; nothing measured that they did
|
|
270
|
+
// anything (#334).
|
|
271
|
+
//
|
|
272
|
+
// Now matchAll with the capture, an optional leading dot so bare
|
|
273
|
+
// `gain(9)` is caught too, whitespace tolerance, and a number
|
|
274
|
+
// pattern that accepts exponent form.
|
|
275
|
+
// `\s*` before the paren, and an optional sign.
|
|
276
|
+
//
|
|
277
|
+
// One space defeated every rule below: `gain (10)`, `eval ("x")` and
|
|
278
|
+
// `while ( true )` all passed as safe while their unspaced forms
|
|
279
|
+
// were errors. #334 fixed this regex's capture group and left the
|
|
280
|
+
// shape of the match, so the advertised guarantee still did not hold
|
|
281
|
+
// (#445). `+10` is the same evasion by a different character.
|
|
282
|
+
const GAIN = /(?:^|[.\s(])gain\s*\(\s*\+?(\d*\.?\d+(?:e[+-]?\d+)?)\s*\)/gi;
|
|
283
|
+
for (const match of pattern.matchAll(GAIN)) {
|
|
284
|
+
const gainValue = Number.parseFloat(match[1]);
|
|
285
|
+
if (!Number.isFinite(gainValue))
|
|
286
|
+
continue;
|
|
287
|
+
if (gainValue > 5) {
|
|
288
|
+
errors.push(`Dangerous gain value: ${String(gainValue)} - above the safe maximum of 2.0`);
|
|
289
|
+
}
|
|
290
|
+
else if (gainValue > 2) {
|
|
291
|
+
warnings.push(`High gain value detected: ${String(gainValue)} - may be too loud`);
|
|
292
|
+
}
|
|
174
293
|
}
|
|
175
294
|
// Check for potentially infinite loops
|
|
176
|
-
if (/while\s*\(true\)/.test(pattern) || /for\s*\(.*;;.*\)/.test(pattern)) {
|
|
295
|
+
if (/while\s*\(\s*true\s*\)/.test(pattern) || /for\s*\(.*;;.*\)/.test(pattern)) {
|
|
177
296
|
errors.push('Potential infinite loop detected');
|
|
178
297
|
}
|
|
179
298
|
// Check for eval or similar dangerous functions
|
|
180
|
-
if (/\beval\(/.test(pattern) ||
|
|
299
|
+
if (/\beval\s*\(/.test(pattern) || /\bFunction\s*\(/.test(pattern)) {
|
|
181
300
|
errors.push('Use of eval() or Function() is not allowed');
|
|
182
301
|
}
|
|
183
302
|
return {
|
|
@@ -204,7 +323,7 @@ export class PatternValidator {
|
|
|
204
323
|
return match;
|
|
205
324
|
});
|
|
206
325
|
// Add missing quotes around sound patterns
|
|
207
|
-
fixed = fixed.replace(/s\(([a-z0-9*]+)\)/gi, (match, sound) => {
|
|
326
|
+
fixed = fixed.replace(/(?<![a-z])s\(([a-z0-9*]+)\)/gi, (match, sound) => {
|
|
208
327
|
if (!sound.includes('"') && !sound.includes("'")) {
|
|
209
328
|
fixes.push(`Added quotes around sound: ${sound}`);
|
|
210
329
|
return `s("${sound}")`;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SafePath - filename sanitization and directory confinement for file writes
|
|
3
|
+
*
|
|
4
|
+
* Every filename this server writes to disk originates in MCP tool
|
|
5
|
+
* arguments, which are model-generated. Anything that reaches an agent's
|
|
6
|
+
* context — a web page it summarized, a pattern it loaded, a MIDI file it
|
|
7
|
+
* imported — can therefore steer a path. Confining writes is not
|
|
8
|
+
* defense against the user; it is defense against the user's agent being
|
|
9
|
+
* talked into something.
|
|
10
|
+
*
|
|
11
|
+
* `PatternStore.sanitizeFilename()` already did this correctly for saved
|
|
12
|
+
* patterns. The export paths did not: `MIDIExportService.exportToFile()`
|
|
13
|
+
* called bare `resolve(filename)` (#224) and `takeScreenshot()` had an
|
|
14
|
+
* inverted guard that passed any filename *containing* a separator
|
|
15
|
+
* through unsanitized (#234). This module is the shared implementation so
|
|
16
|
+
* there is one place to get it right.
|
|
17
|
+
*
|
|
18
|
+
* Behaviour is *neutralize, then report* rather than throw: a traversal
|
|
19
|
+
* attempt is reduced to a safe basename and `wasModified` is set, so the
|
|
20
|
+
* caller can tell the agent where the file actually landed. Failing
|
|
21
|
+
* silently would let a model believe it wrote to `../../secrets/x.mid`;
|
|
22
|
+
* failing loudly on every odd character would make ordinary filenames
|
|
23
|
+
* unusable. Reporting the real path does both jobs.
|
|
24
|
+
*
|
|
25
|
+
* @module utils/SafePath
|
|
26
|
+
* @nist si-10 "Information input validation"
|
|
27
|
+
* @nist ac-3 "Access enforcement"
|
|
28
|
+
*/
|
|
29
|
+
import * as path from 'path';
|
|
30
|
+
/** Windows device names, which are unusable as files even on POSIX shares. */
|
|
31
|
+
const RESERVED_NAMES = new Set([
|
|
32
|
+
'con', 'prn', 'aux', 'nul',
|
|
33
|
+
'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9',
|
|
34
|
+
'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9',
|
|
35
|
+
]);
|
|
36
|
+
const MAX_STEM_LENGTH = 100;
|
|
37
|
+
/**
|
|
38
|
+
* Reduces an arbitrary string to a safe filename stem.
|
|
39
|
+
*
|
|
40
|
+
* Strips every path component, then replaces anything outside
|
|
41
|
+
* `[A-Za-z0-9._-]` so separators, null bytes, and shell metacharacters
|
|
42
|
+
* cannot survive in any encoding.
|
|
43
|
+
*
|
|
44
|
+
* @param requested - Caller-supplied name
|
|
45
|
+
* @param extension - Extension to strip before sanitizing
|
|
46
|
+
* @returns Safe stem, or empty string if nothing usable remained
|
|
47
|
+
*/
|
|
48
|
+
function sanitizeStem(requested, extension) {
|
|
49
|
+
// basename first: strips `../`, absolute roots, and Windows separators
|
|
50
|
+
// once path.basename has normalized them.
|
|
51
|
+
let stem = path.basename(requested.replace(/\\/g, '/'));
|
|
52
|
+
if (stem.toLowerCase().endsWith(extension.toLowerCase())) {
|
|
53
|
+
stem = stem.slice(0, -extension.length);
|
|
54
|
+
}
|
|
55
|
+
stem = stem.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
56
|
+
// A name that is only dots would resolve back to a directory.
|
|
57
|
+
stem = stem.replace(/^\.+/, '');
|
|
58
|
+
if (RESERVED_NAMES.has(stem.toLowerCase())) {
|
|
59
|
+
stem = `_${stem}`;
|
|
60
|
+
}
|
|
61
|
+
return stem.slice(0, MAX_STEM_LENGTH);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Sanitizes a requested filename and confines it to a directory.
|
|
65
|
+
*
|
|
66
|
+
* @param requested - Caller-supplied filename, possibly hostile or absent
|
|
67
|
+
* @param options - Target directory, required extension, and default name
|
|
68
|
+
* @returns The resolved path plus whether the request had to be altered
|
|
69
|
+
* @throws {Error} If the result would escape `directory` — a belt-and-braces
|
|
70
|
+
* check that should be unreachable after sanitization
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* resolveSafeOutputPath('../../etc/passwd', {
|
|
74
|
+
* directory: './exports', extension: '.mid', defaultName: 'pattern.mid',
|
|
75
|
+
* });
|
|
76
|
+
* // -> { filename: 'passwd.mid', wasModified: true, path: '<cwd>/exports/passwd.mid' }
|
|
77
|
+
*
|
|
78
|
+
* @nist si-10 "Information input validation"
|
|
79
|
+
* @nist ac-3 "Access enforcement"
|
|
80
|
+
*/
|
|
81
|
+
export function resolveSafeOutputPath(requested, options) {
|
|
82
|
+
const { directory, extension, defaultName } = options;
|
|
83
|
+
const root = path.resolve(directory);
|
|
84
|
+
const stem = requested ? sanitizeStem(requested, extension) : '';
|
|
85
|
+
const filename = stem.length > 0 ? `${stem}${extension}` : defaultName;
|
|
86
|
+
const resolved = path.resolve(root, filename);
|
|
87
|
+
// Should be unreachable — sanitizeStem removes every separator — but a
|
|
88
|
+
// containment check is cheap and this is the last line before a write.
|
|
89
|
+
if (path.dirname(resolved) !== root) {
|
|
90
|
+
throw new Error(`Refusing to write outside the export directory: ${String(requested)}`);
|
|
91
|
+
}
|
|
92
|
+
const wasModified = requested !== undefined && requested !== filename;
|
|
93
|
+
return {
|
|
94
|
+
filename,
|
|
95
|
+
path: resolved,
|
|
96
|
+
wasModified,
|
|
97
|
+
...(wasModified ? { requested } : {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Directory exports are written to.
|
|
102
|
+
*
|
|
103
|
+
* Defaults to `./exports`, overridable via `exports_dir` in config.json.
|
|
104
|
+
*
|
|
105
|
+
* @param configuredDir - Value of `exports_dir`, if set
|
|
106
|
+
* @returns Absolute export directory path
|
|
107
|
+
*/
|
|
108
|
+
export function resolveExportDirectory(configuredDir) {
|
|
109
|
+
return path.resolve(typeof configuredDir === 'string' && configuredDir.trim().length > 0
|
|
110
|
+
? configuredDir
|
|
111
|
+
: './exports');
|
|
112
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ServerConfig - parsing and validation for config.json
|
|
3
|
+
*
|
|
4
|
+
* `config.json` shipped four keys and the code read two of them.
|
|
5
|
+
* `strudel_url` and `patterns_dir` were documented in the README as
|
|
6
|
+
* working and silently ignored (#227), so anyone pointing the server at a
|
|
7
|
+
* self-hosted Strudel build, or at an existing pattern library, got the
|
|
8
|
+
* defaults with no warning and went debugging the wrong thing.
|
|
9
|
+
*
|
|
10
|
+
* Silently-ignored configuration is worse than none: it actively misleads.
|
|
11
|
+
* So this module both reads the keys and warns about ones it does not
|
|
12
|
+
* recognise, which is what stops the next dead key from being silent.
|
|
13
|
+
*
|
|
14
|
+
* @module utils/ServerConfig
|
|
15
|
+
* @nist si-10 "Information input validation"
|
|
16
|
+
*/
|
|
17
|
+
import * as path from 'path';
|
|
18
|
+
/** Default Strudel REPL, used when `strudel_url` is absent or unusable. */
|
|
19
|
+
export const DEFAULT_STRUDEL_URL = 'https://strudel.cc/';
|
|
20
|
+
/** Default on-disk pattern library. */
|
|
21
|
+
export const DEFAULT_PATTERNS_DIR = './patterns';
|
|
22
|
+
/** Every key config.json may contain. Anything else earns a warning. */
|
|
23
|
+
const KNOWN_KEYS = new Set([
|
|
24
|
+
'headless',
|
|
25
|
+
'strudel_url',
|
|
26
|
+
'patterns_dir',
|
|
27
|
+
'exports_dir',
|
|
28
|
+
'audio_analysis',
|
|
29
|
+
'viewport',
|
|
30
|
+
'block_resources',
|
|
31
|
+
]);
|
|
32
|
+
/**
|
|
33
|
+
* Default page setup, unchanged from the historic hardcoding (#514).
|
|
34
|
+
*
|
|
35
|
+
* Both values exist for headless CI: a pinned viewport keeps
|
|
36
|
+
* screenshots deterministic, and aborting fonts and images speeds up
|
|
37
|
+
* page load. In a headed window the same choices degrade the live
|
|
38
|
+
* editor (fallback-font button metrics, canvases that ignore window
|
|
39
|
+
* resizes), which is why both are now configurable.
|
|
40
|
+
*/
|
|
41
|
+
export const DEFAULT_VIEWPORT = { width: 1280, height: 720 };
|
|
42
|
+
/** Resource types the page route aborts by default (#514). */
|
|
43
|
+
export const DEFAULT_BLOCK_RESOURCES = ['image', 'font', 'media'];
|
|
44
|
+
/**
|
|
45
|
+
* Resource types Playwright may report; `block_resources` values are
|
|
46
|
+
* validated against this set so a typo earns a warning instead of a
|
|
47
|
+
* silently unblocked resource class.
|
|
48
|
+
*/
|
|
49
|
+
const KNOWN_RESOURCE_TYPES = new Set([
|
|
50
|
+
'document',
|
|
51
|
+
'stylesheet',
|
|
52
|
+
'image',
|
|
53
|
+
'media',
|
|
54
|
+
'font',
|
|
55
|
+
'script',
|
|
56
|
+
'texttrack',
|
|
57
|
+
'xhr',
|
|
58
|
+
'fetch',
|
|
59
|
+
'eventsource',
|
|
60
|
+
'websocket',
|
|
61
|
+
'manifest',
|
|
62
|
+
'other',
|
|
63
|
+
]);
|
|
64
|
+
/**
|
|
65
|
+
* Validates a Strudel URL.
|
|
66
|
+
*
|
|
67
|
+
* The value reaches `page.goto()`, so restrict it to http/https rather
|
|
68
|
+
* than letting `file:`, `javascript:` or similar through.
|
|
69
|
+
*
|
|
70
|
+
* @param value - Raw `strudel_url` from config.json
|
|
71
|
+
* @param warnings - Collector for problems found
|
|
72
|
+
* @returns A usable URL, falling back to the default
|
|
73
|
+
* @nist si-10 "Information input validation"
|
|
74
|
+
*/
|
|
75
|
+
function parseStrudelUrl(value, warnings) {
|
|
76
|
+
if (value === undefined)
|
|
77
|
+
return DEFAULT_STRUDEL_URL;
|
|
78
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
79
|
+
warnings.push(`config.strudel_url must be a non-empty string; using ${DEFAULT_STRUDEL_URL}`);
|
|
80
|
+
return DEFAULT_STRUDEL_URL;
|
|
81
|
+
}
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = new URL(value);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
warnings.push(`config.strudel_url is not a valid URL: ${value}; using ${DEFAULT_STRUDEL_URL}`);
|
|
88
|
+
return DEFAULT_STRUDEL_URL;
|
|
89
|
+
}
|
|
90
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
91
|
+
warnings.push(`config.strudel_url must use http or https, got '${parsed.protocol}'; ` +
|
|
92
|
+
`using ${DEFAULT_STRUDEL_URL}`);
|
|
93
|
+
return DEFAULT_STRUDEL_URL;
|
|
94
|
+
}
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Validates a directory path from config.
|
|
99
|
+
*
|
|
100
|
+
* @param value - Raw value from config.json
|
|
101
|
+
* @param key - Key name, for the warning message
|
|
102
|
+
* @param fallback - Value to use when unusable
|
|
103
|
+
* @param warnings - Collector for problems found
|
|
104
|
+
* @returns A usable directory path
|
|
105
|
+
*/
|
|
106
|
+
function parseDirectory(value, key, fallback, warnings) {
|
|
107
|
+
if (value === undefined)
|
|
108
|
+
return fallback;
|
|
109
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
110
|
+
warnings.push(`config.${key} must be a non-empty string; using ${fallback}`);
|
|
111
|
+
return fallback;
|
|
112
|
+
}
|
|
113
|
+
return path.resolve(value);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Parses raw config.json contents into validated settings.
|
|
117
|
+
*
|
|
118
|
+
* Never throws: a bad value falls back to its default and records a
|
|
119
|
+
* warning, so a typo in config.json cannot stop the server from starting.
|
|
120
|
+
*
|
|
121
|
+
* @param raw - Parsed contents of config.json, or undefined if absent
|
|
122
|
+
* @returns Validated config plus any warnings to log
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* const config = parseServerConfig({ strudel_url: 'file:///etc/passwd' });
|
|
126
|
+
* // -> strudelUrl: 'https://strudel.cc/', warnings: ['...must use http or https...']
|
|
127
|
+
*/
|
|
128
|
+
/** Keys `audio_analysis` may contain. Anything else earns a warning. */
|
|
129
|
+
const KNOWN_AUDIO_KEYS = new Set(['fft_size', 'smoothing']);
|
|
130
|
+
/**
|
|
131
|
+
* Parses the `audio_analysis` block, saying so when it cannot.
|
|
132
|
+
*
|
|
133
|
+
* Three ways this used to go wrong in silence, all measured:
|
|
134
|
+
*
|
|
135
|
+
* audio_analysis: "enabled" -> undefined, indistinguishable from
|
|
136
|
+
* leaving the block out entirely
|
|
137
|
+
* audio_analysis: [] -> {} — an array passes a bare
|
|
138
|
+
* `typeof === 'object'` test
|
|
139
|
+
* audio_analysis: {fftSize: ...} -> {} — the natural camelCase
|
|
140
|
+
* spelling of `fft_size`, dropped
|
|
141
|
+
*
|
|
142
|
+
* The last is the sharpest: the same typo one level up IS warned about
|
|
143
|
+
* by the unknown-key loop above, so the file was strict about its own
|
|
144
|
+
* keys and silent about the nested ones (#442).
|
|
145
|
+
*/
|
|
146
|
+
function parseAudioAnalysis(raw, warnings) {
|
|
147
|
+
if (raw === undefined)
|
|
148
|
+
return undefined;
|
|
149
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
150
|
+
warnings.push('config.audio_analysis must be an object with fft_size and/or smoothing; ignoring it');
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
const audio = raw;
|
|
154
|
+
for (const key of Object.keys(audio)) {
|
|
155
|
+
if (!KNOWN_AUDIO_KEYS.has(key)) {
|
|
156
|
+
warnings.push(`Unknown config key 'audio_analysis.${key}' — it will be ignored. ` +
|
|
157
|
+
`Known keys: ${[...KNOWN_AUDIO_KEYS].join(', ')}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (audio.fft_size !== undefined && typeof audio.fft_size !== 'number') {
|
|
161
|
+
warnings.push('config.audio_analysis.fft_size must be a number; ignoring it');
|
|
162
|
+
}
|
|
163
|
+
if (audio.smoothing !== undefined && typeof audio.smoothing !== 'number') {
|
|
164
|
+
warnings.push('config.audio_analysis.smoothing must be a number; ignoring it');
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
fftSize: typeof audio.fft_size === 'number' ? audio.fft_size : undefined,
|
|
168
|
+
smoothing: typeof audio.smoothing === 'number' ? audio.smoothing : undefined,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Parses the `viewport` block (#514).
|
|
173
|
+
*
|
|
174
|
+
* `null` is meaningful, not missing: Playwright treats a null viewport
|
|
175
|
+
* as "use the real window size", which is what makes window resizes
|
|
176
|
+
* reach the page. A malformed value falls back to the pinned default
|
|
177
|
+
* rather than to null, so a typo cannot silently unpin the layout in
|
|
178
|
+
* headless runs.
|
|
179
|
+
*/
|
|
180
|
+
function parseViewport(raw, warnings) {
|
|
181
|
+
if (raw === undefined)
|
|
182
|
+
return DEFAULT_VIEWPORT;
|
|
183
|
+
if (raw === null)
|
|
184
|
+
return null;
|
|
185
|
+
if (typeof raw === 'object' && !Array.isArray(raw)) {
|
|
186
|
+
const vp = raw;
|
|
187
|
+
if (typeof vp.width === 'number' &&
|
|
188
|
+
typeof vp.height === 'number' &&
|
|
189
|
+
Number.isFinite(vp.width) &&
|
|
190
|
+
Number.isFinite(vp.height) &&
|
|
191
|
+
vp.width > 0 &&
|
|
192
|
+
vp.height > 0) {
|
|
193
|
+
return { width: vp.width, height: vp.height };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
warnings.push('config.viewport must be null or an object with positive numeric width and height; ' +
|
|
197
|
+
`using ${DEFAULT_VIEWPORT.width}x${DEFAULT_VIEWPORT.height}`);
|
|
198
|
+
return DEFAULT_VIEWPORT;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Parses the `block_resources` array (#514).
|
|
202
|
+
*
|
|
203
|
+
* An empty array is valid and means "abort nothing" — the live-coding
|
|
204
|
+
* recommendation is `["media"]`, keeping large audio fetches out while
|
|
205
|
+
* letting fonts and images render.
|
|
206
|
+
*/
|
|
207
|
+
function parseBlockResources(raw, warnings) {
|
|
208
|
+
if (raw === undefined)
|
|
209
|
+
return [...DEFAULT_BLOCK_RESOURCES];
|
|
210
|
+
const valid = Array.isArray(raw) &&
|
|
211
|
+
raw.every((t) => typeof t === 'string' && KNOWN_RESOURCE_TYPES.has(t));
|
|
212
|
+
if (valid)
|
|
213
|
+
return [...raw];
|
|
214
|
+
warnings.push('config.block_resources must be an array of Playwright resource types ' +
|
|
215
|
+
`(${[...KNOWN_RESOURCE_TYPES].join(', ')}); using ` +
|
|
216
|
+
`[${DEFAULT_BLOCK_RESOURCES.map((t) => `'${t}'`).join(', ')}]`);
|
|
217
|
+
return [...DEFAULT_BLOCK_RESOURCES];
|
|
218
|
+
}
|
|
219
|
+
export function parseServerConfig(raw) {
|
|
220
|
+
const warnings = [];
|
|
221
|
+
const obj = raw !== null && typeof raw === 'object' ? raw : {};
|
|
222
|
+
if (raw !== undefined && (raw === null || typeof raw !== 'object' || Array.isArray(raw))) {
|
|
223
|
+
warnings.push('config.json must contain a JSON object; using defaults');
|
|
224
|
+
}
|
|
225
|
+
// A key nothing reads is how #227 happened. Say so rather than ignore it.
|
|
226
|
+
for (const key of Object.keys(obj)) {
|
|
227
|
+
if (!KNOWN_KEYS.has(key)) {
|
|
228
|
+
warnings.push(`Unknown config key '${key}' — it will be ignored. ` +
|
|
229
|
+
`Known keys: ${[...KNOWN_KEYS].join(', ')}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (obj.headless !== undefined && typeof obj.headless !== 'boolean') {
|
|
233
|
+
warnings.push(`config.headless must be a boolean; using false`);
|
|
234
|
+
}
|
|
235
|
+
const audioAnalysis = parseAudioAnalysis(obj.audio_analysis, warnings);
|
|
236
|
+
return {
|
|
237
|
+
headless: obj.headless === true,
|
|
238
|
+
strudelUrl: parseStrudelUrl(obj.strudel_url, warnings),
|
|
239
|
+
patternsDir: parseDirectory(obj.patterns_dir, 'patterns_dir', DEFAULT_PATTERNS_DIR, warnings),
|
|
240
|
+
exportsDir: obj.exports_dir === undefined
|
|
241
|
+
? undefined
|
|
242
|
+
: parseDirectory(obj.exports_dir, 'exports_dir', './exports', warnings),
|
|
243
|
+
audioAnalysis,
|
|
244
|
+
viewport: parseViewport(obj.viewport, warnings),
|
|
245
|
+
blockResources: parseBlockResources(obj.block_resources, warnings),
|
|
246
|
+
warnings,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe lookup in a plain-object table keyed by caller-supplied text.
|
|
3
|
+
*
|
|
4
|
+
* `TABLE[key] || fallback` returns an inherited value when `key` is
|
|
5
|
+
* 'constructor', '__proto__', 'toString' and friends — those are
|
|
6
|
+
* neither falsy nor nullish, so neither `||` nor `??` fires, and the
|
|
7
|
+
* inherited value flows on as if it were data.
|
|
8
|
+
*
|
|
9
|
+
* That bug has been found eight separate times in this codebase, one at
|
|
10
|
+
* a time, with symptoms ranging from a TypeError that failed a whole
|
|
11
|
+
* MIDI export (#308) to `s("bd*4").slow(NaN).lpf(NaN)` written into the
|
|
12
|
+
* editor and reported as success (#308) to wrong musical intervals
|
|
13
|
+
* (#318). Finding the ninth by hand is not a plan.
|
|
14
|
+
*
|
|
15
|
+
* @param table - A plain object literal used as a lookup table
|
|
16
|
+
* @param key - Caller-supplied key
|
|
17
|
+
* @param fallback - Value to use when the key is not an own property
|
|
18
|
+
* @returns The table's own value for `key`, or `fallback`
|
|
19
|
+
* @example
|
|
20
|
+
* lookup(FILLS, style, FILLS.techno);
|
|
21
|
+
* @nist si-10 "Information input validation"
|
|
22
|
+
*/
|
|
23
|
+
export function lookup(table, key, fallback) {
|
|
24
|
+
if (typeof key !== 'string')
|
|
25
|
+
return fallback;
|
|
26
|
+
return Object.hasOwn(table, key) ? table[key] : fallback;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* As `lookup`, but with no fallback — returns undefined for a key the
|
|
30
|
+
* table does not own, so the caller can distinguish "absent" from a
|
|
31
|
+
* legitimately falsy value.
|
|
32
|
+
*
|
|
33
|
+
* @param table - A plain object literal used as a lookup table
|
|
34
|
+
* @param key - Caller-supplied key
|
|
35
|
+
* @returns The table's own value for `key`, or undefined
|
|
36
|
+
*/
|
|
37
|
+
export function lookupOrUndefined(table, key) {
|
|
38
|
+
if (typeof key !== 'string')
|
|
39
|
+
return undefined;
|
|
40
|
+
return Object.hasOwn(table, key) ? table[key] : undefined;
|
|
41
|
+
}
|