glashjs 0.1.0 → 0.3.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 +24 -3
- package/package.json +19 -3
- package/src/server/html.mjs +28 -20
- package/src/server/jsx.mjs +150 -0
- package/src/server/router.mjs +4 -2
- package/src/server/server.mjs +77 -9
package/README.md
CHANGED
|
@@ -77,7 +77,24 @@ glash dev # dev server: routing + SSR + API, live route reload
|
|
|
77
77
|
glash serve # production server over routes/ + built assets (Brotli-negotiated)
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
-
Every response carries the secure-by-default headers; static files are served from the build with Brotli negotiation.
|
|
80
|
+
Every response carries the secure-by-default headers; static files are served from the build with Brotli negotiation.
|
|
81
|
+
|
|
82
|
+
### JSX components + client hydration (new in 0.2)
|
|
83
|
+
Author pages as **JSX components**, server-render them, and **hydrate** in the browser so hooks work — real interactivity, the Next-style model. Built on **Preact** (React-compatible) + **esbuild**, added as *optional peers* (`npm i preact preact-render-to-string esbuild`); glashjs core stays zero-dep.
|
|
84
|
+
|
|
85
|
+
```jsx
|
|
86
|
+
// routes/counter.jsx — SSR + hydrated, the button is interactive
|
|
87
|
+
import { useState } from 'preact/hooks';
|
|
88
|
+
export function getServerData(ctx) { return { start: Number(ctx.query.start || 0) }; } // server props
|
|
89
|
+
export default function Counter({ start = 0 }) {
|
|
90
|
+
const [n, setN] = useState(start);
|
|
91
|
+
return <button onClick={() => setN(n + 1)}>count is {n}</button>;
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Hydration is **CSP-safe**: server props ride in a non-executed `<script type="application/json">` and the hydration bundle is an external `'self'` module with a per-request **nonce** — so the strict CSP stays intact (no `'unsafe-inline'`).
|
|
96
|
+
|
|
97
|
+
**Nested layouts** (`_layout.jsx` in any routes dir wrap pages root→leaf, server + hydration), **streaming SSR** (the shell flushes before the component renders — `Transfer-Encoding: chunked`), and **dev live-reload** (`glash dev` auto-refreshes on save over SSE) are all in. **Honest scope:** uses Preact (React-compatible via `preact/compat`), not React; live-reload is auto-refresh, not yet state-preserving fast-refresh; streaming is shell-flush, not Suspense-chunked.
|
|
81
98
|
|
|
82
99
|
## Usage
|
|
83
100
|
|
|
@@ -126,8 +143,12 @@ animatedFavicon: true, // bundled animated glash mark (d
|
|
|
126
143
|
- [x] Server-side rendering (XSS-safe `html` templates) + full-document runtime
|
|
127
144
|
- [x] API routes (per-method handlers, JSON body parsing, typed `json()` responses)
|
|
128
145
|
- [x] Dev/prod server with live route reload + Brotli-negotiated static serving
|
|
129
|
-
- [
|
|
130
|
-
- [
|
|
146
|
+
- [x] JSX components + client-side hydration (Preact + esbuild) — CSP-safe with nonces
|
|
147
|
+
- [x] Client-JS bundling (esbuild) per route
|
|
148
|
+
- [x] Nested layouts (`_layout.jsx` composing root→leaf, server + hydration)
|
|
149
|
+
- [x] Streaming SSR (shell flushed before the component renders)
|
|
150
|
+
- [x] Dev live-reload over SSE (auto-refresh on save)
|
|
151
|
+
- [ ] State-preserving fast-refresh (HMR), Suspense streaming, `<Image>`/`<Video>` components
|
|
131
152
|
- [ ] `<Image>` / `<Video>` components that emit `<picture>`/`<source>` from the manifest
|
|
132
153
|
- [ ] Edge adapter for the glashdb Worker (serve `.br`/`.avif` by `Accept`)
|
|
133
154
|
- [ ] `glash deploy` → glashdb hosting in one command
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "glashjs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "glashjs — a web framework with file-based routing, SSR, API routes, a best-in-class asset optimizer, offline PWA layer, animated favicon, and secure-by-default headers. Zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,13 +19,24 @@
|
|
|
19
19
|
"engines": {
|
|
20
20
|
"node": ">=20"
|
|
21
21
|
},
|
|
22
|
-
"dependencies": {},
|
|
23
22
|
"peerDependencies": {
|
|
23
|
+
"esbuild": ">=0.20.0",
|
|
24
|
+
"preact": "^10.25.0",
|
|
25
|
+
"preact-render-to-string": "^6.5.0",
|
|
24
26
|
"sharp": "^0.34.5"
|
|
25
27
|
},
|
|
26
28
|
"peerDependenciesMeta": {
|
|
27
29
|
"sharp": {
|
|
28
30
|
"optional": true
|
|
31
|
+
},
|
|
32
|
+
"esbuild": {
|
|
33
|
+
"optional": true
|
|
34
|
+
},
|
|
35
|
+
"preact": {
|
|
36
|
+
"optional": true
|
|
37
|
+
},
|
|
38
|
+
"preact-render-to-string": {
|
|
39
|
+
"optional": true
|
|
29
40
|
}
|
|
30
41
|
},
|
|
31
42
|
"keywords": [
|
|
@@ -44,5 +55,10 @@
|
|
|
44
55
|
"server",
|
|
45
56
|
"web-framework"
|
|
46
57
|
],
|
|
47
|
-
"license": "MIT"
|
|
58
|
+
"license": "MIT",
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"esbuild": "^0.28.0",
|
|
61
|
+
"preact": "^10.29.2",
|
|
62
|
+
"preact-render-to-string": "^6.7.0"
|
|
63
|
+
}
|
|
48
64
|
}
|
package/src/server/html.mjs
CHANGED
|
@@ -33,23 +33,8 @@ export function html(strings, ...values) {
|
|
|
33
33
|
* The runtime imports are resilient (try/catch) so pages still render in `dev`
|
|
34
34
|
* before a `glash build` has produced those files.
|
|
35
35
|
*/
|
|
36
|
-
|
|
37
|
-
title = 'glashjs',
|
|
38
|
-
head = '',
|
|
39
|
-
body = '',
|
|
40
|
-
lang = 'en',
|
|
41
|
-
favicon = '/favicon.svg',
|
|
42
|
-
offline = true,
|
|
43
|
-
animatedFavicon = true,
|
|
44
|
-
} = {}) {
|
|
36
|
+
function shellOpen({ title = 'glashjs', head = '', lang = 'en', favicon = '/favicon.svg' }) {
|
|
45
37
|
const headHtml = typeof head === 'object' && head?.__raw ? head.__raw : String(head ?? '');
|
|
46
|
-
const bodyHtml = typeof body === 'object' && body?.__raw ? body.__raw : String(body ?? '');
|
|
47
|
-
const fav = animatedFavicon
|
|
48
|
-
? '<script type="module">try{const m=await import("/glash-favicon.mjs");m.startGlashFavicon&&m.startGlashFavicon();}catch{}</script>'
|
|
49
|
-
: '';
|
|
50
|
-
const off = offline
|
|
51
|
-
? '<script type="module">try{const m=await import("/glash-offline.mjs");m.registerGlashOffline&&m.registerGlashOffline();}catch{}</script>'
|
|
52
|
-
: '';
|
|
53
38
|
return `<!doctype html>
|
|
54
39
|
<html lang="${escapeHtml(lang)}">
|
|
55
40
|
<head>
|
|
@@ -61,8 +46,31 @@ export function renderDocument({
|
|
|
61
46
|
${headHtml}
|
|
62
47
|
</head>
|
|
63
48
|
<body>
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
49
|
+
`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function shellTail({ offline = true, animatedFavicon = true, dev = false, nonce = '' }) {
|
|
53
|
+
const n = nonce ? ` nonce="${escapeHtml(nonce)}"` : '';
|
|
54
|
+
const fav = animatedFavicon
|
|
55
|
+
? `<script type="module"${n}>try{const m=await import("/glash-favicon.mjs");m.startGlashFavicon&&m.startGlashFavicon();}catch{}</script>`
|
|
56
|
+
: '';
|
|
57
|
+
const off = offline
|
|
58
|
+
? `<script type="module"${n}>try{const m=await import("/glash-offline.mjs");m.registerGlashOffline&&m.registerGlashOffline();}catch{}</script>`
|
|
59
|
+
: '';
|
|
60
|
+
// Dev live-reload: a nonce'd inline script (CSP-safe) that reloads on change.
|
|
61
|
+
const hmr = dev
|
|
62
|
+
? `<script${n}>try{new EventSource("/_glash/hmr").onmessage=function(e){if(e.data==="reload")location.reload();};}catch(e){}</script>`
|
|
63
|
+
: '';
|
|
64
|
+
return `\n${fav}${off}${hmr}\n</body>\n</html>`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Full document (buffered). */
|
|
68
|
+
export function renderDocument(opts = {}) {
|
|
69
|
+
const bodyHtml = typeof opts.body === 'object' && opts.body?.__raw ? opts.body.__raw : String(opts.body ?? '');
|
|
70
|
+
return shellOpen(opts) + bodyHtml + shellTail(opts);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Streaming document: `open` flushed before the body renders, `tail` after. */
|
|
74
|
+
export function documentParts(opts = {}) {
|
|
75
|
+
return { open: shellOpen(opts), tail: shellTail(opts) };
|
|
68
76
|
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// glashjs JSX + hydration engine
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// This is the layer that makes glashjs a real Next.js alternative: author
|
|
4
|
+
// pages as JSX components, render them on the server, and HYDRATE them in the
|
|
5
|
+
// browser so hooks (useState/useEffect) work — true interactivity.
|
|
6
|
+
//
|
|
7
|
+
// Built on proven primitives, added as OPTIONAL peers so glashjs core stays
|
|
8
|
+
// zero-dependency:
|
|
9
|
+
// - esbuild -> compiles JSX/TSX (server module + client bundle)
|
|
10
|
+
// - preact -> the component runtime (React-compatible)
|
|
11
|
+
// - preact-render-to-string -> server-side rendering
|
|
12
|
+
//
|
|
13
|
+
// A `.jsx`/`.tsx` route exports a default component and (optionally) an async
|
|
14
|
+
// `getServerData(ctx)` that returns props for SSR + hydration. Plain `.mjs`
|
|
15
|
+
// HTML-render routes keep working with zero deps.
|
|
16
|
+
import { promises as fs, existsSync } from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { pathToFileURL } from 'node:url';
|
|
20
|
+
|
|
21
|
+
const MISSING = 'JSX/TSX routes need the optional peers: npm i esbuild preact preact-render-to-string';
|
|
22
|
+
|
|
23
|
+
let _esbuild;
|
|
24
|
+
async function esbuild() {
|
|
25
|
+
if (_esbuild !== undefined) return _esbuild;
|
|
26
|
+
try { _esbuild = await import('esbuild'); } catch { _esbuild = null; }
|
|
27
|
+
return _esbuild;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let _h, _renderToString;
|
|
31
|
+
async function preactRuntime() {
|
|
32
|
+
if (_h) return { h: _h, renderToString: _renderToString };
|
|
33
|
+
const p = await import('preact').catch(() => null);
|
|
34
|
+
const r = await import('preact-render-to-string').catch(() => null);
|
|
35
|
+
if (!p || !r) return null;
|
|
36
|
+
_h = p.h;
|
|
37
|
+
_renderToString = r.renderToString || r.default;
|
|
38
|
+
return { h: _h, renderToString: _renderToString };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isComponentRoute(file) {
|
|
42
|
+
return /\.(jsx|tsx)$/.test(file);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function routeId(file) {
|
|
46
|
+
return createHash('sha1').update(file).digest('hex').slice(0, 10);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const serverCache = new Map();
|
|
50
|
+
const clientCache = new Map();
|
|
51
|
+
|
|
52
|
+
/** Clear compiled-route caches (used by dev live-reload on file change). */
|
|
53
|
+
export function clearJsxCaches() {
|
|
54
|
+
serverCache.clear();
|
|
55
|
+
clientCache.clear();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Nested layouts: collect `_layout.{jsx,tsx}` from the routes root down to the
|
|
60
|
+
* page's directory (root-first). Each wraps its children, Next-style.
|
|
61
|
+
*/
|
|
62
|
+
export function findLayouts(routesDir, pageFile) {
|
|
63
|
+
const root = path.resolve(routesDir);
|
|
64
|
+
const pageDir = path.dirname(path.resolve(pageFile));
|
|
65
|
+
const rel = path.relative(root, pageDir);
|
|
66
|
+
const dirs = [root];
|
|
67
|
+
let acc = root;
|
|
68
|
+
for (const part of rel ? rel.split(path.sep) : []) { acc = path.join(acc, part); dirs.push(acc); }
|
|
69
|
+
const layouts = [];
|
|
70
|
+
for (const d of dirs) {
|
|
71
|
+
for (const name of ['_layout.jsx', '_layout.tsx']) {
|
|
72
|
+
const f = path.join(d, name);
|
|
73
|
+
if (existsSync(f)) { layouts.push(f); break; }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return layouts;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const compId = (pageFile, layouts) => routeId(pageFile + '|' + layouts.join('|'));
|
|
80
|
+
|
|
81
|
+
function serverEntry(pageFile, layouts) {
|
|
82
|
+
const lines = [`import * as __p from ${JSON.stringify(pageFile)};`];
|
|
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;');
|
|
85
|
+
lines.push(`export const layouts = [${layouts.map((_, i) => `__L${i}`).join(',')}];`);
|
|
86
|
+
return lines.join('\n');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function clientEntry(pageFile, layouts) {
|
|
90
|
+
const imports = [`import { hydrate, h } from 'preact';`, `import Page from ${JSON.stringify(pageFile)};`];
|
|
91
|
+
layouts.forEach((f, i) => imports.push(`import __L${i} from ${JSON.stringify(f)};`));
|
|
92
|
+
return `${imports.join('\n')}
|
|
93
|
+
const layouts = [${layouts.map((_, i) => `__L${i}`).join(',')}];
|
|
94
|
+
const el = document.getElementById('glash-root');
|
|
95
|
+
const pe = document.getElementById('glash-props');
|
|
96
|
+
let props = {};
|
|
97
|
+
try { props = pe ? JSON.parse(pe.textContent) : {}; } catch {}
|
|
98
|
+
let tree = h(Page, props);
|
|
99
|
+
for (let i = layouts.length - 1; i >= 0; i--) tree = h(layouts[i], props, tree);
|
|
100
|
+
if (el) hydrate(tree, el);`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Compile page + its layout chain into a server module ({ Page, layouts, getServerData, title }). */
|
|
104
|
+
export async function loadComponentRoute(pageFile, layouts, root, dev) {
|
|
105
|
+
const eb = await esbuild();
|
|
106
|
+
if (!eb) throw new Error(MISSING);
|
|
107
|
+
const id = compId(pageFile, layouts);
|
|
108
|
+
if (!dev && serverCache.has(id)) return serverCache.get(id);
|
|
109
|
+
const out = path.join(root, '.glash', 'server', id + '.mjs');
|
|
110
|
+
await fs.mkdir(path.dirname(out), { recursive: true });
|
|
111
|
+
await eb.build({
|
|
112
|
+
stdin: { contents: serverEntry(pageFile, layouts), resolveDir: path.dirname(pageFile), loader: 'js', sourcefile: 'glash-server-entry.js' },
|
|
113
|
+
bundle: true, platform: 'node', format: 'esm', jsx: 'automatic', jsxImportSource: 'preact',
|
|
114
|
+
external: ['preact', 'preact/*', 'preact-render-to-string'],
|
|
115
|
+
outfile: out, logLevel: 'silent',
|
|
116
|
+
});
|
|
117
|
+
const mod = await import(pathToFileURL(out).href + (dev ? `?t=${Date.now()}` : ''));
|
|
118
|
+
if (!dev) serverCache.set(id, mod);
|
|
119
|
+
return mod;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Build the browser hydration bundle for page + layouts (preact bundled in). */
|
|
123
|
+
export async function clientBundle(pageFile, layouts, dev) {
|
|
124
|
+
const eb = await esbuild();
|
|
125
|
+
if (!eb) throw new Error(MISSING);
|
|
126
|
+
const id = compId(pageFile, layouts);
|
|
127
|
+
if (!dev && clientCache.has(id)) return clientCache.get(id);
|
|
128
|
+
const res = await eb.build({
|
|
129
|
+
stdin: { contents: clientEntry(pageFile, layouts), resolveDir: path.dirname(pageFile), loader: 'jsx', sourcefile: 'glash-client-entry.jsx' },
|
|
130
|
+
bundle: true, platform: 'browser', format: 'esm', minify: !dev, jsx: 'automatic', jsxImportSource: 'preact',
|
|
131
|
+
write: false, logLevel: 'silent',
|
|
132
|
+
});
|
|
133
|
+
const js = res.outputFiles[0].text;
|
|
134
|
+
if (!dev) clientCache.set(id, js);
|
|
135
|
+
return js;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function compose(h, layouts, Page, props) {
|
|
139
|
+
let tree = h(Page, props);
|
|
140
|
+
for (let i = layouts.length - 1; i >= 0; i--) tree = h(layouts[i], props, tree);
|
|
141
|
+
return tree;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Server-render page + layouts to an HTML string. */
|
|
145
|
+
export async function renderComponent(mod, props) {
|
|
146
|
+
const rt = await preactRuntime();
|
|
147
|
+
if (!rt) throw new Error(MISSING);
|
|
148
|
+
if (typeof mod.Page !== 'function') throw new Error('JSX route must default-export a component');
|
|
149
|
+
return rt.renderToString(compose(rt.h, mod.layouts || [], mod.Page, props));
|
|
150
|
+
}
|
package/src/server/router.mjs
CHANGED
|
@@ -15,7 +15,9 @@ export async function discoverRoutes(routesDir) {
|
|
|
15
15
|
const files = [];
|
|
16
16
|
await walk(root, root, files);
|
|
17
17
|
const routes = files
|
|
18
|
-
.filter((f) => /\.(mjs|js)$/.test(f.rel))
|
|
18
|
+
.filter((f) => /\.(mjs|js|jsx|tsx)$/.test(f.rel))
|
|
19
|
+
// `_`-prefixed files are private (layouts, helpers) — not routes.
|
|
20
|
+
.filter((f) => !f.rel.split('/').some((seg) => seg.startsWith('_')))
|
|
19
21
|
.map((f) => toRoute(f.rel, f.file));
|
|
20
22
|
// Most specific first: static segments beat params beat catch-all.
|
|
21
23
|
routes.sort((a, b) => b.score - a.score);
|
|
@@ -33,7 +35,7 @@ async function walk(root, dir, out) {
|
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
function toRoute(rel, file) {
|
|
36
|
-
const clean = rel.replace(/\.(mjs|js)$/, '');
|
|
38
|
+
const clean = rel.replace(/\.(mjs|js|jsx|tsx)$/, '');
|
|
37
39
|
const isApi = clean === 'api' || clean.startsWith('api/');
|
|
38
40
|
const segs = [];
|
|
39
41
|
for (const part of clean.split('/').filter(Boolean)) {
|
package/src/server/server.mjs
CHANGED
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
// service worker, favicons) with Brotli negotiation.
|
|
7
7
|
// Every response carries the secure-by-default headers.
|
|
8
8
|
import http from 'node:http';
|
|
9
|
-
import { promises as fs, existsSync, statSync } from 'node:fs';
|
|
9
|
+
import { promises as fs, existsSync, statSync, watch } from 'node:fs';
|
|
10
|
+
import { randomBytes } from 'node:crypto';
|
|
10
11
|
import path from 'node:path';
|
|
11
12
|
import { pathToFileURL } from 'node:url';
|
|
12
13
|
import { discoverRoutes, matchRoute } from './router.mjs';
|
|
13
|
-
import { renderDocument } from './html.mjs';
|
|
14
|
+
import { renderDocument, documentParts } from './html.mjs';
|
|
15
|
+
import { isComponentRoute, loadComponentRoute, clientBundle, renderComponent, routeId, findLayouts, clearJsxCaches } from './jsx.mjs';
|
|
14
16
|
import { securityHeaders } from '../security/headers.mjs';
|
|
15
17
|
import { loadConfig } from '../config.mjs';
|
|
16
18
|
|
|
@@ -34,19 +36,52 @@ export async function createGlashServer({ root = process.cwd(), dev = false } =
|
|
|
34
36
|
const importRoute = (file) =>
|
|
35
37
|
import(pathToFileURL(file).href + (dev ? `?t=${Date.now()}` : ''));
|
|
36
38
|
|
|
39
|
+
// Dev live-reload: watch routes/, drop compiled caches, and push a reload
|
|
40
|
+
// event to every connected browser over Server-Sent Events.
|
|
41
|
+
const hmrClients = new Set();
|
|
42
|
+
if (dev) {
|
|
43
|
+
try {
|
|
44
|
+
watch(routesDir, { recursive: true }, () => {
|
|
45
|
+
clearJsxCaches();
|
|
46
|
+
for (const c of hmrClients) c.write('data: reload\n\n');
|
|
47
|
+
});
|
|
48
|
+
} catch { /* fs.watch recursive may be unsupported on some platforms */ }
|
|
49
|
+
}
|
|
50
|
+
|
|
37
51
|
const server = http.createServer(async (req, res) => {
|
|
38
52
|
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
39
53
|
const pathname = safeDecode(url.pathname);
|
|
40
54
|
try {
|
|
41
|
-
if (await serveStatic(res, outDir, pathname, req, secHeaders)) return;
|
|
42
55
|
if (dev) routes = await discoverRoutes(routesDir);
|
|
56
|
+
// Dev live-reload SSE channel.
|
|
57
|
+
if (dev && pathname === '/_glash/hmr') {
|
|
58
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
|
|
59
|
+
res.write('retry: 1000\n\n');
|
|
60
|
+
hmrClients.add(res);
|
|
61
|
+
req.on('close', () => hmrClients.delete(res));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
// Client hydration bundles: /_glash/<routeId>.js
|
|
65
|
+
if (pathname.startsWith('/_glash/')) {
|
|
66
|
+
const id = pathname.slice('/_glash/'.length).replace(/\.js$/, '');
|
|
67
|
+
const comp = routes.find((r) => isComponentRoute(r.file) && routeId(r.file) === id);
|
|
68
|
+
if (!comp) return send(res, 404, 'text/plain', 'not found', secHeaders);
|
|
69
|
+
const js = await clientBundle(comp.file, findLayouts(routesDir, comp.file), dev);
|
|
70
|
+
return send(res, 200, 'text/javascript; charset=utf-8', js, { ...secHeaders, 'cache-control': dev ? 'no-store' : 'public, max-age=31536000, immutable' });
|
|
71
|
+
}
|
|
72
|
+
if (await serveStatic(res, outDir, pathname, req, secHeaders)) return;
|
|
43
73
|
const match = matchRoute(routes, pathname);
|
|
44
74
|
if (!match) return send(res, 404, 'text/plain; charset=utf-8', 'Not found', secHeaders);
|
|
45
|
-
const mod = await importRoute(match.route.file);
|
|
46
75
|
const ctx = makeCtx(req, url, match.params);
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
76
|
+
if (match.route.isApi) {
|
|
77
|
+
const mod = await importRoute(match.route.file);
|
|
78
|
+
return await handleApi(res, mod, req, ctx, secHeaders);
|
|
79
|
+
}
|
|
80
|
+
if (isComponentRoute(match.route.file)) {
|
|
81
|
+
return await handleComponentPage(res, match.route, ctx, cfg, secHeaders, root, routesDir, dev);
|
|
82
|
+
}
|
|
83
|
+
const mod = await importRoute(match.route.file);
|
|
84
|
+
return await handlePage(res, mod, ctx, cfg, secHeaders, dev);
|
|
50
85
|
} catch (err) {
|
|
51
86
|
const msg = dev ? `glashjs error:\n${err?.stack || err}` : 'Internal Server Error';
|
|
52
87
|
send(res, 500, 'text/plain; charset=utf-8', msg, secHeaders);
|
|
@@ -74,19 +109,52 @@ async function handleApi(res, mod, req, ctx, secHeaders) {
|
|
|
74
109
|
send(res, 200, 'application/json', JSON.stringify(result ?? null), secHeaders);
|
|
75
110
|
}
|
|
76
111
|
|
|
77
|
-
async function handlePage(res, mod, ctx, cfg, secHeaders) {
|
|
112
|
+
async function handlePage(res, mod, ctx, cfg, secHeaders, dev) {
|
|
78
113
|
const render = mod.default;
|
|
79
114
|
if (typeof render !== 'function') return send(res, 500, 'text/plain', 'route has no default export', secHeaders);
|
|
80
115
|
const out = await render(ctx);
|
|
81
116
|
const page = typeof out === 'string' || (out && out.__raw) ? { body: out } : (out || {});
|
|
117
|
+
const nonce = randomBytes(16).toString('base64');
|
|
82
118
|
const docHtml = renderDocument({
|
|
83
119
|
title: page.title || mod.title || cfg.name,
|
|
84
120
|
head: page.head || '',
|
|
85
121
|
body: page.body ?? '',
|
|
86
122
|
offline: cfg.offline,
|
|
87
123
|
animatedFavicon: !!cfg.animatedFavicon,
|
|
124
|
+
nonce, dev,
|
|
88
125
|
});
|
|
89
|
-
send(res, page.status || 200, 'text/html; charset=utf-8', docHtml, { ...secHeaders, ...(page.headers || {}) });
|
|
126
|
+
send(res, page.status || 200, 'text/html; charset=utf-8', docHtml, { ...pageHeaders(cfg, secHeaders, nonce), ...(page.headers || {}) });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, routesDir, dev) {
|
|
130
|
+
const layouts = findLayouts(routesDir, route.file);
|
|
131
|
+
const mod = await loadComponentRoute(route.file, layouts, root, dev);
|
|
132
|
+
const props = (typeof mod.getServerData === 'function' ? await mod.getServerData(ctx) : {}) || {};
|
|
133
|
+
const id = routeId(route.file);
|
|
134
|
+
const nonce = randomBytes(16).toString('base64');
|
|
135
|
+
// Props in a non-executed JSON block (CSP-safe); hydration bundle is an
|
|
136
|
+
// external 'self' module — both pass the strict CSP without 'unsafe-inline'.
|
|
137
|
+
const head = `<script type="application/json" id="glash-props">${safeJson(props)}</script>`;
|
|
138
|
+
const { open, tail } = documentParts({
|
|
139
|
+
title: mod.title || cfg.name, head, offline: cfg.offline, animatedFavicon: !!cfg.animatedFavicon, nonce, dev,
|
|
140
|
+
});
|
|
141
|
+
res.writeHead(200, { ...pageHeaders(cfg, secHeaders, nonce), 'content-type': 'text/html; charset=utf-8' });
|
|
142
|
+
res.write(open); // flush the shell first, before rendering the component
|
|
143
|
+
const rendered = await renderComponent(mod, props);
|
|
144
|
+
res.write(`<div id="glash-root">${rendered}</div><script type="module" src="/_glash/${id}.js"></script>`);
|
|
145
|
+
res.end(tail);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Per-request page headers: a fresh CSP carrying this request's script nonce, so
|
|
149
|
+
// the framework's own inline <script>s run while injected scripts stay blocked.
|
|
150
|
+
function pageHeaders(cfg, secHeaders, nonce) {
|
|
151
|
+
const csp = securityHeaders({ ...(cfg.security || {}), csp: { ...((cfg.security || {}).csp || {}), nonce } })['Content-Security-Policy'];
|
|
152
|
+
return { ...secHeaders, 'Content-Security-Policy': csp };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Serialize props for inline injection without breaking out of the <script>.
|
|
156
|
+
function safeJson(value) {
|
|
157
|
+
return JSON.stringify(value ?? {}).replace(/</g, '\\u003c').replace(/-->/g, '--\\u003e');
|
|
90
158
|
}
|
|
91
159
|
|
|
92
160
|
async function serveStatic(res, outDir, pathname, req, secHeaders) {
|