Skip to content

Commit 89a511c

Browse files
committed
feat(api, ui): widget proxy + IframeWidget enhancements for embedding SPAs at subpaths
The widget-extensions MVP (2026-04-21) assumed embedded apps either use relative asset URLs (marimo) or can be configured to serve their server URL via localStorage. SPAs like opencode that emit absolute-path asset references (`/assets/xyz.js`) and absolute-URL fetches via their SDK don't work through the subpath-routing proxy without more machinery. This commit adds that machinery — generic, not opencode-specific. optio-api widget proxy (fastify adapter + core): - onResponse handler takes over text/html forwarding and injects, right after `<head>`, a `<base href="/api/widget/<db>/<prefix>/<pid>/">` so relative asset URLs resolve against the proxy root regardless of how deep the current document URL is. - Alongside <base>, inject an inline script that runs before the SPA's bundle and rewrites location.pathname via history.replaceState to strip the proxy prefix. Client-side routers (@solidjs/router, react-router, ...) then match the SPA's intended URL space (`/:dir/session/:id?`) instead of the proxy-nested one. - Append `'sha256-<hash>'` of the injected script to the outgoing CSP's script-src directive so upstreams that forbid `unsafe-inline` still execute it. The CSP is otherwise preserved (including the existing frame-ancestors strip for anti-embedding defenses). - Strip `Accept-Encoding` from outgoing requests so upstream sends identity bodies — no gzip/br decode pipeline needed for the HTML rewrite. Bandwidth cost is negligible for a dev-tool reverse proxy. - Skip negative-cache entries in resolveWidgetUpstream: after a session teardown the cache retained a null for 5 s, causing the iframe's first request after a relaunch to 404 until the entry expired. The tree-poller-driven invalidation from the design spec would be a cleaner long-term fix; until then, not caching nulls is correct. - Atomic upload pattern (not here, but referenced in the optio-opencode install path, which uses the same idea on remote files). optio-ui: - IframeWidget: resolve the literal token `{widgetProxyUrl}` in both `widgetData.iframeSrc` and each value of `widgetData.localStorageOverrides` via `String.prototype.replaceAll`. Lets workers parameterize by the runtime-computed proxy URL without the worker knowing the apiBaseUrl or database. (Marimo remains unaffected — it sets empty widgetData.) - ProcessDetailView: gate the widget-layout swap on `widgetData` actually being present. Before the worker calls set_widget_data, the default tree+log rendering stays visible, so the process progress bar and log entries from pre-widget setup work (e.g. a long binary upload) are readable; a widget's built-in "Loading..." placeholder would otherwise take over the pane. Test updates reflect the new behavior: 4 new widget-proxy tests (base href, replaceState script, CSP hash, Accept-Encoding strip, non-HTML passthrough, negative-cache skip), IframeWidget substitution tests, and a ProcessDetailView test for the widgetData gate.
1 parent bdf5f60 commit 89a511c

8 files changed

Lines changed: 407 additions & 26 deletions

File tree

packages/optio-api/src/__tests__/widget-proxy-core.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,34 @@ describe('resolveWidgetUpstream', () => {
5353
expect(result).toBeNull();
5454
});
5555

