@bygghammar/cms-platform 0.49.1 → 0.50.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.50.1
4
+
5
+ - `runtime/server-entry`: the injection adapter is typed against `InjectionBundle`, matching the shared `injections-core` helpers exactly.
6
+
7
+ ## 0.50.0
8
+
9
+ - New `runtime/server-entry`: `createServerEntry({ injections })` is the shared production fetch handler — framework entry, h3-swallowed-500 normalisation, backoffice code injection and edge caching/compression headers. A site's `src/server.ts` drops to one call.
10
+ - New `server/media-usage-host`: `createMediaUsageTools({ storage, admin, backofficePrefix, pagePublicPath, userAgent })` returns the staff-guarded `scanMediaUsage`, `importRemoteUrlToMedia` and `rewriteMediaReferences`, so only the page-key → public-path map stays site-local.
11
+
3
12
  ## 0.49.1
4
13
 
5
14
  - `server/http/backup`: the export response streams the archive as an `ArrayBuffer`, keeping the handler typed under `lib.dom` fetch signatures.
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.49.1",
3
+ "version": "0.50.1",
4
4
  "description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
5
5
  "type": "module",
6
6
  "sideEffects": [
7
- "./src/runtime/error-capture.ts"
7
+ "./src/runtime/error-capture.ts",
8
+ "./src/runtime/server-entry.ts"
8
9
  ],
9
10
  "license": "UNLICENSED",
10
11
  "publishConfig": {
@@ -193,7 +194,9 @@
193
194
  "./server/http/cms-content": "./src/server/http/cms-content.ts",
194
195
  "./runtime/error-capture": "./src/runtime/error-capture.ts",
195
196
  "./runtime/error-page": "./src/runtime/error-page.ts",
196
- "./runtime/error-reporting": "./src/runtime/error-reporting.ts"
197
+ "./runtime/error-reporting": "./src/runtime/error-reporting.ts",
198
+ "./server/media-usage-host": "./src/server/media-usage-host.ts",
199
+ "./runtime/server-entry": "./src/runtime/server-entry.ts"
197
200
  },
