mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Field session on a nested SvelteKit app surfaced a self-reinforcing leak: localStorage is per-origin, two projects reused 127.0.0.1:5174, and a React project's leftover cycling session was resumed inside the Svelte project. Its checkpoints then materialized a ghost session in the new project's durable store that kept reattaching after every discard, and a stale adapter module 401'd on live.js, hiding the picker. Four fixes: - Server: only session-creating events (generate, steer) may mint a journal. Progress events (checkpoints, mount acks, accept/discard) for unknown ids are refused with 404 unknown_session and never enqueued, so foreign browser state cannot create ghost sessions. Browser sends are gated so progress never overtakes its own creating POST (the Go-time checkpoint and generate are concurrent fetches; the first sweep caught the out-of-order arrival breaking every SvelteKit flow). Steer checkpoints now follow the steer event for the same reason. - Browser: saved sessions carry the server's appRoot; a session stamped by another project is dropped at load time. Unstamped legacy state is caught by the unknown_session refusal, which clears local state and re-arms the picker with an explanatory toast. - SvelteKit adapter: the layout import carries a token-derived revision query so a helper restart changes the module specifier and no Vite client/SSR cache can serve an adapter with a rotated-out token; live-inject --port reads the running helper's token from server.json instead of writing an unauthenticated live.js URL; script load failures log an actionable console error; and adapter removal is byte-exact (the old regex swallowed the next line's indentation). - live.mjs resolves surface briefs from appRoot, then contextRoot, then repoRoot, matching context.mjs in nested-app repos. Tests: server unknown-session rejection units, adapter revision/ byte-exact-removal units, and a foreign-session e2e scenario that seeds another project's localStorage state and asserts it is cleared, no ghost journal materializes, and picking still works. AI-assisted (Claude Code). Co-Authored-By: Claude Code <noreply@anthropic.com>
56 lines
2.1 KiB
JavaScript
56 lines
2.1 KiB
JavaScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([
|
|
Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }),
|
|
Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }),
|
|
Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }),
|
|
]);
|
|
|
|
export function resolveLiveBrowserScriptParts(scriptsDir, parts = LIVE_BROWSER_SCRIPT_PARTS) {
|
|
if (!scriptsDir) throw new Error('scriptsDir is required');
|
|
return parts.map((part, index) => ({
|
|
...part,
|
|
index,
|
|
path: path.join(scriptsDir, part.file),
|
|
}));
|
|
}
|
|
|
|
export function assertLiveBrowserScriptParts(parts, exists = fs.existsSync) {
|
|
for (const part of parts) {
|
|
if (!exists(part.path)) {
|
|
throw new Error(`Live browser script part missing: ${part.name} (${part.path})`);
|
|
}
|
|
}
|
|
return parts;
|
|
}
|
|
|
|
export function readLiveBrowserScriptParts(parts, readFile = (filePath) => fs.readFileSync(filePath, 'utf-8')) {
|
|
return parts.map((part) => ({
|
|
...part,
|
|
source: readFile(part.path),
|
|
}));
|
|
}
|
|
|
|
export function assembleLiveBrowserScript({ token, port, vocabulary, commandPrefix = '/', appRoot = null, parts }) {
|
|
const prelude =
|
|
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
|
|
`window.__IMPECCABLE_PORT__ = ${port};\n` +
|
|
// Project identity for browser-side session storage. localStorage is
|
|
// keyed by ORIGIN, and two projects routinely share a localhost port
|
|
// across time; saved sessions carry this value so a resume can tell a
|
|
// foreign project's leftovers from its own.
|
|
`window.__IMPECCABLE_APP_ROOT__ = ${JSON.stringify(appRoot)};\n` +
|
|
`window.__IMPECCABLE_COMMAND_PREFIX__ = ${JSON.stringify(commandPrefix)};\n` +
|
|
// Canonical command vocabulary (values + labels + icons). live-browser.js
|
|
// builds its action picker from this instead of an inline copy.
|
|
`window.__IMPECCABLE_VOCAB__ = ${JSON.stringify(vocabulary)};\n`;
|
|
|
|
const body = parts.map((part) => {
|
|
const file = part.file || path.basename(part.path || '');
|
|
return `// --- impeccable live script part: ${part.name} (${file}) ---\n${part.source}`;
|
|
}).join('\n');
|
|
|
|
return prelude + body;
|
|
}
|