feat(server,web): serve Workspace HTML previews from a separate origin (#46)
Serve "open in new tab" HTML previews from a separate origin (the loopback counterpart, or PENGUIN_PREVIEW_ORIGIN) with a signed, host-bound token, so localStorage/cookies/third-party embeds work while Agent-generated pages stay off the app origin. The app is canonicalized onto localhost and the preview host (127.0.0.1) serves only /preview/* — its /api answers 401 and app routes 302 to the canonical host — so the preview origin can neither set nor honor a session cookie.
This commit is contained in:
1 parent
cc1be054de
commit
b18ae8f5f1
22 files changed
+842
-14
No files matched your search
@@ -13,6 +13,7 @@ import { config as loadDotenv } from "dotenv";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { buildAppDeps, createApp } from "./app.js";
|
||||
import { resolveServerConfig } from "./config.js";
|
||||
import { loopbackHostRoles } from "./services/preview-token.js";
|
||||
|
||||
loadDotenv({ quiet: true });
|
||||
|
||||
@@ -26,12 +27,35 @@ await deps.authService.seedAdmin();
|
||||
// Schedule scheduler: startup reconciliation (missed, don't backfill) + periodic scan; only active while the server is running.
|
||||
await deps.scheduler.start();
|
||||
|
||||
// On a loopback bind the App is canonicalized onto one name (`localhost`) and its
|
||||
// counterpart is reserved for previews, so advertise the canonical name — the other one
|
||||
// only 302s back here for App routes (see the canonical-host guard in app.ts).
|
||||
const appHost = loopbackHostRoles(config.host)?.app ?? config.host;
|
||||
const server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => {
|
||||
console.log(`penguin-server started: http://${config.host}:${info.port}`);
|
||||
console.log(`penguin-server started: http://${appHost}:${info.port}`);
|
||||
console.log(`Data root: ${config.root}`);
|
||||
console.log(`SQLite: ${config.dbPath}`);
|
||||
});
|
||||
|
||||
/**
|
||||
* Second loopback listener so the preview origin is actually reachable.
|
||||
*
|
||||
* Workspace HTML previews are served from the loopback counterpart of the host the App
|
||||
* is used on (`127.0.0.1` <-> `localhost`, see design § "Workspace 文件预览"). On most
|
||||
* systems `localhost` resolves to `::1` first, so a server bound only to `127.0.0.1`
|
||||
* would leave every preview URL refusing connections. Binding `::1` as well closes that
|
||||
* gap. Failure is non-fatal — the App keeps working, previews just fall back.
|
||||
*/
|
||||
const ipv6Loopback =
|
||||
config.host === "127.0.0.1" || config.host === "localhost"
|
||||
? serve({ fetch: app.fetch, hostname: "::1", port: config.port })
|
||||
: null;
|
||||
ipv6Loopback?.on("error", (err: NodeJS.ErrnoException) => {
|
||||
console.warn(
|
||||
`[server] IPv6 loopback listener unavailable (${err.code ?? err.message}); previews via localhost may not resolve.`,
|
||||
);
|
||||
});
|
||||
|
||||
let shuttingDown = false;
|
||||
async function shutdown(signal: string, exitCode = 0): Promise<void> {
|
||||
if (shuttingDown) return;
|
||||
@@ -40,6 +64,7 @@ async function shutdown(signal: string, exitCode = 0): Promise<void> {
|
||||
deps.scheduler.stop();
|
||||
await deps.manager.shutdown(5000);
|
||||
deps.channels.dispose();
|
||||
ipv6Loopback?.close();
|
||||
server.close(() => {
|
||||
deps.db.close();
|
||||
process.exit(exitCode);
|
||||
|
||||
Reference in new issue
Block a user