import type { HTMLBundle, Server } from "bun";
// node:fs/promises — no Bun equivalent for unlink of private sockets.
import { unlink } from "node:fs/promises";
// node:os — no Bun equivalent for the platform temporary directory.
import { tmpdir } from "node:os";
// node:path — no Bun path utils.
import { join } from "node:path";
import {
getProductionSpaAssets,
type ProductionSpaAssets,
} from "./spa-production";
type BunServer = Server;
export type SpaBundleSource = {
entryPath: string;
fetch(request: Request): Promise;
close(): Promise;
};
const notFound = (): Response => new Response("Not Found", { status: 404 });
const isEnoent = (error: unknown): boolean =>
Boolean(
error &&
typeof error === "object" &&
"code" in error &&
error.code === "ENOENT"
);
/**
* Host Bun's headerless HTML / asset surface outside the public listener.
*
* Unix hosts use a private Unix-domain socket, so the raw bundle and generated
* assets have no TCP origin at all. Windows falls back to an ephemeral
* loopback listener plus an unguessable entry path. The public server proxies
* the bytes and applies its normal security envelope before browser delivery.
*
* Production (`isDev === false`) serves the split SPA snapshot embedded from
* `assets/spa-production.json.gz`. Source and compiled executables share that
* path so first listen does not wait on `Bun.build`. Compiled binaries also
* cannot call `Bun.build` on `/$bunfs` (ENOENT on the virtual root).
* Development keeps the live HTMLBundle so HMR still works. Refresh the
* snapshot with `bun scripts/build-spa-production.ts`.
*/
export async function createSpaBundleSource(
bundle: HTMLBundle,
isDev: boolean
): Promise {
const nonce = crypto.randomUUID().replaceAll("-", "");
const entryPath = `/__gno_spa_${nonce}`;
if (isDev) {
return hostPrivateSource({
entryPath,
isDev: true,
nonce,
routes: { [entryPath]: bundle },
});
}
return createSplitProductionSource(entryPath, nonce);
}
async function createSplitProductionSource(
entryPath: string,
nonce: string
): Promise {
const assets = await getProductionSpaAssets();
return hostProductionAssets(assets, entryPath, nonce);
}
async function hostProductionAssets(
assets: ProductionSpaAssets,
entryPath: string,
nonce: string
): Promise {
const resolve = (pathname: string): Response => {
if (pathname === entryPath) {
return new Response(assets.html, {
headers: { "Content-Type": "text/html;charset=utf-8" },
});
}
const file = assets.files[pathname];
if (!file) {
return notFound();
}
return new Response(file.text, {
headers: { "Content-Type": file.type },
});
};
return hostPrivateSource({
entryPath,
fetchAsset: resolve,
isDev: false,
nonce,
});
}
async function hostPrivateSource(options: {
entryPath: string;
fetchAsset?: (pathname: string) => Response;
isDev: boolean;
nonce: string;
routes?: Record;
}): Promise {
let server: BunServer;
let fetchPrivate: (request: Request) => Promise;
let socketPath: string | null = null;
const fallback = {
fetch: (request: Request): Response => {
if (!options.fetchAsset) {
return notFound();
}
return options.fetchAsset(new URL(request.url).pathname);
},
};
if (process.platform === "win32") {
server = Bun.serve({
development: options.isDev,
hostname: "127.0.0.1",
port: 0,
routes: options.routes,
...fallback,
});
const origin = `http://127.0.0.1:${server.port}`;
fetchPrivate = async (request): Promise => {
const url = new URL(request.url);
return fetch(`${origin}${url.pathname}${url.search}`, {
method: request.method,
});
};
} else {
socketPath = join(tmpdir(), `gno-spa-${options.nonce.slice(0, 20)}.sock`);
server = Bun.serve({
development: options.isDev,
routes: options.routes,
unix: socketPath,
...fallback,
});
fetchPrivate = async (request): Promise => {
const url = new URL(request.url);
return fetch(`http://localhost${url.pathname}${url.search}`, {
method: request.method,
unix: socketPath ?? undefined,
});
};
}
let closed = false;
return {
close: async (): Promise => {
if (closed) {
return;
}
closed = true;
await server.stop(true);
if (socketPath) {
try {
await unlink(socketPath);
} catch (error) {
if (!isEnoent(error)) {
throw error;
}
}
}
},
entryPath: options.entryPath,
fetch: fetchPrivate,
};
}