No-HMR fallback: fetch raw source and inject variants into DOM

For dev servers without HMR (Bun static imports, simple HTTP servers),
the browser can't see file changes automatically. Three changes fix this:

1. /source endpoint on live server: reads a project file from disk,
   gated by session token + path-traversal guard. The browser fetches
   the raw HTML directly, bypassing the dev server's cache.

2. poll --reply --file flag: agent passes the source file path when
   replying done. The browser receives it via WS and knows where to
   fetch. Skill reference updated to always include --file.

3. Browser injectVariantsFromSource(): on "done" with 0 DOM variants,
   fetches the raw HTML from /source, parses with DOMParser, extracts
   the variant wrapper, finds the matching element in the live DOM by
   class/ID, and replaces it. MutationObserver picks up the injected
   variants and the cycling bar appears.

Also: wrap CLI no longer hides the original element (was display:none).
The original stays visible until the first variant arrives, preventing
a flash of empty content between wrap and variant insertion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-04-12 19:02:41 -07:00
co-authored by Claude Opus 4.6
parent 1671d04dec
commit a832fe778c
5 changed files with 108 additions and 11 deletions
+5 -1
View File
@@ -136,10 +136,14 @@ The first variant should NOT have `style="display: none"` (it should be visible
### Step 3: Signal completion
Include `--file` so the browser can fetch variants directly if the dev server lacks HMR:
```bash
npx impeccable poll --reply EVENT_ID done
npx impeccable poll --reply EVENT_ID done --file RELATIVE_PATH
```
The file path should be relative to the project root (e.g., `public/index.html`, `src/App.tsx`).
## Handle Accept
The event contains: `{id, variantId}`.
+75 -6
View File
@@ -701,6 +701,76 @@
}
}
/**
* No-HMR fallback: fetch the raw source file from the live server,
* parse it, extract the variant wrapper, and inject it into the live DOM.
* This works even when the dev server caches HTML (Bun, static servers).
*/
function injectVariantsFromSource(filePath, sessionId) {
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
fetch(url)
.then(r => { if (!r.ok) throw new Error(r.status); return r.text(); })
.then(html => {
// Parse the raw source HTML
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!srcWrapper) {
console.error('[impeccable] Variant wrapper not found in source file.');
return;
}
// Find the original element in the live DOM.
// The original is inside the wrapper in the source. We find the
// corresponding element in the live DOM by matching the first child's
// tag + classes from the original snapshot.
const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child');
if (!origContent) return;
const tag = origContent.tagName.toLowerCase();
const cls = origContent.className;
let liveEl = null;
if (origContent.id) {
liveEl = document.getElementById(origContent.id);
} else if (cls) {
// Find by tag + exact class match
const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]);
for (const c of candidates) {
if (c.className === cls && !own(c)) { liveEl = c; break; }
}
}
if (!liveEl) {
console.error('[impeccable] Could not find original element in live DOM.');
return;
}
// Replace the live element with the full wrapper from source
const wrapper = srcWrapper.cloneNode(true);
liveEl.parentElement.replaceChild(wrapper, liveEl);
// Update state: count variants, show the first one
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
arrivedVariants = variants.length;
expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants);
visibleVariant = 1;
showVariantInDOM(sessionId, 1);
// Update selectedElement to the visible variant's content
const visEl = wrapper.querySelector('[data-impeccable-variant="1"] > :first-child');
selectedElement = visEl || wrapper.parentElement;
state = 'CYCLING';
updateBarContent('cycling');
saveSession();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
console.error('[impeccable] Failed to fetch source:', err);
showToast('Could not load variants. Try refreshing the page.', 5000);
});
}
function cycleVariant(dir) {
const next = visibleVariant + dir;
if (next < 1 || next > arrivedVariants) return;
@@ -833,12 +903,11 @@
}, 1800);
return;
}
// If variants haven't appeared in the DOM yet (no HMR), reload the
// page. The resumeSession logic will pick them up after reload.
if (arrivedVariants === 0 && expectedVariants > 0) {
console.log('[impeccable] Variants written but not in DOM (no HMR). Reloading...');
saveSession();
location.reload();
// If variants haven't appeared in the DOM (no HMR), fetch the raw
// source file from the live server and inject variants into the DOM.
if (arrivedVariants === 0 && expectedVariants > 0 && msg.file) {
console.log('[impeccable] No HMR detected. Fetching variants from source file...');
injectVariantsFromSource(msg.file, currentSessionId);
return;
}
state = 'CYCLING';
+7 -3
View File
@@ -45,15 +45,18 @@ Options:
const info = readServerInfo();
const base = `http://localhost:${info.port}`;
// Reply mode: npx impeccable poll --reply <id> <status> [message]
// Reply mode: npx impeccable poll --reply <id> <status> [--file path] [message]
const replyIdx = args.indexOf('--reply');
if (replyIdx !== -1) {
const id = args[replyIdx + 1];
const status = args[replyIdx + 2] || 'done';
const message = args[replyIdx + 3] || undefined;
const fileIdx = args.indexOf('--file');
const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined;
// Message is any remaining positional arg that isn't a flag
const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined;
if (!id) {
console.error('Usage: npx impeccable poll --reply <id> <status> [message]');
console.error('Usage: npx impeccable poll --reply <id> <status> [--file path] [message]');
process.exit(1);
}
@@ -66,6 +69,7 @@ Options:
id,
type: status,
message,
file: filePath,
}),
});
+20
View File
@@ -161,6 +161,26 @@ function createRequestHandler({ detectScript, liveScriptWithToken }) {
return;
}
// Read a project file from disk (for no-HMR fallback: the browser fetches
// the raw source to inject variants when the dev server doesn't support HMR).
if (pathname === '/source') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
const filePath = url.searchParams.get('path');
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
const absPath = path.resolve(process.cwd(), filePath);
// Safety: must be within the project directory
if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; }
try {
const content = fs.readFileSync(absPath, 'utf-8');
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(content);
} catch {
res.writeHead(404); res.end('File not found');
}
return;
}
// --- Authenticated endpoints ---
const token = url.searchParams.get('token');
+1 -1
View File
@@ -105,7 +105,7 @@ The agent should insert variant HTML at insertLine.`);
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" style="display: contents">',
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
indent + ' <div data-impeccable-variant="original" style="display: none">',
indent + ' <div data-impeccable-variant="original">',
originalIndented,
indent + ' </div>',
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,