From 1062e996752201097e89a43090ec6dfdf96b7f1f Mon Sep 17 00:00:00 2001 From: alexanderkirtzel Date: Fri, 9 Oct 2026 20:05:56 +0000 Subject: [PATCH 1/6] script load --- .changeset/tag-mode-loader.md | 11 + apps/walkerjs/package.json | 3 +- .../src/__tests__/integration/dist.test.ts | 71 +++++ apps/walkerjs/src/__tests__/moin.test.ts | 88 ++++++ apps/walkerjs/src/index.ts | 4 + .../unit/bundle/bundler-codegen.test.ts | 88 ++++++ .../unit/bundle/preview-codegen.test.ts | 7 +- .../src/__tests__/unit/bundle/wrap.test.ts | 44 +++ .../bundle/__tests__/manifest.test.ts | 35 +++ packages/cli/src/commands/bundle/bundler.ts | 123 +++++++- packages/cli/src/commands/bundle/wrap.ts | 34 +- packages/cli/src/schemas/build-manifest.ts | 5 + .../core/src/__tests__/inject-script.test.ts | 26 ++ packages/core/src/__tests__/moin.test.ts | 295 ++++++++++++++++++ packages/core/src/index.ts | 2 + packages/core/src/inject-script.ts | 69 ++++ packages/core/src/moin.ts | 100 ++++++ packages/core/src/preview.ts | 52 +-- skills/walkeros-using-cli/SKILL.md | 4 +- .../walkeros-using-cli/commands-reference.md | 5 +- website/docs/core/index.mdx | 28 ++ .../getting-started/quickstart/walkerjs.mdx | 30 +- 22 files changed, 1031 insertions(+), 93 deletions(-) create mode 100644 .changeset/tag-mode-loader.md create mode 100644 apps/walkerjs/src/__tests__/integration/dist.test.ts create mode 100644 apps/walkerjs/src/__tests__/moin.test.ts create mode 100644 packages/core/src/__tests__/inject-script.test.ts create mode 100644 packages/core/src/__tests__/moin.test.ts create mode 100644 packages/core/src/inject-script.ts create mode 100644 packages/core/src/moin.ts diff --git a/.changeset/tag-mode-loader.md b/.changeset/tag-mode-loader.md new file mode 100644 index 000000000..46281350d --- /dev/null +++ b/.changeset/tag-mode-loader.md @@ -0,0 +1,11 @@ +--- +'@walkeros/core': patch +'@walkeros/cli': patch +'@walkeros/walker.js': patch +--- + +walker.js and every web bundle the CLI builds now carry the Tag Mode loader, +`moin()` from `@walkeros/core`. It stays idle unless the walkerOS app opens your +page for Tag Mode (`?elbMoin`), then loads the Tag Mode script from +`cdn.walkeros.io`. Without that host in your CSP's `script-src`, it loads +nothing (a `'strict-dynamic'` policy admits it through walker.js's nonce). diff --git a/apps/walkerjs/package.json b/apps/walkerjs/package.json index e9b05af2e..a95807fc9 100644 --- a/apps/walkerjs/package.json +++ b/apps/walkerjs/package.json @@ -18,7 +18,8 @@ ], "scripts": { "build": "tsup", - "test": "jest --colors --coverage", + "test": "jest --colors --coverage --testPathIgnorePatterns '__tests__/integration/'", + "test:integration": "jest --colors --testPathPatterns='__tests__/integration/'", "dev": "jest --watchAll --colors", "typecheck": "tsc --noEmit", "lint": "eslint \"**/*.ts*\"", diff --git a/apps/walkerjs/src/__tests__/integration/dist.test.ts b/apps/walkerjs/src/__tests__/integration/dist.test.ts new file mode 100644 index 000000000..742b99b2f --- /dev/null +++ b/apps/walkerjs/src/__tests__/integration/dist.test.ts @@ -0,0 +1,71 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { gzipSync } from 'zlib'; + +// Reads the built files: `npm run test:integration` runs after the build of +// this package and of core. +const root = join(__dirname, '../../../../..'); +const walkerjs = readFileSync(join(root, 'apps/walkerjs/dist/walker.js')); +const builds: [string, string][] = [ + ['dist/walker.js', walkerjs.toString('utf8')], + ...['index.mjs', 'index.js'].map((name): [string, string] => [ + `core dist/${name}`, + readFileSync(join(root, 'packages/core/dist', name), 'utf8'), + ]), +]; + +// The Tag Mode loader budget: dist/walker.js measured before the loader +// (2026-10-09, gzip level 9) plus at most 700 bytes for the loader. +// In effect this caps walker.js's total size, with ~168 B headroom after the +// loader: a PR that grows walker.js for another reason raises +// BEFORE_LOADER_GZIP_BYTES by its measured delta and says so. +const BEFORE_LOADER_GZIP_BYTES = 34_627; +const LOADER_BUDGET_BYTES = 700; + +it('keeps the Tag Mode loader within its budget', () => { + expect(gzipSync(walkerjs, { level: 9 }).length).toBeLessThanOrEqual( + BEFORE_LOADER_GZIP_BYTES + LOADER_BUDGET_BYTES, + ); +}); + +// A loader target as the minifier emits it: an object literal with `app` and +// `base` in either order. Scoped to that shape, so another URL elsewhere in a +// build cannot trip the check. +const TARGET = + /["']?(app|base)["']?\s*:\s*(["'`])([^"'`]*)\2\s*,\s*["']?(app|base)["']?\s*:\s*(["'`])([^"'`]*)\5/g; + +function targets(code: string): { app: string; base: string }[] { + const found: { app: string; base: string }[] = []; + TARGET.lastIndex = 0; + let match = TARGET.exec(code); + while (match) { + const [, firstKey, , first, secondKey, , second] = match; + if (firstKey !== secondKey) + found.push( + firstKey === 'app' + ? { app: first, base: second } + : { app: second, base: first }, + ); + match = TARGET.exec(code); + } + return found; +} + +it.each(builds)('%s carries the production target only', (_name, code) => { + const found = targets(code); + expect(found).toEqual([ + { + app: 'https://app.walkeros.io', + base: 'https://cdn.walkeros.io/tag-mode/', + }, + ]); + for (const { app, base } of found) + for (const value of [app, base]) + for (const marker of ['localhost', 'stage.', 'http://']) + expect(value).not.toContain(marker); + expect(code).not.toMatch(/stage\.(app|cdn)\.walkeros\.io/); +}); + +it('dist/walker.js names no localhost anywhere', () => { + expect(walkerjs.toString('utf8')).not.toMatch(/localhost/); +}); diff --git a/apps/walkerjs/src/__tests__/moin.test.ts b/apps/walkerjs/src/__tests__/moin.test.ts new file mode 100644 index 000000000..039c3bf0e --- /dev/null +++ b/apps/walkerjs/src/__tests__/moin.test.ts @@ -0,0 +1,88 @@ +// The entry as a page opened for Tag Mode runs it: the loader arms before the +// DOM is ready and trusts only the production app. + +const LOADED = Symbol.for('@walkeros/walker.js'); +const GUARD = Symbol.for('walkeros.moin'); +const FILE = 'A'.repeat(64); + +const load = () => + jest.isolateModules(() => { + jest.requireActual('../index'); + }); + +const ping = (origin: string) => + window.dispatchEvent( + new MessageEvent('message', { origin, data: { elbMoin: 1, file: FILE } }), + ); + +let listen: jest.SpyInstance; + +beforeEach(() => { + Reflect.deleteProperty(window, LOADED); + Reflect.deleteProperty(window, GUARD); + Reflect.deleteProperty(window, 'elb'); + Reflect.deleteProperty(window, 'elbLayer'); + sessionStorage.clear(); + document.head.innerHTML = ''; + // The flow waits for the DOM, so only the loader runs in these tests. + Object.defineProperty(document, 'readyState', { + value: 'loading', + configurable: true, + }); + listen = jest.spyOn(window, 'addEventListener'); +}); + +afterEach(() => { + for (const [type, listener] of listen.mock.calls) + if (type === 'message') window.removeEventListener(type, listener); + Reflect.deleteProperty(document, 'readyState'); + window.history.replaceState({}, '', '/'); +}); + +const messageListeners = () => + listen.mock.calls.filter(([type]) => type === 'message'); + +it('arms before the DOM is ready on a page opened with ?elbMoin', () => { + window.history.replaceState({}, '', '/?elbMoin'); + load(); + expect(messageListeners()).toHaveLength(1); + + ping('https://evil.example'); + expect(document.querySelector('script')).toBeNull(); + + ping('https://app.walkeros.io'); + expect(document.querySelector('script')?.getAttribute('src')).toBe( + `https://cdn.walkeros.io/tag-mode/${FILE}.js`, + ); +}); + +it('stays off on a page opened without it', () => { + load(); + expect(messageListeners()).toHaveLength(0); + expect(sessionStorage.getItem('elbMoin')).toBeNull(); +}); + +describe('a page whose globals throw', () => { + const ownParams = Object.getOwnPropertyDescriptor(window, 'URLSearchParams'); + const ownStorage = Object.getOwnPropertyDescriptor(window, 'sessionStorage'); + + afterEach(() => { + if (ownParams) Object.defineProperty(window, 'URLSearchParams', ownParams); + if (ownStorage) Object.defineProperty(window, 'sessionStorage', ownStorage); + }); + + it.each(['URLSearchParams', 'sessionStorage'])( + 'keeps the bundle running when %s throws', + (name) => { + window.history.replaceState({}, '', '/?elbMoin'); + Object.defineProperty(window, name, { + configurable: true, + get() { + throw new Error('broken page'); + }, + }); + expect(load).not.toThrow(); + expect(typeof Reflect.get(window, 'elb')).toBe('function'); + }, + ); +}); diff --git a/apps/walkerjs/src/index.ts b/apps/walkerjs/src/index.ts index 9e9e0435f..34980330b 100644 --- a/apps/walkerjs/src/index.ts +++ b/apps/walkerjs/src/index.ts @@ -1,4 +1,5 @@ import { startFlow } from '@walkeros/collector'; +import { moin } from '@walkeros/core'; import { sourceBrowser } from '@walkeros/web-source-browser'; import { sourceSession } from '@walkeros/web-source-session'; import { dataLayerDestination } from './destination'; @@ -32,6 +33,9 @@ if (typeof window !== 'undefined' && !Reflect.get(window, LOADED)) { // Set before anything async so a second copy stops here. Reflect.set(window, LOADED, true); + // The Tag Mode loader: idle unless the page was opened for Tag Mode. + moin(); + // Calls made before DOM ready are queued; the browser source adopts // elbLayer and replaces this unmarked stub with its own writer. if (typeof Reflect.get(window, 'elb') !== 'function') diff --git a/packages/cli/src/__tests__/unit/bundle/bundler-codegen.test.ts b/packages/cli/src/__tests__/unit/bundle/bundler-codegen.test.ts index c855a48dc..a696a66d5 100644 --- a/packages/cli/src/__tests__/unit/bundle/bundler-codegen.test.ts +++ b/packages/cli/src/__tests__/unit/bundle/bundler-codegen.test.ts @@ -1134,6 +1134,94 @@ describe('generateWrapEntry platform gating', () => { }); }); +describe('the Tag Mode loader call', () => { + const DATA_PAYLOAD = '{"sources":{},"destinations":{}}'; + const LOADER_IMPORT = "import { moin } from '@walkeros/core';\n"; + const STAGE = { + app: 'https://stage.app.walkeros.io', + base: 'https://stage.cdn.walkeros.io/tag-mode/', + }; + const preview = { + enabled: true, + keyring: [{ kid: 'kid1', spki: 'c3BraQ' }], + iss: 'app:stage', + pb: 'pb_a', + previewOrigin: 'cdn.walkeros.io', + }; + + it('generateWebEntry calls the loader first in a browser entry', () => { + const output = generateWebEntry('./skeleton.mjs', DATA_PAYLOAD, { + platform: 'browser', + }); + expect(output.startsWith(LOADER_IMPORT)).toBe(true); + expect(output).toContain('(async () => {\n moin();\n const config'); + }); + + it('generateWebEntry leaves a node entry without the loader', () => { + const output = generateWebEntry('./skeleton.mjs', DATA_PAYLOAD, { + platform: 'node', + }); + expect(output).not.toContain('moin'); + }); + + it('generateWrapEntry calls the loader before the preview activator', () => { + const output = generateWrapEntry('./skeleton.mjs', { preview }); + expect(output.startsWith(LOADER_IMPORT)).toBe(true); + expect(output).toContain( + '(async () => {\n moin();\n // --- Preview activation ---', + ); + expect(output.indexOf('moin();')).toBeLessThan( + output.indexOf('if (await browserSwapActivator('), + ); + }); + + it('generateWrapEntry calls the loader in a preview artifact', () => { + const output = generateWrapEntry('./skeleton.mjs', { + previewGrantTargets: ['api'], + }); + expect(output).toContain('(async () => {\n moin();\n const config'); + }); + + it('generateWrapEntry passes a stage target as string literals', () => { + const output = generateWrapEntry('./skeleton.mjs', { + preview, + moin: STAGE, + }); + expect(output).toContain( + '(async () => {\n moin({ app: "https://stage.app.walkeros.io", base: "https://stage.cdn.walkeros.io/tag-mode/" });\n // --- Preview activation ---', + ); + }); + + it('generateWrapEntry leaves a node entry without the loader', () => { + const output = generateWrapEntry('./skeleton.mjs', { + platform: 'node', + moin: STAGE, + }); + expect(output).not.toContain('moin'); + }); + + it.each<[string, { app: string; base: string }]>([ + ['an http app', { ...STAGE, app: 'http://stage.app.walkeros.io' }], + [ + 'an app with a path', + { ...STAGE, app: 'https://stage.app.walkeros.io/x' }, + ], + ['an app with a trailing slash', { ...STAGE, app: `${STAGE.app}/` }], + ['an app that is no URL', { ...STAGE, app: 'stage.app.walkeros.io' }], + ['an app with a quote', { ...STAGE, app: 'https://x"});alert(1);//' }], + ['an http base', { ...STAGE, base: 'http://stage.cdn.walkeros.io/t/' }], + [ + 'a base without a trailing slash', + { ...STAGE, base: STAGE.base.slice(0, -1) }, + ], + ['a base with a query', { ...STAGE, base: `${STAGE.base}?x=/` }], + ['a base with a fragment', { ...STAGE, base: `${STAGE.base}#/` }], + ['a base with credentials', { ...STAGE, base: 'https://u:p@cdn.test/t/' }], + ])('generateWrapEntry refuses a target with %s', (_label, moin) => { + expect(() => generateWrapEntry('./skeleton.mjs', { moin })).toThrow(/moin/); + }); +}); + describe('generateServerEntry observer wiring', () => { it('iterates context.observers and installs them on collector.observers after startFlow', () => { const out = generateServerEntry('./skel.mjs', '{}'); diff --git a/packages/cli/src/__tests__/unit/bundle/preview-codegen.test.ts b/packages/cli/src/__tests__/unit/bundle/preview-codegen.test.ts index 75f39ac66..801df3aa6 100644 --- a/packages/cli/src/__tests__/unit/bundle/preview-codegen.test.ts +++ b/packages/cli/src/__tests__/unit/bundle/preview-codegen.test.ts @@ -163,9 +163,10 @@ describe('generateWrapEntry preview-artifact observe connect', () => { observe, }); expect(code).not.toContain('browserSwapActivator'); - // The connect module is pure config; the artifact entry needs zero core - // imports. - expect(code).not.toContain("from '@walkeros/core'"); + // The connect module is pure config; the artifact entry's only core + // import is the Tag Mode loader. + expect(code.match(/from '@walkeros\/core'/g)).toHaveLength(1); + expect(code).toContain("import { moin } from '@walkeros/core';"); }); it('bakes the connect module for a web-only artifact (no grant targets)', () => { diff --git a/packages/cli/src/__tests__/unit/bundle/wrap.test.ts b/packages/cli/src/__tests__/unit/bundle/wrap.test.ts index d10823c5e..630932b43 100644 --- a/packages/cli/src/__tests__/unit/bundle/wrap.test.ts +++ b/packages/cli/src/__tests__/unit/bundle/wrap.test.ts @@ -185,6 +185,50 @@ export const __configData = { test: true }; expect(output).not.toContain('browserSwapActivator'); }); + it('loads Tag Mode from production unless a deploy passes another target', async () => { + const skeletonPath = await writeFakeSkeleton(); + const read = async (name: string, moin?: { app: string; base: string }) => { + const outputPath = path.join(tmpDir, name); + await wrapSkeleton({ + skeletonPath, + platform: 'browser', + outputPath, + minify: false, + ...(moin ? { moin } : {}), + }); + return fs.readFile(outputPath, 'utf-8'); + }; + + const production = await read('production.js'); + expect(production).toContain('"https://app.walkeros.io"'); + expect(production).toContain('"https://cdn.walkeros.io/tag-mode/"'); + expect(production).not.toContain('stage.'); + + const stage = await read('stage.js', { + app: 'https://stage.app.walkeros.io', + base: 'https://stage.cdn.walkeros.io/tag-mode/', + }); + // The call keeps its literals; the minified core renames `moin`. + expect(stage).toMatch( + /\w+\(\{\s*app: "https:\/\/stage\.app\.walkeros\.io",\s*base: "https:\/\/stage\.cdn\.walkeros\.io\/tag-mode\/"\s*\}\)/, + ); + }); + + it('refuses a malformed Tag Mode target', async () => { + const skeletonPath = await writeFakeSkeleton(); + await expect( + wrapSkeleton({ + skeletonPath, + platform: 'browser', + outputPath: path.join(tmpDir, 'walker.js'), + moin: { + app: 'http://localhost:3000', + base: 'http://localhost:9001/tag-mode/', + }, + }), + ).rejects.toThrow(/Invalid moin\.app/); + }); + it('throws when the skeleton does not exist', async () => { const outputPath = path.join(tmpDir, 'walker.js'); await expect( diff --git a/packages/cli/src/commands/bundle/__tests__/manifest.test.ts b/packages/cli/src/commands/bundle/__tests__/manifest.test.ts index 474307c18..31dc5c2a2 100644 --- a/packages/cli/src/commands/bundle/__tests__/manifest.test.ts +++ b/packages/cli/src/commands/bundle/__tests__/manifest.test.ts @@ -618,3 +618,38 @@ describe('BuildManifestSchema headers', () => { expect(result.success).toBe(false); }); }); + +describe('BuildManifestSchema wrap options', () => { + const manifest = (options: Record) => ({ + version: 1, + toolchain: VERSION, + artifacts: [ + { + target: 'wrap', + platform: 'browser', + skeleton: { url: 'https://x/s.mjs' }, + options, + outputName: 'w.js', + putUrl: 'https://x/w', + }, + ], + resultPutUrl: 'https://x/r', + }); + const STAGE = { + app: 'https://stage.app.walkeros.io', + base: 'https://stage.cdn.walkeros.io/tag-mode/', + }; + + it('accepts a Tag Mode loader target', () => { + expect( + BuildManifestSchema.safeParse(manifest({ moin: STAGE })).success, + ).toBe(true); + }); + + it('refuses an unknown field in the loader target', () => { + expect( + BuildManifestSchema.safeParse(manifest({ moin: { ...STAGE, file: 'x' } })) + .success, + ).toBe(false); + }); +}); diff --git a/packages/cli/src/commands/bundle/bundler.ts b/packages/cli/src/commands/bundle/bundler.ts index 94106c6d8..d60d00f88 100644 --- a/packages/cli/src/commands/bundle/bundler.ts +++ b/packages/cli/src/commands/bundle/bundler.ts @@ -1,8 +1,9 @@ import esbuild from 'esbuild'; import { builtinModules } from 'module'; import path from 'path'; +import { fileURLToPath } from 'url'; import fs from 'fs-extra'; -import type { Flow, ObserveWeb, PreviewKey } from '@walkeros/core'; +import type { Flow, MoinTarget, ObserveWeb, PreviewKey } from '@walkeros/core'; import { packageNameToVariable, getStepRuntimeProps, @@ -649,17 +650,22 @@ export async function bundleCore( // `import('/dev')` to externalize here. A future registry-bearing // non-skeleton browser target would need to externalize `/dev` on the // browser branch below (gated on `externalizeDev`) to stay dev-free. - const stage2Entry = - (buildOptions.platform || 'node') === 'browser' - ? generateWebEntry(stage1Path, dataPayload, { - windowCollector: buildOptions.windowCollector, - windowElb: buildOptions.windowElb, - platform: buildOptions.platform as 'browser' | 'node', - observe: readObserveConnect(flowSettings, logger), - }) - : generateServerEntry(stage1Path, dataPayload); - - const stage2EntryPath = path.join(TEMP_DIR, 'stage2.mjs'); + const browser = (buildOptions.platform || 'node') === 'browser'; + const stage2Entry = browser + ? generateWebEntry(stage1Path, dataPayload, { + windowCollector: buildOptions.windowCollector, + windowElb: buildOptions.windowElb, + platform: buildOptions.platform as 'browser' | 'node', + observe: readObserveConnect(flowSettings, logger), + }) + : generateServerEntry(stage1Path, dataPayload); + + // The browser entry imports the Tag Mode loader from @walkeros/core. + // TEMP_DIR's node_modules hold the flow's own core, which may predate + // the loader, so that entry is written outside TEMP_DIR and resolves + // core from the CLI's own dependency tree, as the wrap step does. + const stage2Dir = browser ? await tmpRunDir('wrap') : TEMP_DIR; + const stage2EntryPath = path.join(stage2Dir, 'stage2.mjs'); await fs.writeFile(stage2EntryPath, stage2Entry); // Stage 2 esbuild: resolve imports, inline stage 1, minify @@ -692,6 +698,7 @@ export async function bundleCore( // `window` explicitly, so they still run. The browser entry has zero // exports, so no `globalName` is needed (one would add a window var). stage2Options.format = 'iife'; + stage2Options.nodePaths = getNodeResolutionPaths(); // Every build flag is defined from the flow's needs, so a feature the // flow does not use folds out (see build-flags.ts). stage2Options.define = { @@ -717,6 +724,7 @@ export async function bundleCore( await esbuild.build(stage2Options); } finally { await esbuild.stop(); + if (stage2Dir !== TEMP_DIR) await fs.remove(stage2Dir).catch(() => {}); } } @@ -980,6 +988,29 @@ export function getNodeExternals(): string[] { return externals; } +/** + * Returns the candidate `node_modules` dirs esbuild should consult for a + * stage 2 entry that imports from `@walkeros/core` (the wrap step, and the + * browser entry's Tag Mode loader). We start at this module's own location + * and walk upward, since the build always runs from inside the CLI + * package: either via `node_modules/@walkeros/cli/dist/...` or directly + * from the workspace source tree during tests. + */ +export function getNodeResolutionPaths(): string[] { + const here = path.dirname(fileURLToPath(import.meta.url)); + const candidates: string[] = []; + let dir = here; + // Walk up at most 8 levels looking for node_modules dirs. + for (let i = 0; i < 8; i++) { + const candidate = path.join(dir, 'node_modules'); + candidates.push(candidate); + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return candidates; +} + /** * Server-only post-build pass: run nft on the just-emitted server bundle to * copy every actually-used file into `outDir/node_modules/`, then write a @@ -1926,6 +1957,53 @@ function emitObserveAssignment(observe: ObserveWeb): string { config.observe = ${JSON.stringify(literal)};`; } +/** Imports the Tag Mode loader into a browser entry. */ +const MOIN_IMPORT = "import { moin } from '@walkeros/core';\n"; + +/** + * The Tag Mode loader call, the first statement of a browser entry: the + * production target when none is given, or the target a deploy passes. A + * target must be an https origin and an https base ending in `/`, so a + * malformed value fails the build instead of shipping a loader that never + * loads; its values are emitted as string literals. + */ +function emitMoinCall(target?: MoinTarget): string { + if (!target) + return ` + moin();`; + const app = parseHttps(target.app); + if (!app || app.origin !== target.app) { + throw new Error( + `Invalid moin.app "${target.app}". Must be an https origin such as https://app.example.com.`, + ); + } + const base = parseHttps(target.base); + if ( + !base || + base.href !== target.base || + base.username || + base.password || + base.search || + base.hash || + !base.pathname.endsWith('/') + ) { + throw new Error( + `Invalid moin.base "${target.base}". Must be an https URL ending in "/" without query or fragment.`, + ); + } + return ` + moin({ app: ${JSON.stringify(target.app)}, base: ${JSON.stringify(target.base)} });`; +} + +function parseHttps(value: string): URL | undefined { + try { + const url = new URL(value); + return url.protocol === 'https:' ? url : undefined; + } catch { + return undefined; + } +} + /** * Generate a stage 2 entry file for web/browser bundles. * Imports startFlow and wireConfig from the stage 1 .mjs file, @@ -1982,11 +2060,14 @@ export function generateWebEntry( ? emitObserveAssignment(options.observe) : ''; - return `import { startFlow, wireConfig } from '${stage1Specifier}'; + const loaderImport = platform === 'browser' ? MOIN_IMPORT : ''; + const loaderCall = platform === 'browser' ? emitMoinCall() : ''; + + return `${loaderImport}import { startFlow, wireConfig } from '${stage1Specifier}'; const __configData = ${dataPayload}; -(async () => { +(async () => {${loaderCall} const config = wireConfig(__configData);${envBlock}${observeBlock} const { collector, elb } = await startFlow(config);${assignmentCode} })();`; @@ -2056,6 +2137,11 @@ export function generateWrapEntry( * preview-artifact observation wiring: it bakes no ingest token. */ observe?: ObserveWeb; + /** + * The Tag Mode loader target, internal deploy data like the preview + * keyring. Absent, the loader trusts the production app. + */ + moin?: MoinTarget; } = {}, ): string { const assignments: string[] = []; @@ -2173,9 +2259,14 @@ ${grantTargets ? emitObserveAssignment(options.observe) : ''; - return `${previewImport}import { startFlow, wireConfig, __configData } from '${stage1Specifier}'; + // The loader runs first, before the activator: host and artifact both call + // it, and its page guard keeps the first one. + const loaderImport = platform === 'browser' ? MOIN_IMPORT : ''; + const loaderCall = platform === 'browser' ? emitMoinCall(options.moin) : ''; + + return `${loaderImport}${previewImport}import { startFlow, wireConfig, __configData } from '${stage1Specifier}'; -(async () => {${previewBlock} +(async () => {${loaderCall}${previewBlock} const config = wireConfig(__configData);${envBlock}${previewGrantBlock}${observeBlock} const { collector, elb } = await startFlow(config);${assignmentCode} })();`; diff --git a/packages/cli/src/commands/bundle/wrap.ts b/packages/cli/src/commands/bundle/wrap.ts index 66b1ff8bc..576aa6cbe 100644 --- a/packages/cli/src/commands/bundle/wrap.ts +++ b/packages/cli/src/commands/bundle/wrap.ts @@ -16,15 +16,15 @@ */ import * as path from 'path'; -import { fileURLToPath } from 'url'; import fs from 'fs-extra'; import * as esbuild from 'esbuild'; import { generateWrapEntry, generateWrapEntryServer, getNodeExternals, + getNodeResolutionPaths, } from './bundler.js'; -import type { ObserveWeb } from '@walkeros/core'; +import type { MoinTarget, ObserveWeb } from '@walkeros/core'; import { tmpRunDir } from '../../core/tmp-names.js'; import { buildFlagDefines, readNeedsMarker } from './build-flags.js'; import type { WrapEntryPreview } from './bundler.js'; @@ -81,6 +81,13 @@ export interface WrapSkeletonOptions { */ observe?: ObserveWeb; + /** + * Browser-only: the Tag Mode loader target, internal deploy data the stage + * app passes like the preview keyring. Absent, the loader trusts the + * production app. + */ + moin?: MoinTarget; + /** * esbuild target. @default 'es2018' for browser, 'node18' for node. */ @@ -93,13 +100,6 @@ export interface WrapSkeletonOptions { minifyOptions?: MinifyOptions; } -/** - * Returns the candidate `node_modules` dirs esbuild should consult for - * the wrap step's stage 2 entry. We start at this module's own location - * and walk upward, since the wrap step always runs from inside the CLI - * package — either via `node_modules/@walkeros/cli/dist/...` or directly - * from the workspace source tree during tests. - */ /** * Extracts the `/dev` specifiers from a skeleton's lazy `__devExports` * registry by reading its literal `import('/dev')` thunks. The registry is @@ -117,21 +117,6 @@ export function extractDevExternals(skeletonText: string): string[] { return Array.from(found); } -function getNodeResolutionPaths(): string[] { - const here = path.dirname(fileURLToPath(import.meta.url)); - const candidates: string[] = []; - let dir = here; - // Walk up at most 8 levels looking for node_modules dirs. - for (let i = 0; i < 8; i++) { - const candidate = path.join(dir, 'node_modules'); - candidates.push(candidate); - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; - } - return candidates; -} - export async function wrapSkeleton( options: WrapSkeletonOptions, ): Promise { @@ -203,6 +188,7 @@ export async function wrapSkeleton( : {}), platform, ...(options.observe ? { observe: options.observe } : {}), + ...(options.moin ? { moin: options.moin } : {}), }) : generateWrapEntryServer(absoluteSkeletonPath); diff --git a/packages/cli/src/schemas/build-manifest.ts b/packages/cli/src/schemas/build-manifest.ts index a759ed49a..a38b58a08 100644 --- a/packages/cli/src/schemas/build-manifest.ts +++ b/packages/cli/src/schemas/build-manifest.ts @@ -128,6 +128,11 @@ export const BuildWrapOptionsSchema = z }) .strict() .optional(), + moin: z + .object({ app: z.string(), base: z.string() }) + .strict() + .optional() + .describe('Tag Mode loader target; absent, the production app'), minify: z.boolean().optional(), minifyOptions: z .object({ diff --git a/packages/core/src/__tests__/inject-script.test.ts b/packages/core/src/__tests__/inject-script.test.ts new file mode 100644 index 000000000..96f153702 --- /dev/null +++ b/packages/core/src/__tests__/inject-script.test.ts @@ -0,0 +1,26 @@ +/** + * @jest-environment jsdom + */ +import { injectScript } from '../inject-script'; + +beforeEach(() => { + document.head.innerHTML = ''; +}); + +it('resolves false when the page refuses the script URL', async () => { + // A page that requires Trusted Types throws on a plain string src. + const script = document.createElement('script'); + Object.defineProperty(script, 'src', { + set() { + throw new TypeError( + "This document requires 'TrustedScriptURL' assignment.", + ); + }, + }); + jest.spyOn(document, 'createElement').mockReturnValueOnce(script); + + await expect( + injectScript('https://cdn.test/tag-mode/x.js', 'sha384-x'), + ).resolves.toBe(false); + expect(document.head.querySelector('script')).toBeNull(); +}); diff --git a/packages/core/src/__tests__/moin.test.ts b/packages/core/src/__tests__/moin.test.ts new file mode 100644 index 000000000..6c53855ef --- /dev/null +++ b/packages/core/src/__tests__/moin.test.ts @@ -0,0 +1,295 @@ +/** + * @jest-environment jsdom + * @jest-environment-options {"url": "https://shop.example.com/page"} + */ +import { + MOIN_PARAM, + MOIN_PRODUCTION, + moin, + runMoin, + type MoinGlobals, + type MoinTarget, +} from '../moin'; + +const T: MoinTarget = { + app: 'https://app.test', + base: 'https://cdn.test/tag-mode/', +}; +const FILE = 'A'.repeat(64); +const GUARD = Symbol.for('walkeros.moin'); + +let listen: jest.SpyInstance; + +function setSearch(search: string) { + window.history.replaceState({}, '', `/page${search}`); +} + +function ping(origin: string, data: unknown) { + window.dispatchEvent(new MessageEvent('message', { origin, data })); +} + +function messageListeners(): unknown[] { + return listen.mock.calls + .filter(([type]) => type === 'message') + .map(([, listener]) => listener); +} + +function scripts(): HTMLScriptElement[] { + return Array.from(document.querySelectorAll('script')); +} + +beforeEach(() => { + setSearch(''); + sessionStorage.clear(); + Reflect.deleteProperty(window, GUARD); + document.head.innerHTML = ''; + listen = jest.spyOn(window, 'addEventListener'); +}); + +afterEach(() => { + // Listeners outlive a test on the shared jsdom window: drop every one the + // loader added, so a later ping reaches only the loader under test. + for (const [type, listener] of listen.mock.calls) + if (type === 'message') window.removeEventListener(type, listener); + jest.restoreAllMocks(); +}); + +describe('the frozen contract', () => { + it('names the parameter elbMoin', () => { + expect(MOIN_PARAM).toBe('elbMoin'); + }); + + it('trusts only the production app and loads from the production base', () => { + expect(MOIN_PRODUCTION).toEqual({ + app: 'https://app.walkeros.io', + base: 'https://cdn.walkeros.io/tag-mode/', + }); + }); +}); + +describe('arming', () => { + it('stays off without the parameter or the tab flag', () => { + const write = jest.spyOn(Storage.prototype, 'setItem'); + moin(T); + expect(messageListeners()).toHaveLength(0); + expect(write).not.toHaveBeenCalled(); + expect(Reflect.get(window, GUARD)).toBeUndefined(); + ping(T.app, { elbMoin: 1, file: FILE }); + expect(scripts()).toHaveLength(0); + }); + + it('arms on ?elbMoin and sets the tab flag', () => { + setSearch('?elbMoin'); + moin(T); + expect(messageListeners()).toHaveLength(1); + expect(sessionStorage.getItem('elbMoin')).toBe('1'); + expect(Reflect.get(window, GUARD)).toBe(true); + }); + + it('arms on ?elbMoin with any value beside other parameters', () => { + setSearch('?utm_source=x&elbMoin=anything'); + moin(T); + expect(messageListeners()).toHaveLength(1); + }); + + it('arms on the tab flag alone', () => { + sessionStorage.setItem('elbMoin', '1'); + moin(T); + expect(messageListeners()).toHaveLength(1); + }); + + it('stays off on a tab flag other than "1"', () => { + sessionStorage.setItem('elbMoin', 'true'); + moin(T); + expect(messageListeners()).toHaveLength(0); + }); + + describe('where storage is denied', () => { + const own = Object.getOwnPropertyDescriptor(window, 'sessionStorage'); + + beforeEach(() => { + Object.defineProperty(window, 'sessionStorage', { + configurable: true, + get() { + throw new DOMException('denied', 'SecurityError'); + }, + }); + }); + + afterEach(() => { + if (own) Object.defineProperty(window, 'sessionStorage', own); + }); + + it('arms by the parameter alone', () => { + setSearch('?elbMoin'); + moin(T); + expect(messageListeners()).toHaveLength(1); + ping(T.app, { elbMoin: 1, file: FILE }); + expect(scripts()).toHaveLength(1); + }); + + it('stays off without the parameter', () => { + moin(T); + expect(messageListeners()).toHaveLength(0); + }); + }); + + describe('frames', () => { + // A frame window: the parameter is present, so only the top check decides. + function frame(top: 'self' | 'other') { + const addEventListener = jest.fn(); + const frameWindow: MoinGlobals['window'] = { + top: null, + location: { search: '?elbMoin' }, + sessionStorage: window.sessionStorage, + addEventListener, + removeEventListener: jest.fn(), + }; + frameWindow.top = top === 'self' ? frameWindow : window; + runMoin({ window: frameWindow, URLSearchParams }, T); + return addEventListener; + } + + it('stays off in a sub-frame', () => { + expect(frame('other')).not.toHaveBeenCalled(); + }); + + it('arms the same window when it is the top frame', () => { + expect(frame('self')).toHaveBeenCalledWith( + 'message', + expect.any(Function), + ); + }); + }); + + describe('broken globals', () => { + class BrokenParams { + constructor() { + throw new Error('broken polyfill'); + } + has(): boolean { + return false; + } + } + + const fail = (): never => { + throw new Error('broken page'); + }; + + function broken(part: 'params' | 'top' | 'listener'): MoinGlobals { + const frameWindow: MoinGlobals['window'] = { + top: null, + location: { search: '?elbMoin' }, + sessionStorage: window.sessionStorage, + addEventListener: part === 'listener' ? fail : jest.fn(), + removeEventListener: jest.fn(), + }; + if (part === 'top') + Object.defineProperty(frameWindow, 'top', { get: fail }); + else frameWindow.top = frameWindow; + return { + window: frameWindow, + URLSearchParams: part === 'params' ? BrokenParams : URLSearchParams, + }; + } + + it.each(['params', 'top', 'listener'] as const)( + 'never throws when %s throws', + (part) => { + expect(() => runMoin(broken(part), T)).not.toThrow(); + }, + ); + }); + + it('lets only the first loader on a page arm', () => { + setSearch('?elbMoin'); + moin(T); + moin({ app: 'https://other.test', base: 'https://cdn.other.test/x/' }); + expect(messageListeners()).toHaveLength(1); + ping('https://other.test', { elbMoin: 1, file: FILE }); + expect(scripts()).toHaveLength(0); + }); +}); + +describe('the ping', () => { + beforeEach(() => setSearch('?elbMoin')); + + it('loads the named script with integrity from the same hash', () => { + const file = 'ab-_'.repeat(16); + moin(T); + ping(T.app, { elbMoin: 1, file }); + const [script, ...rest] = scripts(); + expect(rest).toHaveLength(0); + expect(script.getAttribute('src')).toBe(`${T.base}${file}.js`); + expect(script.getAttribute('integrity')).toBe( + `sha384-${'ab+/'.repeat(16)}`, + ); + expect(script.getAttribute('crossorigin')).toBe('anonymous'); + expect(script.async).toBe(true); + expect(script.parentNode).toBe(document.head); + }); + + it('accepts only the first ping', () => { + const drop = jest.spyOn(window, 'removeEventListener'); + moin(T); + ping(T.app, { elbMoin: 1, file: FILE }); + ping(T.app, { elbMoin: 1, file: 'B'.repeat(64) }); + expect(scripts()).toHaveLength(1); + expect(drop).toHaveBeenCalledWith('message', messageListeners()[0]); + }); + + it('trusts the production target by default', () => { + moin(); + ping(T.app, { elbMoin: 1, file: FILE }); + expect(scripts()).toHaveLength(0); + ping('https://app.walkeros.io', { elbMoin: 1, file: FILE }); + expect(scripts().map((script) => script.getAttribute('src'))).toEqual([ + `https://cdn.walkeros.io/tag-mode/${FILE}.js`, + ]); + }); + + it.each<[string, string, unknown]>([ + ['a wrong origin', 'https://evil.test', { elbMoin: 1, file: FILE }], + [ + 'an origin with a lookalike suffix', + 'https://app.test.evil.net', + { elbMoin: 1, file: FILE }, + ], + ['the app origin over http', 'http://app.test', { elbMoin: 1, file: FILE }], + [ + 'the app origin with a trailing slash', + 'https://app.test/', + { elbMoin: 1, file: FILE }, + ], + ['an opaque origin', 'null', { elbMoin: 1, file: FILE }], + ['an empty origin', '', { elbMoin: 1, file: FILE }], + ['not an object', T.app, 'elbMoin'], + ['null data', T.app, null], + ['an array', T.app, [1, FILE]], + ['elbMoin true', T.app, { elbMoin: true, file: FILE }], + ['elbMoin "1"', T.app, { elbMoin: '1', file: FILE }], + ['elbMoin missing', T.app, { file: FILE }], + ['file missing', T.app, { elbMoin: 1 }], + ['file a number', T.app, { elbMoin: 1, file: 1 }], + ['../', T.app, { elbMoin: 1, file: '../' + 'A'.repeat(61) }], + ['%2e%2e/', T.app, { elbMoin: 1, file: '%2e%2e/' + 'A'.repeat(57) }], + ['.%2E/', T.app, { elbMoin: 1, file: '.%2E/' + 'A'.repeat(59) }], + ['backslash', T.app, { elbMoin: 1, file: '..\\' + 'A'.repeat(61) }], + ['x/../../', T.app, { elbMoin: 1, file: 'x/../../' + 'A'.repeat(56) }], + ['..%2f', T.app, { elbMoin: 1, file: '..%2f' + 'A'.repeat(59) }], + ['query', T.app, { elbMoin: 1, file: 'a?b' + 'A'.repeat(61) }], + ['fragment', T.app, { elbMoin: 1, file: 'a#b' + 'A'.repeat(61) }], + ['a dot', T.app, { elbMoin: 1, file: 'A'.repeat(63) + '.' }], + ['base64 padding', T.app, { elbMoin: 1, file: 'A'.repeat(63) + '=' }], + ['a trailing newline', T.app, { elbMoin: 1, file: FILE + '\n' }], + ['63 chars', T.app, { elbMoin: 1, file: 'A'.repeat(63) }], + ['65 chars', T.app, { elbMoin: 1, file: 'A'.repeat(65) }], + ['empty', T.app, { elbMoin: 1, file: '' }], + ])('ignores %s and keeps listening', (_label, origin, data) => { + moin(T); + ping(origin, data); + expect(scripts()).toHaveLength(0); + ping(T.app, { elbMoin: 1, file: FILE }); + expect(scripts()).toHaveLength(1); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cdb727773..d841a39a4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -40,6 +40,8 @@ export * from './useHooks'; export * from './telemetry'; export * from './telemetryResolver'; export * from './preview'; +export { MOIN_PARAM, MOIN_PRODUCTION, moin } from './moin'; +export type { MoinTarget } from './moin'; export * from './journey'; export { getTraceUntil, setTraceUntil } from './traceState'; export { emitStep } from './emitStep'; diff --git a/packages/core/src/inject-script.ts b/packages/core/src/inject-script.ts new file mode 100644 index 000000000..819d1d847 --- /dev/null +++ b/packages/core/src/inject-script.ts @@ -0,0 +1,69 @@ +// Structural slices of the browser globals the injector touches. Core +// compiles without the DOM lib, so document and the timer functions are +// untyped here; reading them off globalThis behind guards is the +// batchedPoster precedent. +interface ScriptEl { + onload: (() => void) | null; + onerror: (() => void) | null; + src: string; + async: boolean; + setAttribute(name: string, value: string): void; + parentNode: { removeChild(el: ScriptEl): void } | null; +} + +interface InjectGlobals { + document?: { + head: { appendChild(el: ScriptEl): void }; + createElement(tag: 'script'): ScriptEl; + }; + setTimeout?: (fn: () => void, ms: number) => number; + clearTimeout?: (id: number) => void; +} + +/** + * Append an async `