1/**
2 * Static-asset behavior shared across website hosts.
3 *
4 * Origin routing (`notFoundHandling`, `htmlHandling`) is host-agnostic.
5 * `Content-Type` / `Cache-Control` used when uploading a client directory
6 * (Fly Tigris today) are also host-agnostic HTTP policy: HTML is never
7 * cached so a deploy is visible immediately; content-hashed files are
8 * immutable. CDN topology (CloudFront vs Tigris vs none) stays host-specific.
9 *
10 * AWS.Website.AssetDeployment has its own table: it takes `fileOptions` /
11 * `textEncoding` overrides and historically used `application/javascript`.
12 * Do not force that API onto this module until those knobs are needed here.
13 *
14 * Keep {@link contentTypeOf} in sync with the MIME table inlined by
15 * `makeNodeServeEntrySource` (`frontend-frameworks` NodeServe) — that
16 * generated origin cannot import this file.
17 */
18
19/**
20 * How unmatched GET paths are answered. Same names as Cloudflare
21 * Workers `assets.notFoundHandling`.
22 */
23export type WebsiteNotFoundHandling =
24 | "none"
25 | "single-page-application"
26 | "404-page";
27
28/**
29 * Static-asset routing on the origin (`notFoundHandling`, `htmlHandling`).
30 */
31export interface WebsiteAssetsProps {
32 notFoundHandling?: WebsiteNotFoundHandling;
33 htmlHandling?: "none" | "drop-trailing-slash";
34}
35
36/** Map {@link WebsiteAssetsProps} onto the generated Node serve entry. */
37export const staticConfigFromAssets = (
38 assets: WebsiteAssetsProps | undefined,
39 defaults?: { notFoundHandling?: WebsiteNotFoundHandling },
40): { spa?: boolean; errorPage?: string } => {
41 const handling = assets?.notFoundHandling ?? defaults?.notFoundHandling;
42 if (handling === "single-page-application") return { spa: true };
43 if (handling === "404-page") return { errorPage: "404.html" };
44 if (handling === "none") return { spa: false };
45 return {};
46};
47
48/** HTML documents: never cached so a new deploy is visible immediately. */
49export const htmlCacheControl = "max-age=0,no-cache,no-store,must-revalidate";
50
51/** Content-hashed static files (JS/CSS/images): cache forever. */
52export const assetCacheControl = "max-age=31536000,public,immutable";
53
54/**
55 * `Content-Type` for a static website file, from its extension.
56 *
57 * JS is `text/javascript` (the HTML-spec JavaScript MIME type) so
58 * `<script type="module">` loads. Unknown extensions are
59 * `application/octet-stream`.
60 */
61export const contentTypeOf = (relative: string): string => {
62 const slash = Math.max(relative.lastIndexOf("/"), relative.lastIndexOf("\\"));
63 const base = slash >= 0 ? relative.slice(slash + 1) : relative;
64 const dot = base.lastIndexOf(".");
65 const ext = dot > 0 ? base.slice(dot + 1).toLowerCase() : "";
66 switch (ext) {
67 case "html":
68 case "htm":
69 return "text/html; charset=utf-8";
70 case "js":
71 case "mjs":
72 return "text/javascript; charset=utf-8";
73 case "css":
74 return "text/css; charset=utf-8";
75 case "json":
76 return "application/json";
77 case "svg":
78 return "image/svg+xml";
79 case "png":
80 return "image/png";
81 case "jpg":
82 case "jpeg":
83 return "image/jpeg";
84 case "gif":
85 return "image/gif";
86 case "webp":
87 return "image/webp";
88 case "ico":
89 return "image/x-icon";
90 case "woff":
91 return "font/woff";
92 case "woff2":
93 return "font/woff2";
94 case "txt":
95 return "text/plain; charset=utf-8";
96 case "wasm":
97 return "application/wasm";
98 case "map":
99 return "application/json";
100 default:
101 return "application/octet-stream";
102 }
103};
104
105/**
106 * `Cache-Control` for a static website file. HTML (and `.htm`) is never
107 * cached; everything else is immutable. Frameworks content-hash JS/CSS
108 * into `/assets/...`, so a new deploy changes the URL instead of mutating
109 * a cached object.
110 */
111export const cacheControlOf = (relative: string): string => {
112 const lower = relative.toLowerCase();
113 return lower.endsWith(".html") || lower.endsWith(".htm")
114 ? htmlCacheControl
115 : assetCacheControl;
116};