import { isDid, isHandle } from "@atcute/lexicons/syntax";
import { redirect, type RequestEvent } from "@sveltejs/kit";
import type { Handle, HandleFetch, HandleServerError, ServerInit } from "@sveltejs/kit/hooks";
import { Agent, Client, Pool, setGlobalDispatcher } from "undici";
import { ClientResponseError, createBobbinClient } from "#lib/api/client.ts";
import { errorMessage, httpStatusFor } from "#lib/api/load.ts";
import { resolveRepoByRepoDid } from "#lib/api/repo.ts";
import { signAvatarSrcs } from "#lib/server/avatar.ts";
import { getConfig } from "#lib/server/config.ts";
import { startLookups } from "#lib/server/lookupEvents.ts";
import { setBlobUrl } from "#lib/markup/paths.ts";
import { themeAttribute, themeFromCookies } from "#lib/theme.ts";
import { decodeOrEmpty } from "#lib/uri.ts";
// node's fetch hands back the body decoded but keeps these, which would make the
// browser decode it again
const passThrough = (res: Response) => {
const headers = new Headers(res.headers);
headers.delete("content-encoding");
headers.delete("content-length");
return new Response(res.body, { status: res.status, headers });
};
// set on every response the app renders, unless a route set its own. none of them changes
// what a page may load or run: nosniff only stops a script or stylesheet served under the
// wrong type, the referrer policy is the browsers' default spelled out, and the frame
// rules refuse being framed (nothing frames the app, and an embedded tangled page could
// otherwise be clickjacked into a star, fork or settings change). a full content policy
// and hsts need their own rollout
const SECURITY_HEADERS: [string, string][] = [
["x-content-type-options", "nosniff"],
["referrer-policy", "strict-origin-when-cross-origin"],
["x-frame-options", "DENY"],
["content-security-policy", "frame-ancestors 'none'"]
];
const withSecurityHeaders = (res: Response): Response => {
const missing = SECURITY_HEADERS.filter(([name]) => !res.headers.has(name));
if (missing.length === 0) return res;
try {
for (const [name, value] of missing) res.headers.set(name, value);
return res;
} catch {
// an upstream fetch's response handed back as is has immutable headers
const copy = new Response(res.body, res);
for (const [name, value] of missing) copy.headers.set(name, value);
return copy;
}
};
const firstSegment = (url: URL) => /^\/([^/]*)(.*)$/.exec(url.pathname) ?? [];
// links from the old appview: /tangled.sh/x means /tangled.org/x, and a handle
// may be written with a leading @
const legacyRedirect = (url: URL): Response | undefined => {
const [, first = "", rest = ""] = firstSegment(url);
const segment = first.replace(/^%40/i, "@");
const redirect = (status: number, handle: string) =>
new Response(null, { status, headers: { location: `/${handle}${rest}${url.search}` } });
if (segment === "tangled.sh" || segment === "@tangled.sh") return redirect(301, "tangled.org");
if (segment.startsWith("@") && isHandle(segment.slice(1)))
return redirect(302, segment.slice(1));
return undefined;
};
// a repo did permalink goes to the repo's url with whatever path follows, even one no
// route matches. thrown, so a client navigation's data request gets it as data
const repoDidRedirect = async (event: RequestEvent) => {
const [, first = "", rest = ""] = firstSegment(event.url);
// kit answers a malformed uri with a 400 once it routes, so don't throw before that
const did = decodeOrEmpty(first);
// git clones the permalink, that route sends it to the knot in one hop
if (!isDid(did) || event.route.id === "/[handle]/info/refs") return;
const ctx = createBobbinClient({ serviceUrl: getConfig().bobbinUrl, fetch: event.fetch });
const repo = await resolveRepoByRepoDid(ctx, did);
if (repo) redirect(307, `/${repo.owner}/${repo.name}${rest}${event.url.search}`);
};
// our own services get a few shared http/2 connections instead of one per call in flight.
// undici only multiplexes up to pipelining streams per connection
export const upstreamPool =
(ours: ReadonlySet<string>) => (origin: string | URL, options: Pool.Options) =>
ours.has(new URL(origin).origin)
? new Pool(origin, { ...options, allowH2: true, connections: 8, pipelining: 100 })
: new Pool(origin, { ...options, allowH2: true, factory: multiplexOverH2Only });
// null brings back undici's per protocol default, which the constructor overwrites with 1:
// as many streams as the server allows over http/2, one request at a time over http/1.1.
// knots and pdses sit behind whatever their owners chose and may not survive pipelining
const multiplexOverH2Only = (origin: URL, options: object) => {
const client = new Client(origin, options);
client.pipelining = null as unknown as number;
return client;
};
export const init: ServerInit = () => {
const config = getConfig();
const ours = new Set(
[
config.bobbinUrl,
config.deliberiUrl,
config.delegationUrl,
config.migratorUrl,
config.emailDidUrl,
config.sitemapUrl,
config.ogreUrl,
config.sitesUrl
]
.filter(Boolean)
.map((url) => new URL(url).origin)
);
setGlobalDispatcher(new Agent({ factory: upstreamPool(ours) }));
startLookups(config);
};
// a stalled upstream would hold the page and an instance's request slot until cloud run
// gives up at 300 s. only the wait for headers is bounded. big bodies still stream
export const handleFetch: HandleFetch = async ({ request, fetch }) => {
const { upstreamTimeoutMs } = getConfig();
if (request.method !== "GET" || upstreamTimeoutMs === 0) return fetch(request);
const timeout = new AbortController();
const timer = setTimeout(() => timeout.abort(), upstreamTimeoutMs);
try {
return await fetch(
new Request(request, { signal: AbortSignal.any([request.signal, timeout.signal]) })
);
} catch (error) {
if (timeout.signal.aborted) {
const url = new URL(request.url);
console.warn(
`[hooks.server] ${url.host}${url.pathname} gave no answer in ${upstreamTimeoutMs} ms`
);
}
throw error;
} finally {
clearTimeout(timer);
}
};
export const handleError: HandleServerError = ({ kind, error, issues }) => {
if (kind === "validation") {
console.error("Remote function schema validation failed:", issues);
return;
}
if (kind !== "unknown") return;
console.error(error);
if (error instanceof ClientResponseError) {
return { status: httpStatusFor(error), message: errorMessage(error) };
}
if (error instanceof DOMException && error.name === "AbortError") {
return {
status: 504,
message: "A service this page needs took too long to respond. Try again in a moment."
};
}
};
export const handle: Handle = async ({ event, resolve }) => {
setBlobUrl(getConfig().blobUrl);
const legacy = legacyRedirect(event.url);
if (legacy) return legacy;
await repoDidRedirect(event);
// Keep bobbin off the public network. In Cloudflare this is a Worker
// service binding; the browser only ever sees the web origin.
if (event.url.pathname === "/xrpc" || event.url.pathname.startsWith("/xrpc/")) {
const bobbin = event.platform?.env?.BOBBIN;
if (bobbin) {
const upstream = new URL(event.request.url);
upstream.hostname = "bobbin.internal";
return bobbin.fetch(new Request(upstream, event.request));
}
}
if (event.url.pathname === "/sitemap.xml" || event.url.pathname.startsWith("/sitemaps/")) {
const sitemap = event.platform?.env?.SITEMAP;
if (sitemap) {
const upstream = new URL(event.request.url);
upstream.hostname = "sitemap.internal";
return sitemap.fetch(new Request(upstream, event.request));
}
const { sitemapUrl } = getConfig();
if (sitemapUrl) {
return passThrough(
await fetch(`${sitemapUrl}${event.url.pathname}${event.url.search}`)
);
}
console.warn("[hooks.server] no SITEMAP binding or SITEMAP_URL; sitemap request unhandled");
}
// stamp the saved theme onto <html> so the first paint is already right
const theme = themeAttribute(themeFromCookies(event.request.headers.get("cookie")));
const { avatarUrl, avatarSecret, camoUrl } = getConfig();
const response = await resolve(event, {
transformPageChunk: ({ html }) => {
const page = html
.replace("%tangled.theme%", theme)
.replace("%tangled.avatar%", avatarUrl)
.replace("%tangled.camo%", camoUrl);
return avatarSecret ? signAvatarSrcs(page, avatarUrl, avatarSecret) : page;
},
// sveltekit blocks atcute fetch handler from reading headers
// because it assumes backend APIs might return sensitive headers.
// this happens when during CSR we run a fetch that was the same
// as one ran during SSR, so sveltekit tries to give that fetch
// the data we already had.
// so we allow these headers to have atcute function properly.
filterSerializedResponseHeaders(name) {
return name === "content-type" || name === "content-length";
}
});
return withSecurityHeaders(response);
};