198
201
  "dependencies": {
199
202
  "fflate": "^0.8.3"
@@ -0,0 +1,133 @@
1
+ /**
2
+ * The shared production server entry.
3
+ *
4
+ * Static files (hashed /assets/*, self-hosted /fonts/*, favicons) are served by
5
+ * the framework's public-asset handler, which runs before any route
6
+ * middleware, so wrapping the entry is the only place where those responses can
7
+ * still receive the deployment's caching policy.
8
+ *
9
+ * Compression stays opt-in (`EDGE_COMPRESSION`) so the app never competes with
10
+ * Traefik/Cloudflare.
11
+ */
12
+ import "./error-capture";
13
+
14
+ import { consumeLastCapturedError } from "./error-capture";
15
+ import { renderErrorPage } from "./error-page";
16
+ import { applyEdgeHeaders } from "../http/response-headers";
17
+ import type { InjectionBundle } from "../tracking/injections";
18
+
19
+ export type SiteCodeInjections = {
20
+ /** Whether backoffice-managed code may be injected on this path. */
21
+ isInjectablePath: (pathname: string, backofficePath?: string) => boolean;
22
+ /** Head / body-start / body-end snippets for the requesting host. */
23
+ resolve: (host: string) => Promise<InjectionBundle>;
24
+ /** Applies a resolved bundle to the rendered HTML. */
25
+ apply: (html: string, bundle: InjectionBundle) => string;
26
+ };
27
+
28
+ export type ServerEntryOptions = {
29
+ /**
30
+ * Loads the site's injection helpers lazily, so the tracking module never
31
+ * enters the SSR module graph for sites that do not use it.
32
+ */
33
+ injections?: () => Promise<SiteCodeInjections>;
34
+ };
35
+
36
+ type ServerEntry = {
37
+ fetch: (request: Request, env: unknown, ctx: unknown) => Promise<Response> | Response;
38
+ };
39
+
40
+ function compressionEnabled(): boolean {
41
+ const flag = process.env["EDGE_COMPRESSION"];
42
+ return flag === "1" || flag === "on";
43
+ }
44
+
45
+ let serverEntryPromise: Promise<ServerEntry> | undefined;
46
+
47
+ async function getFrameworkEntry(): Promise<ServerEntry> {
48
+ if (!serverEntryPromise) {
49
+ serverEntryPromise = import("@tanstack/react-start/server-entry").then(
50
+ (m) => ((m as { default?: ServerEntry }).default ?? m) as ServerEntry,
51
+ );
52
+ }
53
+ return serverEntryPromise;
54
+ }
55
+
56
+ function isH3SwallowedErrorBody(body: string): boolean {
57
+ try {
58
+ const payload = JSON.parse(body) as { unhandled?: unknown; message?: unknown };
59
+ return payload.unhandled === true && payload.message === "HTTPError";
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * h3 swallows in-handler throws into a normal 500 Response with body
67
+ * `{"unhandled":true,"message":"HTTPError"}` — try/catch alone never fires for
68
+ * those, so the swallowed body is detected and replaced with the error page.
69
+ */
70
+ async function normalizeCatastrophicSsrResponse(response: Response): Promise<Response> {
71
+ if (response.status < 500) return response;
72
+ const contentType = response.headers.get("content-type") ?? "";
73
+ if (!contentType.includes("application/json")) return response;
74
+
75
+ const body = await response.clone().text();
76
+ if (!isH3SwallowedErrorBody(body)) return response;
77
+
78
+ console.error(consumeLastCapturedError() ?? new Error(`h3 swallowed SSR error: ${body}`));
79
+ return new Response(renderErrorPage(), {
80
+ status: 500,
81
+ headers: { "content-type": "text/html; charset=utf-8" },
82
+ });
83
+ }
84
+
85
+ async function injectSiteCode(
86
+ request: Request,
87
+ response: Response,
88
+ load: () => Promise<SiteCodeInjections>,
89
+ ): Promise<Response> {
90
+ if (response.status >= 400) return response;
91
+ const contentType = response.headers.get("content-type") ?? "";
92
+ if (!contentType.includes("text/html")) return response;
93
+
94
+ try {
95
+ const injections = await load();
96
+ const url = new URL(request.url);
97
+ if (!injections.isInjectablePath(url.pathname)) return response;
98
+
99
+ const bundle = await injections.resolve(url.host);
100
+ if (!bundle.head && !bundle.bodyStart && !bundle.bodyEnd) return response;
101
+
102
+ const html = injections.apply(await response.text(), bundle);
103
+ const headers = new Headers(response.headers);
104
+ headers.delete("content-length");
105
+ return new Response(html, { status: response.status, headers });
106
+ } catch (error) {
107
+ console.error("site code injection failed", error);
108
+ return response;
109
+ }
110
+ }
111
+
112
+ /** Builds the default-exported fetch handler for a site's `src/server.ts`. */
113
+ export function createServerEntry(options: ServerEntryOptions = {}) {
114
+ return {
115
+ async fetch(request: Request, env: unknown, ctx: unknown): Promise<Response> {
116
+ try {
117
+ const handler = await getFrameworkEntry();
118
+ const response = await handler.fetch(request, env, ctx);
119
+ const normalized = await normalizeCatastrophicSsrResponse(response);
120
+ const injected = options.injections
121
+ ? await injectSiteCode(request, normalized, options.injections)
122
+ : normalized;
123
+ return await applyEdgeHeaders(request, injected, { compress: compressionEnabled() });
124
+ } catch (error) {
125
+ console.error(error);
126
+ return new Response(renderErrorPage(), {
127
+ status: 500,
128
+ headers: { "content-type": "text/html; charset=utf-8" },
129
+ });
130
+ }
131
+ },
132
+ };
133
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Host wiring for the media usage tools.
3
+ *
4
+ * The scanning, import and rewrite logic lives in `media-usage-core`. This
5
+ * factory turns it into the three staff-guarded functions every site exposes,
6
+ * so a site only declares the facts it owns: how to build its storage provider,
7
+ * its backoffice prefix, its page-key → public-path map and a User-Agent.
8
+ */
9
+ import { assertStaff } from "./guards";
10
+ import {
11
+ importRemoteUrlCore,
12
+ rewriteMediaReferencesCore,
13
+ scanMediaUsageCore,
14
+ type MediaImportHost,
15
+ type MediaUsageHost,
16
+ type UsageStorage,
17
+ } from "./media-usage-core";
18
+ import type { MediaImportResult, MediaUsageReport } from "../media/usage-types";
19
+
20
+ /* eslint-disable @typescript-eslint/no-explicit-any */
21
+
22
+ export type MediaUsageToolsOptions = {
23
+ /** Builds the site's storage provider from the request-scoped client. */
24
+ storage: (db: any) => UsageStorage;
25
+ /** Privileged client factory, used for reference rewriting on import. */
26
+ admin: () => Promise<any>;
27
+ /** Backoffice route prefix, e.g. `/kontoret`. */
28
+ backofficePrefix?: string;
29
+ /** Public URL path for a CMS page key, when the site has one. */
30
+ pagePublicPath?: (pageKey: string) => string | undefined;
31
+ /** User-Agent sent when fetching remote media. */
32
+ userAgent?: string;
33
+ };
34
+
35
+ export type MediaUsageTools = {
36
+ scanMediaUsage: (context: any) => Promise<MediaUsageReport>;
37
+ importRemoteUrlToMedia: (
38
+ data: { url: string; filename?: string },
39
+ context: any,
40
+ ) => Promise<MediaImportResult>;
41
+ rewriteMediaReferences: (db: any, oldUrls: string[], newUrl: string) => Promise<number>;
42
+ };
43
+
44
+ export function createMediaUsageTools(options: MediaUsageToolsOptions): MediaUsageTools {
45
+ const hostFor = (context: any): MediaUsageHost => {
46
+ const db = context.supabase;
47
+ return {
48
+ db,
49
+ storage: options.storage(db),
50
+ backofficePrefix: options.backofficePrefix,
51
+ pagePublicPath: options.pagePublicPath,
52
+ };
53
+ };
54
+
55
+ return {
56
+ async scanMediaUsage(context) {
57
+ await assertStaff(context);
58
+ return scanMediaUsageCore(hostFor(context));
59
+ },
60
+
61
+ async importRemoteUrlToMedia(data, context) {
62
+ await assertStaff(context);
63
+ const admin = await options.admin();
64
+ const host: MediaImportHost = {
65
+ ...hostFor(context),
66
+ admin,
67
+ userAgent: options.userAgent,
68
+ };
69
+ return importRemoteUrlCore(data, host);
70
+ },
71
+
72
+ rewriteMediaReferences(db, oldUrls, newUrl) {
73
+ return rewriteMediaReferencesCore(db, oldUrls, newUrl);
74
+ },
75
+ };
76
+ }