Skip to content
File

Blob: src/worker/lib/spa-shell.ts

typescript189 lines
1import { type PublicClientConfig } from "@/shared/types";
2import {
3 applyBaselineSecurityHeaders,
4 applyDocumentSecurityHeaders,
5 createCspNonce,
6} from "@/worker/lib/security-headers";
7 
8const SPA_SHELL_SOURCE_PATH = "/index.html";
9 
10type SpaShellHint = {
11 href: string;
12 rel: "preconnect" | "preload";
13 as?: "script" | "style";
14 crossorigin?: true;
15};
16 
17type SpaShellHintsManifest = {
18 links: SpaShellHint[];
19};
20 
21let shellHintsPromise: Promise<SpaShellHintsManifest | null> | null = null;
22 
23function serializeJsonForInlineScript(value: unknown): string {
24 return JSON.stringify(value)
25 .replace(/</g, "\\u003c")
26 .replace(/\u2028/g, "\\u2028")
27 .replace(/\u2029/g, "\\u2029");
28}
29 
30function getPublicClientConfig(env: Pick<Env, "SENTRY_DSN">): PublicClientConfig {
31 return {
32 sentry_dsn: env.SENTRY_DSN || null,
33 };
34}
35 
36export function createPublicClientConfigScript(env: Pick<Env, "SENTRY_DSN">, cspNonce?: string): string {
37 let script = `window.__BLAND_PUBLIC_CONFIG__=${serializeJsonForInlineScript(getPublicClientConfig(env))};`;
38 if (cspNonce) {
39 script += `window.__BLAND_CSP_NONCE__=${serializeJsonForInlineScript(cspNonce)};`;
40 }
41 return script;
42}
43 
44class HeadBootstrapInjector {
45 constructor(
46 private readonly script: string,
47 private readonly nonce: string,
48 ) {}
49 
50 element(element: Element) {
51 element.append(`<script nonce="${this.nonce}">${this.script}</script>`, { html: true });
52 }
53}
54 
55class ScriptNonceInjector {
56 constructor(private readonly nonce: string) {}
57 
58 element(element: Element) {
59 element.setAttribute("nonce", this.nonce);
60 }
61}
62 
63class ShellHintCollector {
64 private readonly seen = new Set<string>();
65 readonly links: SpaShellHint[] = [];
66 
67 private addLink(link: SpaShellHint) {
68 const key = JSON.stringify(link);
69 if (this.seen.has(key)) return;
70 
71 this.seen.add(key);
72 this.links.push(link);
73 }
74 
75 element(element: Element) {
76 if (element.tagName === "script") {
77 const src = element.getAttribute("src");
78 if (element.getAttribute("type") === "module" && src) {
79 this.addLink({
80 href: src,
81 rel: "preload",
82 as: "script",
83 crossorigin: element.hasAttribute("crossorigin") ? true : undefined,
84 });
85 }
86 return;
87 }
88 
89 if (element.tagName !== "link") {
90 return;
91 }
92 
93 const rel = element.getAttribute("rel");
94 const href = element.getAttribute("href");
95 if (!rel || !href) return;
96 
97 if (rel === "preconnect") {
98 this.addLink({
99 href,
100 rel: "preconnect",
101 crossorigin: element.hasAttribute("crossorigin") ? true : undefined,
102 });
103 return;
104 }
105 
106 if (rel === "modulepreload" && href.startsWith("/")) {
107 this.addLink({
108 href,
109 rel: "preload",
110 as: "script",
111 crossorigin: element.hasAttribute("crossorigin") ? true : undefined,
112 });
113 return;
114 }
115 
116 if (rel === "stylesheet" && href.startsWith("/")) {
117 this.addLink({
118 href,
119 rel: "preload",
120 as: "style",
121 crossorigin: element.hasAttribute("crossorigin") ? true : undefined,
122 });
123 }
124 }
125}
126 
127function createShellHintLinkValue(link: SpaShellHint): string {
128 const parts = [`<${link.href}>`, `rel=${link.rel}`];
129 if (link.as) parts.push(`as=${link.as}`);
130 if (link.crossorigin) parts.push("crossorigin");
131 return parts.join("; ");
132}
133 
134function appendShellHintHeaders(headers: Headers, manifest: SpaShellHintsManifest | null): Headers {
135 for (const link of manifest?.links ?? []) {
136 headers.append("Link", createShellHintLinkValue(link));
137 }
138 return headers;
139}
140 
141async function loadShellHints(request: Request, env: Pick<Env, "ASSETS">): Promise<SpaShellHintsManifest | null> {
142 const url = new URL(SPA_SHELL_SOURCE_PATH, request.url);
143 const response = await env.ASSETS.fetch(new Request(url.toString()));
144 if (!response.ok) return null;
145 
146 const collector = new ShellHintCollector();
147 const parsed = await Promise.resolve(
148 new HTMLRewriter().on("script", collector).on("link", collector).transform(response),
149 );
150 await parsed.arrayBuffer();
151 return { links: collector.links };
152}
153 
154async function getShellHints(request: Request, env: Pick<Env, "ASSETS">): Promise<SpaShellHintsManifest | null> {
155 shellHintsPromise ??= loadShellHints(request, env).catch(() => null);
156 return shellHintsPromise;
157}
158 
159export function resetShellHintsCacheForTests() {
160 shellHintsPromise = null;
161}
162 
163export async function renderSpaShell(request: Request, env: Pick<Env, "ASSETS" | "SENTRY_DSN">): Promise<Response> {
164 const shell = await env.ASSETS.fetch(request);
165 if (!shell.ok) {
166 return applyBaselineSecurityHeaders(shell);
167 }
168 
169 const nonce = createCspNonce();
170 const transformed = await Promise.resolve(
171 new HTMLRewriter()
172 .on("head", new HeadBootstrapInjector(createPublicClientConfigScript(env, nonce), nonce))
173 .on("script", new ScriptNonceInjector(nonce))
174 .transform(shell),
175 );
176 
177 const response = new Response(transformed.body, {
178 status: transformed.status,
179 statusText: transformed.statusText,
180 headers: appendShellHintHeaders(new Headers(transformed.headers), await getShellHints(request, env)),
181 });
182 
183 return applyDocumentSecurityHeaders(response, {
184 nonce,
185 requestUrl: request.url,
186 sentryDsn: env.SENTRY_DSN || null,
187 });
188}