From 7aea1554e4c34b5ed0e5f74166ee81d693125773 Mon Sep 17 00:00:00 2001 From: Mario Tarosso Date: Mon, 24 Aug 2026 10:39:31 +0100 Subject: [PATCH 1/3] [feat] Carry the production marker to server-rendered builds The connector has two injection paths and only one of them reaches a server-rendered deploy. The widget tag is written into source, so it ships with the app; `__PATCHSTACK_PROD__` is stamped into built HTML, and a server-rendered root never produces a static HTML file to stamp. On those stacks the marker never arrives, so the widget reads the live site as build mode and shows the claim flow to visitors. `mark-build` made this invisible: finding a build directory with no HTML in it printed "Nothing to mark" and exited 0, which is indistinguishable from success. - `guide` now asks for the marker whenever the root shell is code rather than HTML, printing the JSX snippet for React-family roots and describing the requirement for the rest, and counts it as an outstanding step. - The snippet is gated on the framework's own production expression. An ungated marker would also fire in the hosted builder's preview, which is where the owner still needs the claim flow. - The marker stays an inline document script. The widget tag is `defer`, so only a parser-executed script is ordered ahead of its init for certain. - `mark-build` now names the server-rendered case and points at `guide` instead of reporting nothing to do. Co-Authored-By: Claude Opus 5 (1M context) --- src/cli.ts | 12 ++++++- src/guide.ts | 77 +++++++++++++++++++++++++++++++++++++++- src/mark-build.ts | 66 ++++++++++++++++++++++++++++++++++ tests/guide.test.ts | 60 +++++++++++++++++++++++++++++++ tests/mark-build.test.ts | 54 ++++++++++++++++++++++++++++ 5 files changed, 267 insertions(+), 2 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 3b88a050..b2845273 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -932,7 +932,17 @@ async function runMarkBuild(args: ParsedArgs): Promise { const files = findHtmlFiles(dir); if (files.length === 0) { - console.warn(`mark-build: no HTML files found under ${dir}. Nothing to mark.`); + // A build directory with no HTML in it is the signature of a server-rendered + // app: the document is produced per request, so there is no file to stamp and + // the marker only reaches production if it ships in the source shell. Silence + // here reads as success, and the widget then treats the live site as a build. + console.warn(`mark-build: found ${dir} but no HTML files in it.`); + console.warn( + 'mark-build: this build looks server-rendered, so the production flag has nothing to stamp.', + ); + console.warn( + 'mark-build: add the marker to your root shell instead — run `patchstack-connect guide` for the snippet.', + ); return 0; } diff --git a/src/guide.ts b/src/guide.ts index ccecf64d..f9ce1558 100644 --- a/src/guide.ts +++ b/src/guide.ts @@ -14,9 +14,17 @@ import { DEFAULT_ENDPOINT, buildClaimUrl } from './client.js'; import { resolveConfig } from './config.js'; import { runVerify } from './protect/install/index.js'; import type { VerifyCheck } from './protect/install/types.js'; +import { + buildSourceMarkerSnippet, + hasJsxShell, + productionGate, +} from './mark-build.js'; import { detectStack } from './stack.js'; import { buildWidgetTag } from './widget.js'; +/** Global the widget reads to decide it is running on a published build. */ +const PROD_MARKER_NEEDLE = '__PATCHSTACK_PROD__'; + /** Substring that marks the widget as installed anywhere in the source tree. */ const WIDGET_NEEDLE = 'patchstack-widget'; @@ -46,6 +54,11 @@ export interface GuideState { framework: string | null; /** Existing file the widget snippet belongs in, best-effort. */ widgetFileHint: string | null; + /** + * True when the root shell already sets `__PATCHSTACK_PROD__`. Only meaningful + * for code shells: HTML shells get the marker stamped by `mark-build` instead. + */ + productionMarkerWired: boolean; /** Result of the same local inspection used by `protect --check`. */ protectionWired: boolean; protectionStack: string; @@ -260,6 +273,22 @@ export function findWidgetMarker(cwd: string, siteUuid?: string | null): WidgetS }; } +/** + * Whether the root shell already carries the production marker. Reads only the + * file the snippet belongs in — the marker has one correct home, so a tree walk + * would cost more and answer no better. + */ +function findProductionMarker(cwd: string, widgetFileHint: string | null): boolean { + if (widgetFileHint === null) { + return false; + } + try { + return readFileSync(path.join(cwd, widgetFileHint), 'utf8').includes(PROD_MARKER_NEEDLE); + } catch { + return false; + } +} + function resolveWidgetFileHint(cwd: string, framework: string | null): string | null { const candidates = [ ...(framework !== null ? WIDGET_FILE_CANDIDATES[framework] ?? [] : []), @@ -317,6 +346,7 @@ export async function collectGuideState(cwd: string): Promise { ); const widget = findWidgetMarker(cwd, siteUuid); + const widgetFileHint = resolveWidgetFileHint(cwd, stack.framework); const protection = runVerify(cwd); return { @@ -341,7 +371,8 @@ export async function collectGuideState(cwd: string): Promise { widgetTokenMatches: widget.uuidMatches, widgetOptOut, framework: stack.framework, - widgetFileHint: resolveWidgetFileHint(cwd, stack.framework), + widgetFileHint: widgetFileHint, + productionMarkerWired: findProductionMarker(cwd, widgetFileHint), protectionWired: protection.wired, protectionStack: protection.stack, protectionChecks: protection.checks, @@ -358,6 +389,18 @@ const ANSI = { }; /** Setup steps still missing — 0 means the checklist is fully green. */ +/** + * True when the site's root shell is code rather than an HTML file. Those roots + * are server-rendered, so no built HTML file carries the marker to production and + * `mark-build`'s HTML pass has nothing to stamp. + */ +export function needsSourceProductionMarker(state: GuideState): boolean { + if (state.siteUuid === null || state.widgetOptOut || state.widgetFileHint === null) { + return false; + } + return !state.widgetFileHint.toLowerCase().endsWith('.html'); +} + export function countRemainingSteps(state: GuideState): number { return [ state.installed?.section === 'dependencies', @@ -365,6 +408,7 @@ export function countRemainingSteps(state: GuideState): number { state.installScanWired, !state.hasBuildScript || (state.prebuildWired && state.postbuildWired), state.widgetOptOut || (state.widgetInstalled && state.widgetTokenMatches !== false), + !needsSourceProductionMarker(state) || state.productionMarkerWired, state.protectionWired, ].filter((step) => !step).length; } @@ -474,6 +518,37 @@ export function renderGuideChecklist(state: GuideState, useColor: boolean): stri lines.push(detail('The site UUID is public by design — it ships in client-side HTML.')); } + // 5b. Production marker on server-rendered roots. A code root never emits a + // static HTML file, so `mark-build` has nothing to stamp and the marker only + // reaches production if it ships in the shell alongside the widget tag. + if (needsSourceProductionMarker(state)) { + if (state.productionMarkerWired) { + lines.push(done('Production marker wired (widget switches to report mode on the published site)')); + } else { + lines.push(todo('Add the production marker yourself — this root is server-rendered, so mark-build cannot stamp it')); + lines.push( + detail( + 'Without it the widget treats the published site as build mode and shows the claim flow to visitors.', + ), + ); + const gate = productionGate(state.framework); + if (hasJsxShell(state.framework)) { + lines.push(detail(`Edit ${state.widgetFileHint} → put it in , above the widget tag:`)); + for (const snippetLine of buildSourceMarkerSnippet(state.framework).split('\n')) { + lines.push(detail(` ${snippetLine}`)); + } + } else { + lines.push( + detail( + `Edit ${state.widgetFileHint} → using the framework's head mechanism, emit an inline`, + ), + ); + lines.push(detail(` only when ${gate}.`)); + } + lines.push(detail(`The ${gate} guard is required — an ungated marker also hides the claim flow in preview.`)); + } + } + // 6. Runtime protection if (state.protectionWired) { lines.push(done(`Runtime protection wired (${state.protectionStack})`)); diff --git a/src/mark-build.ts b/src/mark-build.ts index e95b3738..cb19a4ef 100644 --- a/src/mark-build.ts +++ b/src/mark-build.ts @@ -88,3 +88,69 @@ export function injectMarker(html: string, snippet: string): string { } return stripped + snippet; } + +/* ------------------------------------------------------------------ */ +/* Source-shell marking (server-rendered roots) */ +/* ------------------------------------------------------------------ */ + +/** + * Frameworks whose root shell is JSX. These get a literal snippet; every other + * code shell gets the requirement described instead, because its head mechanism + * (`useHead`, ``, Astro frontmatter) is not a plain script tag and + * a wrong snippet costs more than an accurate sentence. + */ +const JSX_SHELL_FRAMEWORKS = new Set([ + 'next', + 'remix', + 'react-router', + 'tanstack-start', + 'gatsby', +]); + +/** + * How each framework spells "this is a production build" in source. Vite-built + * roots use `import.meta.env.PROD`, which the bundler statically replaces; + * bundlers that don't define it fall back to NODE_ENV. + * + * The gate is load-bearing: an ungated marker also fires in the hosted builder's + * dev preview, which is exactly where the owner still needs the claim flow. + */ +const PRODUCTION_GATES: Record = { + next: "process.env.NODE_ENV === 'production'", + gatsby: "process.env.NODE_ENV === 'production'", + nuxt: '!import.meta.dev', +}; + +const DEFAULT_PRODUCTION_GATE = 'import.meta.env.PROD'; + +/** The build-time expression that must guard the marker for this framework. */ +export function productionGate(framework: string | null): string { + const mapped = framework !== null ? PRODUCTION_GATES[framework] : undefined; + return mapped ?? DEFAULT_PRODUCTION_GATE; +} + +/** True when `guide` can print a literal marker snippet for this framework. */ +export function hasJsxShell(framework: string | null): boolean { + return framework !== null && JSX_SHELL_FRAMEWORKS.has(framework); +} + +/** + * The production marker as it belongs in a JSX root shell. + * + * Server-rendered roots never produce a static HTML file for `mark-build` to + * stamp, so on those stacks this is the only path the marker has to production. + * It stays an inline document script rather than a module-level assignment: the + * widget tag is `defer`, and only a parser-executed inline script is ordered + * ahead of it for certain. + */ +export function buildSourceMarkerSnippet(framework: string | null): string { + const gate = productionGate(framework); + return ( + `{${gate} && (\n` + + ` \n` + + `)}` + ); +} diff --git a/tests/guide.test.ts b/tests/guide.test.ts index 5f1976dd..97697e84 100644 --- a/tests/guide.test.ts +++ b/tests/guide.test.ts @@ -10,6 +10,7 @@ import { detectPackageManager, findWidgetMarker, installCommand, + needsSourceProductionMarker, renderGuideChecklist, } from '../src/guide.js'; @@ -328,4 +329,63 @@ describe('guide', () => { expect(output).toContain('No package.json found'); }); }); + + describe('production marker on server-rendered roots', () => { + const tanstackProject = (rootContents: string): void => { + writeJson('package.json', { + name: 'ssr-app', + dependencies: { '@tanstack/react-start': '1.0.0', react: '18.0.0' }, + }); + writeJson('.patchstackrc.json', { siteUuid: VALID_UUID }); + mkdirSync(path.join(cwd, 'src', 'routes'), { recursive: true }); + writeFileSync(path.join(cwd, 'src', 'routes', '__root.tsx'), rootContents); + }; + + const widgetTag = ``; + + it('asks for the marker when the root is code and does not have it', async () => { + tanstackProject(`export const Root = () => ${widgetTag};`); + const state = await collectGuideState(cwd); + + expect(needsSourceProductionMarker(state)).toBe(true); + expect(state.productionMarkerWired).toBe(false); + + const output = renderGuideChecklist(state, false); + expect(output).toContain('Add the production marker yourself'); + expect(output).toContain('import.meta.env.PROD &&'); + expect(output).toContain('window.__PATCHSTACK_PROD__=true;'); + }); + + it('counts the missing marker as an outstanding step', async () => { + tanstackProject(`export const Root = () => ${widgetTag};`); + const withoutMarker = countRemainingSteps(await collectGuideState(cwd)); + + tanstackProject( + `export const Root = () => {import.meta.env.PROD && only when ${productionGate(framework)}.`, + ); + } + break; + } + } catch (err) { + console.warn(`Production marker: skipped (${(err as Error).message}).`); + } +} + async function runProtectCommand(args: ParsedArgs): Promise { // `--check`: verify the guard is wired (for the agent/CI loop). Non-zero exit if not. if (args.flags.get('check') === true) { diff --git a/src/guide.ts b/src/guide.ts index f9ce1558..962e5399 100644 --- a/src/guide.ts +++ b/src/guide.ts @@ -289,7 +289,7 @@ function findProductionMarker(cwd: string, widgetFileHint: string | null): boole } } -function resolveWidgetFileHint(cwd: string, framework: string | null): string | null { +export function resolveWidgetFileHint(cwd: string, framework: string | null): string | null { const candidates = [ ...(framework !== null ? WIDGET_FILE_CANDIDATES[framework] ?? [] : []), ...GENERIC_WIDGET_FILES, @@ -525,7 +525,7 @@ export function renderGuideChecklist(state: GuideState, useColor: boolean): stri if (state.productionMarkerWired) { lines.push(done('Production marker wired (widget switches to report mode on the published site)')); } else { - lines.push(todo('Add the production marker yourself — this root is server-rendered, so mark-build cannot stamp it')); + lines.push(todo('Add the production marker — this root is server-rendered, so mark-build cannot stamp it')); lines.push( detail( 'Without it the widget treats the published site as build mode and shows the claim flow to visitors.', @@ -533,7 +533,8 @@ export function renderGuideChecklist(state: GuideState, useColor: boolean): stri ); const gate = productionGate(state.framework); if (hasJsxShell(state.framework)) { - lines.push(detail(`Edit ${state.widgetFileHint} → put it in , above the widget tag:`)); + lines.push(detail(`Run → npx @patchstack/connect scan (adds it to ${state.widgetFileHint} automatically)`)); + lines.push(detail('Or add it by hand, in above the widget tag:')); for (const snippetLine of buildSourceMarkerSnippet(state.framework).split('\n')) { lines.push(detail(` ${snippetLine}`)); } @@ -545,7 +546,7 @@ export function renderGuideChecklist(state: GuideState, useColor: boolean): stri ); lines.push(detail(` only when ${gate}.`)); } - lines.push(detail(`The ${gate} guard is required — an ungated marker also hides the claim flow in preview.`)); + lines.push(detail(`The ${gate} guard is required — an ungated marker also hides the claim flow in preview.`)) } } diff --git a/src/mark-build.ts b/src/mark-build.ts index cb19a4ef..4ef5998f 100644 --- a/src/mark-build.ts +++ b/src/mark-build.ts @@ -1,4 +1,4 @@ -import { existsSync, readdirSync, statSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { isEmptyStack, type StackDescriptor } from './stack.js'; @@ -154,3 +154,104 @@ export function buildSourceMarkerSnippet(framework: string | null): string { `)}` ); } + +/** Global the widget reads to decide it is running on a published build. */ +const PROD_MARKER_GLOBAL = '__PATCHSTACK_PROD__'; + +/** + * JSX comment fence around the injected marker. Mirrors the `#region` fence the + * protect installer uses on server entries: it makes the block obviously ours, + * and lets a re-run replace it instead of stacking copies. + */ +const REGION_OPEN = '{/* #region patchstack (managed by patchstack-connect — do not edit) */}'; +const REGION_CLOSE = '{/* #endregion patchstack */}'; +const REGION_RE = /[ \t]*\{\/\* #region patchstack[\s\S]*?#endregion patchstack \*\/\}\n?/g; + +export type SourceMarkerAction = + /** The managed block was inserted (or refreshed in place). */ + | 'added' + /** A marker is already present and not ours — adopted, left untouched. */ + | 'manual' + /** No JSX anchor to attach to; the caller falls back to printing the snippet. */ + | 'no-anchor' + /** This framework's root shell is not JSX, so there is no snippet to insert. */ + | 'unsupported'; + +export interface SourceMarkerResult { + source: string; + action: SourceMarkerAction; +} + +/** + * Insert the production marker into a JSX root shell, idempotently. + * + * Anchors on the widget tag when one is present, so the marker is ordered ahead + * of it in the document; otherwise on ``, then ``. A marker the + * developer placed themselves is adopted rather than duplicated. + */ +export function ensureMarkerInJsxShell(source: string, framework: string | null): SourceMarkerResult { + if (!hasJsxShell(framework)) { + return { source, action: 'unsupported' }; + } + + const stripped = source.replace(REGION_RE, ''); + if (stripped.includes(PROD_MARKER_GLOBAL)) { + return { source, action: 'manual' }; + } + + const block = (indent: string): string => + [REGION_OPEN, ...buildSourceMarkerSnippet(framework).split('\n'), REGION_CLOSE] + .map((line) => `${indent}${line}`) + .join('\n'); + + const widget = stripped.match(/^([ \t]*).*patchstack-widget.*$/m); + if (widget?.index !== undefined) { + const indent = widget[1] ?? ''; + return { + source: `${stripped.slice(0, widget.index)}${block(indent)}\n${stripped.slice(widget.index)}`, + action: 'added', + }; + } + + for (const anchor of [/^([ \t]*)[ \t]*$/m, /^([ \t]*)[ \t]*$/m]) { + const match = stripped.match(anchor); + if (match?.index === undefined) { + continue; + } + const end = match.index + match[0].length; + const indent = `${match[1] ?? ''} `; + return { + source: `${stripped.slice(0, end)}\n${block(indent)}${stripped.slice(end)}`, + action: 'added', + }; + } + + return { source, action: 'no-anchor' }; +} + +export interface EnsureSourceMarkerResult extends SourceMarkerResult { + /** Project-relative shell that was inspected, or null when there is none. */ + shell: string | null; +} + +/** + * Ensure the production marker in the project's JSX root shell. Runs during + * `scan`, which is a pre-build hook, so the edit is picked up by the build that + * follows — the marker reaches production without anyone pasting anything. + */ +export function ensureSourceMarker( + cwd: string, + shell: string | null, + framework: string | null, +): EnsureSourceMarkerResult { + if (shell === null) { + return { shell: null, source: '', action: 'no-anchor' }; + } + const file = path.resolve(cwd, shell); + const before = readFileSync(file, 'utf8'); + const result = ensureMarkerInJsxShell(before, framework); + if (result.source !== before && result.action === 'added') { + writeFileSync(file, result.source); + } + return { ...result, shell }; +} diff --git a/tests/guide.test.ts b/tests/guide.test.ts index 97697e84..d193440c 100644 --- a/tests/guide.test.ts +++ b/tests/guide.test.ts @@ -351,7 +351,8 @@ describe('guide', () => { expect(state.productionMarkerWired).toBe(false); const output = renderGuideChecklist(state, false); - expect(output).toContain('Add the production marker yourself'); + expect(output).toContain('Add the production marker'); + expect(output).toContain('npx @patchstack/connect scan'); expect(output).toContain('import.meta.env.PROD &&'); expect(output).toContain('window.__PATCHSTACK_PROD__=true;'); }); diff --git a/tests/mark-build.test.ts b/tests/mark-build.test.ts index 0db0c258..5c82a57d 100644 --- a/tests/mark-build.test.ts +++ b/tests/mark-build.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -6,6 +6,8 @@ import { MARKER_ATTR, buildInjectionSnippet, buildSourceMarkerSnippet, + ensureMarkerInJsxShell, + ensureSourceMarker, findHtmlFiles, hasJsxShell, injectMarker, @@ -172,3 +174,82 @@ describe('buildSourceMarkerSnippet', () => { expect(buildSourceMarkerSnippet('tanstack-start')).toContain('dangerouslySetInnerHTML'); }); }); + + +describe('ensureMarkerInJsxShell', () => { + const widgetLine = + ' }'); + const { source, action } = ensureMarkerInJsxShell(hand, 'tanstack-start'); + expect(action).toBe('manual'); + expect(source).toBe(hand); + }); + + it('reports no-anchor when the file has no JSX shell to attach to', () => { + const { action } = ensureMarkerInJsxShell('export const x = 1;\n', 'tanstack-start'); + expect(action).toBe('no-anchor'); + }); + + it('declines frameworks whose root shell is not JSX', () => { + expect(ensureMarkerInJsxShell(doc(widgetLine), 'nuxt').action).toBe('unsupported'); + expect(ensureMarkerInJsxShell(doc(widgetLine), 'sveltekit').action).toBe('unsupported'); + }); +}); + +describe('ensureSourceMarker', () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(path.join(tmpdir(), 'psmarker-')); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('writes the marker into the shell so the next build compiles it in', () => { + mkdirSync(path.join(root, 'src', 'routes'), { recursive: true }); + const shell = path.join('src', 'routes', '__root.tsx'); + writeFileSync( + path.join(root, shell), + 'export const Root = () => (\n \n \n \n \n);\n', + ); + + const result = ensureSourceMarker(root, shell, 'tanstack-start'); + expect(result.action).toBe('added'); + expect(readFileSync(path.join(root, shell), 'utf8')).toContain('window.__PATCHSTACK_PROD__=true;'); + }); + + it('leaves the file untouched when there is no anchor', () => { + const shell = 'root.tsx'; + writeFileSync(path.join(root, shell), 'export const x = 1;\n'); + + expect(ensureSourceMarker(root, shell, 'tanstack-start').action).toBe('no-anchor'); + expect(readFileSync(path.join(root, shell), 'utf8')).toBe('export const x = 1;\n'); + }); +}); From 7c5d385d9053c2f81e8d4bb60aa522ed18a7f30e Mon Sep 17 00:00:00 2001 From: Mario Tarosso Date: Mon, 24 Aug 2026 10:51:30 +0100 Subject: [PATCH 3/3] [fix] Write the production marker before the manifest post Build scripts commonly chain the scan as `patchstack-connect scan || true`, so a failed or offline post is expected to be survivable. Running the marker pass after `postManifest` made it the opposite: any network error skipped the write and the build shipped without a production flag, silently. The marker carries no site UUID and needs no round-trip, so nothing about it justified waiting on the server. It now runs as soon as the framework is known and before the post, still after the --dry-run return so a preview stays side-effect free. Co-Authored-By: Claude Opus 5 (1M context) --- src/cli.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index 8469c683..ae305909 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -473,6 +473,14 @@ async function runScan( return 0; } + // Ahead of the post deliberately. The marker carries no site UUID and needs no + // server round-trip, while `scan` is commonly chained as `scan || true`, so a + // failed or offline post must not be what decides whether a published build + // gets its production flag. --dry-run has already returned by here. + if (config.widget) { + reportSourceMarker(detectStack(payload.packages).framework); + } + const provisioning = config.siteUuid === null; if (provisioning) { console.log('No site UUID configured — provisioning a new Patchstack site from this manifest…'); @@ -510,7 +518,6 @@ async function runScan( const effectiveUuid = config.siteUuid ?? response.uuid ?? null; if (config.widget && effectiveUuid !== null && effectiveUuid.length > 0) { reportSourceWidget(effectiveUuid); - reportSourceMarker(detectStack(payload.packages).framework); } // On the first scan (provisioning), surface the dashboard URL so the user can