56+
it('does NOT cache a negative lookup, so a later worker-side set_widget_upstream is seen on the next request', async () => {
57+
// Covers the regression where a relaunched process was 404'd in the
58+
// iframe until the 5s TTL expired because the dashboard loaded a cached
59+
// null from the previous session's teardown.
60+
const reg = createWidgetUpstreamRegistry({ ttlMs: 5000 });
61+
const oid = new ObjectId();
62+
await db.collection(`${PREFIX}_processes`).insertOne({
63+
_id: oid, processId: 'p', name: 'P',
64+
rootId: oid, depth: 0, order: 0,
65+
status: { state: 'running' },
66+
progress: { percent: null }, log: [],
67+
cancellable: true,
68+
widgetUpstream: null,
69+
});
70+
71+
const first = await resolveWidgetUpstream(db, PREFIX, reg, oid.toString());
72+
expect(first).toBeNull();
73+
74+
// Worker registers upstream (simulating Python's set_widget_upstream).
75+
await db.collection(`${PREFIX}_processes`).updateOne(
76+
{ _id: oid },
77+
{ $set: { widgetUpstream: { url: 'http://127.0.0.1:9000', innerAuth: null } } },
78+
);
79+
80+
const second = await resolveWidgetUpstream(db, PREFIX, reg, oid.toString());
81+
expect(second?.url).toBe('http://127.0.0.1:9000');
82+
});
83+
5684
it('returns widgetUpstream when set and caches it', async () => {
5785
const reg = createWidgetUpstreamRegistry({ ttlMs: 5000 });
5886
const oid = new ObjectId();

packages/optio-api/src/adapters/__tests__/fastify-widget-proxy.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,126 @@ describe('registerWidgetProxy — HTTP path', () => {
230230
await app.close();
231231
});
232232

233+
it('injects <base href> into text/html responses pointing at the widget-proxy prefix', async () => {
234+
upstreamResponder = (_req, res, _body) => {
235+
res.statusCode = 200;
236+
res.setHeader('content-type', 'text/html; charset=utf-8');
237+
res.end('<!doctype html>\n<html><head><title>t</title></head><body></body></html>');
238+
};
239+
const app = await makeApp();
240+
const oid = await insertProcess({ url: `http://127.0.0.1:${upstreamPort}`, innerAuth: null });
241+
const res = await app.inject({ method: 'GET', url: widgetUrl(oid, '/some/deep/path') });
242+
expect(res.statusCode).toBe(200);
243+
const expectedBase = `<base href="/api/widget/${encodeURIComponent(DB_NAME)}/${encodeURIComponent(PREFIX)}/${oid}/">`;
244+
expect(res.body).toContain(expectedBase);
245+
// Injection is right after <head>.
246+
expect(res.body).toMatch(new RegExp(`<head[^>]*>${expectedBase.replace(/[\\^$.*+?()|[\]{}]/g, '\\$&')}`));
247+
// Content-length matches the transformed body length.
248+
const lenHeader = res.headers['content-length'];
249+
if (lenHeader !== undefined) {
250+
expect(Number(lenHeader)).toBe(Buffer.byteLength(res.body, 'utf-8'));
251+
}
252+
await app.close();
253+
});
254+
255+
it('injects a history.replaceState script that strips the widget-proxy prefix from location.pathname', async () => {
256+
upstreamResponder = (_req, res, _body) => {
257+
res.statusCode = 200;
258+
res.setHeader('content-type', 'text/html; charset=utf-8');
259+
res.end('<!doctype html>\n<html><head></head><body></body></html>');
260+
};
261+
const app = await makeApp();
262+
const oid = await insertProcess({ url: `http://127.0.0.1:${upstreamPort}`, innerAuth: null });
263+
const res = await app.inject({ method: 'GET', url: widgetUrl(oid, '/foo/bar') });
264+
expect(res.statusCode).toBe(200);
265+
// The injected script references the proxy prefix (without trailing slash)
266+
// inside a JSON-encoded string literal for a RegExp.
267+
const literalPrefix = `/api/widget/${encodeURIComponent(DB_NAME)}/${encodeURIComponent(PREFIX)}/${oid}`;
268+
expect(res.body).toContain('history.replaceState');
269+
expect(res.body).toContain(JSON.stringify(literalPrefix));
270+
// Script lives inside the <head>.
271+
expect(res.body).toMatch(/<head[^>]*><base[^>]*><script>[\s\S]*?history\.replaceState[\s\S]*?<\/script>/);
272+
await app.close();
273+
});
274+
275+
it('appends SHA-256 hash of the inline script to script-src so CSPs that forbid inline scripts still allow it', async () => {
276+
upstreamResponder = (_req, res, _body) => {
277+
res.statusCode = 200;
278+
res.setHeader('content-type', 'text/html; charset=utf-8');
279+
res.setHeader(
280+
'content-security-policy',
281+
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'",
282+
);
283+
res.end('<!doctype html>\n<html><head></head><body></body></html>');
284+
};
285+
const app = await makeApp();
286+
const oid = await insertProcess({ url: `http://127.0.0.1:${upstreamPort}`, innerAuth: null });
287+
const res = await app.inject({ method: 'GET', url: widgetUrl(oid, '/') });
288+
expect(res.statusCode).toBe(200);
289+
290+
const csp = String(res.headers['content-security-policy']);
291+
// Extract the base64 hash value following 'sha256-' and before the closing
292+
// single-quote within script-src.
293+
const m = csp.match(/script-src [^;]*'sha256-([A-Za-z0-9+/=]+)'/);
294+
expect(m).not.toBeNull();
295+
const cspHash = m![1];
296+
297+
// Recompute the hash of the <script> body found in the response and
298+
// confirm it matches what got appended to the CSP.
299+
const scriptMatch = res.body.match(/<script>([\s\S]*?)<\/script>/);
300+
expect(scriptMatch).not.toBeNull();
301+
const actualHash = require('node:crypto').createHash('sha256').update(scriptMatch![1], 'utf-8').digest('base64');
302+
expect(cspHash).toBe(actualHash);
303+
304+
// Original directives preserved.
305+
expect(csp).toContain("default-src 'self'");
306+
expect(csp).toContain("'wasm-unsafe-eval'");
307+
await app.close();
308+
});
309+
310+
it('does not touch CSP when upstream sends none', async () => {
311+
upstreamResponder = (_req, res, _body) => {
312+
res.statusCode = 200;
313+
res.setHeader('content-type', 'text/html; charset=utf-8');
314+
res.end('<!doctype html>\n<html><head></head><body></body></html>');
315+
};
316+
const app = await makeApp();
317+
const oid = await insertProcess({ url: `http://127.0.0.1:${upstreamPort}`, innerAuth: null });
318+
const res = await app.inject({ method: 'GET', url: widgetUrl(oid, '/') });
319+
expect(res.statusCode).toBe(200);
320+
expect(res.headers['content-security-policy']).toBeUndefined();
321+
await app.close();
322+
});
323+
324+
it('does NOT inject <base href> into non-HTML responses (e.g. JS assets)', async () => {
325+
const jsSource = 'export const x = "/assets/foo.png"; // literal path string';
326+
upstreamResponder = (_req, res, _body) => {
327+
res.statusCode = 200;
328+
res.setHeader('content-type', 'application/javascript');
329+
res.end(jsSource);
330+
};
331+
const app = await makeApp();
332+
const oid = await insertProcess({ url: `http://127.0.0.1:${upstreamPort}`, innerAuth: null });
333+
const res = await app.inject({ method: 'GET', url: widgetUrl(oid, '/assets/x.js') });
334+
expect(res.statusCode).toBe(200);
335+
expect(res.body).toBe(jsSource);
336+
expect(res.body).not.toContain('<base');
337+
await app.close();
338+
});
339+
340+
it('strips Accept-Encoding from the outgoing upstream request', async () => {
341+
const app = await makeApp();
342+
const oid = await insertProcess({ url: `http://127.0.0.1:${upstreamPort}`, innerAuth: null });
343+
await app.inject({
344+
method: 'GET',
345+
url: widgetUrl(oid, '/'),
346+
headers: { 'accept-encoding': 'gzip, deflate, br' },
347+
});
348+
expect(upstreamRequests.length).toBeGreaterThan(0);
349+
expect(upstreamRequests[0].headers['accept-encoding']).toBeUndefined();
350+
await app.close();
351+
});
352+
233353
async function makeAppWithLog(verbose: boolean | undefined): Promise<{ app: FastifyInstance; logs: any[] }> {
234354
const logs: any[] = [];
235355
const stream = { write(line: string) { try { logs.push(JSON.parse(line)); } catch { logs.push(line); } } };

packages/optio-api/src/adapters/fastify.ts

Lines changed: 169 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { createListPoller, createTreePoller } from '../stream-poller.js';
1313
import { discoverInstances } from '../discovery.js';
1414
import { resolveDb, type DbOptions } from '../resolve-db.js';
1515
import httpProxy from '@fastify/http-proxy';
16+
import { createHash } from 'node:crypto';
1617
import type { AuthCallback } from '../auth.js';
1718
import { checkAuth } from '../auth.js';
1819
import { createWidgetUpstreamRegistry } from '../widget-upstream-registry.js';
@@ -40,6 +41,100 @@ interface WidgetProxyInternalOptions {
4041
verbose?: boolean;
4142
}
4243

44+
function rewriteResponseHeaders(headers: Record<string, any>): Record<string, any> {
45+
// The proxy's purpose is to make the upstream embeddable in an iframe under
46+
// optio-api's outer auth. Strip `X-Frame-Options` and any `frame-ancestors`
47+
// CSP directive so upstreams (marimo, jupyter, internal tools) that default
48+
// to anti-embedding headers don't block that. Clickjacking defense is
49+
// provided by optio-api's authenticate callback: the proxy is unreachable
50+
// without a valid session.
51+
const out = { ...headers };
52+
delete out['x-frame-options'];
53+
const csp = out['content-security-policy'];
54+
if (typeof csp === 'string') {
55+
const stripped = csp
56+
.split(';')
57+
.map((d) => d.trim())
58+
.filter((d) => d.length > 0 && !d.toLowerCase().startsWith('frame-ancestors'))
59+
.join('; ');
60+
if (stripped) out['content-security-policy'] = stripped;
61+
else delete out['content-security-policy'];
62+
}
63+
return out;
64+
}
65+
66+
function widgetProxyPrefix(database: string, prefix: string, processId: string): string {
67+
return `/api/widget/${encodeURIComponent(database)}/${encodeURIComponent(prefix)}/${processId}/`;
68+
}
69+
70+
interface HtmlInjection {
71+
html: string;
72+
stripScriptSha256: string; // base64-encoded SHA-256 of the inline script body (for CSP)
73+
}
74+
75+
function injectBaseHref(html: string, proxyPrefix: string): HtmlInjection {
76+
// Inject two things right after <head>:
77+
//
78+
// 1. <base href="<proxyPrefix>"> — forces relative URLs in the page to
79+
// resolve against the proxy root regardless of how deep the current
80+
// document URL is. Required for SPAs (like opencode) whose HTML uses
81+
// relative asset paths ("./assets/x.js") but are loaded via a routing-
82+
// deep URL like /api/widget/<db>/<prefix>/<pid>/<workdir>/session/.
83+
// Without it, assets resolve to `.../workdir/session/assets/x.js` and
84+
// hit the upstream's SPA fallback with wrong MIME types.
85+
//
86+
// 2. An inline <script> that runs before the SPA bundle and strips the
87+
// proxy prefix from `location.pathname` via history.replaceState.
88+
// This makes the SPA's client-side router (e.g. @solidjs/router, react
89+
// -router) see the application's intended URL space (`/:dir/session/`
90+
// in opencode's case) rather than the proxy-nested one
91+
// (`/api/widget/<db>/<prefix>/<pid>/:dir/session/`). `<base href>` is
92+
// unaffected, so asset loading continues to work.
93+
//
94+
// Because the inline script violates `script-src 'self'` CSPs, we also
95+
// return its SHA-256 so the caller can append `'sha256-…'` to the
96+
// outgoing CSP's script-src directive.
97+
const baseTag = `<base href="${proxyPrefix}">`;
98+
// Escape regex-meta characters in the prefix for the runtime match.
99+
const prefixLiteralForRegex = proxyPrefix
100+
.replace(/\/$/, '') // the script uses the stripped form without trailing slash
101+
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
102+
const scriptBody =
103+
`(function(){var r=new RegExp('^' + ${JSON.stringify(prefixLiteralForRegex)});` +
104+
`var p=location.pathname;var m=p.match(r);` +
105+
`if(m){history.replaceState(null,'',(p.slice(m[0].length)||'/')+location.search+location.hash);}` +
106+
`})();`;
107+
const stripScriptSha256 = createHash('sha256').update(scriptBody, 'utf-8').digest('base64');
108+
const stripScript = `<script>${scriptBody}</script>`;
109+
const injection = `${baseTag}${stripScript}`;
110+
const m = html.match(/<head(\s[^>]*)?>/i);
111+
const transformed = m
112+
? html.replace(m[0], `${m[0]}${injection}`)
113+
: `${injection}${html}`;
114+
return { html: transformed, stripScriptSha256 };
115+
}
116+
117+
// Extend the upstream CSP's script-src (or add one) with the SHA-256 of our
118+
// injected inline script. Called only when HTML body rewriting has happened.
119+
function appendScriptHashToCsp(csp: string, scriptHashBase64: string): string {
120+
const hashToken = `'sha256-${scriptHashBase64}'`;
121+
const parts = csp
122+
.split(';')
123+
.map((d) => d.trim())
124+
.filter((d) => d.length > 0);
125+
let found = false;
126+
const updated = parts.map((directive) => {
127+
const lower = directive.toLowerCase();
128+
if (lower.startsWith('script-src ') || lower === 'script-src') {
129+
found = true;
130+
return `${directive} ${hashToken}`;
131+
}
132+
return directive;
133+
});
134+
if (!found) updated.push(`script-src ${hashToken}`);
135+
return updated.join('; ');
136+
}
137+
43138
function registerWidgetProxy(app: FastifyInstance, opts: WidgetProxyInternalOptions): void {
44139
const registry = createWidgetUpstreamRegistry({ ttlMs: opts.ttlMs ?? WIDGET_CACHE_TTL_MS });
45140

@@ -112,12 +207,22 @@ function registerWidgetProxy(app: FastifyInstance, opts: WidgetProxyInternalOpti
112207
}
113208

114209
// Store upstream on raw request for getUpstream + rewriteRequestHeaders to pick up
115-
(req.raw as any).__optioWidget = { processId, upstream };
210+
(req.raw as any).__optioWidget = {
211+
processId,
212+
upstream,
213+
proxyPrefix: widgetProxyPrefix(database, prefix, processId),
214+
};
116215

117216
// Strip /api/widget/<database>/<prefix>/<processId> from the URL, leaving the sub-path.
118217
// Then apply query-based inner auth if needed.
119218
const stripped = fullUrl.replace(WIDGET_PREFIX_STRIP, '') || '/';
120219
req.raw.url = applyInnerAuthQuery(upstream.innerAuth, stripped);
220+
221+
// Strip Accept-Encoding so the upstream returns uncompressed bodies.
222+
// We need to rewrite text/html bodies in onResponse to inject <base href>,
223+
// and handling gzip/br variants per upstream is more friction than the
224+
// bandwidth savings are worth for a dev-tool reverse proxy.
225+
delete (req.raw.headers as Record<string, any>)['accept-encoding'];
121226
},
122227

123228
// Rewrite headers for outgoing WebSocket handshake (inner-auth injection).
@@ -141,26 +246,70 @@ function registerWidgetProxy(app: FastifyInstance, opts: WidgetProxyInternalOpti
141246
if (!widget) return headers;
142247
return applyInnerAuthHeaders(widget.upstream.innerAuth, headers);
143248
},
144-
// The proxy's purpose is to make the upstream embeddable in an iframe under
145-
// optio-api's outer auth. Strip `X-Frame-Options` and any `frame-ancestors`
146-
// CSP directive so upstreams (marimo, jupyter, internal tools) that default
147-
// to anti-embedding headers don't block that. Clickjacking defense is
148-
// provided by optio-api's authenticate callback: the proxy is unreachable
149-
// without a valid session.
150-
rewriteHeaders: (headers: Record<string, any>) => {
151-
const out = { ...headers };
152-
delete out['x-frame-options'];
153-
const csp = out['content-security-policy'];
154-
if (typeof csp === 'string') {
155-
const stripped = csp
156-
.split(';')
157-
.map((d) => d.trim())
158-
.filter((d) => d.length > 0 && !d.toLowerCase().startsWith('frame-ancestors'))
159-
.join('; ');
160-
if (stripped) out['content-security-policy'] = stripped;
161-
else delete out['content-security-policy'];
249+
rewriteHeaders: rewriteResponseHeaders,
250+
// onResponse takes over the response forwarding so we can rewrite
251+
// text/html bodies (inject <base href> — see injectBaseHref).
252+
// Non-HTML responses stream through unchanged.
253+
//
254+
// We use reply.hijack() + reply.raw directly because fastify 5's
255+
// reply.send() does not accept Node IncomingMessage streams, and the
256+
// header/body coordination for a transformed HTML body is simpler on
257+
// the raw ServerResponse anyway. reply-from does NOT auto-apply
258+
// rewriteHeaders when onResponse is defined, so apply them here
259+
// explicitly to the outgoing response.
260+
onResponse: (request: any, reply: any, res: any) => {
261+
// @fastify/reply-from passes a wrapper: res.stream is the Readable
262+
// upstream body; res.headers / res.statusCode are the top-level fields.
263+
const stream: NodeJS.ReadableStream = res.stream;
264+
reply.hijack();
265+
const rawRes: import('http').ServerResponse = reply.raw;
266+
const statusCode: number = res.statusCode ?? 200;
267+
const incomingHeaders = res.headers as Record<string, any>;
268+
const contentType = String(incomingHeaders['content-type'] ?? '').toLowerCase();
269+
270+
if (!contentType.includes('text/html')) {
271+
const outHeaders = rewriteResponseHeaders(incomingHeaders);
272+
rawRes.writeHead(statusCode, outHeaders as any);
273+
stream.pipe(rawRes);
274+
stream.on('error', (err: any) => {
275+
request.log.error({ err }, 'widget-proxy: upstream body read error');
276+
if (!rawRes.headersSent) rawRes.writeHead(502);
277+
rawRes.end();
278+
});
279+
return;
162280
}
163-
return out;
281+
282+
const widget = (request.raw as any).__optioWidget;
283+
const proxyPrefix: string = widget?.proxyPrefix ?? '/';
284+
const chunks: Buffer[] = [];
285+
stream.on('data', (c: Buffer) => {
286+
chunks.push(c);
287+
});
288+
stream.on('end', () => {
289+
const html = Buffer.concat(chunks).toString('utf-8');
290+
const { html: transformed, stripScriptSha256 } = injectBaseHref(html, proxyPrefix);
291+
const body = Buffer.from(transformed, 'utf-8');
292+
// Rebuild headers: copy upstream, then override transform-affected ones.
293+
const outHeaders: Record<string, any> = rewriteResponseHeaders(incomingHeaders);
294+
outHeaders['content-length'] = String(body.byteLength);
295+
// Drop content-encoding — we already decoded if upstream gzipped.
296+
// (We also strip Accept-Encoding on the way out in preHandler so in
297+
// practice upstream should send identity, but belt-and-braces.)
298+
delete outHeaders['content-encoding'];
299+
// If a CSP is present, allowlist our injected inline script by hash
300+
// so it isn't blocked by `script-src 'self'`.
301+
const csp = outHeaders['content-security-policy'];
302+
if (typeof csp === 'string' && csp.length > 0) {
303+
outHeaders['content-security-policy'] = appendScriptHashToCsp(csp, stripScriptSha256);
304+
}
305+
rawRes.writeHead(statusCode, outHeaders as any);
306+
rawRes.end(body);
307+
});
308+
stream.on('error', (err: any) => {
309+
request.log.error({ err }, 'widget-proxy: upstream body read error');
310+
if (!rawRes.headersSent) rawRes.writeHead(502);
311+
rawRes.end();
312+
});
164313
},
165314
// Map connection errors (ECONNREFUSED → InternalServerError/500 by default) to
166315
// 502 Bad Gateway so callers get a meaningful gateway error.

0 commit comments

Comments
 (0)