| 1 | // Everything Strudel expects from a browser that isn't audio. |
| 2 | // |
| 3 | // node-web-audio-api's polyfill installs the Web Audio constructors on |
| 4 | // globalThis and creates a bare `window` object, which covers the audio half. |
| 5 | // What's left are DOM touchpoints that Strudel reaches for at module scope or in |
| 6 | // code paths we never take: |
| 7 | // |
| 8 | // - @strudel/core's logger dispatches every log line as a `strudel.log` |
| 9 | // CustomEvent on `document`. That's not incidental — it's the cleanest way |
| 10 | // for a frontend to consume engine messages, so `document` is a real |
| 11 | // EventTarget rather than a stub. See subscribeToLogs() below. |
| 12 | // - superdough's dspworklet.mjs registers a `window.addEventListener('message')` |
| 13 | // handler at import time, for the user-defined `dsp` feature. |
| 14 | // - superdough's reverbGen.mjs draws impulse responses to a <canvas> for visual |
| 15 | // debugging. Never called during playback, but the module must import. |
| 16 | // |
| 17 | // `createElement` throws rather than returning a plausible-looking fake: if some |
| 18 | // path really does need a DOM element, that should surface as an error naming |
| 19 | // the tag, not as silently wrong behaviour further downstream. |
| 20 | // |
| 21 | // The casts below are all of the same kind and all deliberate: TypeScript's DOM |
| 22 | // lib describes a real browser, and these objects are knowingly partial stand-ins |
| 23 | // for a handful of members. Widening the globals to `any` would hide genuine |
| 24 | // mistakes elsewhere, so the lies stay local and named. |
| 25 | import 'node-web-audio-api/polyfill.js'; |
| 26 | |
| 27 | /** A log line from either Strudel or superdough. */ |
| 28 | export interface LogEntry { |
| 29 | message: string; |
| 30 | type?: string; |
| 31 | data?: Record<string, unknown>; |
| 32 | } |
| 33 | |
| 34 | type PartialGlobal = Record<string, unknown>; |
| 35 | |
| 36 | const noop = (): void => {}; |
| 37 | |
| 38 | // Node has EventTarget and CustomEvent built in, so `document` can be the real |
| 39 | // thing — addEventListener/dispatchEvent behave exactly as Strudel expects. |
| 40 | function makeEventTargetHost(base: PartialGlobal): PartialGlobal { |
| 41 | const target = new EventTarget(); |
| 42 | base.addEventListener = target.addEventListener.bind(target); |
| 43 | base.removeEventListener = target.removeEventListener.bind(target); |
| 44 | base.dispatchEvent = target.dispatchEvent.bind(target); |
| 45 | return base; |
| 46 | } |
| 47 | |
| 48 | const LOCATION = { href: 'http://localhost/', origin: 'http://localhost', protocol: 'http:' }; |
| 49 | |
| 50 | const win = globalThis.window as unknown as PartialGlobal; |
| 51 | makeEventTargetHost(win); |
| 52 | win.postMessage = noop; |
| 53 | win.location = LOCATION; |
| 54 | |
| 55 | if (!globalThis.document) { |
| 56 | const doc = makeEventTargetHost({ |
| 57 | createElement: (tag: string) => { |
| 58 | throw new Error(`[browser-shim] document.createElement('${tag}') is not available in Node`); |
| 59 | }, |
| 60 | }); |
| 61 | globalThis.document = doc as unknown as Document; |
| 62 | } |
| 63 | |
| 64 | // `window.document` is deliberately NOT set, even though `document` is a global. |
| 65 | // |
| 66 | // Libraries sniff for a browser by testing `window && window.document && |
| 67 | // navigator` — the Anthropic SDK does exactly this, and refuses to construct a |
| 68 | // client in what it believes is a browser, because that would risk leaking an |
| 69 | // API key to a web page. Setting `window.document` makes this process look like |
| 70 | // a browser to every library in it, not just to Strudel, and the chat pane stops |
| 71 | // working with a confusing error about credentials. |
| 72 | // |
| 73 | // The fix is to keep the lie as small as it needs to be, rather than to pass |
| 74 | // `dangerouslyAllowBrowser` and claim we accept a risk that cannot occur here. |
| 75 | // Nothing in Strudel or superdough reads `window.document`; they use the bare |
| 76 | // `document` global, which is still there. |
| 77 | |
| 78 | if (!globalThis.location) { |
| 79 | globalThis.location = LOCATION as unknown as Location; |
| 80 | } |
| 81 | |
| 82 | /** |
| 83 | * Subscribe to engine log messages — both Strudel's (via the `strudel.log` DOM |
| 84 | * event) and superdough's (via its setLogger hook, which has no DOM path). |
| 85 | * |
| 86 | * Returns an unsubscribe function. |
| 87 | */ |
| 88 | export async function subscribeToLogs( |
| 89 | onLog: (entry: LogEntry) => void, |
| 90 | ): Promise<() => void> { |
| 91 | const handler = (e: Event): void => onLog((e as CustomEvent<LogEntry>).detail); |
| 92 | globalThis.document.addEventListener('strudel.log', handler); |
| 93 | |
| 94 | const { setLogger } = await import('superdough'); |
| 95 | setLogger((message, type) => onLog({ message, type })); |
| 96 | |
| 97 | return () => { |
| 98 | globalThis.document.removeEventListener('strudel.log', handler); |
| 99 | setLogger((msg) => console.log(msg)); |
| 100 | }; |
| 101 | } |