diff --git a/.gitignore b/.gitignore index defad02..3eced54 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,5 @@ public/* !public/favicon.ico !public/manifest.webmanifest !public/robots.txt +!public/speculation-rules.json !public/sw.js diff --git a/components/my-layout.html b/components/my-layout.html index 17a513a..8381fcf 100644 --- a/components/my-layout.html +++ b/components/my-layout.html @@ -30,5 +30,25 @@ + diff --git a/public/_headers b/public/_headers index a0ce769..27ff364 100644 --- a/public/_headers +++ b/public/_headers @@ -8,6 +8,7 @@ permissions-policy: browsing-topics=(),interest-cohort=() referrer-policy: same-origin x-robots-tag: noai, noimageai + speculation-rules: "/speculation-rules.json?v=%DEPLOY_HASH%" /assets/* x-robots-tag: noindex @@ -24,3 +25,6 @@ /rss.xml content-type: application/xml; charset=utf-8 + +/speculation-rules.json + content-type: application/speculationrules+json diff --git a/public/speculation-rules.json b/public/speculation-rules.json new file mode 100644 index 0000000..3065ae6 --- /dev/null +++ b/public/speculation-rules.json @@ -0,0 +1,18 @@ +{ + "prefetch": [ + { + "where": { + "href_matches": "/*" + }, + "eagerness": "immediate" + } + ], + "prerender": [ + { + "where": { + "href_matches": "/*" + }, + "eagerness": "moderate" + } + ] +} diff --git a/routes/(headers)/index.ts b/routes/(headers)/index.ts index d9e64c0..31d60cf 100644 --- a/routes/(headers)/index.ts +++ b/routes/(headers)/index.ts @@ -4,7 +4,7 @@ import { authorized } from "@src/shared.ts"; export const pattern = "/_headers"; export const order = 9999; -export const GET: HyperHandle = async ({ request, response }) => { +export const GET: HyperHandle = async ({ request, response, platform }) => { if (!authorized(request)) { return null; } @@ -17,6 +17,7 @@ export const GET: HyperHandle = async ({ request, response }) => { `default-src 'self'`; let body = await response.text(); body = body.replace("%CONTENT_SECURITY_POLICY%", csp); + body = body.replaceAll("%DEPLOY_HASH%", platform.deployHash); response = new Response(body, response); response.headers.set("content-length", body.length.toString()); return response; diff --git a/src/middleware.ts b/src/middleware.ts index e733b1b..f8cd095 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -19,10 +19,16 @@ export const middleware: HyperHandle = ({ request, platform }) => { ["x-content-type-options", "nosniff"], ["permissions-policy", "browsing-topics=(),interest-cohort=()"], ["referrer-policy", "same-origin"], + ["speculation-rules", '"/speculation-rules.json"'], ["x-img-src", "data:"], // TODO - generate? - Hash for Logo inline styles ["x-style-src", `'sha256-kXLrG8qzlz0MMhgMvdF9YD6tca5CYXeC1iFSTHDsO8w='`], - // Allow WASM (search) + // Inline speculation rules + ["x-script-src", `'sha256-wJ17tFso+XVW2pKPhXkRCUyukGeWjM3DmjQUc7cNNMw='`], + // "this.rel=`stylesheet`" + ["x-script-src", `'unsafe-hashes'`], + ["x-script-src", `'sha256-BGXQRYq/G9+8wEtSYSbQRnDRz8/8apfi/W/CUBMh9w0='`], + // Allow search WASM and fallback ["x-script-src", `'wasm-unsafe-eval'`], ["x-form-action", `https://duckduckgo.com`], ]; @@ -32,6 +38,10 @@ export const middleware: HyperHandle = ({ request, platform }) => { pageHeaders.push(["x-style-src", `'sha256-${style.hash}'`]); }); + if (url.pathname.startsWith("/speculation-rules.json")) { + pageHeaders.push(["content-type", "application/speculationrules+json"]); + } + // CORS headers if (/\/(rss|sitemap)\.xml$/.test(url.pathname)) { pageHeaders.push(["access-control-allow-origin", "*"]); diff --git a/src/shared.ts b/src/shared.ts index 7be0790..91fc8fc 100644 --- a/src/shared.ts +++ b/src/shared.ts @@ -1,10 +1,16 @@ +const SET_HEADERS = new Set(["content-type"]); + export const appendHeaders = ( response: Response, headers: Array<[string, string]>, ) => { try { headers.forEach(([name, value]) => { - response.headers.append(name, value); + if (SET_HEADERS.has(name.toLowerCase())) { + response.headers.set(name, value); + } else { + response.headers.append(name, value); + } }); } catch { // Ignore immutable headers