@bygghammar/cms-platform 0.47.0 → 0.49.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
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+
3
+ import { useT } from "../i18n/context";
4
+ import { resolveMediaSrc } from "./urls";
5
+
6
+ /**
7
+ * Bandwidth-safe, mobile-safe <video> wrapper.
8
+ *
9
+ * Mobile browsers (iOS Safari in particular) only start an inline autoplaying
10
+ * video when `muted`, `playsinline` and `autoplay` are present as real
11
+ * attributes on the element that owns the media, and when at least one source
12
+ * is present when the element is parsed. Sources are therefore always
13
+ * rendered — the download itself stays cheap because `preload` is `none`
14
+ * until the element is near the viewport.
15
+ *
16
+ * - the poster paints first (cheap image, correct visual design),
17
+ * - `preload` is upgraded to the caller's hint once near the viewport,
18
+ * - a timeout fallback guarantees the video is never left permanently
19
+ * unloaded when IntersectionObserver never fires,
20
+ * - `play()` is called explicitly and its promise is always caught: a refused
21
+ * autoplay never crashes the page, it reveals an accessible tap-to-play
22
+ * button over the poster,
23
+ * - reduced motion keeps the poster and the tap-to-play button instead of
24
+ * removing the media.
25
+ */
26
+ export type LazyVideoProps = {
27
+ src?: string;
28
+ /** Optional alternative sources (e.g. WebM) tried before `src`. */
29
+ sources?: Array<{ src: string; type: string }>;
30
+ poster?: string;
31
+ className?: string;
32
+ autoPlay?: boolean;
33
+ loop?: boolean;
34
+ muted?: boolean;
35
+ controls?: boolean;
36
+ /** Preload hint used once the element is near the viewport. */
37
+ preload?: "none" | "metadata" | "auto";
38
+ ariaLabel?: string;
39
+ /** Localized accessible label for the tap-to-play fallback control. */
40
+ playLabel?: { sv: string } & Record<string, string>;
41
+ };
42
+
43
+ export function prefersReducedMotion(): boolean {
44
+ if (typeof window === "undefined" || !window.matchMedia) return false;
45
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
46
+ }
47
+
48
+ /**
49
+ * Order the sources so the broadly compatible MP4/H.264 rendition is always
50
+ * offered. Safari picks the first source it claims to support, and some iOS
51
+ * versions claim WebM support they cannot actually decode, so MP4 goes first.
52
+ */
53
+ export function orderedSources(
54
+ rawSrc: string | undefined,
55
+ rawSources: Array<{ src: string; type: string }> | undefined,
56
+ ): Array<{ src: string; type: string }> {
57
+ const src = resolveMediaSrc(rawSrc);
58
+ const sources = rawSources?.map((s) => ({ ...s, src: resolveMediaSrc(s.src) }));
59
+ const list =
60
+ sources && sources.length ? [...sources] : src ? [{ src, type: guessType(src) }] : [];
61
+ const seen = new Set<string>();
62
+ const unique = list.filter((s) => {
63
+ if (!s.src || seen.has(s.src)) return false;
64
+ seen.add(s.src);
65
+ return true;
66
+ });
67
+ if (src && !seen.has(src)) unique.push({ src, type: guessType(src) });
68
+ const rank = (type: string) => (/mp4/i.test(type) ? 0 : /webm/i.test(type) ? 1 : 2);
69
+ return unique.sort((a, b) => rank(a.type) - rank(b.type));
70
+ }
71
+
72
+ function guessType(url: string): string {
73
+ const clean = url.split("?")[0]!.toLowerCase();
74
+ if (clean.endsWith(".webm")) return "video/webm";
75
+ if (clean.endsWith(".mov")) return "video/quicktime";
76
+ return "video/mp4";
77
+ }
78
+
79
+ export function LazyVideo({
80
+ src,
81
+ sources,
82
+ poster,
83
+ className,
84
+ autoPlay = true,
85
+ loop = true,
86
+ muted = true,
87
+ controls = false,
88
+ preload = "metadata",
89
+ ariaLabel,
90
+ playLabel,
91
+ }: LazyVideoProps) {
92
+ const { t } = useT();
93
+ const ref = useRef<HTMLVideoElement>(null);
94
+ const [active, setActive] = useState(false);
95
+ const [reduced, setReduced] = useState(false);
96
+ const [blocked, setBlocked] = useState(false);
97
+
98
+ const list = orderedSources(src, sources);
99
+
100
+ useEffect(() => {
101
+ setReduced(prefersReducedMotion());
102
+ }, []);
103
+
104
+ useEffect(() => {
105
+ if (active) return;
106
+ const el = ref.current;
107
+ if (!el) return;
108
+ if (typeof IntersectionObserver === "undefined") {
109
+ setActive(true);
110
+ return;
111
+ }
112
+ const io = new IntersectionObserver(
113
+ (entries) => {
114
+ if (entries.some((e) => e.isIntersecting)) {
115
+ setActive(true);
116
+ io.disconnect();
117
+ }
118
+ },
119
+ { rootMargin: "200px" },
120
+ );
121
+ io.observe(el);
122
+ // Never leave the video permanently unloaded (mobile browsers can skip
123
+ // observer callbacks for elements inside transformed/animated ancestors).
124
+ const timer = setTimeout(() => setActive(true), 3000);
125
+ return () => {
126
+ io.disconnect();
127
+ clearTimeout(timer);
128
+ };
129
+ }, [active]);
130
+
131
+ // Explicit playback attempt: a refused promise must never propagate.
132
+ useEffect(() => {
133
+ const el = ref.current;
134
+ if (!el || !active) return;
135
+ if (!autoPlay || reduced) return;
136
+ let cancelled = false;
137
+ el.muted = muted;
138
+ const attempt = el.play();
139
+ if (attempt && typeof attempt.catch === "function") {
140
+ attempt
141
+ .then(() => {
142
+ if (!cancelled) setBlocked(false);
143
+ })
144
+ .catch(() => {
145
+ if (!cancelled) setBlocked(true);
146
+ });
147
+ }
148
+ return () => {
149
+ cancelled = true;
150
+ };
151
+ }, [active, autoPlay, muted, reduced]);
152
+
153
+ const play = useCallback(() => {
154
+ const el = ref.current;
155
+ if (!el) return;
156
+ setActive(true);
157
+ el.muted = muted;
158
+ const attempt = el.play();
159
+ if (attempt && typeof attempt.catch === "function") {
160
+ attempt.then(() => setBlocked(false)).catch(() => setBlocked(true));
161
+ }
162
+ }, [muted]);
163
+
164
+ const showPlayButton = !controls && (blocked || (reduced && autoPlay));
165
+
166
+ return (
167
+ <>
168
+ <video
169
+ ref={ref}
170
+ className={className}
171
+ poster={resolveMediaSrc(poster) || undefined}
172
+ preload={active ? preload : "none"}
173
+ autoPlay={autoPlay && !reduced}
174
+ loop={loop}
175
+ muted={muted}
176
+ controls={controls}
177
+ playsInline
178
+ aria-label={ariaLabel}
179
+ >
180
+ {list.map((s) => (
181
+ <source key={s.src} src={s.src} type={s.type} />
182
+ ))}
183
+ </video>
184
+ {showPlayButton ? (
185
+ <button
186
+ type="button"
187
+ onClick={play}
188
+ aria-label={t(
189
+ playLabel ?? { sv: ariaLabel ? `Spela upp ${ariaLabel}` : "Spela upp video" },
190
+ )}
191
+ className="absolute inset-0 z-10 grid place-items-center bg-transparent"
192
+ >
193
+ <span className="grid size-14 place-items-center rounded-full bg-white/85 text-black shadow-lg">
194
+ <svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true">
195
+ <path d="M8 5v14l11-7z" />
196
+ </svg>
197
+ </span>
198
+ </button>
199
+ ) : null}
200
+ </>
201
+ );
202
+ }
@@ -0,0 +1,33 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+ import { useQuery } from "@tanstack/react-query";
3
+
4
+ import { lookupMediaMeta, type MediaMeta, type MediaMetaMap } from "./media-meta";
5
+
6
+ const MediaMetaContext = createContext<MediaMetaMap>({});
7
+
8
+ /** Structural shape of a site's `queryOptions(...)` for its media metadata. */
9
+ export type MediaMetaQueryOptions = {
10
+ queryKey: readonly unknown[];
11
+ queryFn: () => Promise<MediaMetaMap>;
12
+ staleTime?: number;
13
+ };
14
+
15
+ export function MediaMetaProvider({
16
+ queryOptions,
17
+ children,
18
+ }: {
19
+ queryOptions: MediaMetaQueryOptions;
20
+ children: ReactNode;
21
+ }) {
22
+ const { data } = useQuery({ ...queryOptions, placeholderData: {} as MediaMetaMap });
23
+ return <MediaMetaContext.Provider value={data ?? {}}>{children}</MediaMetaContext.Provider>;
24
+ }
25
+
26
+ export function useMediaMetaMap(): MediaMetaMap {
27
+ return useContext(MediaMetaContext);
28
+ }
29
+
30
+ /** Saved metadata (alt / OG title / description / credit) for one image URL. */
31
+ export function useMediaMeta(src: string | undefined): MediaMeta {
32
+ return lookupMediaMeta(useMediaMetaMap(), src);
33
+ }
@@ -0,0 +1,90 @@
1
+ import { useCallback, useState } from "react";
2
+ import { useQueryClient } from "@tanstack/react-query";
3
+
4
+ import { optimizeForUpload } from "./optimize";
5
+ import { pickVideoPoster } from "./pick-video-poster";
6
+ import { cleanFileName, splitExt, uniqueFileName } from "./filename";
7
+
8
+ export type UploadProgress = { label: string; percent: number } | null;
9
+
10
+ /** One file in a site's media library, as far as uploading is concerned. */
11
+ export type UploadExistingItem = { name: string };
12
+
13
+ /** The site's server call that stores an already-optimized bundle. */
14
+ export type UploadBundleFn = (input: {
15
+ data: {
16
+ files: Array<{ name: string; contentType: string; bytes: number[] }>;
17
+ };
18
+ }) => Promise<{ urls: string[] }>;
19
+
20
+ /**
21
+ * Shared browser-side media upload (optimize → storage → cache invalidate)
22
+ * with a coarse percentage so callers can render a progress bar.
23
+ *
24
+ * The storage call itself is site infrastructure and is injected.
25
+ */
26
+ export function useMediaUploadWith(
27
+ uploadBundle: UploadBundleFn,
28
+ existing: UploadExistingItem[] = [],
29
+ ) {
30
+ const queryClient = useQueryClient();
31
+ const [progress, setProgress] = useState<UploadProgress>(null);
32
+ const [uploading, setUploading] = useState(false);
33
+
34
+ const upload = useCallback(
35
+ async (files: FileList | File[] | null): Promise<string[]> => {
36
+ const arr = files ? Array.from(files as ArrayLike<File>) : [];
37
+ if (!arr.length) return [];
38
+ setUploading(true);
39
+ const urls: string[] = [];
40
+ const taken = new Set<string>(
41
+ existing.flatMap((i) => {
42
+ const { base } = splitExt(i.name);
43
+ return [i.name, base, base.replace(/-(?:480|768|1200|1920)$/, "")];
44
+ }),
45
+ );
46
+ try {
47
+ for (let idx = 0; idx < arr.length; idx++) {
48
+ const file = arr[idx]!;
49
+ const step = (frac: number, label: string) =>
50
+ setProgress({
51
+ label: `${label} ${idx + 1}/${arr.length}: ${file.name}`,
52
+ percent: Math.round(((idx + frac) / arr.length) * 100),
53
+ });
54
+
55
+ // Videos: let the editor pick the poster frame before we transcode.
56
+ const posterTime = file.type.startsWith("video/") ? await pickVideoPoster(file) : null;
57
+
58
+ step(0.05, "Optimizing");
59
+ const { base } = splitExt(cleanFileName(file.name));
60
+ const baseSlug = uniqueFileName(base, taken);
61
+ taken.add(baseSlug);
62
+ const bundle = await optimizeForUpload(file, baseSlug, {
63
+ posterTime: posterTime ?? undefined,
64
+ });
65
+
66
+ step(0.4, "Uploading");
67
+ const all = [bundle.primary, ...bundle.extras];
68
+ const payload = await Promise.all(
69
+ all.map(async (part) => ({
70
+ name: part.name,
71
+ contentType: part.contentType,
72
+ bytes: Array.from(new Uint8Array(await part.blob.arrayBuffer())),
73
+ })),
74
+ );
75
+ const { urls: uploaded } = await uploadBundle({ data: { files: payload } });
76
+ urls.push(uploaded[0]!);
77
+ step(1, "Uploading");
78
+ }
79
+ await queryClient.invalidateQueries({ queryKey: ["media-library"] });
80
+ return urls;
81
+ } finally {
82
+ setUploading(false);
83
+ setProgress(null);
84
+ }
85
+ },
86
+ [existing, queryClient, uploadBundle],
87
+ );
88
+
89
+ return { upload, uploading, progress };
90
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Captures the original Error out-of-band so the server entry can recover the
3
+ * stack when the HTTP layer has already swallowed the throw into a generic 500.
4
+ *
5
+ * Import this once from the server entry (side-effecting).
6
+ */
7
+ let lastCapturedError: { error: unknown; at: number } | undefined;
8
+ const TTL_MS = 5_000;
9
+
10
+ function record(error: unknown) {
11
+ lastCapturedError = { error, at: Date.now() };
12
+ }
13
+
14
+ // h3's HTTPError serializes to {"status":500,"unhandled":true,"message":"HTTPError"} —
15
+ // no stack, no cause — so a plain console.error(error) reaches the log pipeline with
16
+ // the failure detail stripped. Expand Error-like args into a string that keeps the
17
+ // message, stack, and the full cause chain.
18
+ const CAUSE_DEPTH_LIMIT = 5;
19
+ const DESCRIPTION_LENGTH_LIMIT = 8_000;
20
+
21
+ export function describeError(error: unknown): string {
22
+ const parts: string[] = [];
23
+ let current: unknown = error;
24
+ for (let depth = 0; depth < CAUSE_DEPTH_LIMIT && current != null; depth++) {
25
+ if (!(current instanceof Error)) {
26
+ parts.push(typeof current === "string" ? current : safeStringify(current));
27
+ break;
28
+ }
29
+ const label = depth === 0 ? "" : "caused by: ";
30
+ const status = describeStatus(current);
31
+ parts.push(`${label}${current.stack ?? `${current.name}: ${current.message}`}${status}`);
32
+ current = current.cause;
33
+ }
34
+ return parts.join("\n").slice(0, DESCRIPTION_LENGTH_LIMIT);
35
+ }
36
+
37
+ function describeStatus(error: Error): string {
38
+ const { status, statusCode } = error as { status?: unknown; statusCode?: unknown };
39
+ const value = status ?? statusCode;
40
+ return typeof value === "number" ? ` (status ${value})` : "";
41
+ }
42
+
43
+ function safeStringify(value: unknown): string {
44
+ try {
45
+ return JSON.stringify(value) ?? String(value);
46
+ } catch {
47
+ return String(value);
48
+ }
49
+ }
50
+
51
+ function isErrorLike(value: unknown): value is Error {
52
+ return value instanceof Error;
53
+ }
54
+
55
+ // Wrap console.error so errors logged by any layer — including the HTTP layer's
56
+ // internal unhandled-error logging, which cannot be hooked directly — are both
57
+ // recorded for consumeLastCapturedError and expanded before serialization.
58
+ const originalConsoleError = console.error.bind(console);
59
+ console.error = (...args: unknown[]) => {
60
+ const expanded = args.map((arg) => {
61
+ if (!isErrorLike(arg)) return arg;
62
+ record(arg);
63
+ return describeError(arg);
64
+ });
65
+ originalConsoleError(...expanded);
66
+ };
67
+
68
+ if (typeof globalThis.addEventListener === "function") {
69
+ globalThis.addEventListener("error", (event) => record((event as ErrorEvent).error ?? event));
70
+ globalThis.addEventListener("unhandledrejection", (event) =>
71
+ record((event as PromiseRejectionEvent).reason),
72
+ );
73
+ }
74
+
75
+ export function consumeLastCapturedError(): unknown {
76
+ if (!lastCapturedError) return undefined;
77
+ if (Date.now() - lastCapturedError.at > TTL_MS) {
78
+ lastCapturedError = undefined;
79
+ return undefined;
80
+ }
81
+ const { error } = lastCapturedError;
82
+ lastCapturedError = undefined;
83
+ return error;
84
+ }
@@ -0,0 +1,31 @@
1
+ /** Minimal, dependency-free HTML shown when SSR itself failed. */
2
+ export function renderErrorPage(): string {
3
+ return `<!doctype html>
4
+ <html lang="en">
5
+ <head>
6
+ <meta charset="utf-8" />
7
+ <title>This page didn't load</title>
8
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
9
+ <style>
10
+ body { font: 15px/1.5 system-ui, -apple-system, sans-serif; background: #fafafa; color: #111; display: grid; place-items: center; min-height: 100vh; margin: 0; padding: 1.5rem; }
11
+ .card { max-width: 28rem; width: 100%; text-align: center; padding: 2rem; }
12
+ h1 { font-size: 1.25rem; margin: 0 0 0.5rem; }
13
+ p { color: #4b5563; margin: 0 0 1.5rem; }
14
+ .actions { display: flex; gap: 0.5rem; justify-content: center; flex-wrap: wrap; }
15
+ a, button { padding: 0.5rem 1rem; border-radius: 0.375rem; font: inherit; cursor: pointer; text-decoration: none; border: 1px solid transparent; }
16
+ .primary { background: #111; color: #fff; }
17
+ .secondary { background: #fff; color: #111; border-color: #d1d5db; }
18
+ </style>
19
+ </head>
20
+ <body>
21
+ <div class="card">
22
+ <h1>This page didn't load</h1>
23
+ <p>Something went wrong on our end. You can try refreshing or head back home.</p>
24
+ <div class="actions">
25
+ <button class="primary" onclick="location.reload()">Try again</button>
26
+ <a class="secondary" href="/">Go home</a>
27
+ </div>
28
+ </div>
29
+ </body>
30
+ </html>`;
31
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Forwards React error-boundary failures to the Lovable editor's telemetry
3
+ * hooks when present. A no-op outside the browser and outside the editor.
4
+ */
5
+ type LovableErrorOptions = {
6
+ mechanism?: "manual" | "onerror" | "unhandledrejection" | "react_error_boundary";
7
+ handled?: boolean;
8
+ severity?: "error" | "warning" | "info";
9
+ };
10
+
11
+ type LovableEvents = {
12
+ captureException?: (
13
+ error: unknown,
14
+ context?: Record<string, unknown>,
15
+ options?: LovableErrorOptions,
16
+ ) => void;
17
+ };
18
+
19
+ declare global {
20
+ interface Window {
21
+ __lovableEvents?: LovableEvents;
22
+ __lovableReportRuntimeError?: (payload: {
23
+ message: string;
24
+ stack?: string;
25
+ filename?: string;
26
+ }) => void;
27
+ }
28
+ }
29
+
30
+ export function reportLovableError(error: unknown, context: Record<string, unknown> = {}) {
31
+ if (typeof window === "undefined") return;
32
+ window.__lovableEvents?.captureException?.(
33
+ error,
34
+ {
35
+ source: "react_error_boundary",
36
+ route: window.location.pathname,
37
+ ...context,
38
+ },
39
+ {
40
+ mechanism: "react_error_boundary",
41
+ handled: false,
42
+ severity: "error",
43
+ },
44
+ );
45
+ // Prod React does not rethrow boundary-caught errors to window.onerror, so the
46
+ // editor's telemetry never sees them. Forward to lovable.js's reporting hook,
47
+ // which is present only inside the editor preview.
48
+ // Loaders and server fns commonly throw a raw Response; String(it) is the
49
+ // opaque "[object Response]", so pull out the status and URL instead.
50
+ const message =
51
+ error instanceof Response
52
+ ? `Response ${error.status}${error.url ? ` at ${error.url}` : ""}`
53
+ : error instanceof Error
54
+ ? error.message
55
+ : String(error);
56
+ window.__lovableReportRuntimeError?.({
57
+ message,
58
+ stack: error instanceof Error ? error.stack : undefined,
59
+ filename: window.location.pathname,
60
+ });
61
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Request authentication for protected server functions (site-agnostic).
3
+ *
4
+ * Validates the Supabase bearer token on the incoming request and returns
5
+ * `{ supabase, userId, claims }`. The returned client acts as the signed-in
6
+ * user, so RLS applies normally.
7
+ */
8
+ import { createClient, type SupabaseClient } from "@supabase/supabase-js";
9
+
10
+ import { resolveSupabaseServerEnvironment } from "./env";
11
+
12
+ export type PlatformAuthContext<DB = unknown> = {
13
+ supabase: SupabaseClient<DB>;
14
+ userId: string;
15
+ claims: Record<string, unknown>;
16
+ };
17
+
18
+ /**
19
+ * Resolve the auth context from a request's `Authorization: Bearer` header.
20
+ * Throws on any missing/invalid token.
21
+ */
22
+ export async function resolveAuthContextCore<DB = unknown>(
23
+ request: Request | undefined,
24
+ ): Promise<PlatformAuthContext<DB>> {
25
+ if (!request?.headers) throw new Error("Unauthorized: No request headers available");
26
+
27
+ const { url, publishableKey: key } = resolveSupabaseServerEnvironment();
28
+ if (!url || !key) throw new Error("Missing Supabase environment variables.");
29
+
30
+ const header = request.headers.get("authorization");
31
+ if (!header?.startsWith("Bearer "))
32
+ throw new Error("Unauthorized: No authorization header provided");
33
+ const token = header.slice(7);
34
+ if (token.split(".").length !== 3) throw new Error("Unauthorized: Invalid token");
35
+
36
+ const supabase = createClient<DB>(url, key, {
37
+ global: {
38
+ headers: { Authorization: `Bearer ${token}` },
39
+ // Opaque sb_ keys are not JWTs: send only `apikey`.
40
+ fetch: (input: RequestInfo | URL, init?: RequestInit) => {
41
+ const h = new Headers(init?.headers);
42
+ if (key.startsWith("sb_") && h.get("Authorization") === `Bearer ${key}`)
43
+ h.delete("Authorization");
44
+ h.set("apikey", key);
45
+ return fetch(input, { ...init, headers: h });
46
+ },
47
+ },
48
+ auth: { storage: undefined, persistSession: false, autoRefreshToken: false },
49
+ });
50
+
51
+ const { data, error } = await supabase.auth.getClaims(token);
52
+ if (error || !data?.claims?.sub) throw new Error("Unauthorized: Invalid token");
53
+ return {
54
+ supabase,
55
+ userId: data.claims.sub as string,
56
+ claims: data.claims as Record<string, unknown>,
57
+ };
58
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Server-side Supabase clients (site-agnostic).
3
+ *
4
+ * Server-only. Sites call `createBackendClients<Database>()` once and expose the
5
+ * result, so privilege level is explicit at every call site and both current
6
+ * and legacy server keys are supported.
7
+ */
8
+ import { createClient, type SupabaseClient } from "@supabase/supabase-js";
9
+
10
+ import { createServerFetch, resolveSupabaseServerEnvironment } from "./env";
11
+
12
+ export type BackendClients<DB> = {
13
+ /** Privileged client: bypasses RLS. Trusted server paths only. */
14
+ admin: SupabaseClient<DB>;
15
+ /** Async accessor for call sites using the dynamic-import pattern. */
16
+ adminClient: () => Promise<SupabaseClient<DB>>;
17
+ /** Anonymous/public client: RLS applies as the anon role. */
18
+ publicClient: () => Promise<SupabaseClient<DB>>;
19
+ };
20
+
21
+ export function createBackendClients<DB>(): BackendClients<DB> {
22
+ let resolved: SupabaseClient<DB> | undefined;
23
+
24
+ function build(): SupabaseClient<DB> {
25
+ const { url, secretKey } = resolveSupabaseServerEnvironment();
26
+ if (!url || !secretKey) {
27
+ const missing = [
28
+ ...(!url ? ["SUPABASE_URL"] : []),
29
+ ...(!secretKey
30
+ ? ["SUPABASE_SECRET_KEYS, SUPABASE_SECRET_KEY, or SUPABASE_SERVICE_ROLE_KEY"]
31
+ : []),
32
+ ];
33
+ throw new Error(`Missing Supabase server environment: ${missing.join("; ")}.`);
34
+ }
35
+
36
+ return createClient<DB>(url, secretKey, {
37
+ global: { fetch: createServerFetch(secretKey) },
38
+ auth: { storage: undefined, persistSession: false, autoRefreshToken: false },
39
+ });
40
+ }
41
+
42
+ // Resolved on property access so nothing is initialised at module scope.
43
+ const admin = new Proxy({} as SupabaseClient<DB>, {
44
+ get(_t, prop, receiver) {
45
+ if (!resolved) resolved = build();
46
+ return Reflect.get(resolved as object, prop, receiver);
47
+ },
48
+ });
49
+
50
+ return {
51
+ admin,
52
+ adminClient: async () => admin,
53
+ publicClient: async () => {
54
+ const { url, publishableKey: key } = resolveSupabaseServerEnvironment();
55
+ if (!url || !key) {
56
+ throw new Error("Missing SUPABASE_URL or Supabase publishable server key.");
57
+ }
58
+ return createClient<DB>(url, key, {
59
+ auth: { storage: undefined, persistSession: false, autoRefreshToken: false },
60
+ global: { fetch: createServerFetch(key) },
61
+ });
62
+ },
63
+ };
64
+ }