@universalcms/connect 0.0.0-stage → 0.13.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/README.md +779 -2
- package/dist/angular-runtime.d.mts +4 -0
- package/dist/angular-runtime.mjs +7 -0
- package/dist/angular-setup.mjs +124 -0
- package/dist/astro-runtime.d.mts +6 -0
- package/dist/astro-runtime.mjs +255 -0
- package/dist/astro-setup.mjs +215 -0
- package/dist/astro-source.mjs +451 -0
- package/dist/astro-website.d.mts +5 -0
- package/dist/astro-website.mjs +42 -0
- package/dist/cli.mjs +552 -0
- package/dist/connector-runtime.mjs +130 -0
- package/dist/content-next-setup.mjs +200 -0
- package/dist/content-registry-validation.mjs +40 -0
- package/dist/content-registry.mjs +44 -0
- package/dist/content-setup.mjs +13 -0
- package/dist/content-storefront-setup.mjs +155 -0
- package/dist/content-vite-setup.mjs +107 -0
- package/dist/core.mjs +205 -0
- package/dist/framework-ownership.mjs +68 -0
- package/dist/framework.mjs +54 -0
- package/dist/guided.mjs +335 -0
- package/dist/native-adapters.mjs +31 -0
- package/dist/native-chrome-model.d.mts +3 -0
- package/dist/native-chrome-model.mjs +14 -0
- package/dist/native-chrome-review.mjs +49 -0
- package/dist/native-chrome-source.mjs +69 -0
- package/dist/native-route-review.mjs +152 -0
- package/dist/native-routes.mjs +27 -0
- package/dist/native-website-model.d.mts +18 -0
- package/dist/native-website-model.mjs +67 -0
- package/dist/native-website-server.d.mts +1 -0
- package/dist/native-website-server.mjs +35222 -0
- package/dist/native-website-server.mjs.LEGAL.txt +56 -0
- package/dist/native-website.d.mts +3 -0
- package/dist/native-website.mjs +120 -0
- package/dist/native-website.mjs.LEGAL.txt +56 -0
- package/dist/next-publication.d.mts +12 -0
- package/dist/next-publication.mjs +107 -0
- package/dist/next-settings-layout.mjs +233 -0
- package/dist/next-source.d.mts +8 -0
- package/dist/next-source.mjs +93 -0
- package/dist/page-discovery.mjs +236 -0
- package/dist/page-mapping-review.mjs +160 -0
- package/dist/preflight-recovery.mjs +122 -0
- package/dist/preflight.mjs +138 -0
- package/dist/project-files.mjs +56 -0
- package/dist/public-bindings.d.mts +3 -0
- package/dist/public-bindings.mjs +28 -0
- package/dist/publication-browser.d.mts +5 -0
- package/dist/publication-browser.mjs +258 -0
- package/dist/publication-check.mjs +141 -0
- package/dist/publication-document.d.mts +2 -0
- package/dist/publication-document.mjs +59 -0
- package/dist/publication-next-setup.mjs +134 -0
- package/dist/publication-setup.mjs +138 -0
- package/dist/publication-verification.mjs +85 -0
- package/dist/publication.css +6 -0
- package/dist/publication.d.mts +31 -0
- package/dist/publication.mjs +320 -0
- package/dist/react-publication.d.mts +22 -0
- package/dist/react-publication.mjs +301 -0
- package/dist/react-website.d.mts +15 -0
- package/dist/react-website.mjs +735 -0
- package/dist/reviewed-commerce-ownership.mjs +26 -0
- package/dist/reviewed-commerce-settings-setup.mjs +243 -0
- package/dist/reviewed-native-ownership.mjs +118 -0
- package/dist/reviewed-storefront-source.mjs +227 -0
- package/dist/runtime-dependencies.mjs +59 -0
- package/dist/setup-environment.mjs +307 -0
- package/dist/source-bindings.mjs +77 -0
- package/dist/source-check.mjs +149 -0
- package/dist/source-collect.mjs +80 -0
- package/dist/source-config.mjs +20 -0
- package/dist/source-extract.mjs +514 -0
- package/dist/source-finalize.mjs +52 -0
- package/dist/source-routes.mjs +65 -0
- package/dist/source-setup.mjs +490 -0
- package/dist/staged-setup.mjs +287 -0
- package/dist/static-runtime.mjs +67 -0
- package/dist/static-setup.mjs +136 -0
- package/dist/svelte-runtime.d.mts +27 -0
- package/dist/svelte-runtime.mjs +40 -0
- package/dist/svelte-setup.mjs +135 -0
- package/dist/svelte-template.mjs +48 -0
- package/dist/templates.mjs +248 -0
- package/dist/vendor/analyze-project.mjs +82 -0
- package/dist/vendor/astro-compiler/LICENSE +53 -0
- package/dist/vendor/astro-compiler/astro.wasm +0 -0
- package/dist/vendor/astro-compiler/chunk-W5DTLHV4.js +1 -0
- package/dist/vendor/astro-compiler/node/index.js +1 -0
- package/dist/vendor/check-delivery.mjs +158 -0
- package/dist/vendor/dependency-diagnostics.mjs +72 -0
- package/dist/vendor/inspect-installed-dependencies.mjs +125 -0
- package/dist/vendor/project-analysis.mjs +498 -0
- package/dist/vendor/react-LICENSE.txt +21 -0
- package/dist/vendor/react-document.mjs +124 -0
- package/dist/vendor/react-dom-LICENSE.txt +21 -0
- package/dist/vendor/react-form.mjs +63 -0
- package/dist/vendor/react-safety.mjs +47 -0
- package/dist/vendor/source-manifest-provider.mjs +104 -0
- package/dist/vendor/typescript-LICENSE.txt +55 -0
- package/dist/vendor/typescript.cjs +200276 -0
- package/dist/vite-handler.mjs +54 -0
- package/dist/vite-publication-server.d.mts +3 -0
- package/dist/vite-publication-server.mjs +82 -0
- package/dist/vite-publication.d.mts +15 -0
- package/dist/vite-publication.mjs +241 -0
- package/dist/vite-source-discovery.mjs +315 -0
- package/dist/vite-source.d.mts +30 -0
- package/dist/vite-source.mjs +141 -0
- package/dist/vue-runtime.d.mts +10 -0
- package/dist/vue-runtime.mjs +50 -0
- package/dist/vue-server.d.mts +2 -0
- package/dist/vue-server.mjs +58 -0
- package/dist/vue-setup.mjs +127 -0
- package/dist/vue-source.mjs +168 -0
- package/dist/website-check.mjs +41 -0
- package/dist/website-http.mjs +149 -0
- package/dist/website-model.d.mts +20 -0
- package/dist/website-model.mjs +155 -0
- package/dist/website-next.d.mts +2 -0
- package/dist/website-next.mjs +29 -0
- package/dist/website-node.d.mts +3 -0
- package/dist/website-node.mjs +50 -0
- package/dist/website-runtime.d.mts +13 -0
- package/dist/website-runtime.mjs +396 -0
- package/dist/website-schema.mjs +22175 -0
- package/dist/website-setup.mjs +256 -0
- package/dist/website.css +49 -0
- package/dist/wizard.mjs +298 -0
- package/package.json +60 -4
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Public published-page projection. Supply the fixed, private SDK client. */
|
|
2
|
+
export function createPageHandler(cms, { locale, websiteOrigin, maxConcurrent = 16 }) {
|
|
3
|
+
const origin = new URL(websiteOrigin);
|
|
4
|
+
if (origin.origin !== websiteOrigin || (origin.protocol !== 'https:' && !(origin.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(origin.hostname)))) throw Error('Configure the website HTTPS origin.');
|
|
5
|
+
const canonicalLocale = value => {
|
|
6
|
+
if (typeof value !== 'string' || !value) return null;
|
|
7
|
+
try { return Intl.getCanonicalLocales(value)[0] ?? null; } catch { return null; }
|
|
8
|
+
};
|
|
9
|
+
const requestedLocale = canonicalLocale(locale);
|
|
10
|
+
if (!requestedLocale) throw Error('Configure a valid content locale.');
|
|
11
|
+
let active = 0, count = 0, windowStart = Date.now();
|
|
12
|
+
function publicBlock(block) {
|
|
13
|
+
if (block.type === 'form') { const { notifyEmail, subscribe, ...data } = block.data; return { ...block, data }; }
|
|
14
|
+
if (block.type === 'section') return { ...block, data: { ...block.data, blocks: block.data.blocks.map(publicBlock) } };
|
|
15
|
+
if (block.type === 'columns') return { ...block, data: { ...block.data, columns: block.data.columns.map(column => column.map(publicBlock)) } };
|
|
16
|
+
return block;
|
|
17
|
+
}
|
|
18
|
+
return async function pageHandler(req, res) {
|
|
19
|
+
const send = (status, value) => {
|
|
20
|
+
if (res.destroyed || res.writableEnded) return;
|
|
21
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer' });
|
|
22
|
+
res.end(JSON.stringify(value));
|
|
23
|
+
};
|
|
24
|
+
let url;
|
|
25
|
+
try {
|
|
26
|
+
if (typeof req.url !== 'string' || req.url.length > 512 || !req.url.startsWith('/') || req.url.startsWith('//') || req.url.includes('\\')) throw Error();
|
|
27
|
+
url = new URL(req.url, websiteOrigin);
|
|
28
|
+
} catch { send(400, { error: 'Invalid request.' }); return; }
|
|
29
|
+
if (url.pathname !== '/api/universalcms/page') { send(404, { error: 'Not found.' }); return; }
|
|
30
|
+
if (req.method !== 'GET') { send(405, { error: 'Method not allowed.' }); return; }
|
|
31
|
+
if (req.headers.origin && req.headers.origin !== websiteOrigin) { send(403, { error: 'Origin not allowed.' }); return; }
|
|
32
|
+
if ([...url.searchParams.keys()].some(key => key !== 'slug') || url.searchParams.getAll('slug').length !== 1) { send(400, { error: 'One page slug is required.' }); return; }
|
|
33
|
+
const slug = url.searchParams.get('slug');
|
|
34
|
+
if (!slug || slug.length > 160 || !/^[a-z0-9]+(?:[-/][a-z0-9]+)*$/.test(slug)) { send(400, { error: 'Invalid page slug.' }); return; }
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
if (now - windowStart >= 60_000) { windowStart = now; count = 0; }
|
|
37
|
+
if (active >= maxConcurrent || ++count > 600) { send(429, { error: 'Try again later.' }); return; }
|
|
38
|
+
active++;
|
|
39
|
+
const controller = new AbortController();
|
|
40
|
+
const cancel = () => { if (!res.writableEnded) controller.abort(); };
|
|
41
|
+
res.on('close', cancel);
|
|
42
|
+
try {
|
|
43
|
+
const entry = await cms.content.getBySlug(slug, { kind: 'page', locale: requestedLocale }, { signal: controller.signal });
|
|
44
|
+
if (!entry) { send(404, { error: 'Page not found.' }); return; }
|
|
45
|
+
// Keep the response bound to the same query even if a delivery adapter
|
|
46
|
+
// returns a valid entry for a different slug, type or language.
|
|
47
|
+
if (entry.kind !== 'page' || entry.slug !== slug || canonicalLocale(entry.locale) !== requestedLocale) throw Error('Invalid delivery identity.');
|
|
48
|
+
// Browser receives only public renderer fields. No custom fields, form
|
|
49
|
+
// notification destinations, request headers, config or SDK credentials.
|
|
50
|
+
send(200, { entry: { id: entry.id, document: { title: entry.document.title, excerpt: entry.document.excerpt, blocks: entry.document.blocks.map(publicBlock) } } });
|
|
51
|
+
} catch { send(503, { error: 'Content is temporarily unavailable.' }); }
|
|
52
|
+
finally { active--; res.off('close', cancel); }
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { Server } from 'node:http';
|
|
2
|
+
/** Private runtime Node host; call listen only after configuring the deployed origin. */
|
|
3
|
+
export function createVitePublicationServer(options: { root: string; configuration: unknown; sourceConfiguration?: unknown; contentBindings?: unknown; env?: Record<string, string | undefined>; fetcher?: typeof fetch }): Server;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { lstat, open, realpath } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { createVitePublicationHandler } from './vite-publication.mjs';
|
|
6
|
+
import { createViteSourceHandler } from './vite-source.mjs';
|
|
7
|
+
import { publicationErrorHtml, resolvePublicationRoute, validatePublicationConfig } from './publication.mjs';
|
|
8
|
+
import { publicationVerification } from './publication-verification.mjs';
|
|
9
|
+
|
|
10
|
+
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.avif': 'image/avif', '.gif': 'image/gif', '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.txt': 'text/plain; charset=utf-8', '.xml': 'application/xml; charset=utf-8', '.webm': 'video/webm', '.mp4': 'video/mp4', '.mp3': 'audio/mpeg', '.pdf': 'application/pdf' };
|
|
11
|
+
const noStore = { 'Cache-Control': 'private, no-store, max-age=0', 'CDN-Cache-Control': 'no-store', 'Surrogate-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' };
|
|
12
|
+
const stamp = stat => [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs, stat.nlink].join(':');
|
|
13
|
+
|
|
14
|
+
async function asset(root, relative) {
|
|
15
|
+
const dist = path.join(root, 'dist');
|
|
16
|
+
const resolved = await realpath(root);
|
|
17
|
+
let target = resolved;
|
|
18
|
+
for (const part of ['dist', ...relative.split('/')]) {
|
|
19
|
+
target = path.join(target, part);
|
|
20
|
+
const stat = await lstat(target);
|
|
21
|
+
if (stat.isSymbolicLink() || await realpath(target) !== target || (target === path.join(resolved, 'dist', relative) ? !stat.isFile() || stat.nlink !== 1 : !stat.isDirectory())) throw Error('Invalid public file');
|
|
22
|
+
}
|
|
23
|
+
const file = await open(path.join(dist, relative), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
24
|
+
try {
|
|
25
|
+
const stat = await file.stat();
|
|
26
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > 64 * 1024 * 1024) throw Error('Invalid public file');
|
|
27
|
+
const buffer = Buffer.alloc(stat.size);
|
|
28
|
+
let size = 0;
|
|
29
|
+
while (size < buffer.length) {
|
|
30
|
+
const result = await file.read(buffer, size, buffer.length - size, size);
|
|
31
|
+
if (!result.bytesRead) break;
|
|
32
|
+
size += result.bytesRead;
|
|
33
|
+
}
|
|
34
|
+
if (size !== stat.size || stamp(await file.stat()) !== stamp(stat) || stamp(await lstat(target)) !== stamp(stat)) throw Error('Public file changed');
|
|
35
|
+
return buffer;
|
|
36
|
+
} finally { await file.close(); }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sendError(request, response, status) {
|
|
40
|
+
if (response.destroyed || response.writableEnded) return;
|
|
41
|
+
const body = publicationErrorHtml(status);
|
|
42
|
+
response.writeHead(status, { ...noStore, 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': Buffer.byteLength(body) });
|
|
43
|
+
response.end(request.method === 'HEAD' ? undefined : body);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Deployable Node host for the Vite dist folder. Unknown routes and missing files
|
|
48
|
+
* never fall back to the app shell. Finite native paths from an exact reviewed
|
|
49
|
+
* profile retain their existing application UI. Private/preview/custom server routes
|
|
50
|
+
* require reviewed composition with createVitePublicationHandler instead.
|
|
51
|
+
*/
|
|
52
|
+
export function createVitePublicationServer({ root, configuration, sourceConfiguration, contentBindings, env, fetcher = globalThis.fetch }) {
|
|
53
|
+
const config = validatePublicationConfig(configuration), sourceRoot = path.resolve(root);
|
|
54
|
+
const verification = publicationVerification(config, sourceConfiguration, contentBindings);
|
|
55
|
+
const publication = createVitePublicationHandler({ sourceRoot, configuration: config, sourceConfiguration, contentBindings, env, fetcher });
|
|
56
|
+
const source = sourceConfiguration ? createViteSourceHandler({ sourceRoot, configuration: sourceConfiguration }) : null;
|
|
57
|
+
async function handle(request, response) {
|
|
58
|
+
if (source && await source(request, response)) return;
|
|
59
|
+
if (await publication(request, response)) return;
|
|
60
|
+
if (!['GET', 'HEAD'].includes(request.method)) { sendError(request, response, 404); return; }
|
|
61
|
+
const pathname = decodeURIComponent(request.url.split('?')[0]);
|
|
62
|
+
let route;
|
|
63
|
+
try { route = resolvePublicationRoute(config, pathname === '/index.html' ? '/' : pathname); } catch { route = null; }
|
|
64
|
+
const native = verification?.nativePaths.includes(pathname.length > 1 ? pathname.replace(/\/$/, '') : pathname) === true;
|
|
65
|
+
const relative = route || native ? 'index.html' : pathname.slice(1);
|
|
66
|
+
// The manifest/source endpoint is the only reviewed way to export source.
|
|
67
|
+
// Dotfiles, source maps, arbitrary HTML and extensionless SPA paths are not
|
|
68
|
+
// public assets, even if an accidental build copied them into dist.
|
|
69
|
+
const extension = path.extname(relative).toLowerCase();
|
|
70
|
+
if (!relative || relative.split('/').some(part => !part || part.startsWith('.') || ['node_modules', 'src', 'api'].includes(part)) || !MIME[extension] || !route && !native && extension === '.html') {
|
|
71
|
+
sendError(request, response, 404); return;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const body = await asset(sourceRoot, relative);
|
|
75
|
+
response.writeHead(200, { ...noStore, 'Content-Type': MIME[extension], 'Content-Length': body.length });
|
|
76
|
+
response.end(request.method === 'HEAD' ? undefined : body);
|
|
77
|
+
} catch { sendError(request, response, route || native ? 503 : 404); }
|
|
78
|
+
}
|
|
79
|
+
return createServer({ requestTimeout: 15000, headersTimeout: 10000, maxHeaderSize: 8192 }, (request, response) => {
|
|
80
|
+
void handle(request, response).catch(() => { sendError(request, response, 503); });
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
type Middleware = (request: IncomingMessage, response: ServerResponse, next: () => void) => void;
|
|
3
|
+
type VitePublicationServerOptions = { port?: number; strictPort?: boolean; proxy?: Record<string, unknown> };
|
|
4
|
+
export const PUBLICATION_DOCUMENT_PATH: '/api/universalcms/document';
|
|
5
|
+
/** Mount before every static/SPA/proxy handler. Private .env.local is server-only. */
|
|
6
|
+
export function createVitePublicationHandler(options: { sourceRoot: string; configuration: unknown; sourceConfiguration?: unknown; contentBindings?: unknown; env?: Record<string, string | undefined>; fetcher?: typeof fetch }): (request: IncomingMessage, response: ServerResponse) => Promise<boolean>;
|
|
7
|
+
export function universalCmsPublication(options: { configuration: unknown; sourceConfiguration?: unknown; contentBindings?: unknown }): {
|
|
8
|
+
name: string;
|
|
9
|
+
enforce: 'pre';
|
|
10
|
+
config(config: { server?: VitePublicationServerOptions; preview?: VitePublicationServerOptions }, environment: { command: 'build' | 'serve' }): { server: VitePublicationServerOptions & { port: number; strictPort: true }; preview: VitePublicationServerOptions & { port: number; strictPort: true } } | undefined;
|
|
11
|
+
configResolved(config: { root: string; base?: string; appType?: string; publicDir?: string | false; command?: 'build' | 'serve'; server?: VitePublicationServerOptions; preview?: VitePublicationServerOptions }): void;
|
|
12
|
+
configureServer(server: { middlewares: { use(handler: Middleware): unknown } }): void;
|
|
13
|
+
configurePreviewServer(server: { middlewares: { use(handler: Middleware): unknown } }): void;
|
|
14
|
+
transformIndexHtml: { order: 'pre'; handler(): { tag: 'script'; attrs: { src: string }; injectTo: 'head-prepend' }[] };
|
|
15
|
+
};
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { constants, lstatSync } from 'node:fs';
|
|
2
|
+
import { lstat, open, realpath } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { parseEnv } from 'node:util';
|
|
5
|
+
import { createPublishedDocumentReader, isExcludedPublicationPath, normalizePublicationPath, publicationErrorHtml, resolvePublicationRoute, validatePublicationConfig, publicationRevision, publicPublishedEntry } from './publication.mjs';
|
|
6
|
+
import { createPublicationBrowserScript, PUBLICATION_SCRIPT_PATH, PUBLICATION_STATUS_PATH, PUBLICATION_DOCUMENT_PATH } from './publication-browser.mjs';
|
|
7
|
+
import { publicationVerification, verificationRequest, verificationBody, publicationRouteHeaders } from './publication-verification.mjs';
|
|
8
|
+
export { PUBLICATION_DOCUMENT_PATH };
|
|
9
|
+
|
|
10
|
+
const PRIVATE_ENV_KEYS = ['CMS_API_KEY', 'CMS_API_BASE_URL', 'CMS_SITE_ID', 'CMS_ENVIRONMENT_ID', 'CMS_WEBSITE_ORIGIN', 'CMS_LOCALE'];
|
|
11
|
+
const stamp = stat => [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs, stat.nlink].join(':');
|
|
12
|
+
const privateHeaders = { 'Cache-Control': 'private, no-store, max-age=0', 'CDN-Cache-Control': 'no-store', 'Surrogate-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer' };
|
|
13
|
+
const reservedPaths = [PUBLICATION_DOCUMENT_PATH, PUBLICATION_STATUS_PATH, PUBLICATION_SCRIPT_PATH];
|
|
14
|
+
const collision = () => { throw Error('A reserved Universal CMS publication endpoint overlaps an existing website proxy or public asset. Preserve the existing endpoint and integrate publication manually.'); };
|
|
15
|
+
|
|
16
|
+
function checkProxyOwnership(proxy) {
|
|
17
|
+
if (!proxy || typeof proxy !== 'object') return;
|
|
18
|
+
for (const key of Object.keys(proxy)) for (const endpoint of reservedPaths) {
|
|
19
|
+
if (key === endpoint || key.startsWith(`${endpoint}?`) || key.startsWith(`${endpoint}/`)) collision();
|
|
20
|
+
if (!key.startsWith('^')) continue;
|
|
21
|
+
let pattern;
|
|
22
|
+
try { pattern = new RegExp(key); } catch { continue; } // Vite reports invalid proxy expressions itself.
|
|
23
|
+
const matches = pattern.test(endpoint) || pattern.test(`${endpoint}?path=%2F`) || pattern.test(`${endpoint}/`);
|
|
24
|
+
// Preserve broad /api or /.well-known proxies: our explicitly reserved
|
|
25
|
+
// endpoints precede them. A pattern selecting this endpoint specifically
|
|
26
|
+
// needs review, including exact regex alternatives/query boundaries.
|
|
27
|
+
const sibling = `${endpoint.slice(0, endpoint.lastIndexOf('/'))}/__universalcms_ownership_probe__`;
|
|
28
|
+
if (matches && !pattern.test(sibling) && !pattern.test(`${sibling}?path=%2F`)) collision();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function checkPublicOwnership(resolved) {
|
|
33
|
+
if (resolved.publicDir === false) return;
|
|
34
|
+
const directory = path.resolve(resolved.root, resolved.publicDir ?? 'public');
|
|
35
|
+
for (const endpoint of reservedPaths) {
|
|
36
|
+
try { lstatSync(path.join(directory, endpoint.slice(1))); }
|
|
37
|
+
catch (error) { if (error?.code === 'ENOENT') continue; throw Error('Could not verify reserved publication endpoint ownership.'); }
|
|
38
|
+
collision();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Read the fixed private server file without Vite env injection or interpolation. */
|
|
43
|
+
export async function readServerEnvironment(root) {
|
|
44
|
+
const sourceRoot = path.resolve(root), rootStat = await lstat(sourceRoot);
|
|
45
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw Error('Invalid website root');
|
|
46
|
+
const actualRoot = await realpath(sourceRoot), filename = path.join(actualRoot, '.env.local');
|
|
47
|
+
let local = {};
|
|
48
|
+
try {
|
|
49
|
+
const stat = await lstat(filename);
|
|
50
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > 128 * 1024) throw Error('Invalid private configuration');
|
|
51
|
+
const file = await open(filename, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
52
|
+
try {
|
|
53
|
+
const before = await file.stat();
|
|
54
|
+
if (stamp(before) !== stamp(stat)) throw Error('Private configuration changed');
|
|
55
|
+
const buffer = Buffer.alloc(128 * 1024 + 1);
|
|
56
|
+
let size = 0;
|
|
57
|
+
while (size < buffer.length) {
|
|
58
|
+
const result = await file.read(buffer, size, buffer.length - size, size);
|
|
59
|
+
if (!result.bytesRead) break;
|
|
60
|
+
size += result.bytesRead;
|
|
61
|
+
}
|
|
62
|
+
if (size > 128 * 1024 || stamp(await file.stat()) !== stamp(before) || stamp(await lstat(filename)) !== stamp(before)) throw Error('Private configuration changed');
|
|
63
|
+
local = parseEnv(new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, size)));
|
|
64
|
+
} finally { await file.close(); }
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error?.code !== 'ENOENT') throw Error('Invalid private configuration');
|
|
67
|
+
}
|
|
68
|
+
// An explicit server process environment has standard precedence. Neither
|
|
69
|
+
// map is assigned to process.env, returned to the browser or logged.
|
|
70
|
+
return Object.fromEntries(PRIVATE_ENV_KEYS.filter(key => process.env[key] !== undefined || local[key] !== undefined).map(key => [key, process.env[key] ?? local[key]]));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function send(response, method, status, type, body, headers = {}) {
|
|
74
|
+
if (response.destroyed || response.writableEnded) return;
|
|
75
|
+
response.writeHead(status, { ...privateHeaders, ...headers, 'Content-Type': type, 'Content-Length': Buffer.byteLength(body) });
|
|
76
|
+
response.end(method === 'HEAD' ? undefined : body);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Node middleware: install before static serving, user proxies and SPA fallback. */
|
|
80
|
+
export function createVitePublicationHandler({ sourceRoot, configuration, sourceConfiguration, contentBindings, env, fetcher = globalThis.fetch }) {
|
|
81
|
+
const config = validatePublicationConfig(configuration);
|
|
82
|
+
const verification = publicationVerification(config, sourceConfiguration, contentBindings);
|
|
83
|
+
const authority = new URL(config.websiteOrigin).host;
|
|
84
|
+
const script = createPublicationBrowserScript(config);
|
|
85
|
+
const fixedEnv = env ? { ...env } : null;
|
|
86
|
+
let checker, lastEnvironment;
|
|
87
|
+
return async function handle(request, response) {
|
|
88
|
+
const raw = request.url;
|
|
89
|
+
// Do not let URL's dot-segment normalization turn a tampered request into a
|
|
90
|
+
// route outside the registry before its publication check.
|
|
91
|
+
const rawPath = typeof raw === 'string' ? raw.split('?')[0] : '';
|
|
92
|
+
let pathname;
|
|
93
|
+
try {
|
|
94
|
+
if (!rawPath.startsWith('/') || rawPath.startsWith('//') || rawPath.length > 1500 || /[\\\s#\u0000-\u001f\u007f]/.test(rawPath) || /%(?![0-9a-f]{2})|%(?:2f|5c|2e|25|3f|23|0[0-9a-f]|1[0-9a-f]|7f)/i.test(rawPath)) throw Error('Invalid path');
|
|
95
|
+
pathname = decodeURIComponent(rawPath);
|
|
96
|
+
if (pathname.split('/').some(part => part === '.' || part === '..') || pathname.includes('//')) throw Error('Invalid path');
|
|
97
|
+
if (pathname.length > 1) pathname = pathname.replace(/\/$/, '');
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
send(response, request.method, 400, 'text/plain; charset=utf-8', 'Invalid request');
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
const statusEndpoint = pathname === PUBLICATION_STATUS_PATH, documentEndpoint = pathname === PUBLICATION_DOCUMENT_PATH, scriptEndpoint = pathname === PUBLICATION_SCRIPT_PATH;
|
|
104
|
+
const dataEndpoint = statusEndpoint || documentEndpoint;
|
|
105
|
+
let route = null;
|
|
106
|
+
try { route = resolvePublicationRoute(config, pathname === '/index.html' ? '/' : pathname); } catch { /* Ordinary asset names are not publication paths. */ }
|
|
107
|
+
if (!dataEndpoint && !scriptEndpoint && !route) {
|
|
108
|
+
if (verification?.nativePaths.includes(pathname)) {
|
|
109
|
+
if (request.headers?.host !== authority) { send(response, request.method, 403, 'text/plain; charset=utf-8', 'Forbidden'); return true; }
|
|
110
|
+
if (!['GET', 'HEAD'].includes(request.method)) { send(response, request.method, 405, 'text/plain; charset=utf-8', 'Method not allowed'); return true; }
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
// Vite's virtual module identifiers may contain colons. They remain under
|
|
114
|
+
// Vite's existing filesystem/host boundary and never select a CMS route.
|
|
115
|
+
if (/^\/@(?:vite|id|fs)(?:\/|$)/.test(pathname) || pathname === '/@react-refresh') return false;
|
|
116
|
+
try { if (isExcludedPublicationPath(pathname)) return false; } catch { /* Unsupported document URLs fail closed below. */ }
|
|
117
|
+
send(response, request.method, 404, 'text/html; charset=utf-8', publicationErrorHtml(404));
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
const origin = request.headers?.origin;
|
|
121
|
+
const probe = statusEndpoint ? verificationRequest({ configuration: config, verification, url: new URL(raw, config.websiteOrigin), method: request.method, headers: new Headers(request.headers) }) : null;
|
|
122
|
+
if (probe?.status) {
|
|
123
|
+
send(response, request.method, probe.status, 'application/json; charset=utf-8', probe.status === 204 ? '' : '{"error":"Publication verification unavailable."}', probe.headers);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
if (request.headers?.host !== authority || !probe && (dataEndpoint || scriptEndpoint) && (origin !== undefined && origin !== config.websiteOrigin || request.headers?.['sec-fetch-site'] === 'cross-site')) {
|
|
127
|
+
send(response, request.method, 403, 'text/plain; charset=utf-8', 'Forbidden');
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
if (!['GET', 'HEAD'].includes(request.method)) {
|
|
131
|
+
send(response, request.method, 405, 'text/plain; charset=utf-8', 'Method not allowed');
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
if (scriptEndpoint) {
|
|
135
|
+
if (raw !== PUBLICATION_SCRIPT_PATH) send(response, request.method, 400, 'text/plain; charset=utf-8', 'Invalid request');
|
|
136
|
+
else send(response, request.method, 200, 'text/javascript; charset=utf-8', script);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
let target = probe?.pathname ?? route?.path ?? pathname;
|
|
140
|
+
if (dataEndpoint && !probe) {
|
|
141
|
+
const query = new URLSearchParams(raw.slice(raw.indexOf('?') + 1));
|
|
142
|
+
if (!raw.includes('?') || query.size !== 1 || !query.has('path')) {
|
|
143
|
+
send(response, request.method, 400, 'application/json; charset=utf-8', JSON.stringify({ state: 'unavailable' }));
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
target = normalizePublicationPath(query.get('path'));
|
|
148
|
+
if (target === '/index.html') target = '/';
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
send(response, request.method, 400, 'application/json; charset=utf-8', JSON.stringify({ state: 'unavailable' }));
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
let result;
|
|
156
|
+
try {
|
|
157
|
+
const runtimeEnv = fixedEnv ?? await readServerEnvironment(sourceRoot);
|
|
158
|
+
const identity = PRIVATE_ENV_KEYS.map(key => runtimeEnv[key]);
|
|
159
|
+
if (!checker || identity.some((value, index) => value !== lastEnvironment[index])) {
|
|
160
|
+
checker = createPublishedDocumentReader({ configuration: config, env: runtimeEnv, fetcher });
|
|
161
|
+
lastEnvironment = identity;
|
|
162
|
+
}
|
|
163
|
+
result = await checker(target);
|
|
164
|
+
} catch { result = { state: 'unavailable', status: 503 }; }
|
|
165
|
+
if (probe) {
|
|
166
|
+
const extraHeaders = result.reason ? { 'X-UniversalCMS-Delivery-Status': result.reason } : {};
|
|
167
|
+
send(response, request.method, result.state === 'unavailable' ? 503 : 200, 'application/json; charset=utf-8', JSON.stringify(verificationBody(verification, target, result)), { ...probe.headers, ...extraHeaders });
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
if (dataEndpoint) {
|
|
171
|
+
const state = result.state === 'unmanaged' ? 'missing' : result.state;
|
|
172
|
+
const extraHeaders = result.reason ? { 'X-UniversalCMS-Delivery-Status': result.reason } : {};
|
|
173
|
+
// Keep the original one-key envelope for already-open 0.5.0 browser tabs.
|
|
174
|
+
const includeRevision = documentEndpoint || request.headers?.['x-universalcms-include-revision'] === '1';
|
|
175
|
+
const body = { state, ...(result.entry && includeRevision ? { revision: publicationRevision(result.entry), ...(documentEndpoint ? { entry: publicPublishedEntry(result.entry) } : {}) } : {}) };
|
|
176
|
+
send(response, request.method, state === 'published' ? 200 : state === 'missing' ? 404 : 503, 'application/json; charset=utf-8', JSON.stringify(body), extraHeaders);
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
if (result.state !== 'published') {
|
|
180
|
+
const status = result.state === 'missing' ? 404 : 503;
|
|
181
|
+
send(response, request.method, status, 'text/html; charset=utf-8', publicationErrorHtml(status), publicationRouteHeaders(config, target, result, verification, origin, !!request.headers.authorization || !!request.headers.cookie));
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
// Preserve no-store even when downstream static middleware chooses its own
|
|
185
|
+
// cache policy or receives an If-None-Match/If-Modified-Since request.
|
|
186
|
+
const managedHeaders = { ...privateHeaders, ...publicationRouteHeaders(config, target, result, verification, origin, !!request.headers.authorization || !!request.headers.cookie) };
|
|
187
|
+
for (const [name, value] of Object.entries(managedHeaders)) response.setHeader(name, value);
|
|
188
|
+
delete request.headers['if-none-match']; delete request.headers['if-modified-since'];
|
|
189
|
+
const writeHead = response.writeHead;
|
|
190
|
+
response.writeHead = function (status, reason, headers) {
|
|
191
|
+
const values = typeof reason === 'object' ? reason : headers;
|
|
192
|
+
if (values) for (const name of Object.keys(values)) if (['cache-control', 'cdn-cache-control', 'surrogate-control', 'etag', 'last-modified'].includes(name.toLowerCase())) delete values[name];
|
|
193
|
+
for (const [name, value] of Object.entries(managedHeaders)) this.setHeader(name, value);
|
|
194
|
+
this.removeHeader?.('ETag'); this.removeHeader?.('Last-Modified');
|
|
195
|
+
return writeHead.call(this, status, reason, headers);
|
|
196
|
+
};
|
|
197
|
+
return false;
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Builds inject the same client; production still needs the Node middleware. */
|
|
202
|
+
export function universalCmsPublication({ configuration, sourceConfiguration, contentBindings }) {
|
|
203
|
+
const config = validatePublicationConfig(configuration);
|
|
204
|
+
const origin = new URL(config.websiteOrigin);
|
|
205
|
+
const localPort = origin.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(origin.hostname) ? Number(origin.port || 80) : null;
|
|
206
|
+
const portError = () => { throw Error(`Universal CMS publication requires the registered website port ${localPort} with strictPort enabled. Remove conflicting Vite port overrides or update the reviewed website origin.`); };
|
|
207
|
+
let sourceRoot;
|
|
208
|
+
function install(server) {
|
|
209
|
+
if (!sourceRoot) throw Error('Universal CMS publication requires the resolved website root');
|
|
210
|
+
const handle = createVitePublicationHandler({ sourceRoot, configuration: config, sourceConfiguration, contentBindings });
|
|
211
|
+
server.middlewares.use((request, response, next) => {
|
|
212
|
+
void handle(request, response).then(handled => { if (!handled) next(); }, () => {
|
|
213
|
+
send(response, request.method, 503, 'text/html; charset=utf-8', publicationErrorHtml(503));
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
name: 'universalcms-publication', enforce: 'pre',
|
|
219
|
+
config(value, environment) {
|
|
220
|
+
checkProxyOwnership(value.server?.proxy); checkProxyOwnership(value.preview?.proxy);
|
|
221
|
+
if (localPort === null) return;
|
|
222
|
+
// Vite preview otherwise defaults to 4173 and dev may silently move to the
|
|
223
|
+
// next port. Both would violate the exact registered authority boundary.
|
|
224
|
+
if (environment.command === 'serve' && [value.server?.port, value.preview?.port].some(port => port !== undefined && port !== localPort)) portError();
|
|
225
|
+
return {
|
|
226
|
+
server: { ...value.server, port: localPort, strictPort: true },
|
|
227
|
+
preview: { ...value.preview, port: localPort, strictPort: true },
|
|
228
|
+
};
|
|
229
|
+
},
|
|
230
|
+
configResolved(resolved) {
|
|
231
|
+
if (resolved.base && resolved.base !== '/' || resolved.appType === 'custom') throw Error('Custom Vite base paths and servers need reviewed publication integration');
|
|
232
|
+
checkProxyOwnership(resolved.server?.proxy); checkProxyOwnership(resolved.preview?.proxy);
|
|
233
|
+
checkPublicOwnership(resolved);
|
|
234
|
+
if (localPort !== null && resolved.command === 'serve' && [resolved.server, resolved.preview].some(server => server?.port !== localPort || server?.strictPort !== true)) portError();
|
|
235
|
+
sourceRoot = resolved.root;
|
|
236
|
+
},
|
|
237
|
+
configureServer: install,
|
|
238
|
+
configurePreviewServer: install,
|
|
239
|
+
transformIndexHtml: { order: 'pre', handler() { return [{ tag: 'script', attrs: { src: PUBLICATION_SCRIPT_PATH }, injectTo: 'head-prepend' }]; } },
|
|
240
|
+
};
|
|
241
|
+
}
|