@bygghammar/cms-platform 0.49.0 → 0.50.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.
- package/CHANGELOG.md +9 -0
- package/package.json +6 -3
- package/src/runtime/server-entry.ts +139 -0
- package/src/server/http/backup.ts +2 -1
- package/src/server/media-usage-host.ts +76 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.50.0
|
|
4
|
+
|
|
5
|
+
- 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.
|
|
6
|
+
- 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.
|
|
7
|
+
|
|
8
|
+
## 0.49.1
|
|
9
|
+
|
|
10
|
+
- `server/http/backup`: the export response streams the archive as an `ArrayBuffer`, keeping the handler typed under `lib.dom` fetch signatures.
|
|
11
|
+
|
|
3
12
|
## 0.49.0
|
|
4
13
|
|
|
5
14
|
- Public-site runtime and backend plumbing are package-owned. New `site/routes`: `createSiteRoutes(pages)` derives `resolveRoute`, `pageHeadMeta`, canonical/hreflang assembly and `sitemapIndexEntries`/`sitemapUrls` from a site's page registry, so every site gets identical SEO behaviour.
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.50.0",
|
|
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,139 @@
|
|
|
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
|
+
|
|
18
|
+
export type SiteCodeInjections = {
|
|
19
|
+
/** Whether backoffice-managed code may be injected on this path. */
|
|
20
|
+
isInjectablePath: (pathname: string) => boolean;
|
|
21
|
+
/** Head / body-start / body-end snippets for the requesting host. */
|
|
22
|
+
resolve: (host: string) => Promise<{
|
|
23
|
+
head?: string;
|
|
24
|
+
bodyStart?: string;
|
|
25
|
+
bodyEnd?: string;
|
|
26
|
+
}>;
|
|
27
|
+
/** Applies a resolved bundle to the rendered HTML. */
|
|
28
|
+
apply: (
|
|
29
|
+
html: string,
|
|
30
|
+
bundle: { head?: string; bodyStart?: string; bodyEnd?: string },
|
|
31
|
+
) => string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type ServerEntryOptions = {
|
|
35
|
+
/**
|
|
36
|
+
* Loads the site's injection helpers lazily, so the tracking module never
|
|
37
|
+
* enters the SSR module graph for sites that do not use it.
|
|
38
|
+
*/
|
|
39
|
+
injections?: () => Promise<SiteCodeInjections>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type ServerEntry = {
|
|
43
|
+
fetch: (request: Request, env: unknown, ctx: unknown) => Promise<Response> | Response;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function compressionEnabled(): boolean {
|
|
47
|
+
const flag = process.env["EDGE_COMPRESSION"];
|
|
48
|
+
return flag === "1" || flag === "on";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let serverEntryPromise: Promise<ServerEntry> | undefined;
|
|
52
|
+
|
|
53
|
+
async function getFrameworkEntry(): Promise<ServerEntry> {
|
|
54
|
+
if (!serverEntryPromise) {
|
|
55
|
+
serverEntryPromise = import("@tanstack/react-start/server-entry").then(
|
|
56
|
+
(m) => ((m as { default?: ServerEntry }).default ?? m) as ServerEntry,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return serverEntryPromise;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isH3SwallowedErrorBody(body: string): boolean {
|
|
63
|
+
try {
|
|
64
|
+
const payload = JSON.parse(body) as { unhandled?: unknown; message?: unknown };
|
|
65
|
+
return payload.unhandled === true && payload.message === "HTTPError";
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* h3 swallows in-handler throws into a normal 500 Response with body
|
|
73
|
+
* `{"unhandled":true,"message":"HTTPError"}` — try/catch alone never fires for
|
|
74
|
+
* those, so the swallowed body is detected and replaced with the error page.
|
|
75
|
+
*/
|
|
76
|
+
async function normalizeCatastrophicSsrResponse(response: Response): Promise<Response> {
|
|
77
|
+
if (response.status < 500) return response;
|
|
78
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
79
|
+
if (!contentType.includes("application/json")) return response;
|
|
80
|
+
|
|
81
|
+
const body = await response.clone().text();
|
|
82
|
+
if (!isH3SwallowedErrorBody(body)) return response;
|
|
83
|
+
|
|
84
|
+
console.error(consumeLastCapturedError() ?? new Error(`h3 swallowed SSR error: ${body}`));
|
|
85
|
+
return new Response(renderErrorPage(), {
|
|
86
|
+
status: 500,
|
|
87
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function injectSiteCode(
|
|
92
|
+
request: Request,
|
|
93
|
+
response: Response,
|
|
94
|
+
load: () => Promise<SiteCodeInjections>,
|
|
95
|
+
): Promise<Response> {
|
|
96
|
+
if (response.status >= 400) return response;
|
|
97
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
98
|
+
if (!contentType.includes("text/html")) return response;
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const injections = await load();
|
|
102
|
+
const url = new URL(request.url);
|
|
103
|
+
if (!injections.isInjectablePath(url.pathname)) return response;
|
|
104
|
+
|
|
105
|
+
const bundle = await injections.resolve(url.host);
|
|
106
|
+
if (!bundle.head && !bundle.bodyStart && !bundle.bodyEnd) return response;
|
|
107
|
+
|
|
108
|
+
const html = injections.apply(await response.text(), bundle);
|
|
109
|
+
const headers = new Headers(response.headers);
|
|
110
|
+
headers.delete("content-length");
|
|
111
|
+
return new Response(html, { status: response.status, headers });
|
|
112
|
+
} catch (error) {
|
|
113
|
+
console.error("site code injection failed", error);
|
|
114
|
+
return response;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Builds the default-exported fetch handler for a site's `src/server.ts`. */
|
|
119
|
+
export function createServerEntry(options: ServerEntryOptions = {}) {
|
|
120
|
+
return {
|
|
121
|
+
async fetch(request: Request, env: unknown, ctx: unknown): Promise<Response> {
|
|
122
|
+
try {
|
|
123
|
+
const handler = await getFrameworkEntry();
|
|
124
|
+
const response = await handler.fetch(request, env, ctx);
|
|
125
|
+
const normalized = await normalizeCatastrophicSsrResponse(response);
|
|
126
|
+
const injected = options.injections
|
|
127
|
+
? await injectSiteCode(request, normalized, options.injections)
|
|
128
|
+
: normalized;
|
|
129
|
+
return await applyEdgeHeaders(request, injected, { compress: compressionEnabled() });
|
|
130
|
+
} catch (error) {
|
|
131
|
+
console.error(error);
|
|
132
|
+
return new Response(renderErrorPage(), {
|
|
133
|
+
status: 500,
|
|
134
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -76,7 +76,8 @@ export async function backupExportResponse(
|
|
|
76
76
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
77
77
|
const name = `${(body.label ?? "backup").replace(/[^a-z0-9_-]/gi, "")}-${stamp}.zip`;
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
const zipBytes = new Uint8Array(zip as ArrayBufferLike);
|
|
80
|
+
return new Response(zipBytes.buffer as ArrayBuffer, {
|
|
80
81
|
status: 200,
|
|
81
82
|
headers: {
|
|
82
83
|
"content-type": "application/zip",
|
|
@@ -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
|
+
}
|