mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
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:
co-authored by
Claude Opus 4.6
parent
1671d04dec
commit
a832fe778c
@@ -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
@@ -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
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user