@bygghammar/cms-platform 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,202 @@
1
+ // Human-readable diffing of form definitions for the audit trail.
2
+ // Every backoffice change to a form (settings + fields) is turned into a list
3
+ // of short change descriptions so the Activity view answers who/what/when.
4
+
5
+ export type FormAuditChange = {
6
+ /** Machine scope: "form" | "settings" | "field" */
7
+ scope: "form" | "settings" | "field";
8
+ /** Field key when scope === "field" */
9
+ key?: string;
10
+ label: string;
11
+ from?: string;
12
+ to?: string;
13
+ };
14
+
15
+ type AnyRec = Record<string, unknown>;
16
+
17
+ export type FormSnapshotInput = {
18
+ key: string;
19
+ name: string;
20
+ form_type: string;
21
+ is_active?: boolean;
22
+ fields: unknown;
23
+ public_settings: unknown;
24
+ private_settings: unknown;
25
+ };
26
+
27
+ const MAX_LEN = 160;
28
+
29
+ function short(v: unknown): string {
30
+ if (v == null) return "—";
31
+ if (typeof v === "string") return v.length > MAX_LEN ? `${v.slice(0, MAX_LEN)}…` : v || "—";
32
+ if (typeof v === "boolean") return v ? "on" : "off";
33
+ if (typeof v === "number") return String(v);
34
+ try {
35
+ const s = JSON.stringify(v);
36
+ return s.length > MAX_LEN ? `${s.slice(0, MAX_LEN)}…` : s;
37
+ } catch {
38
+ return "…";
39
+ }
40
+ }
41
+
42
+ function same(a: unknown, b: unknown): boolean {
43
+ return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
44
+ }
45
+
46
+ function asRec(v: unknown): AnyRec {
47
+ return v && typeof v === "object" && !Array.isArray(v) ? (v as AnyRec) : {};
48
+ }
49
+
50
+ function asFields(v: unknown): AnyRec[] {
51
+ return Array.isArray(v) ? (v as AnyRec[]).filter((f) => f && typeof f === "object") : [];
52
+ }
53
+
54
+ /** Label of a field, best-effort across localized shapes. */
55
+ export function fieldLabel(field: AnyRec | undefined): string {
56
+ if (!field) return "";
57
+ const l = field["label"];
58
+ if (typeof l === "string") return l;
59
+ const rec = asRec(l);
60
+ const first = (rec["sv"] ?? rec["en"] ?? Object.values(rec)[0]) as unknown;
61
+ return typeof first === "string" ? first : String(field["key"] ?? "");
62
+ }
63
+
64
+ const SETTING_LABELS: Record<string, string> = {
65
+ submitLabel: "Submit button",
66
+ successMessage: "Success message",
67
+ consentText: "Consent text",
68
+ intro: "Intro text",
69
+ thankyouMode: "Thank you mode",
70
+ thankyouRedirect: "Thank you redirect",
71
+ notifyTo: "Notification recipients",
72
+ notifySubject: "Notification subject",
73
+ notifyBody: "Notification body",
74
+ autoreplyEnabled: "Auto-reply",
75
+ autoreplySubject: "Auto-reply subject",
76
+ autoreplyBody: "Auto-reply body",
77
+ sheetId: "Google Sheet ID",
78
+ sheetTab: "Google Sheet tab",
79
+ sheetLiveAppend: "Live sheet append",
80
+ webhookUrl: "Webhook URL",
81
+ retentionDays: "Retention (days)",
82
+ savePartials: "Save partial entries",
83
+ rules: "Conditional rules",
84
+ };
85
+
86
+ function humanKey(k: string): string {
87
+ return (
88
+ SETTING_LABELS[k] ??
89
+ k
90
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
91
+ .replace(/[._-]+/g, " ")
92
+ .replace(/^./, (c) => c.toUpperCase())
93
+ );
94
+ }
95
+
96
+ const FIELD_PROPS = [
97
+ "label",
98
+ "type",
99
+ "required",
100
+ "enabled",
101
+ "removed",
102
+ "placeholder",
103
+ "help",
104
+ "options",
105
+ "width",
106
+ "autocomplete",
107
+ "courseYear",
108
+ "condition",
109
+ "defaultValue",
110
+ ] as const;
111
+
112
+ /** Diff two form definitions into a readable change list. */
113
+ export function diffForms(
114
+ prev: Partial<FormSnapshotInput> | null | undefined,
115
+ next: FormSnapshotInput,
116
+ ): FormAuditChange[] {
117
+ const out: FormAuditChange[] = [];
118
+ if (!prev) {
119
+ out.push({ scope: "form", label: "Form created" });
120
+ return out;
121
+ }
122
+
123
+ for (const k of ["name", "key", "form_type", "is_active"] as const) {
124
+ if (!same(prev[k], next[k])) {
125
+ out.push({
126
+ scope: "form",
127
+ label: humanKey(k === "form_type" ? "Type" : k),
128
+ from: short(prev[k]),
129
+ to: short(next[k]),
130
+ });
131
+ }
132
+ }
133
+
134
+ for (const [group, label] of [
135
+ ["public_settings", "Presentation"],
136
+ ["private_settings", "Delivery"],
137
+ ] as const) {
138
+ const a = asRec(prev[group]);
139
+ const b = asRec(next[group]);
140
+ const keys = Array.from(new Set([...Object.keys(a), ...Object.keys(b)]));
141
+ for (const k of keys) {
142
+ if (same(a[k], b[k])) continue;
143
+ out.push({
144
+ scope: "settings",
145
+ label: `${label}: ${humanKey(k)}`,
146
+ from: short(a[k]),
147
+ to: short(b[k]),
148
+ });
149
+ }
150
+ }
151
+
152
+ const pf = asFields(prev.fields);
153
+ const nf = asFields(next.fields);
154
+ const pMap = new Map(pf.map((f) => [String(f["key"] ?? ""), f]));
155
+ const nMap = new Map(nf.map((f) => [String(f["key"] ?? ""), f]));
156
+
157
+ for (const [k, f] of nMap) {
158
+ if (!pMap.has(k)) {
159
+ out.push({ scope: "field", key: k, label: `Added field “${fieldLabel(f) || k}”` });
160
+ }
161
+ }
162
+ for (const [k, f] of pMap) {
163
+ if (!nMap.has(k)) {
164
+ out.push({ scope: "field", key: k, label: `Deleted field “${fieldLabel(f) || k}”` });
165
+ }
166
+ }
167
+ for (const [k, nfield] of nMap) {
168
+ const pfield = pMap.get(k);
169
+ if (!pfield) continue;
170
+ for (const prop of FIELD_PROPS) {
171
+ if (same(pfield[prop], nfield[prop])) continue;
172
+ out.push({
173
+ scope: "field",
174
+ key: k,
175
+ label: `Field “${fieldLabel(nfield) || k}” · ${humanKey(prop)}`,
176
+ from: short(pfield[prop]),
177
+ to: short(nfield[prop]),
178
+ });
179
+ }
180
+ }
181
+
182
+ const pOrder = pf.map((f) => String(f["key"] ?? "")).filter((k) => nMap.has(k));
183
+ const nOrder = nf.map((f) => String(f["key"] ?? "")).filter((k) => pMap.has(k));
184
+ if (pOrder.join("|") !== nOrder.join("|")) {
185
+ out.push({
186
+ scope: "field",
187
+ label: "Reordered fields",
188
+ from: pOrder.join(", "),
189
+ to: nOrder.join(", "),
190
+ });
191
+ }
192
+
193
+ return out;
194
+ }
195
+
196
+ /** One-line summary used in list rows. */
197
+ export function summarizeChanges(changes: FormAuditChange[]): string {
198
+ if (!changes.length) return "No changes";
199
+ const head = changes.slice(0, 3).map((c) => c.label);
200
+ const rest = changes.length - head.length;
201
+ return rest > 0 ? `${head.join(" · ")} +${rest} more` : head.join(" · ");
202
+ }
@@ -0,0 +1,76 @@
1
+ import type { ReactNode } from "react";
2
+ import { normalizePolicyUrl } from "./conditions";
3
+
4
+ export { normalizePolicyUrl };
5
+
6
+ /** Words that get auto-linked when the consent text has no explicit link. */
7
+ export const POLICY_WORDS =
8
+ /(integritetspolicyn|integritetspolicy|dataskyddspolicyn|dataskyddspolicy|privacy policy|integrity policy)/i;
9
+
10
+ /**
11
+ * Renders a plain (already localized) string with simple markdown links:
12
+ *
13
+ * Jag godkänner [integritetspolicyn](/integritetspolicy).
14
+ *
15
+ * The special href `privacy` resolves to the shared privacy policy URL from
16
+ * the form defaults, so the policy page can move without touching any form.
17
+ * Everything outside a link is rendered as text — no HTML is ever injected.
18
+ *
19
+ * If the text contains no markdown link at all but a privacy URL is set, the
20
+ * first occurrence of a policy word is linked automatically, so consent texts
21
+ * written as plain prose still point at the policy page.
22
+ */
23
+ export function renderLinkedText(
24
+ text: string,
25
+ opts: { privacyUrl?: string; className?: string } = {},
26
+ ): ReactNode[] {
27
+ const policyUrl = normalizePolicyUrl(opts.privacyUrl);
28
+ const out: ReactNode[] = [];
29
+ const re = /\[([^\]\n]+)\]\(([^)\s]+)\)/g;
30
+ let last = 0;
31
+ let m: RegExpExecArray | null;
32
+ let i = 0;
33
+
34
+ const linkClass =
35
+ opts.className ?? "underline underline-offset-2 transition-colors hover:text-sea";
36
+
37
+ const link = (label: string, href: string) => {
38
+ const external = /^https?:\/\//i.test(href);
39
+ return (
40
+ <a
41
+ key={`lnk-${i++}`}
42
+ href={href}
43
+ target={external ? "_blank" : undefined}
44
+ rel={external ? "noopener noreferrer" : undefined}
45
+ onClick={(e) => e.stopPropagation()}
46
+ className={linkClass}
47
+ >
48
+ {label}
49
+ </a>
50
+ );
51
+ };
52
+
53
+ while ((m = re.exec(text))) {
54
+ if (m.index > last) out.push(text.slice(last, m.index));
55
+ const label = m[1];
56
+ const raw = m[2];
57
+ const href = raw === "privacy" || raw === "{{privacy}}" ? policyUrl : normalizePolicyUrl(raw);
58
+ if (!href) out.push(label);
59
+ else out.push(link(label, href));
60
+ last = m.index + m[0].length;
61
+ }
62
+
63
+ if (out.length === 0 && policyUrl) {
64
+ // No markdown link in the text — auto-link a recognised policy word.
65
+ const w = POLICY_WORDS.exec(text);
66
+ if (w) {
67
+ if (w.index > 0) out.push(text.slice(0, w.index));
68
+ out.push(link(w[0], policyUrl));
69
+ out.push(text.slice(w.index + w[0].length));
70
+ return out;
71
+ }
72
+ }
73
+
74
+ if (last < text.length) out.push(text.slice(last));
75
+ return out;
76
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Deterministic HTTP caching policy for the self-hosted deployment.
3
+ *
4
+ * Backend-neutral: no Supabase, no environment reads at module scope. The
5
+ * same table is used by the Node process, by the header tests and by the
6
+ * documented Traefik configuration.
7
+ */
8
+
9
+ export type CachePolicy = {
10
+ /** Value for `Cache-Control`. */
11
+ cacheControl: string;
12
+ /** Seconds used to derive a compatible `Expires` header (0 = omit). */
13
+ expiresSeconds: number;
14
+ /** Human readable bucket, used by tests and docs. */
15
+ kind:
16
+ | "hashed-asset"
17
+ | "versioned-media"
18
+ | "mutable-media"
19
+ | "stable-public-file"
20
+ | "feed"
21
+ | "html"
22
+ | "no-store";
23
+ };
24
+
25
+ const YEAR = 31536000;
26
+
27
+ const IMMUTABLE: CachePolicy = {
28
+ cacheControl: `public, max-age=${YEAR}, immutable`,
29
+ expiresSeconds: YEAR,
30
+ kind: "hashed-asset",
31
+ };
32
+
33
+ /** Paths that must never be stored by any cache. */
34
+ const NO_STORE_PREFIXES = ["/kontoret", "/api/", "/auth", "/_serverFn", "/media/.migration"];
35
+
36
+ /** Long-lived, replaceable-by-deploy public files at the site root. */
37
+ const STABLE_PUBLIC_FILES = new Set([
38
+ "/favicon.ico",
39
+ "/favicon.svg",
40
+ "/favicon-96x96.png",
41
+ "/apple-touch-icon.png",
42
+ "/site.webmanifest",
43
+ "/manifest.webmanifest",
44
+ "/web-app-manifest-192x192.png",
45
+ "/web-app-manifest-512x512.png",
46
+ "/robots.txt",
47
+ ]);
48
+
49
+ /** Fonts we self-host under /fonts/* are content-versioned by filename. */
50
+ function isSelfHostedFont(pathname: string): boolean {
51
+ return pathname.startsWith("/fonts/") && /\.(woff2|woff|ttf|otf)$/i.test(pathname);
52
+ }
53
+
54
+ /**
55
+ * Media is served under its original filename (an SEO requirement), so a file
56
+ * can be replaced in place. Immutable caching is only safe when the URL itself
57
+ * changes, i.e. when a version/content token is present in the query string.
58
+ */
59
+ export function isVersionedMediaUrl(search: string | URLSearchParams): boolean {
60
+ const params = typeof search === "string" ? new URLSearchParams(search) : search;
61
+ const token = params.get("v") ?? params.get("version");
62
+ return typeof token === "string" && token.length > 0;
63
+ }
64
+
65
+ /** Resolve the caching policy for one request path (+ query string). */
66
+ export function cachePolicyFor(pathname: string, search = ""): CachePolicy {
67
+ if (NO_STORE_PREFIXES.some((p) => pathname === p || pathname.startsWith(p))) {
68
+ return { cacheControl: "private, no-store, max-age=0", expiresSeconds: 0, kind: "no-store" };
69
+ }
70
+
71
+ if (pathname.startsWith("/assets/") || pathname.startsWith("/_build/")) {
72
+ return { ...IMMUTABLE, kind: "hashed-asset" };
73
+ }
74
+
75
+ if (isSelfHostedFont(pathname)) {
76
+ return { ...IMMUTABLE, kind: "hashed-asset" };
77
+ }
78
+
79
+ if (pathname.startsWith("/media/")) {
80
+ return isVersionedMediaUrl(search)
81
+ ? { ...IMMUTABLE, kind: "versioned-media" }
82
+ : {
83
+ // Replaceable in place: revalidate, but allow cheap 304s.
84
+ cacheControl: "public, max-age=300, stale-while-revalidate=86400",
85
+ expiresSeconds: 300,
86
+ kind: "mutable-media",
87
+ };
88
+ }
89
+
90
+ if (STABLE_PUBLIC_FILES.has(pathname)) {
91
+ return {
92
+ cacheControl: "public, max-age=604800",
93
+ expiresSeconds: 604800,
94
+ kind: "stable-public-file",
95
+ };
96
+ }
97
+
98
+ if (/^\/sitemap.*\.xml$/.test(pathname) || pathname === "/rss.xml") {
99
+ return { cacheControl: "public, max-age=3600", expiresSeconds: 3600, kind: "feed" };
100
+ }
101
+
102
+ // Dynamic, server-rendered HTML: always revalidate, never immutable.
103
+ return { cacheControl: "no-cache", expiresSeconds: 0, kind: "html" };
104
+ }
105
+
106
+ /** RFC 7231 `Expires` value for a policy, or null when it must be omitted. */
107
+ export function expiresHeader(policy: CachePolicy, now = new Date()): string | null {
108
+ if (policy.expiresSeconds <= 0) return null;
109
+ return new Date(now.getTime() + policy.expiresSeconds * 1000).toUTCString();
110
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Content negotiation helpers for the self-hosted edge.
3
+ *
4
+ * Traefik's compress middleware handles Brotli/gzip in the documented
5
+ * deployment. These helpers make the Node process behave identically when it
6
+ * is exposed directly (bare `docker compose up`, tests, or a proxy without
7
+ * compression), and give the tests one shared definition of "compressible".
8
+ */
9
+
10
+ export type Encoding = "br" | "gzip" | null;
11
+
12
+ /** Content types worth compressing. Everything else is already compressed. */
13
+ const COMPRESSIBLE = [
14
+ /^text\//i,
15
+ /^application\/(json|manifest\+json|ld\+json|xml|javascript|x-javascript|rss\+xml|atom\+xml)\b/i,
16
+ /^image\/svg\+xml\b/i,
17
+ /^application\/wasm\b/i,
18
+ ];
19
+
20
+ /** Never compress: already-compressed containers and byte-range media. */
21
+ const NEVER_COMPRESS = [
22
+ /^image\/(?!svg\+xml)/i,
23
+ /^video\//i,
24
+ /^audio\//i,
25
+ /^font\//i,
26
+ /^application\/(zip|gzip|x-7z-compressed|pdf|octet-stream|font-woff2?)\b/i,
27
+ ];
28
+
29
+ export function isCompressibleType(contentType: string | null | undefined): boolean {
30
+ if (!contentType) return false;
31
+ const type = contentType.split(";")[0]!.trim();
32
+ if (NEVER_COMPRESS.some((re) => re.test(type))) return false;
33
+ return COMPRESSIBLE.some((re) => re.test(type));
34
+ }
35
+
36
+ /**
37
+ * Pick the best encoding the client advertises. Brotli wins when offered;
38
+ * gzip is the fallback for clients that advertise gzip but not Brotli.
39
+ */
40
+ export function negotiateEncoding(acceptEncoding: string | null | undefined): Encoding {
41
+ if (!acceptEncoding) return null;
42
+ const offers = new Map<string, number>();
43
+ for (const part of acceptEncoding.split(",")) {
44
+ const [rawName, ...params] = part.trim().split(";");
45
+ if (!rawName) continue;
46
+ const name = rawName.trim().toLowerCase();
47
+ const q = params.map((p) => p.trim()).find((p) => p.startsWith("q="));
48
+ const weight = q ? Number.parseFloat(q.slice(2)) : 1;
49
+ if (!Number.isNaN(weight)) offers.set(name, weight);
50
+ }
51
+ const weightOf = (name: string) => offers.get(name) ?? offers.get("*") ?? 0;
52
+ if (weightOf("br") > 0) return "br";
53
+ if (weightOf("gzip") > 0) return "gzip";
54
+ return null;
55
+ }
56
+
57
+ /** True when this response may be compressed at all. */
58
+ export function shouldCompressResponse(response: Response, request: Request): boolean {
59
+ if (response.status === 204 || response.status === 304) return false;
60
+ // Ranged / partial media must be passed through byte-for-byte.
61
+ if (response.status === 206 || request.headers.has("range")) return false;
62
+ if (response.headers.has("content-encoding")) return false;
63
+ if (request.method === "HEAD") return false;
64
+ if (!isCompressibleType(response.headers.get("content-type"))) return false;
65
+ const length = Number(response.headers.get("content-length") ?? NaN);
66
+ if (!Number.isNaN(length) && length < 1024) return false;
67
+ return true;
68
+ }
69
+
70
+ /** Collapse a Vary header to a single, de-duplicated, order-preserving list. */
71
+ export function dedupeVary(headers: Headers): void {
72
+ const current = headers.get("vary");
73
+ if (!current) return;
74
+ if (current.trim() === "*") {
75
+ headers.set("vary", "*");
76
+ return;
77
+ }
78
+ const seen = new Set<string>();
79
+ const parts: string[] = [];
80
+ for (const raw of current.split(",")) {
81
+ const value = raw.trim();
82
+ if (!value) continue;
83
+ const key = value.toLowerCase();
84
+ if (seen.has(key)) continue;
85
+ seen.add(key);
86
+ parts.push(value);
87
+ }
88
+ headers.set("vary", parts.join(", "));
89
+ }
90
+
91
+ /** Append `Accept-Encoding` to an existing Vary header without duplicating it. */
92
+ export function withVaryAcceptEncoding(headers: Headers): void {
93
+ const current = headers.get("vary");
94
+ if (!current) {
95
+ headers.set("vary", "Accept-Encoding");
96
+ return;
97
+ }
98
+ const parts = current.split(",").map((p) => p.trim().toLowerCase());
99
+ if (!parts.includes("accept-encoding")) {
100
+ headers.set("vary", `${current}, Accept-Encoding`);
101
+ }
102
+ dedupeVary(headers);
103
+ }
104
+
105
+ /** Compress a body with Brotli/gzip using Node's zlib (self-hosted runtime). */
106
+ export async function compressBody(body: Uint8Array, encoding: Exclude<Encoding, null>) {
107
+ const zlib = await import("node:zlib");
108
+ const { promisify } = await import("node:util");
109
+ if (encoding === "br") {
110
+ const brotli = promisify(zlib.brotliCompress);
111
+ return new Uint8Array(
112
+ await brotli(body, {
113
+ params: {
114
+ [zlib.constants.BROTLI_PARAM_QUALITY]: 5,
115
+ [zlib.constants.BROTLI_PARAM_SIZE_HINT]: body.byteLength,
116
+ },
117
+ }),
118
+ );
119
+ }
120
+ const gzip = promisify(zlib.gzip);
121
+ return new Uint8Array(await gzip(body, { level: 6 }));
122
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Edge response hardening for the self-hosted deployment: explicit caching
3
+ * policy, negotiated compression (Brotli, gzip fallback) and
4
+ * `Vary: Accept-Encoding` on everything negotiated.
5
+ *
6
+ * In the documented deployment Traefik terminates compression; this layer is
7
+ * idempotent with it (it never touches a response that already carries
8
+ * `Content-Encoding`) and keeps a bare `docker compose up` correct too.
9
+ */
10
+
11
+ import { createMiddleware } from "@tanstack/react-start";
12
+
13
+ import { cachePolicyFor, expiresHeader } from "./cache-policy";
14
+ import {
15
+ compressBody,
16
+ dedupeVary,
17
+ isCompressibleType,
18
+ negotiateEncoding,
19
+ shouldCompressResponse,
20
+ withVaryAcceptEncoding,
21
+ } from "./compression";
22
+
23
+ /**
24
+ * Compression is only performed by this process in the self-hosted deployment
25
+ * exposed directly (no Traefik). On the Lovable-hosted runtime the platform
26
+ * proxy handles transfer encoding, and compressing here produces a
27
+ * double-encoded body that renders as binary garbage in the preview.
28
+ */
29
+ function compressionEnabled(): boolean {
30
+ const flag = process.env["EDGE_COMPRESSION"];
31
+ if (flag === "1" || flag === "on") return true;
32
+ if (flag === "0" || flag === "off") return false;
33
+ return false;
34
+ }
35
+
36
+ export async function applyEdgeHeaders(
37
+ request: Request,
38
+ response: Response,
39
+ options: { compress?: boolean } = {},
40
+ ): Promise<Response> {
41
+ const compress = options.compress ?? true;
42
+ const url = new URL(request.url);
43
+ const headers = new Headers(response.headers);
44
+
45
+ // 1. Caching policy. A handler's explicit decision wins for mutable content,
46
+ // but content-addressed URLs (hashed assets, hashed font filenames,
47
+ // version-tokened media) and never-store paths are owned by the
48
+ // deployment: the static-file handler otherwise emits its own weak
49
+ // `cache-control` and no `Expires`, which loses the one-year policy.
50
+ const policy = cachePolicyFor(url.pathname, url.search);
51
+ const deploymentOwned =
52
+ policy.kind === "hashed-asset" ||
53
+ policy.kind === "versioned-media" ||
54
+ policy.kind === "no-store";
55
+ if (deploymentOwned || !headers.has("cache-control")) {
56
+ headers.set("cache-control", policy.cacheControl);
57
+ const expires = expiresHeader(policy);
58
+ if (expires) headers.set("expires", expires);
59
+ else headers.delete("expires");
60
+ }
61
+
62
+ // Byte-range support must survive for media (MP4 seeking).
63
+ if (!headers.has("accept-ranges") && url.pathname.startsWith("/media/")) {
64
+ headers.set("accept-ranges", "bytes");
65
+ }
66
+
67
+ // 2. Vary. Only the layer that actually negotiates encoding may own
68
+ // compression headers: when app compression is off the edge (Traefik /
69
+ // Cloudflare) adds `Vary: Accept-Encoding` itself, and adding it here too
70
+ // produces a duplicate value.
71
+ if (compress && isCompressibleType(headers.get("content-type"))) {
72
+ withVaryAcceptEncoding(headers);
73
+ }
74
+ dedupeVary(headers);
75
+
76
+ // 3. Compression.
77
+ if (
78
+ !compress ||
79
+ !shouldCompressResponse(new Response(null, { status: response.status, headers }), request)
80
+ ) {
81
+ return new Response(response.body, {
82
+ status: response.status,
83
+ statusText: response.statusText,
84
+ headers,
85
+ });
86
+ }
87
+
88
+ const encoding = negotiateEncoding(request.headers.get("accept-encoding"));
89
+ if (!encoding) {
90
+ return new Response(response.body, {
91
+ status: response.status,
92
+ statusText: response.statusText,
93
+ headers,
94
+ });
95
+ }
96
+
97
+ const raw = new Uint8Array(await response.arrayBuffer());
98
+ if (raw.byteLength < 1024) {
99
+ return new Response(raw, { status: response.status, statusText: response.statusText, headers });
100
+ }
101
+
102
+ const compressed = await compressBody(raw, encoding);
103
+ headers.set("content-encoding", encoding);
104
+ headers.set("content-length", String(compressed.byteLength));
105
+ headers.delete("etag"); // strong ETag would no longer match the payload
106
+ withVaryAcceptEncoding(headers);
107
+ return new Response(compressed, {
108
+ status: response.status,
109
+ statusText: response.statusText,
110
+ headers,
111
+ });
112
+ }
113
+
114
+ /** Request middleware wrapper; tolerates both Response and `{ response }`. */
115
+ export const edgeHeadersMiddleware = createMiddleware().server(async ({ next, request }) => {
116
+ const result: any = await next();
117
+ if (result instanceof Response) {
118
+ return (await applyEdgeHeaders(request, result, { compress: compressionEnabled() })) as any;
119
+ }
120
+ if (result && typeof result === "object" && result.response instanceof Response) {
121
+ result.response = await applyEdgeHeaders(request, result.response, {
122
+ compress: compressionEnabled(),
123
+ });
124
+ }
125
+ return result;
126
+ });
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Structural JSON type, matching what a Postgres `jsonb` column round-trips.
3
+ *
4
+ * Declared here so shared code never has to import a site's generated database
5
+ * types.
6
+ */
7
+ export type Json = string | number | boolean | null | { [key: string]: Json } | Json[];
@@ -0,0 +1,14 @@
1
+ import { DEFAULT_LOCALE, isLocale, type Locale } from "./locales";
2
+
3
+ /**
4
+ * Locale for the root shell. The shell must also render when the root loader
5
+ * failed (or a child route threw before loader data existed), so anything
6
+ * unusable falls back to DEFAULT_LOCALE instead of throwing.
7
+ */
8
+ export function resolveShellLocale(loaderData: unknown): Locale {
9
+ if (loaderData && typeof loaderData === "object" && "locale" in loaderData) {
10
+ const value = (loaderData as { locale?: unknown }).locale;
11
+ if (isLocale(value)) return value;
12
+ }
13
+ return DEFAULT_LOCALE;
14
+ }