the preview panel follows the app's navigation
The panel knew one URL: the one you typed. Navigate three pages into an app and the header still showed the front door, "open in a new tab" still went there, and a reload — which fires by itself whenever a dev server restarts — threw away where you were and started again at `/`. Now the app reports where it is, and a reload composes it back. ── How the panel finds out ── The frame is same-origin for a proxied preview, so `contentWindow.location` is readable. It is PUSHED rather than read, because a dev server is an SPA: a route change is `history.pushState`, which fires no event and never reloads the document. Polling would have to be constant and would still lag. The script the proxy already injects now patches `pushState`/`replaceState`, listens for `popstate`/`hashchange`, and posts the path — with the proxy prefix stripped, since that is plumbing and the panel should not have to know about it to show a URL. Only proxied previews can do this. A cross-origin frame reports nothing, and grafting a remembered path onto someone else's host would be inventing a URL, so `targetUrl` composes only when there is a port. ── Two fields, and the loop that forced them ── `url` is what you pointed the panel at; `current` is where it has got to. The obvious design is one field — a browser has one address bar — and it feeds itself: the stored value IS the iframe's `src`, so recording a route change hands the frame a new `src` for the page it is already showing, which browsers treat as a navigation. Every click would cost a reload. Belt and braces, because one of the two would be enough only until someone changed the other: the fields are separate, AND `PreviewFrame` captures its `src` at mount and ignores later changes. Remount-by-key is still how a reload happens. `ev.source` identifies which frame is talking. `message` is a window-level event and two same-origin previews hear each other; comparing the posting window to this iframe's `contentWindow` is exact, where matching on URL would break the moment two panels pointed at the same port. Writes are debounced 800ms. `useDashboardState.setValue` PATCHes the server on every call and an SPA emits a route change per click; the value is only read at mount, so being a second behind costs nothing. Also: the header shows `localhost:3001/settings` rather than `api/dev-proxy/3001/settings`, and editing starts from that — one is a thing you can retype, the other is not. ── Tests ── The reporter is RUN, not string-matched: `new Function` against a fake window, which also means a syntax error in the injected script fails the suite rather than silently breaking every proxied page. Covers prefix stripping, the app root reporting `/` rather than empty, query and hash surviving, the report on load, pushState, and popstate. Plus the composition helpers, including that an ordinary URL is never composed with a path. ── One stale comment corrected while in there ── The file claimed `allow-same-origin` "is present, and has to be". There is no `sandbox` attribute at all, so nothing was present — and a sandbox with `allow-same-origin` plus `allow-scripts` is worth roughly nothing anyway, since the frame can remove its own sandbox attribute. Now says that. Not verified against a live dev server through the real proxy — that needs the server restarted with this code. tsgo clean, 934 pass / 7 fail unchanged, frontend builds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
import { rewriteAssetPaths, rewriteHtml } from './html-rewrite';
|
import { proxyOverrideScript, rewriteAssetPaths, rewriteHtml } from './html-rewrite';
|
||||||
|
|
||||||
const BASE = '/api/dev-proxy/3001';
|
const BASE = '/api/dev-proxy/3001';
|
||||||
const attrs = (html: string) => [...html.matchAll(/(?:src|href)="([^"]+)"/g)].map((m) => m[1]!);
|
const attrs = (html: string) => [...html.matchAll(/(?:src|href)="([^"]+)"/g)].map((m) => m[1]!);
|
||||||
@@ -75,3 +75,103 @@ describe('rewriteAssetPaths', () => {
|
|||||||
expect(rewriteAssetPaths(js, BASE)).toBe(js);
|
expect(rewriteAssetPaths(js, BASE)).toBe(js);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── The location reporter ──
|
||||||
|
//
|
||||||
|
// The injected script tells the panel where the app has navigated to, so a reload can come back to the same
|
||||||
|
// page. Asserting on the script's TEXT would prove only that a string contains a substring, so it is run
|
||||||
|
// here against a fake window — which is what catches the part that can actually be wrong: stripping the
|
||||||
|
// proxy prefix back off to recover the app's own path.
|
||||||
|
|
||||||
|
type Posted = { path: string };
|
||||||
|
|
||||||
|
const runReporter = (base: string, startAt: string) => {
|
||||||
|
const origin = 'https://platform.officer.dev';
|
||||||
|
const body = proxyOverrideScript(base)
|
||||||
|
.replace(/^<script>/, '')
|
||||||
|
.replace(/<\/script>$/, '');
|
||||||
|
|
||||||
|
const posted: Posted[] = [];
|
||||||
|
const listeners: Record<string, (() => void)[]> = {};
|
||||||
|
let url = new URL(startAt, origin);
|
||||||
|
|
||||||
|
const location = {
|
||||||
|
get origin() {
|
||||||
|
return origin;
|
||||||
|
},
|
||||||
|
get pathname() {
|
||||||
|
return url.pathname;
|
||||||
|
},
|
||||||
|
get search() {
|
||||||
|
return url.search;
|
||||||
|
},
|
||||||
|
get hash() {
|
||||||
|
return url.hash;
|
||||||
|
},
|
||||||
|
get href() {
|
||||||
|
return url.href;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const history = { pushState: () => {}, replaceState: () => {} };
|
||||||
|
const parent = { postMessage: (msg: Posted) => posted.push(msg) };
|
||||||
|
const addEventListener = (name: string, fn: () => void) => {
|
||||||
|
(listeners[name] ??= []).push(fn);
|
||||||
|
};
|
||||||
|
const win = { fetch: () => {}, WebSocket: class {} };
|
||||||
|
const xhr = { prototype: { open: () => {} } };
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-new-func
|
||||||
|
new Function('window', 'location', 'history', 'parent', 'addEventListener', 'XMLHttpRequest', body)(
|
||||||
|
win,
|
||||||
|
location,
|
||||||
|
history,
|
||||||
|
parent,
|
||||||
|
addEventListener,
|
||||||
|
xhr,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
posted,
|
||||||
|
// What an SPA does on a route change: change the URL, then call the patched pushState.
|
||||||
|
navigate: (to: string) => {
|
||||||
|
url = new URL(to, origin);
|
||||||
|
history.pushState(); // the PATCHED one: the script replaced it on the object above
|
||||||
|
},
|
||||||
|
fire: (event: string) => listeners[event]?.forEach((fn) => fn()),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('the injected location reporter', () => {
|
||||||
|
test('reports the app path with the proxy prefix stripped off', () => {
|
||||||
|
const { posted } = runReporter(BASE, '/api/dev-proxy/3001/settings/profile');
|
||||||
|
expect(posted.at(-1)?.path).toBe('/settings/profile');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the app root reports `/`, not an empty string', () => {
|
||||||
|
expect(runReporter(BASE, '/api/dev-proxy/3001/').posted.at(-1)?.path).toBe('/');
|
||||||
|
// No trailing slash at all — the shape a bare `localhost:3001` produces.
|
||||||
|
expect(runReporter(BASE, '/api/dev-proxy/3001').posted.at(-1)?.path).toBe('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('query and hash are carried, since a route can live in either', () => {
|
||||||
|
const { posted } = runReporter(BASE, '/api/dev-proxy/3001/search?q=bun#hit');
|
||||||
|
expect(posted.at(-1)?.path).toBe('/search?q=bun#hit');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it reports once on load, without waiting for a navigation', () => {
|
||||||
|
expect(runReporter(BASE, '/api/dev-proxy/3001/x').posted).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a pushState route change is reported — the case no browser event covers', () => {
|
||||||
|
const r = runReporter(BASE, '/api/dev-proxy/3001/');
|
||||||
|
r.navigate('/api/dev-proxy/3001/deep/page');
|
||||||
|
expect(r.posted.at(-1)?.path).toBe('/deep/page');
|
||||||
|
expect(r.posted).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the back button reports too', () => {
|
||||||
|
const r = runReporter(BASE, '/api/dev-proxy/3001/a');
|
||||||
|
r.fire('popstate');
|
||||||
|
expect(r.posted).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -44,6 +44,26 @@ export const proxyOverrideScript = (base: string) =>
|
|||||||
`return p!==undefined?new NWS(u,p):new NWS(u)};` +
|
`return p!==undefined?new NWS(u,p):new NWS(u)};` +
|
||||||
`window.WebSocket.prototype=NWS.prototype;window.WebSocket.CONNECTING=NWS.CONNECTING;` +
|
`window.WebSocket.prototype=NWS.prototype;window.WebSocket.CONNECTING=NWS.CONNECTING;` +
|
||||||
`window.WebSocket.OPEN=NWS.OPEN;window.WebSocket.CLOSING=NWS.CLOSING;window.WebSocket.CLOSED=NWS.CLOSED;` +
|
`window.WebSocket.OPEN=NWS.OPEN;window.WebSocket.CLOSING=NWS.CLOSING;window.WebSocket.CLOSED=NWS.CLOSED;` +
|
||||||
|
// ── Tell the panel where the app has navigated to ──
|
||||||
|
//
|
||||||
|
// The panel is same-origin with this document, so it COULD read `contentWindow.location` directly. It is
|
||||||
|
// pushed instead of polled because a modern dev server is an SPA: a route change is `history.pushState`,
|
||||||
|
// which fires no event of its own and never reloads the document. Polling would have to be constant and
|
||||||
|
// would still lag; patching the two methods reports the moment it happens.
|
||||||
|
//
|
||||||
|
// `popstate` and `hashchange` cover the back button and anchors, and the bare call at the end covers the
|
||||||
|
// ordinary case of a full page load — including the one after a reload, which is what lets the panel
|
||||||
|
// reopen on the page you were on rather than at the app's root.
|
||||||
|
//
|
||||||
|
// The path is reported WITHOUT the proxy prefix: it is the app's own path, and the panel should not have
|
||||||
|
// to know about the plumbing to display it.
|
||||||
|
`function rp(){try{var p=location.pathname;` +
|
||||||
|
`if(p.indexOf(b)===0)p=p.slice(b.length);` +
|
||||||
|
`if(!p||p.charAt(0)!=="/")p="/"+p;` +
|
||||||
|
`parent.postMessage({officerPreview:1,path:p+location.search+location.hash},o)}catch(e){}}` +
|
||||||
|
`var HP=history.pushState;history.pushState=function(){var r=HP.apply(this,arguments);rp();return r};` +
|
||||||
|
`var HR=history.replaceState;history.replaceState=function(){var r=HR.apply(this,arguments);rp();return r};` +
|
||||||
|
`addEventListener("popstate",rp);addEventListener("hashchange",rp);rp();` +
|
||||||
`})()</script>`;
|
`})()</script>`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Globe } from 'lucide-react';
|
import { Globe } from 'lucide-react';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { useWorkspace } from '../../components/Workspace';
|
import { useWorkspace } from '../../components/Workspace';
|
||||||
@@ -7,19 +7,20 @@ import { normalizeUrl, usePreview } from './use-preview';
|
|||||||
/**
|
/**
|
||||||
* A live app, in a panel.
|
* A live app, in a panel.
|
||||||
*
|
*
|
||||||
* The iframe is remounted by `key` on reload rather than reaching for `contentWindow.location.reload()`:
|
* The iframe is remounted by `key` on reload rather than reaching for `contentWindow.location.reload()`.
|
||||||
* the frame is a different origin, so touching its location object throws a cross-origin error. Replacing
|
* For an ordinary URL that is the only option — the frame is a different origin, and touching its location
|
||||||
* the element re-requests the URL, which is the same thing from the user's side and the only thing the
|
* object throws. A PROXIED preview is same-origin and could be reloaded in place, but remounting is kept
|
||||||
* parent is allowed to do.
|
* for both: one mechanism, and the remount is also what re-runs the dev-proxy session request below.
|
||||||
*
|
*
|
||||||
* `allow-same-origin` is present, and has to be: without it the framed app gets an opaque origin, which
|
* There is no `sandbox` attribute, deliberately. A sandboxed frame without `allow-same-origin` gets an
|
||||||
* means no `localStorage` — and it authenticates with a JWT it keeps there, so it could never stay logged
|
* opaque origin and therefore no `localStorage` — and an app that keeps a JWT there could never stay logged
|
||||||
* in. It is combined with `allow-scripts`, which the two together do weaken — but the alternative is a
|
* in — while a sandbox WITH `allow-same-origin` and `allow-scripts` is worth roughly nothing, since the
|
||||||
* panel that cannot run the app it exists to show, and the URL is one the owner typed.
|
* frame can reach up and remove its own sandbox attribute. The URL is one the owner typed on their own
|
||||||
|
* machine; pretending otherwise with an attribute that does not hold is worse than not claiming it.
|
||||||
*/
|
*/
|
||||||
export const PreviewApp = ({ panelId }: { panelId: string }) => {
|
export const PreviewApp = ({ panelId }: { panelId: string }) => {
|
||||||
const { workspace } = useWorkspace();
|
const { workspace } = useWorkspace();
|
||||||
const { url, setUrl, reloadToken } = usePreview(panelId, workspace);
|
const { url, target, setUrl, reportLocation, reloadToken } = usePreview(panelId, workspace);
|
||||||
const [draft, setDraft] = useState('');
|
const [draft, setDraft] = useState('');
|
||||||
|
|
||||||
if (!url) {
|
if (!url) {
|
||||||
@@ -55,7 +56,7 @@ export const PreviewApp = ({ panelId }: { panelId: string }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <DevProxyFrame url={url} reloadToken={reloadToken} />;
|
return <DevProxyFrame url={target} reloadToken={reloadToken} onLocation={reportLocation} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,7 +71,9 @@ export const PreviewApp = ({ panelId }: { panelId: string }) => {
|
|||||||
*
|
*
|
||||||
* An ordinary URL mounts immediately — nothing to arrange, and no reason to make it wait.
|
* An ordinary URL mounts immediately — nothing to arrange, and no reason to make it wait.
|
||||||
*/
|
*/
|
||||||
const DevProxyFrame = ({ url, reloadToken }: { url: string; reloadToken: number }) => {
|
type DevProxyFrameProps = { url: string; reloadToken: number; onLocation: (path: string) => void };
|
||||||
|
|
||||||
|
const DevProxyFrame = ({ url, reloadToken, onLocation }: DevProxyFrameProps) => {
|
||||||
const { get } = useClient();
|
const { get } = useClient();
|
||||||
const needsSession = url.startsWith('/api/dev-proxy/');
|
const needsSession = url.startsWith('/api/dev-proxy/');
|
||||||
const [ready, setReady] = useState(!needsSession);
|
const [ready, setReady] = useState(!needsSession);
|
||||||
@@ -104,5 +107,45 @@ const DevProxyFrame = ({ url, reloadToken }: { url: string; reloadToken: number
|
|||||||
if (!ready) {
|
if (!ready) {
|
||||||
return <div className="flex h-full items-center justify-center text-xs text-duck-dark/40">Connecting…</div>;
|
return <div className="flex h-full items-center justify-center text-xs text-duck-dark/40">Connecting…</div>;
|
||||||
}
|
}
|
||||||
return <iframe key={reloadToken} src={url} title="Preview" className="h-full w-full border-none bg-white" />;
|
return <PreviewFrame key={reloadToken} initialSrc={url} onLocation={onLocation} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The frame itself, and why its `src` is captured once.
|
||||||
|
*
|
||||||
|
* `initialSrc` composes the entry URL with wherever the app had navigated to — so a reload, or a dev server
|
||||||
|
* restarting underneath, comes back to the page you were on rather than to the app's front door.
|
||||||
|
*
|
||||||
|
* It is read into state at mount and then ignored, which is the load-bearing part. That composed value
|
||||||
|
* CHANGES as the app navigates, and letting it flow into the `src` prop would set the attribute to the page
|
||||||
|
* the frame is already showing — which browsers treat as a navigation. Every click would cost a reload, and
|
||||||
|
* a preview that reloads whenever you use it is worse than one that never follows along at all.
|
||||||
|
*
|
||||||
|
* Remounting is still how a reload works: `key={reloadToken}` above throws this component away, and the new
|
||||||
|
* one captures the composed URL afresh.
|
||||||
|
*
|
||||||
|
* ── Which frame is talking ──
|
||||||
|
*
|
||||||
|
* Two preview panels on the same dashboard receive each other's messages — `message` is a window-level
|
||||||
|
* event and both frames are same-origin. `ev.source` is the posting window itself, so comparing it to this
|
||||||
|
* iframe's `contentWindow` answers "is this mine" exactly, rather than by matching on the URL and hoping
|
||||||
|
* two panels are never pointed at the same port.
|
||||||
|
*/
|
||||||
|
const PreviewFrame = ({ initialSrc, onLocation }: { initialSrc: string; onLocation: (path: string) => void }) => {
|
||||||
|
const [src] = useState(initialSrc);
|
||||||
|
const frame = useRef<HTMLIFrameElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onMessage = (ev: MessageEvent) => {
|
||||||
|
if (ev.origin !== window.location.origin) return;
|
||||||
|
if (!frame.current || ev.source !== frame.current.contentWindow) return;
|
||||||
|
const data = ev.data as { officerPreview?: unknown; path?: unknown } | null;
|
||||||
|
if (data?.officerPreview !== 1 || typeof data.path !== 'string') return;
|
||||||
|
onLocation(data.path);
|
||||||
|
};
|
||||||
|
window.addEventListener('message', onMessage);
|
||||||
|
return () => window.removeEventListener('message', onMessage);
|
||||||
|
}, [onLocation]);
|
||||||
|
|
||||||
|
return <iframe ref={frame} src={src} title="Preview" className="h-full w-full border-none bg-white" />;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,15 +14,17 @@ import { normalizeUrl, usePreview } from './use-preview';
|
|||||||
*/
|
*/
|
||||||
export const PreviewHeader = ({ panelId }: { panelId?: string }) => {
|
export const PreviewHeader = ({ panelId }: { panelId?: string }) => {
|
||||||
const { workspace } = useWorkspace();
|
const { workspace } = useWorkspace();
|
||||||
const { url, setUrl, reload } = usePreview(panelId, workspace);
|
const { url, target, display, setUrl, reload } = usePreview(panelId, workspace);
|
||||||
|
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState(url);
|
const [draft, setDraft] = useState(display);
|
||||||
|
|
||||||
// Follow the stored value while not editing, so a URL set from elsewhere shows up here.
|
// Follow the stored value while not editing, so a URL set from elsewhere — or a page the app navigated to
|
||||||
|
// by itself — shows up here. Editing starts from what is displayed rather than from the proxy path,
|
||||||
|
// because `localhost:3001/settings` is a thing you can retype and `/api/dev-proxy/3001/settings` is not.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editing) setDraft(url);
|
if (!editing) setDraft(display);
|
||||||
}, [url, editing]);
|
}, [display, editing]);
|
||||||
|
|
||||||
const commit = () => {
|
const commit = () => {
|
||||||
const next = normalizeUrl(draft);
|
const next = normalizeUrl(draft);
|
||||||
@@ -63,8 +65,8 @@ export const PreviewHeader = ({ panelId }: { panelId?: string }) => {
|
|||||||
<button onClick={reload} title="Reload" className="p-0.5 rounded hover:bg-black/10 cursor-pointer shrink-0">
|
<button onClick={reload} title="Reload" className="p-0.5 rounded hover:bg-black/10 cursor-pointer shrink-0">
|
||||||
<RefreshCw className="h-3 w-3" />
|
<RefreshCw className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
<span className="min-w-0 flex-1 truncate text-[10px] font-mono opacity-60">
|
<span className="min-w-0 flex-1 truncate text-[10px] font-mono opacity-60" title={display}>
|
||||||
{url.replace(/^https?:\/\//, '')}
|
{display}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditing(true)}
|
onClick={() => setEditing(true)}
|
||||||
@@ -74,7 +76,7 @@ export const PreviewHeader = ({ panelId }: { panelId?: string }) => {
|
|||||||
<Pencil className="h-3 w-3" />
|
<Pencil className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
<a
|
<a
|
||||||
href={url}
|
href={target}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
title="Open in a new tab"
|
title="Open in a new tab"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
import { normalizeUrl } from './use-preview';
|
import { displayUrl, normalizeUrl, proxyPort, targetUrl } from './use-preview';
|
||||||
|
|
||||||
// The panel puts this string straight into an iframe `src`, so what it accepts matters more than it looks.
|
// The panel puts this string straight into an iframe `src`, so what it accepts matters more than it looks.
|
||||||
|
|
||||||
@@ -76,3 +76,60 @@ describe('loopback URLs', () => {
|
|||||||
// panel now asks `/dev-proxy/session` with the authenticated client and mounts the frame afterwards,
|
// panel now asks `/dev-proxy/session` with the authenticated client and mounts the frame afterwards,
|
||||||
// so the URL carries nothing and there is no ordering to get wrong.
|
// so the URL carries nothing and there is no ordering to get wrong.
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Following the app's own navigation ──
|
||||||
|
//
|
||||||
|
// The panel keeps two things: the URL you pointed it at, and where the app has got to since. These compose
|
||||||
|
// them. The split exists because storing one value would feed the iframe its own `src` on every route
|
||||||
|
// change — see the note on `PreviewPanelState`.
|
||||||
|
|
||||||
|
describe('proxyPort', () => {
|
||||||
|
test('finds the port of a proxied preview, with or without a path', () => {
|
||||||
|
expect(proxyPort('/api/dev-proxy/3001/')).toBe('3001');
|
||||||
|
expect(proxyPort('/api/dev-proxy/3001/settings/profile')).toBe('3001');
|
||||||
|
expect(proxyPort('/api/dev-proxy/80')).toBe('80');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an ordinary URL has none — which is what stops the composing below', () => {
|
||||||
|
expect(proxyPort('https://jesper.edge.pertento.ai/')).toBeNull();
|
||||||
|
expect(proxyPort('')).toBeNull();
|
||||||
|
// Not the proxy, however much it looks like it.
|
||||||
|
expect(proxyPort('https://example.com/api/dev-proxy/3001/')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('targetUrl', () => {
|
||||||
|
test('a reload goes back to the page you were on, not the front door', () => {
|
||||||
|
expect(targetUrl('/api/dev-proxy/3001/', '/settings/profile')).toBe('/api/dev-proxy/3001/settings/profile');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('query and hash survive, since a route can live in either', () => {
|
||||||
|
expect(targetUrl('/api/dev-proxy/3001/', '/search?q=x#hit')).toBe('/api/dev-proxy/3001/search?q=x#hit');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no navigation reported yet means the entry URL, unchanged', () => {
|
||||||
|
expect(targetUrl('/api/dev-proxy/3001/', undefined)).toBe('/api/dev-proxy/3001/');
|
||||||
|
expect(targetUrl('/api/dev-proxy/3001/', '')).toBe('/api/dev-proxy/3001/');
|
||||||
|
});
|
||||||
|
|
||||||
|
// A cross-origin frame reports nothing, so there is never a path to compose. Grafting one on would be
|
||||||
|
// inventing a URL on someone else's host.
|
||||||
|
test('an ordinary URL is never composed with a path', () => {
|
||||||
|
expect(targetUrl('https://jesper.edge.pertento.ai/', '/settings')).toBe('https://jesper.edge.pertento.ai/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('displayUrl', () => {
|
||||||
|
test('shows what you typed, not the plumbing', () => {
|
||||||
|
expect(displayUrl('/api/dev-proxy/3001/', '/settings')).toBe('localhost:3001/settings');
|
||||||
|
expect(displayUrl('/api/dev-proxy/3001/', undefined)).toBe('localhost:3001');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the root path adds nothing — `localhost:3001/` reads as a stray slash', () => {
|
||||||
|
expect(displayUrl('/api/dev-proxy/3001/', '/')).toBe('localhost:3001');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an ordinary URL loses only its scheme, as before', () => {
|
||||||
|
expect(displayUrl('https://jesper.edge.pertento.ai/', undefined)).toBe('jesper.edge.pertento.ai/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -30,10 +30,54 @@ import { useBackgroundJobChanged } from '../../channels';
|
|||||||
* The URL persists (dashboard state, keyed per panel). The reload counter does NOT: it is a signal, and a
|
* The URL persists (dashboard state, keyed per panel). The reload counter does NOT: it is a signal, and a
|
||||||
* reload that survived a refresh would be meaningless.
|
* reload that survived a refresh would be meaningless.
|
||||||
*/
|
*/
|
||||||
export type PreviewPanelState = { url: string };
|
/**
|
||||||
|
* `url` is what you pointed the panel at. `current` is where the app has navigated to since.
|
||||||
|
*
|
||||||
|
* They are separate on purpose. Collapsing them into one field reads well — a browser has one address bar
|
||||||
|
* — but the panel would then feed its own iframe: the stored value is the `src`, so recording a route
|
||||||
|
* change would hand the frame a new `src` and re-navigate it to the page it is already on. Once round that
|
||||||
|
* loop per click.
|
||||||
|
*
|
||||||
|
* So `url` changes only when a person types one, and `current` follows the app. A reload composes them.
|
||||||
|
*/
|
||||||
|
export type PreviewPanelState = { url: string; current?: string };
|
||||||
|
|
||||||
const EMPTY: Record<string, PreviewPanelState> = {};
|
const EMPTY: Record<string, PreviewPanelState> = {};
|
||||||
|
|
||||||
|
/** How long to sit on a route change before writing it down. */
|
||||||
|
const REPORT_DEBOUNCE_MS = 800;
|
||||||
|
|
||||||
|
const DEV_PROXY = /^\/api\/dev-proxy\/([0-9]{1,5})(?:\/|$)/;
|
||||||
|
|
||||||
|
/** The port a proxied preview is pointing at, or null for an ordinary URL. */
|
||||||
|
export function proxyPort(url: string): string | null {
|
||||||
|
return url.match(DEV_PROXY)?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a fresh mount should load: where you pointed it, plus wherever it had got to.
|
||||||
|
*
|
||||||
|
* Only proxied previews can compose, because only they report their location — a cross-origin frame will
|
||||||
|
* not tell us anything, and guessing a path onto someone else's host would be inventing a URL.
|
||||||
|
*/
|
||||||
|
export function targetUrl(url: string, current: string | undefined): string {
|
||||||
|
const port = proxyPort(url);
|
||||||
|
if (!port || !current) return url;
|
||||||
|
return `/api/dev-proxy/${port}${current.startsWith('/') ? current : `/${current}`}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What to show a human.
|
||||||
|
*
|
||||||
|
* `api/dev-proxy/3001/settings` is the plumbing; `localhost:3001/settings` is the thing you asked for and
|
||||||
|
* the thing you would type again.
|
||||||
|
*/
|
||||||
|
export function displayUrl(url: string, current: string | undefined): string {
|
||||||
|
const port = proxyPort(url);
|
||||||
|
if (!port) return url.replace(/^https?:\/\//, '');
|
||||||
|
return `localhost:${port}${current && current !== '/' ? current : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function usePreview(panelId: string | undefined, workspace: WorkspaceIdentity | null) {
|
export function usePreview(panelId: string | undefined, workspace: WorkspaceIdentity | null) {
|
||||||
const { value: bag, setValue: setBag } = useDashboardState<Record<string, PreviewPanelState>>(
|
const { value: bag, setValue: setBag } = useDashboardState<Record<string, PreviewPanelState>>(
|
||||||
panelStateKey('preview', workspace),
|
panelStateKey('preview', workspace),
|
||||||
@@ -41,18 +85,47 @@ export function usePreview(panelId: string | undefined, workspace: WorkspaceIden
|
|||||||
);
|
);
|
||||||
|
|
||||||
const url = panelId ? (bag[panelId]?.url ?? '') : '';
|
const url = panelId ? (bag[panelId]?.url ?? '') : '';
|
||||||
|
const current = panelId ? bag[panelId]?.current : undefined;
|
||||||
|
|
||||||
const setUrl = useCallback(
|
const setUrl = useCallback(
|
||||||
(next: string) => {
|
(next: string) => {
|
||||||
if (!panelId) return;
|
if (!panelId) return;
|
||||||
setBag((prev) => {
|
setBag((prev) => {
|
||||||
if (prev[panelId]?.url === next) return prev;
|
if (prev[panelId]?.url === next) return prev;
|
||||||
|
// A new address starts at that address: keeping the old `current` would compose someone's typed URL
|
||||||
|
// with a path from the app that used to be there.
|
||||||
return { ...prev, [panelId]: { url: next } };
|
return { ...prev, [panelId]: { url: next } };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[panelId, setBag],
|
[panelId, setBag],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record where the app has navigated to, debounced.
|
||||||
|
*
|
||||||
|
* `useDashboardState.setValue` PATCHes the server on every call, and an SPA emits a route change per
|
||||||
|
* click — so writing straight through would turn browsing a preview into a stream of requests. The value
|
||||||
|
* is only ever read when the frame is (re)mounted, so being a second behind costs nothing.
|
||||||
|
*/
|
||||||
|
const pending = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const reportLocation = useCallback(
|
||||||
|
(path: string) => {
|
||||||
|
if (!panelId) return;
|
||||||
|
if (pending.current) clearTimeout(pending.current);
|
||||||
|
pending.current = setTimeout(() => {
|
||||||
|
setBag((prev) => {
|
||||||
|
const panel = prev[panelId];
|
||||||
|
// No entry for this panel means nothing pointed it anywhere; a location without one is noise.
|
||||||
|
if (!panel || panel.current === path) return prev;
|
||||||
|
return { ...prev, [panelId]: { ...panel, current: path } };
|
||||||
|
});
|
||||||
|
}, REPORT_DEBOUNCE_MS);
|
||||||
|
},
|
||||||
|
[panelId, setBag],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => () => void (pending.current && clearTimeout(pending.current)), []);
|
||||||
|
|
||||||
// Keyed per panel so two previews reload independently. Same shape the terminal uses for its connection
|
// Keyed per panel so two previews reload independently. Same shape the terminal uses for its connection
|
||||||
// state, and deliberately not persisted.
|
// state, and deliberately not persisted.
|
||||||
const [reloadToken, setReloadToken] = useGlobal<number>(`preview-reload-${panelId ?? 'none'}`, 0);
|
const [reloadToken, setReloadToken] = useGlobal<number>(`preview-reload-${panelId ?? 'none'}`, 0);
|
||||||
@@ -79,7 +152,18 @@ export function usePreview(panelId: string | undefined, workspace: WorkspaceIden
|
|||||||
reload();
|
reload();
|
||||||
}, [jobSignal]);
|
}, [jobSignal]);
|
||||||
|
|
||||||
return { url, setUrl, reloadToken, reload };
|
return {
|
||||||
|
url,
|
||||||
|
current,
|
||||||
|
// What to load: the entry point for a plain URL, the current page for a proxied one. This is what makes
|
||||||
|
// a reload — and a dev server restarting under you — come back to the page you were on.
|
||||||
|
target: targetUrl(url, current),
|
||||||
|
display: displayUrl(url, current),
|
||||||
|
setUrl,
|
||||||
|
reportLocation,
|
||||||
|
reloadToken,
|
||||||
|
reload,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user