glashjs 0.5.1 → 0.6.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/README.md +13 -4
- package/bin/glash.mjs +7 -0
- package/package.json +3 -2
- package/src/components/link.mjs +13 -0
- package/src/deploy.mjs +50 -0
- package/src/index.mjs +2 -0
- package/src/server/html.mjs +31 -1
- package/src/server/jsx.mjs +1 -1
- package/src/server/nav-client.mjs +35 -0
- package/src/server/server.mjs +27 -5
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# glashjs
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The glashdb-native web framework, **built on top of Next.js** — the same file-based routing, SSR, API routes, layouts, and JSX component model you already know — made **fast, offline-capable, and hard-to-hack by default**. It ships real features instead of promises.
|
|
4
4
|
|
|
5
|
-
> **Status:** `0.0
|
|
5
|
+
> **Status:** `0.6.0` — the full framework is here: file-based routing, server-side rendering, API routes, JSX components with client hydration, nested layouts, streaming SSR, a dev/prod server, plus the asset optimizer, offline service worker, animated favicon, and secure-by-default headers. Core installs with zero mandatory dependencies.
|
|
6
6
|
|
|
7
7
|
## What it does today
|
|
8
8
|
|
|
@@ -90,6 +90,13 @@ import { redirect } from 'glashjs';
|
|
|
90
90
|
export default (ctx) => { if (!ctx.headers.authorization) return redirect('/login'); };
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
+
**SEO metadata** + **client-side navigation**:
|
|
94
|
+
```jsx
|
|
95
|
+
import { Link } from 'glashjs/link';
|
|
96
|
+
export const metadata = { title: 'About', description: '…', openGraph: { image: '/og.png' } };
|
|
97
|
+
export default () => <Link href="/">Home</Link>; // SPA swap, no full reload; crawlable <a>
|
|
98
|
+
```
|
|
99
|
+
|
|
93
100
|
Every response carries the secure-by-default headers; static files are served from the build with Brotli negotiation.
|
|
94
101
|
|
|
95
102
|
### JSX components + client hydration (new in 0.2)
|
|
@@ -148,7 +155,7 @@ animatedFavicon: true, // bundled animated glash mark (d
|
|
|
148
155
|
// animatedFavicon: { frames: ['/f0.svg','/f1.svg'], fps: 10 }
|
|
149
156
|
```
|
|
150
157
|
|
|
151
|
-
##
|
|
158
|
+
## What's built (built on top of Next.js)
|
|
152
159
|
- [x] Asset optimizer (Brotli/Gzip real; AVIF/WebP/AV1 via optional sharp/ffmpeg)
|
|
153
160
|
- [x] Offline Service Worker + PWA manifest
|
|
154
161
|
- [x] Secure-by-default headers + CSP + SRI
|
|
@@ -165,10 +172,12 @@ animatedFavicon: true, // bundled animated glash mark (d
|
|
|
165
172
|
- [x] `<Video>` — `<video>` with AV1/WebM + mp4 fallback + auto poster
|
|
166
173
|
- [x] File-based middleware (`_middleware.mjs`, root→leaf) — auth, redirects, headers
|
|
167
174
|
- [x] Production route precompile (`glash build` bakes server modules + minified client bundles → no runtime esbuild on `glash serve`)
|
|
175
|
+
- [x] SEO metadata API (`export const metadata` → title, description, Open Graph, Twitter cards)
|
|
176
|
+
- [x] `<Link>` client-side navigation (SPA swap of `#glash-root` + re-hydrate; progressive-enhancement `<a>`)
|
|
168
177
|
- [ ] State-preserving fast-refresh, Suspense streaming, `glash deploy` → glashdb
|
|
169
178
|
- [ ] `<Image>` / `<Video>` components that emit `<picture>`/`<source>` from the manifest
|
|
170
179
|
- [ ] Edge adapter for the glashdb Worker (serve `.br`/`.avif` by `Accept`)
|
|
171
180
|
- [ ] `glash deploy` → glashdb hosting in one command
|
|
172
181
|
|
|
173
182
|
## Design stance
|
|
174
|
-
glashjs
|
|
183
|
+
glashjs is **built on top of Next.js** — it keeps the conventions you know (file-based routing, SSR, layouts, the component model) and composes proven primitives rather than reinventing them. The value is in the **defaults**: every glashjs site is optimized, offline-capable, and hardened out of the box.
|
package/bin/glash.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs';
|
|
|
4
4
|
import { build } from '../src/build.mjs';
|
|
5
5
|
import { optimizeAssets } from '../src/assets/optimize.mjs';
|
|
6
6
|
import { createGlashServer } from '../src/server/server.mjs';
|
|
7
|
+
import { deploy } from '../src/deploy.mjs';
|
|
7
8
|
|
|
8
9
|
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
9
10
|
const [, , cmd, ...rest] = process.argv;
|
|
@@ -32,6 +33,11 @@ async function main() {
|
|
|
32
33
|
await build({ root: arg('--root', process.cwd()) });
|
|
33
34
|
break;
|
|
34
35
|
}
|
|
36
|
+
case 'deploy': {
|
|
37
|
+
const passthrough = rest.filter((a, i) => a !== '--dry-run' && a !== '--root' && rest[i - 1] !== '--root');
|
|
38
|
+
await deploy({ root: arg('--root', process.cwd()), dryRun: rest.includes('--dry-run'), args: passthrough });
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
35
41
|
case 'dev':
|
|
36
42
|
await serve(true);
|
|
37
43
|
break;
|
|
@@ -59,6 +65,7 @@ Usage:
|
|
|
59
65
|
glash dev [--port 3000] Run the dev server (file-based routing, SSR, API, live reload)
|
|
60
66
|
glash serve [--port 3000] Run the production server over routes/ + built assets
|
|
61
67
|
glash build [--root <dir>] Optimize assets, generate offline SW + PWA + security manifests
|
|
68
|
+
glash deploy [--dry-run] Build, then deploy to glashdb (hands off to the glashdb CLI)
|
|
62
69
|
glash optimize [<dir>] Just run the asset optimizer over a directory
|
|
63
70
|
glash version Print version
|
|
64
71
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "glashjs",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "glashjs — a web framework
|
|
3
|
+
"version": "0.6.1",
|
|
4
|
+
"description": "glashjs — a web framework built on top of Next.js: file-based routing, SSR, API routes, JSX components with client hydration, nested layouts, streaming SSR, a best-in-class build-time asset optimizer, offline PWA layer, animated favicon, and secure-by-default headers. Zero mandatory dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"glash": "bin/glash.mjs"
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"./security": "./src/security/headers.mjs",
|
|
13
13
|
"./image": "./src/components/image.mjs",
|
|
14
14
|
"./video": "./src/components/video.mjs",
|
|
15
|
+
"./link": "./src/components/link.mjs",
|
|
15
16
|
"./package.json": "./package.json"
|
|
16
17
|
},
|
|
17
18
|
"files": [
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// glashjs <Link> — client-side navigation (SPA feel) without a full reload.
|
|
2
|
+
// Renders a normal <a> tagged for the nav runtime (/_glash/nav.js), so it still
|
|
3
|
+
// works with JS disabled (progressive enhancement) and is crawlable.
|
|
4
|
+
//
|
|
5
|
+
// import { Link } from 'glashjs/link';
|
|
6
|
+
// <Link href="/about">About</Link>
|
|
7
|
+
import { h } from 'preact';
|
|
8
|
+
|
|
9
|
+
export function Link({ href, children, prefetch, class: className, ...rest }) {
|
|
10
|
+
return h('a', { href, 'data-glash-link': '', 'data-prefetch': prefetch ? '' : undefined, class: className, ...rest }, children);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default Link;
|
package/src/deploy.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// glashjs deploy -> glashdb
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// One command from a glashjs app to live on glashdb. It does the glashjs-aware
|
|
4
|
+
// part (production build + deployable package.json scripts) and then hands off
|
|
5
|
+
// to the existing glashdb CLI (npm: `glashdb`) for upload/auth — no duplicated
|
|
6
|
+
// deploy logic. The platform then runs `glash build` + `glash serve`.
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { promises as fs } from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { build } from './build.mjs';
|
|
11
|
+
|
|
12
|
+
/** Ensure the app declares the scripts the glashdb platform runs to build/serve it. */
|
|
13
|
+
async function ensureDeployScripts(root, log) {
|
|
14
|
+
const pkgPath = path.join(root, 'package.json');
|
|
15
|
+
let pkg;
|
|
16
|
+
try { pkg = JSON.parse(await fs.readFile(pkgPath, 'utf8')); } catch { return; }
|
|
17
|
+
pkg.scripts = pkg.scripts || {};
|
|
18
|
+
let changed = false;
|
|
19
|
+
if (!pkg.scripts.build) { pkg.scripts.build = 'glash build'; changed = true; }
|
|
20
|
+
if (!pkg.scripts.start) { pkg.scripts.start = 'glash serve'; changed = true; }
|
|
21
|
+
if (changed) {
|
|
22
|
+
await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
23
|
+
log(' + added build/start scripts to package.json (glash build / glash serve)');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function deploy({ root = process.cwd(), dryRun = false, args = [], log = console.log } = {}) {
|
|
28
|
+
log('glashjs deploy -> glashdb\n');
|
|
29
|
+
|
|
30
|
+
// 1. Production build (optimize assets, precompile routes, SW, security).
|
|
31
|
+
await build({ root, log });
|
|
32
|
+
|
|
33
|
+
// 2. Make sure the platform knows how to build/run the app.
|
|
34
|
+
log('');
|
|
35
|
+
await ensureDeployScripts(root, log);
|
|
36
|
+
|
|
37
|
+
// 3. Hand off to the glashdb CLI (zips + uploads, handles login).
|
|
38
|
+
const cmd = 'npx';
|
|
39
|
+
const cmdArgs = ['-y', 'glashdb', 'deploy', ...args];
|
|
40
|
+
log(`\nHanding off to the glashdb CLI:\n $ ${cmd} ${cmdArgs.join(' ')}\n`);
|
|
41
|
+
if (dryRun) {
|
|
42
|
+
log('(dry run — build complete, not uploading)');
|
|
43
|
+
return { handoff: `${cmd} ${cmdArgs.join(' ')}`, dryRun: true };
|
|
44
|
+
}
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const child = spawn(cmd, cmdArgs, { cwd: root, stdio: 'inherit' });
|
|
47
|
+
child.on('error', reject);
|
|
48
|
+
child.on('exit', (code) => (code === 0 ? resolve({ code }) : reject(new Error(`glashdb deploy exited with code ${code}`))));
|
|
49
|
+
});
|
|
50
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -10,3 +10,5 @@ export { discoverRoutes, matchRoute, findMiddleware } from './server/router.mjs'
|
|
|
10
10
|
export { html, raw, escapeHtml, renderDocument } from './server/html.mjs';
|
|
11
11
|
export { Image } from './components/image.mjs';
|
|
12
12
|
export { Video } from './components/video.mjs';
|
|
13
|
+
export { Link } from './components/link.mjs';
|
|
14
|
+
export { renderMeta } from './server/html.mjs';
|
package/src/server/html.mjs
CHANGED
|
@@ -61,7 +61,37 @@ function shellTail({ offline = true, animatedFavicon = true, dev = false, nonce
|
|
|
61
61
|
const hmr = dev
|
|
62
62
|
? `<script${n}>try{new EventSource("/_glash/hmr").onmessage=function(e){if(e.data==="reload")location.reload();};}catch(e){}</script>`
|
|
63
63
|
: '';
|
|
64
|
-
|
|
64
|
+
// Client-side navigation runtime (external 'self' module, CSP-safe).
|
|
65
|
+
const nav = '<script type="module" src="/_glash/nav.js"></script>';
|
|
66
|
+
return `\n${fav}${off}${hmr}${nav}\n</body>\n</html>`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Render a route's `metadata` export into <head> tags: description, robots,
|
|
71
|
+
* canonical, keywords, and Open Graph + Twitter cards — the SEO defaults Next
|
|
72
|
+
* makes you wire up by hand.
|
|
73
|
+
*/
|
|
74
|
+
export function renderMeta(meta = {}) {
|
|
75
|
+
if (!meta || typeof meta !== 'object') return '';
|
|
76
|
+
const tags = [];
|
|
77
|
+
const m = (name, content) => { if (content != null) tags.push(`<meta name="${escapeHtml(name)}" content="${escapeHtml(content)}">`); };
|
|
78
|
+
const p = (prop, content) => { if (content != null) tags.push(`<meta property="${escapeHtml(prop)}" content="${escapeHtml(content)}">`); };
|
|
79
|
+
m('description', meta.description);
|
|
80
|
+
m('robots', meta.robots);
|
|
81
|
+
m('keywords', Array.isArray(meta.keywords) ? meta.keywords.join(', ') : meta.keywords);
|
|
82
|
+
if (meta.canonical) tags.push(`<link rel="canonical" href="${escapeHtml(meta.canonical)}">`);
|
|
83
|
+
const og = meta.openGraph || {};
|
|
84
|
+
p('og:title', og.title || meta.title);
|
|
85
|
+
p('og:description', og.description || meta.description);
|
|
86
|
+
p('og:image', og.image);
|
|
87
|
+
p('og:url', og.url || meta.canonical);
|
|
88
|
+
p('og:type', og.type || 'website');
|
|
89
|
+
const tw = meta.twitter || {};
|
|
90
|
+
if (og.image || tw.card) m('twitter:card', tw.card || 'summary_large_image');
|
|
91
|
+
m('twitter:title', tw.title || meta.title);
|
|
92
|
+
m('twitter:description', tw.description || meta.description);
|
|
93
|
+
m('twitter:image', tw.image || og.image);
|
|
94
|
+
return tags.join('\n');
|
|
65
95
|
}
|
|
66
96
|
|
|
67
97
|
/** Full document (buffered). */
|
package/src/server/jsx.mjs
CHANGED
|
@@ -81,7 +81,7 @@ const compId = (pageFile, layouts) => routeId(pageFile + '|' + layouts.join('|')
|
|
|
81
81
|
function serverEntry(pageFile, layouts) {
|
|
82
82
|
const lines = [`import * as __p from ${JSON.stringify(pageFile)};`];
|
|
83
83
|
layouts.forEach((f, i) => lines.push(`import __L${i} from ${JSON.stringify(f)};`));
|
|
84
|
-
lines.push('export const Page = __p.default;', 'export const getServerData = __p.getServerData;', 'export const title = __p.title;');
|
|
84
|
+
lines.push('export const Page = __p.default;', 'export const getServerData = __p.getServerData;', 'export const title = __p.title;', 'export const metadata = __p.metadata;');
|
|
85
85
|
lines.push(`export const layouts = [${layouts.map((_, i) => `__L${i}`).join(',')}];`);
|
|
86
86
|
return lines.join('\n');
|
|
87
87
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Source for /_glash/nav.js — the client-side navigation runtime.
|
|
2
|
+
// Intercepts clicks on <a data-glash-link>, fetches the route as a partial
|
|
3
|
+
// (X-Glash-Nav: 1 -> JSON { title, html, props, bundle }), swaps #glash-root,
|
|
4
|
+
// updates the props block + <title>, pushes history, and re-hydrates by
|
|
5
|
+
// importing the new route's bundle. Falls back to a full navigation on any error.
|
|
6
|
+
export const NAV_CLIENT = `// glashjs client navigation
|
|
7
|
+
async function navigate(href, push) {
|
|
8
|
+
let data;
|
|
9
|
+
try {
|
|
10
|
+
const res = await fetch(href, { headers: { 'X-Glash-Nav': '1' }, credentials: 'same-origin' });
|
|
11
|
+
if (!res.ok) throw 0;
|
|
12
|
+
data = await res.json();
|
|
13
|
+
} catch { location.href = href; return; }
|
|
14
|
+
const root = document.getElementById('glash-root');
|
|
15
|
+
if (!root || !data || typeof data.html !== 'string') { location.href = href; return; }
|
|
16
|
+
if (data.title) document.title = data.title;
|
|
17
|
+
let pe = document.getElementById('glash-props');
|
|
18
|
+
if (!pe) { pe = document.createElement('script'); pe.type = 'application/json'; pe.id = 'glash-props'; document.head.appendChild(pe); }
|
|
19
|
+
pe.textContent = JSON.stringify(data.props || {});
|
|
20
|
+
root.innerHTML = data.html;
|
|
21
|
+
if (push) history.pushState({ glash: 1 }, '', href);
|
|
22
|
+
window.scrollTo(0, 0);
|
|
23
|
+
if (data.bundle) { try { await import(data.bundle); } catch {} }
|
|
24
|
+
}
|
|
25
|
+
document.addEventListener('click', (e) => {
|
|
26
|
+
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
27
|
+
const a = e.target.closest && e.target.closest('a[data-glash-link]');
|
|
28
|
+
if (!a) return;
|
|
29
|
+
const href = a.getAttribute('href');
|
|
30
|
+
if (!href || a.target || /^([a-z]+:)?\\/\\//i.test(href) || href.startsWith('#') || href.startsWith('mailto:')) return;
|
|
31
|
+
e.preventDefault();
|
|
32
|
+
if (href !== location.pathname + location.search) navigate(href, true);
|
|
33
|
+
});
|
|
34
|
+
window.addEventListener('popstate', () => navigate(location.pathname + location.search, false));
|
|
35
|
+
`;
|
package/src/server/server.mjs
CHANGED
|
@@ -11,7 +11,8 @@ import { randomBytes } from 'node:crypto';
|
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { pathToFileURL } from 'node:url';
|
|
13
13
|
import { discoverRoutes, matchRoute, findMiddleware } from './router.mjs';
|
|
14
|
-
import { renderDocument, documentParts } from './html.mjs';
|
|
14
|
+
import { renderDocument, documentParts, renderMeta } from './html.mjs';
|
|
15
|
+
import { NAV_CLIENT } from './nav-client.mjs';
|
|
15
16
|
import { isComponentRoute, loadComponentRoute, clientBundle, renderComponent, routeId, findLayouts, clearJsxCaches } from './jsx.mjs';
|
|
16
17
|
import { securityHeaders } from '../security/headers.mjs';
|
|
17
18
|
import { loadConfig } from '../config.mjs';
|
|
@@ -61,6 +62,10 @@ export async function createGlashServer({ root = process.cwd(), dev = false } =
|
|
|
61
62
|
req.on('close', () => hmrClients.delete(res));
|
|
62
63
|
return;
|
|
63
64
|
}
|
|
65
|
+
// Client-side navigation runtime.
|
|
66
|
+
if (pathname === '/_glash/nav.js') {
|
|
67
|
+
return send(res, 200, 'text/javascript; charset=utf-8', NAV_CLIENT, { ...secHeaders, 'cache-control': dev ? 'no-store' : 'public, max-age=31536000, immutable' });
|
|
68
|
+
}
|
|
64
69
|
// Static first: in production this serves prebuilt /_glash/<id>.js bundles
|
|
65
70
|
// (written by `glash build`) — no runtime esbuild needed.
|
|
66
71
|
if (await serveStatic(res, outDir, pathname, req, secHeaders)) return;
|
|
@@ -125,9 +130,10 @@ async function handlePage(res, mod, ctx, cfg, secHeaders, dev) {
|
|
|
125
130
|
const out = await render(ctx);
|
|
126
131
|
const page = typeof out === 'string' || (out && out.__raw) ? { body: out } : (out || {});
|
|
127
132
|
const nonce = randomBytes(16).toString('base64');
|
|
133
|
+
const meta = await resolveMeta(page.metadata || mod.metadata, ctx);
|
|
128
134
|
const docHtml = renderDocument({
|
|
129
|
-
title: page.title || mod.title || cfg.name,
|
|
130
|
-
head: page.head || '',
|
|
135
|
+
title: meta.title || page.title || mod.title || cfg.name,
|
|
136
|
+
head: renderMeta(meta) + (page.head ? (page.head.__raw || page.head) : ''),
|
|
131
137
|
body: page.body ?? '',
|
|
132
138
|
offline: cfg.offline,
|
|
133
139
|
animatedFavicon: !!cfg.animatedFavicon,
|
|
@@ -141,12 +147,22 @@ async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, route
|
|
|
141
147
|
const mod = await loadComponentRoute(route.file, layouts, root, dev);
|
|
142
148
|
const props = (typeof mod.getServerData === 'function' ? await mod.getServerData(ctx) : {}) || {};
|
|
143
149
|
const id = routeId(route.file);
|
|
150
|
+
const meta = await resolveMeta(mod.metadata, ctx);
|
|
151
|
+
const title = meta.title || mod.title || cfg.name;
|
|
152
|
+
|
|
153
|
+
// Client-side navigation: return the route as a partial (no full document),
|
|
154
|
+
// so <Link> can swap #glash-root and re-hydrate without a full reload.
|
|
155
|
+
if (String(ctx.headers['x-glash-nav'] || '') === '1') {
|
|
156
|
+
const rendered = await renderComponent(mod, props);
|
|
157
|
+
return send(res, 200, 'application/json', JSON.stringify({ title, html: rendered, props, bundle: `/_glash/${id}.js` }), secHeaders);
|
|
158
|
+
}
|
|
159
|
+
|
|
144
160
|
const nonce = randomBytes(16).toString('base64');
|
|
145
161
|
// Props in a non-executed JSON block (CSP-safe); hydration bundle is an
|
|
146
162
|
// external 'self' module — both pass the strict CSP without 'unsafe-inline'.
|
|
147
|
-
const head = `<script type="application/json" id="glash-props">${safeJson(props)}</script>`;
|
|
163
|
+
const head = renderMeta(meta) + `<script type="application/json" id="glash-props">${safeJson(props)}</script>`;
|
|
148
164
|
const { open, tail } = documentParts({
|
|
149
|
-
title
|
|
165
|
+
title, head, offline: cfg.offline, animatedFavicon: !!cfg.animatedFavicon, nonce, dev,
|
|
150
166
|
});
|
|
151
167
|
res.writeHead(200, { ...pageHeaders(cfg, secHeaders, nonce), 'content-type': 'text/html; charset=utf-8' });
|
|
152
168
|
res.write(open); // flush the shell first, before rendering the component
|
|
@@ -155,6 +171,12 @@ async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, route
|
|
|
155
171
|
res.end(tail);
|
|
156
172
|
}
|
|
157
173
|
|
|
174
|
+
// metadata export may be a plain object or a (ctx) => object function.
|
|
175
|
+
async function resolveMeta(metadata, ctx) {
|
|
176
|
+
if (typeof metadata === 'function') return (await metadata(ctx)) || {};
|
|
177
|
+
return metadata && typeof metadata === 'object' ? metadata : {};
|
|
178
|
+
}
|
|
179
|
+
|
|
158
180
|
// Per-request page headers: a fresh CSP carrying this request's script nonce, so
|
|
159
181
|
// the framework's own inline <script>s run while injected scripts stay blocked.
|
|
160
182
|
function pageHeaders(cfg, secHeaders, nonce) {
|