paste localhost:3000 into the preview and see the server's port
No proxy host, no certificate, no port opened. `/api/dev-proxy/5173/…` forwards to `http://127.0.0.1:5173/…` over the platform's own origin, and the panel maps what you type onto it. ── Why a proxy at all, when the ask was "just point at localhost" ── Neither direct route works from a browser on https://<platform>. `http://localhost:5173` resolves on the VIEWER's machine, not the server's, and `http://<server>:5173` is mixed content inside an https page and is blocked before it is even attempted. Both fail looking exactly like the dev server being down. The bytes have to arrive over an origin the page already trusts, which is the platform. A dev server can therefore stay bound to 127.0.0.1. The workflow this replaces — one NPM proxy host per app — needs it on 0.0.0.0 plus a firewall rule for the docker bridge, so this is both less setup and less exposure. ── What came back from `188b45c1^`, and what did not ── The 46-line HTML/asset rewrite, which is the hard-won part: absolute paths in the document, plus an injected script that intercepts fetch/XHR/WebSocket for the URLs built at runtime. Its JWT injection is deleted — those apps called the PLATFORM's API; a dev server proxied here talks to itself, and handing its process the owner's bearer token would be giving credentials to whatever is on that port. The other 314 lines are not back: spawning, port discovery, idle timers, log collection, a proxyId registry. The port is the id, and the background-job button already starts dev servers. ── HMR is bridged, because "see it" without "follow it" is half the feature ── The injected script rewrites the dev server's socket under the prefix; the relay dials `ws://127.0.0.1:<port>` and forwards frames both ways. Same shape as the vault relay including the non-obvious part — state registered synchronously and frames queued until the upstream opens, because Bun requires the upgrade to be synchronous and an HMR client speaks immediately. An upstream close closes the client, which is what makes a dev-server restart reconnect by itself. ── Access, which was not in the ask and is not optional ── This route reaches any loopback port on the machine, on a host that answers from the public internet. An iframe cannot carry a bearer token — it is a navigation — so the panel puts it in the URL once, the proxy verifies it is the OWNER through `resolveAuthToken`, and issues a short-lived cookie every sub-resource then carries. Anonymous and non-owner requests get 401, verified. Owner-only rather than a permission: reading an arbitrary loopback port is the `execution` power the registry says can never be granted. Both exemptions are registered with reasons, and the boot check earned its keep — it refused to start until the socket provider was claimed too. ── Verified against a real loopback-only server ── Confirmed unreachable from outside, then through the proxy: index served and rewritten, `/_bun/` chunk resolved, JSON passed through untouched, 401 anonymous, 401 with a bad token, "nothing is listening" on a dead port. Websocket: refused without the cookie, target parsed, frames relayed in both directions. One bug found by testing rather than reading: the upstream path was derived by slicing a hardcoded prefix, so every asset request returned the index page. It is found from the `/<port>` segment now. A test asserting `http://localhost:5173` is kept verbatim is now inverted, with the reason written beside it — that was correct when the panel only framed real hostnames. tsgo clean, frontend builds, 890 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Globe } from 'lucide-react';
|
||||
import { useWorkspace } from '../../components/Workspace';
|
||||
import { normalizeUrl, usePreview } from './use-preview';
|
||||
import { iframeSrc, normalizeUrl, usePreview } from './use-preview';
|
||||
|
||||
/**
|
||||
* A live app, in a panel.
|
||||
@@ -54,5 +54,7 @@ export const PreviewApp = ({ panelId }: { panelId: string }) => {
|
||||
);
|
||||
}
|
||||
|
||||
return <iframe key={reloadToken} src={url} title="Preview" className="h-full w-full border-none bg-white" />;
|
||||
return (
|
||||
<iframe key={reloadToken} src={iframeSrc(url)} title="Preview" className="h-full w-full border-none bg-white" />
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { normalizeUrl } from './use-preview';
|
||||
import { iframeSrc, normalizeUrl } from './use-preview';
|
||||
|
||||
// The panel puts this string straight into an iframe `src`, so what it accepts matters more than it looks.
|
||||
|
||||
@@ -8,9 +8,17 @@ describe('normalizeUrl', () => {
|
||||
expect(normalizeUrl('jesper.edge.pertento.ai')).toBe('https://jesper.edge.pertento.ai/');
|
||||
});
|
||||
|
||||
test('an explicit scheme is kept, including http for a local dev server', () => {
|
||||
test('an explicit scheme is kept for a real host', () => {
|
||||
expect(normalizeUrl('https://jesper.edge.pertento.ai')).toBe('https://jesper.edge.pertento.ai/');
|
||||
expect(normalizeUrl('http://localhost:5173')).toBe('http://localhost:5173/');
|
||||
expect(normalizeUrl('http://media.example.com:8096')).toBe('http://media.example.com:8096/');
|
||||
});
|
||||
|
||||
// This case asserted `http://localhost:5173` was kept verbatim, which was right when the panel only ever
|
||||
// framed a real hostname. It is now wrong on purpose: kept as written, the iframe resolves localhost on
|
||||
// the VIEWER's machine and — on an https page — is blocked as mixed content before it gets that far. See
|
||||
// the loopback tests at the bottom of this file.
|
||||
test('a loopback host is no longer kept verbatim', () => {
|
||||
expect(normalizeUrl('http://localhost:5173')).toBe('/api/dev-proxy/5173/');
|
||||
});
|
||||
|
||||
test('a path survives', () => {
|
||||
@@ -32,3 +40,41 @@ describe('normalizeUrl', () => {
|
||||
expect(normalizeUrl('not a url at all')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── `localhost:3000` means the SERVER's port ──
|
||||
//
|
||||
// It is the natural thing to type and it cannot work as written: `localhost` in an iframe resolves on the
|
||||
// viewer's machine, and `http://<server>:3000` is mixed content inside an https page and is blocked. Both
|
||||
// fail looking like the dev server being down, so it is read as what was meant and pointed at the proxy.
|
||||
|
||||
describe('loopback URLs', () => {
|
||||
test('a loopback host becomes the dev proxy, whichever spelling', () => {
|
||||
expect(normalizeUrl('localhost:3000')).toBe('/api/dev-proxy/3000/');
|
||||
expect(normalizeUrl('127.0.0.1:5173')).toBe('/api/dev-proxy/5173/');
|
||||
expect(normalizeUrl('0.0.0.0:8080')).toBe('/api/dev-proxy/8080/');
|
||||
expect(normalizeUrl('http://localhost:9051')).toBe('/api/dev-proxy/9051/');
|
||||
});
|
||||
|
||||
test('bare localhost is read as port 80 rather than refused', () => {
|
||||
expect(normalizeUrl('localhost')).toBe('/api/dev-proxy/80/');
|
||||
});
|
||||
|
||||
test('a real host is left alone — this must not capture ordinary URLs', () => {
|
||||
expect(normalizeUrl('https://jesper.edge.pertento.ai')).toBe('https://jesper.edge.pertento.ai/');
|
||||
expect(normalizeUrl('example.com')).toBe('https://example.com/');
|
||||
// Contains "localhost" but is not one.
|
||||
expect(normalizeUrl('localhost.example.com')).toBe('https://localhost.example.com/');
|
||||
});
|
||||
|
||||
test('the scheme guard still refuses what it always did', () => {
|
||||
expect(normalizeUrl('javascript:alert(1)')).toBeNull();
|
||||
expect(normalizeUrl('data:text/html,x')).toBeNull();
|
||||
});
|
||||
|
||||
test('the token rides on the dev proxy URL only, and only when there is one', () => {
|
||||
localStorage.setItem('BEARER_TOKEN', 'tok-123');
|
||||
expect(iframeSrc('/api/dev-proxy/3000/')).toBe('/api/dev-proxy/3000/?token=tok-123');
|
||||
expect(iframeSrc('https://example.com/')).toBe('https://example.com/');
|
||||
localStorage.removeItem('BEARER_TOKEN');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,6 +95,10 @@ export function usePreview(panelId: string | undefined, workspace: WorkspaceIden
|
||||
export function normalizeUrl(raw: string): string | null {
|
||||
const text = raw.trim();
|
||||
if (!text) return null;
|
||||
|
||||
const loopback = loopbackPort(text);
|
||||
if (loopback) return `/api/dev-proxy/${loopback}/`;
|
||||
|
||||
const withScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(text) ? text : `https://${text}`;
|
||||
try {
|
||||
const parsed = new URL(withScheme);
|
||||
@@ -104,3 +108,36 @@ export function normalizeUrl(raw: string): string | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `localhost:3000` typed into this panel means the SERVER's port, not the viewer's.
|
||||
*
|
||||
* It is the natural thing to type and it cannot work as written: `localhost` in an iframe resolves on the
|
||||
* machine looking at the screen, and `http://<server>:3000` is mixed content inside an https page and is
|
||||
* blocked outright. Both fail in ways that look like the dev server being down.
|
||||
*
|
||||
* So it is read as what was meant — port 3000 on the server — and pointed at the proxy that can actually
|
||||
* reach it. `127.0.0.1`, `0.0.0.0` and `[::1]` mean the same thing to whoever types them.
|
||||
*/
|
||||
function loopbackPort(text: string): string | null {
|
||||
const match = text.match(/^(?:https?:\/\/)?(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::([0-9]{2,5}))?\/?$/i);
|
||||
if (!match) return null;
|
||||
// Bare `localhost` with no port is ambiguous rather than wrong; 80 is the only defensible reading.
|
||||
return match[1] ?? '80';
|
||||
}
|
||||
|
||||
/**
|
||||
* The iframe's URL, with the one-time token when it points at the dev proxy.
|
||||
*
|
||||
* An iframe is a navigation and carries no Authorization header, so the proxy has no other way to know who
|
||||
* is asking. It answers by setting a short-lived cookie, which every sub-resource then carries by itself —
|
||||
* so the token appears exactly once, on the document request, and never on anything the app can see.
|
||||
*/
|
||||
export function iframeSrc(url: string): string {
|
||||
if (!url.startsWith('/api/dev-proxy/')) return url;
|
||||
const token =
|
||||
localStorage.getItem('PERTENTO_EDITOR_AUTH_TOKEN') ??
|
||||
localStorage.getItem('BEARER_TOKEN') ??
|
||||
sessionStorage.getItem('BEARER_TOKEN');
|
||||
return token ? `${url}?token=${encodeURIComponent(token)}` : url;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user