glashjs 0.2.0 → 0.4.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 +22 -2
- package/package.json +4 -2
- package/src/components/image.mjs +42 -0
- package/src/index.mjs +3 -2
- package/src/server/html.mjs +28 -22
- package/src/server/jsx.mjs +78 -41
- package/src/server/router.mjs +26 -1
- package/src/server/server.mjs +69 -23
package/README.md
CHANGED
|
@@ -77,6 +77,19 @@ 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
|
+
**`<Image>`** (better than `next/image` — no runtime image server, uses the build's AVIF/WebP):
|
|
81
|
+
```jsx
|
|
82
|
+
import { Image } from 'glashjs/image';
|
|
83
|
+
<Image src="/hero.png" alt="Hero" width={1200} height={630} />
|
|
84
|
+
// -> <picture><source …avif><source …webp><img loading=lazy decoding=async></picture>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**File-based middleware** (`_middleware.mjs`, runs root→leaf before the route):
|
|
88
|
+
```js
|
|
89
|
+
import { redirect } from 'glashjs';
|
|
90
|
+
export default (ctx) => { if (!ctx.headers.authorization) return redirect('/login'); };
|
|
91
|
+
```
|
|
92
|
+
|
|
80
93
|
Every response carries the secure-by-default headers; static files are served from the build with Brotli negotiation.
|
|
81
94
|
|
|
82
95
|
### JSX components + client hydration (new in 0.2)
|
|
@@ -92,7 +105,9 @@ export default function Counter({ start = 0 }) {
|
|
|
92
105
|
}
|
|
93
106
|
```
|
|
94
107
|
|
|
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'`).
|
|
108
|
+
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'`).
|
|
109
|
+
|
|
110
|
+
**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.
|
|
96
111
|
|
|
97
112
|
## Usage
|
|
98
113
|
|
|
@@ -143,7 +158,12 @@ animatedFavicon: true, // bundled animated glash mark (d
|
|
|
143
158
|
- [x] Dev/prod server with live route reload + Brotli-negotiated static serving
|
|
144
159
|
- [x] JSX components + client-side hydration (Preact + esbuild) — CSP-safe with nonces
|
|
145
160
|
- [x] Client-JS bundling (esbuild) per route
|
|
146
|
-
- [
|
|
161
|
+
- [x] Nested layouts (`_layout.jsx` composing root→leaf, server + hydration)
|
|
162
|
+
- [x] Streaming SSR (shell flushed before the component renders)
|
|
163
|
+
- [x] Dev live-reload over SSE (auto-refresh on save)
|
|
164
|
+
- [x] `<Image>` — zero-config `<picture>` with AVIF/WebP from the optimizer (beats next/image: no runtime image server)
|
|
165
|
+
- [x] File-based middleware (`_middleware.mjs`, root→leaf) — auth, redirects, headers
|
|
166
|
+
- [ ] State-preserving fast-refresh, Suspense streaming, `<Video>`, `glash deploy`
|
|
147
167
|
- [ ] `<Image>` / `<Video>` components that emit `<picture>`/`<source>` from the manifest
|
|
148
168
|
- [ ] Edge adapter for the glashdb Worker (serve `.br`/`.avif` by `Accept`)
|
|
149
169
|
- [ ] `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.4.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": {
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
"exports": {
|
|
10
10
|
".": "./src/index.mjs",
|
|
11
11
|
"./config": "./src/config.mjs",
|
|
12
|
-
"./security": "./src/security/headers.mjs"
|
|
12
|
+
"./security": "./src/security/headers.mjs",
|
|
13
|
+
"./image": "./src/components/image.mjs",
|
|
14
|
+
"./package.json": "./package.json"
|
|
13
15
|
},
|
|
14
16
|
"files": [
|
|
15
17
|
"bin",
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// glashjs <Image> — a zero-config image component that's better than next/image
|
|
2
|
+
// out of the box: it emits a <picture> that prefers the AVIF/WebP variants the
|
|
3
|
+
// glashjs asset optimizer already produced at build time, with no runtime image
|
|
4
|
+
// server, no signed URLs, and no config. Width/height are required-by-habit to
|
|
5
|
+
// prevent layout shift (CLS), and images lazy-load + async-decode by default.
|
|
6
|
+
//
|
|
7
|
+
// import { Image } from 'glashjs/image';
|
|
8
|
+
// <Image src="/hero.png" alt="Hero" width={1200} height={630} />
|
|
9
|
+
//
|
|
10
|
+
// After `glash build`, /hero.avif and /hero.webp exist next to /hero.png, so
|
|
11
|
+
// the browser downloads the smallest format it supports. Deterministic output
|
|
12
|
+
// (same on server + during hydration), so it never causes a hydration mismatch.
|
|
13
|
+
import { h } from 'preact';
|
|
14
|
+
|
|
15
|
+
const RASTER = /\.(png|jpe?g|webp|avif)$/i;
|
|
16
|
+
|
|
17
|
+
export function Image({ src, alt = '', width, height, sizes, loading = 'lazy', fetchpriority, class: className, style, ...rest }) {
|
|
18
|
+
if (!src || !RASTER.test(src)) {
|
|
19
|
+
// SVG/unknown: render a plain <img>, nothing to transcode.
|
|
20
|
+
return h('img', { src, alt, width, height, loading, decoding: 'async', class: className, style, ...rest });
|
|
21
|
+
}
|
|
22
|
+
const base = src.replace(RASTER, '');
|
|
23
|
+
return h(
|
|
24
|
+
'picture',
|
|
25
|
+
{ class: className, style },
|
|
26
|
+
h('source', { srcset: `${base}.avif`, type: 'image/avif', sizes }),
|
|
27
|
+
h('source', { srcset: `${base}.webp`, type: 'image/webp', sizes }),
|
|
28
|
+
h('img', {
|
|
29
|
+
src,
|
|
30
|
+
alt,
|
|
31
|
+
width,
|
|
32
|
+
height,
|
|
33
|
+
sizes,
|
|
34
|
+
loading,
|
|
35
|
+
fetchpriority,
|
|
36
|
+
decoding: 'async',
|
|
37
|
+
...rest,
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default Image;
|
package/src/index.mjs
CHANGED
|
@@ -5,6 +5,7 @@ export { optimizeAssets } from './assets/optimize.mjs';
|
|
|
5
5
|
export { generateAnimatedFavicon } from './assets/animated-favicon.mjs';
|
|
6
6
|
export { generateServiceWorker } from './offline/generate-sw.mjs';
|
|
7
7
|
export { securityHeaders, buildCsp, sri, glashSecurity } from './security/headers.mjs';
|
|
8
|
-
export { createGlashServer, json } from './server/server.mjs';
|
|
9
|
-
export { discoverRoutes, matchRoute } from './server/router.mjs';
|
|
8
|
+
export { createGlashServer, json, redirect } from './server/server.mjs';
|
|
9
|
+
export { discoverRoutes, matchRoute, findMiddleware } from './server/router.mjs';
|
|
10
10
|
export { html, raw, escapeHtml, renderDocument } from './server/html.mjs';
|
|
11
|
+
export { Image } from './components/image.mjs';
|
package/src/server/html.mjs
CHANGED
|
@@ -33,25 +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
|
-
nonce = '',
|
|
45
|
-
} = {}) {
|
|
36
|
+
function shellOpen({ title = 'glashjs', head = '', lang = 'en', favicon = '/favicon.svg' }) {
|
|
46
37
|
const headHtml = typeof head === 'object' && head?.__raw ? head.__raw : String(head ?? '');
|
|
47
|
-
const bodyHtml = typeof body === 'object' && body?.__raw ? body.__raw : String(body ?? '');
|
|
48
|
-
const n = nonce ? ` nonce="${escapeHtml(nonce)}"` : '';
|
|
49
|
-
const fav = animatedFavicon
|
|
50
|
-
? `<script type="module"${n}>try{const m=await import("/glash-favicon.mjs");m.startGlashFavicon&&m.startGlashFavicon();}catch{}</script>`
|
|
51
|
-
: '';
|
|
52
|
-
const off = offline
|
|
53
|
-
? `<script type="module"${n}>try{const m=await import("/glash-offline.mjs");m.registerGlashOffline&&m.registerGlashOffline();}catch{}</script>`
|
|
54
|
-
: '';
|
|
55
38
|
return `<!doctype html>
|
|
56
39
|
<html lang="${escapeHtml(lang)}">
|
|
57
40
|
<head>
|
|
@@ -63,8 +46,31 @@ export function renderDocument({
|
|
|
63
46
|
${headHtml}
|
|
64
47
|
</head>
|
|
65
48
|
<body>
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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) };
|
|
70
76
|
}
|
package/src/server/jsx.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// A `.jsx`/`.tsx` route exports a default component and (optionally) an async
|
|
14
14
|
// `getServerData(ctx)` that returns props for SSR + hydration. Plain `.mjs`
|
|
15
15
|
// HTML-render routes keep working with zero deps.
|
|
16
|
-
import { promises as fs } from 'node:fs';
|
|
16
|
+
import { promises as fs, existsSync } from 'node:fs';
|
|
17
17
|
import path from 'node:path';
|
|
18
18
|
import { createHash } from 'node:crypto';
|
|
19
19
|
import { pathToFileURL } from 'node:url';
|
|
@@ -49,65 +49,102 @@ export function routeId(file) {
|
|
|
49
49
|
const serverCache = new Map();
|
|
50
50
|
const clientCache = new Map();
|
|
51
51
|
|
|
52
|
-
/**
|
|
53
|
-
export
|
|
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) {
|
|
54
105
|
const eb = await esbuild();
|
|
55
106
|
if (!eb) throw new Error(MISSING);
|
|
56
|
-
|
|
57
|
-
|
|
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');
|
|
58
110
|
await fs.mkdir(path.dirname(out), { recursive: true });
|
|
59
111
|
await eb.build({
|
|
60
|
-
|
|
61
|
-
bundle: true,
|
|
62
|
-
platform: 'node',
|
|
63
|
-
format: 'esm',
|
|
64
|
-
jsx: 'automatic',
|
|
65
|
-
jsxImportSource: 'preact',
|
|
66
|
-
// Keep preact external so the route shares the framework's preact instance
|
|
67
|
-
// (vnodes from the route and renderToString must come from the same preact).
|
|
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',
|
|
68
114
|
external: ['preact', 'preact/*', 'preact-render-to-string'],
|
|
69
|
-
outfile: out,
|
|
70
|
-
logLevel: 'silent',
|
|
115
|
+
outfile: out, logLevel: 'silent',
|
|
71
116
|
});
|
|
72
117
|
const mod = await import(pathToFileURL(out).href + (dev ? `?t=${Date.now()}` : ''));
|
|
73
|
-
if (!dev) serverCache.set(
|
|
118
|
+
if (!dev) serverCache.set(id, mod);
|
|
74
119
|
return mod;
|
|
75
120
|
}
|
|
76
121
|
|
|
77
|
-
/** Build the browser hydration bundle for
|
|
78
|
-
export async function clientBundle(
|
|
122
|
+
/** Build the browser hydration bundle for page + layouts (preact bundled in). */
|
|
123
|
+
export async function clientBundle(pageFile, layouts, dev) {
|
|
79
124
|
const eb = await esbuild();
|
|
80
125
|
if (!eb) throw new Error(MISSING);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
// hydration works under glashjs's strict CSP (no inline executable scripts).
|
|
84
|
-
const entry = `import { hydrate, h } from 'preact';
|
|
85
|
-
import Page from ${JSON.stringify(file)};
|
|
86
|
-
const el = document.getElementById('glash-root');
|
|
87
|
-
const pe = document.getElementById('glash-props');
|
|
88
|
-
let props = {};
|
|
89
|
-
try { props = pe ? JSON.parse(pe.textContent) : {}; } catch {}
|
|
90
|
-
if (el) hydrate(h(Page, props), el);`;
|
|
126
|
+
const id = compId(pageFile, layouts);
|
|
127
|
+
if (!dev && clientCache.has(id)) return clientCache.get(id);
|
|
91
128
|
const res = await eb.build({
|
|
92
|
-
stdin: { contents:
|
|
93
|
-
bundle: true,
|
|
94
|
-
|
|
95
|
-
format: 'esm',
|
|
96
|
-
minify: !dev,
|
|
97
|
-
jsx: 'automatic',
|
|
98
|
-
jsxImportSource: 'preact',
|
|
99
|
-
write: false,
|
|
100
|
-
logLevel: 'silent',
|
|
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',
|
|
101
132
|
});
|
|
102
133
|
const js = res.outputFiles[0].text;
|
|
103
|
-
if (!dev) clientCache.set(
|
|
134
|
+
if (!dev) clientCache.set(id, js);
|
|
104
135
|
return js;
|
|
105
136
|
}
|
|
106
137
|
|
|
107
|
-
|
|
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. */
|
|
108
145
|
export async function renderComponent(mod, props) {
|
|
109
146
|
const rt = await preactRuntime();
|
|
110
147
|
if (!rt) throw new Error(MISSING);
|
|
111
|
-
if (typeof mod.
|
|
112
|
-
return rt.renderToString(rt.h
|
|
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));
|
|
113
150
|
}
|
package/src/server/router.mjs
CHANGED
|
@@ -7,15 +7,40 @@
|
|
|
7
7
|
// routes/docs/[...path].mjs -> /docs/*path (catch-all)
|
|
8
8
|
// routes/api/hello.mjs -> /api/hello (API route — exports GET/POST/…)
|
|
9
9
|
// Anything under routes/api/ is an API route; everything else is a page (SSR).
|
|
10
|
-
import { promises as fs } from 'node:fs';
|
|
10
|
+
import { promises as fs, existsSync } from 'node:fs';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* File-based middleware: `_middleware.{mjs,js}` in any routes dir runs before
|
|
15
|
+
* the route, root→leaf. Each default-exports `(ctx) => Response | void` — return
|
|
16
|
+
* a value to short-circuit (redirect/auth/json), or nothing to continue. The
|
|
17
|
+
* root middleware runs for every request; `dash/_middleware` only for /dash/*.
|
|
18
|
+
*/
|
|
19
|
+
export function findMiddleware(routesDir, routeFile) {
|
|
20
|
+
const root = path.resolve(routesDir);
|
|
21
|
+
const dir = path.dirname(path.resolve(routeFile));
|
|
22
|
+
const rel = path.relative(root, dir);
|
|
23
|
+
const dirs = [root];
|
|
24
|
+
let acc = root;
|
|
25
|
+
for (const part of rel ? rel.split(path.sep) : []) { acc = path.join(acc, part); dirs.push(acc); }
|
|
26
|
+
const files = [];
|
|
27
|
+
for (const d of dirs) {
|
|
28
|
+
for (const name of ['_middleware.mjs', '_middleware.js']) {
|
|
29
|
+
const f = path.join(d, name);
|
|
30
|
+
if (existsSync(f)) { files.push(f); break; }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return files;
|
|
34
|
+
}
|
|
35
|
+
|
|
13
36
|
export async function discoverRoutes(routesDir) {
|
|
14
37
|
const root = path.resolve(routesDir);
|
|
15
38
|
const files = [];
|
|
16
39
|
await walk(root, root, files);
|
|
17
40
|
const routes = files
|
|
18
41
|
.filter((f) => /\.(mjs|js|jsx|tsx)$/.test(f.rel))
|
|
42
|
+
// `_`-prefixed files are private (layouts, helpers) — not routes.
|
|
43
|
+
.filter((f) => !f.rel.split('/').some((seg) => seg.startsWith('_')))
|
|
19
44
|
.map((f) => toRoute(f.rel, f.file));
|
|
20
45
|
// Most specific first: static segments beat params beat catch-all.
|
|
21
46
|
routes.sort((a, b) => b.score - a.score);
|
package/src/server/server.mjs
CHANGED
|
@@ -6,13 +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
10
|
import { randomBytes } from 'node:crypto';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { pathToFileURL } from 'node:url';
|
|
13
|
-
import { discoverRoutes, matchRoute } from './router.mjs';
|
|
14
|
-
import { renderDocument } from './html.mjs';
|
|
15
|
-
import { isComponentRoute, loadComponentRoute, clientBundle, renderComponent, routeId } from './jsx.mjs';
|
|
13
|
+
import { discoverRoutes, matchRoute, findMiddleware } from './router.mjs';
|
|
14
|
+
import { renderDocument, documentParts } from './html.mjs';
|
|
15
|
+
import { isComponentRoute, loadComponentRoute, clientBundle, renderComponent, routeId, findLayouts, clearJsxCaches } from './jsx.mjs';
|
|
16
16
|
import { securityHeaders } from '../security/headers.mjs';
|
|
17
17
|
import { loadConfig } from '../config.mjs';
|
|
18
18
|
|
|
@@ -36,32 +36,60 @@ export async function createGlashServer({ root = process.cwd(), dev = false } =
|
|
|
36
36
|
const importRoute = (file) =>
|
|
37
37
|
import(pathToFileURL(file).href + (dev ? `?t=${Date.now()}` : ''));
|
|
38
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
|
+
|
|
39
51
|
const server = http.createServer(async (req, res) => {
|
|
40
52
|
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
41
53
|
const pathname = safeDecode(url.pathname);
|
|
42
54
|
try {
|
|
43
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
|
+
}
|
|
44
64
|
// Client hydration bundles: /_glash/<routeId>.js
|
|
45
65
|
if (pathname.startsWith('/_glash/')) {
|
|
46
66
|
const id = pathname.slice('/_glash/'.length).replace(/\.js$/, '');
|
|
47
67
|
const comp = routes.find((r) => isComponentRoute(r.file) && routeId(r.file) === id);
|
|
48
68
|
if (!comp) return send(res, 404, 'text/plain', 'not found', secHeaders);
|
|
49
|
-
const js = await clientBundle(comp.file, dev);
|
|
69
|
+
const js = await clientBundle(comp.file, findLayouts(routesDir, comp.file), dev);
|
|
50
70
|
return send(res, 200, 'text/javascript; charset=utf-8', js, { ...secHeaders, 'cache-control': dev ? 'no-store' : 'public, max-age=31536000, immutable' });
|
|
51
71
|
}
|
|
52
72
|
if (await serveStatic(res, outDir, pathname, req, secHeaders)) return;
|
|
53
73
|
const match = matchRoute(routes, pathname);
|
|
54
74
|
if (!match) return send(res, 404, 'text/plain; charset=utf-8', 'Not found', secHeaders);
|
|
55
|
-
const ctx = makeCtx(req, url, match.params);
|
|
75
|
+
const ctx = makeCtx(req, res, url, match.params);
|
|
76
|
+
// Run the middleware chain (root -> leaf). Any return value short-circuits.
|
|
77
|
+
for (const mwFile of findMiddleware(routesDir, match.route.file)) {
|
|
78
|
+
const mwMod = await importRoute(mwFile);
|
|
79
|
+
const mw = mwMod.default || mwMod.middleware;
|
|
80
|
+
if (typeof mw !== 'function') continue;
|
|
81
|
+
const result = await mw(ctx);
|
|
82
|
+
if (result) return sendMiddlewareResult(res, result, secHeaders);
|
|
83
|
+
}
|
|
56
84
|
if (match.route.isApi) {
|
|
57
85
|
const mod = await importRoute(match.route.file);
|
|
58
86
|
return await handleApi(res, mod, req, ctx, secHeaders);
|
|
59
87
|
}
|
|
60
88
|
if (isComponentRoute(match.route.file)) {
|
|
61
|
-
return await handleComponentPage(res, match.route, ctx, cfg, secHeaders, root, dev);
|
|
89
|
+
return await handleComponentPage(res, match.route, ctx, cfg, secHeaders, root, routesDir, dev);
|
|
62
90
|
}
|
|
63
91
|
const mod = await importRoute(match.route.file);
|
|
64
|
-
return await handlePage(res, mod, ctx, cfg, secHeaders);
|
|
92
|
+
return await handlePage(res, mod, ctx, cfg, secHeaders, dev);
|
|
65
93
|
} catch (err) {
|
|
66
94
|
const msg = dev ? `glashjs error:\n${err?.stack || err}` : 'Internal Server Error';
|
|
67
95
|
send(res, 500, 'text/plain; charset=utf-8', msg, secHeaders);
|
|
@@ -89,7 +117,7 @@ async function handleApi(res, mod, req, ctx, secHeaders) {
|
|
|
89
117
|
send(res, 200, 'application/json', JSON.stringify(result ?? null), secHeaders);
|
|
90
118
|
}
|
|
91
119
|
|
|
92
|
-
async function handlePage(res, mod, ctx, cfg, secHeaders) {
|
|
120
|
+
async function handlePage(res, mod, ctx, cfg, secHeaders, dev) {
|
|
93
121
|
const render = mod.default;
|
|
94
122
|
if (typeof render !== 'function') return send(res, 500, 'text/plain', 'route has no default export', secHeaders);
|
|
95
123
|
const out = await render(ctx);
|
|
@@ -101,30 +129,28 @@ async function handlePage(res, mod, ctx, cfg, secHeaders) {
|
|
|
101
129
|
body: page.body ?? '',
|
|
102
130
|
offline: cfg.offline,
|
|
103
131
|
animatedFavicon: !!cfg.animatedFavicon,
|
|
104
|
-
nonce,
|
|
132
|
+
nonce, dev,
|
|
105
133
|
});
|
|
106
134
|
send(res, page.status || 200, 'text/html; charset=utf-8', docHtml, { ...pageHeaders(cfg, secHeaders, nonce), ...(page.headers || {}) });
|
|
107
135
|
}
|
|
108
136
|
|
|
109
|
-
async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, dev) {
|
|
110
|
-
const
|
|
137
|
+
async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, routesDir, dev) {
|
|
138
|
+
const layouts = findLayouts(routesDir, route.file);
|
|
139
|
+
const mod = await loadComponentRoute(route.file, layouts, root, dev);
|
|
111
140
|
const props = (typeof mod.getServerData === 'function' ? await mod.getServerData(ctx) : {}) || {};
|
|
112
|
-
const rendered = await renderComponent(mod, props);
|
|
113
141
|
const id = routeId(route.file);
|
|
114
142
|
const nonce = randomBytes(16).toString('base64');
|
|
115
143
|
// Props in a non-executed JSON block (CSP-safe); hydration bundle is an
|
|
116
144
|
// external 'self' module — both pass the strict CSP without 'unsafe-inline'.
|
|
117
145
|
const head = `<script type="application/json" id="glash-props">${safeJson(props)}</script>`;
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
title: mod.title || cfg.name,
|
|
121
|
-
head,
|
|
122
|
-
body,
|
|
123
|
-
offline: cfg.offline,
|
|
124
|
-
animatedFavicon: !!cfg.animatedFavicon,
|
|
125
|
-
nonce,
|
|
146
|
+
const { open, tail } = documentParts({
|
|
147
|
+
title: mod.title || cfg.name, head, offline: cfg.offline, animatedFavicon: !!cfg.animatedFavicon, nonce, dev,
|
|
126
148
|
});
|
|
127
|
-
|
|
149
|
+
res.writeHead(200, { ...pageHeaders(cfg, secHeaders, nonce), 'content-type': 'text/html; charset=utf-8' });
|
|
150
|
+
res.write(open); // flush the shell first, before rendering the component
|
|
151
|
+
const rendered = await renderComponent(mod, props);
|
|
152
|
+
res.write(`<div id="glash-root">${rendered}</div><script type="module" src="/_glash/${id}.js"></script>`);
|
|
153
|
+
res.end(tail);
|
|
128
154
|
}
|
|
129
155
|
|
|
130
156
|
// Per-request page headers: a fresh CSP carrying this request's script nonce, so
|
|
@@ -160,9 +186,10 @@ async function serveStatic(res, outDir, pathname, req, secHeaders) {
|
|
|
160
186
|
return false;
|
|
161
187
|
}
|
|
162
188
|
|
|
163
|
-
function makeCtx(req, url, params) {
|
|
189
|
+
function makeCtx(req, res, url, params) {
|
|
164
190
|
return {
|
|
165
191
|
req,
|
|
192
|
+
res,
|
|
166
193
|
method: req.method,
|
|
167
194
|
url,
|
|
168
195
|
path: url.pathname,
|
|
@@ -195,3 +222,22 @@ function safeDecode(p) {
|
|
|
195
222
|
export function json(body, { status = 200, headers } = {}) {
|
|
196
223
|
return { __response: true, status, contentType: 'application/json', body, headers };
|
|
197
224
|
}
|
|
225
|
+
|
|
226
|
+
/** Middleware/handler helper: redirect to another path. */
|
|
227
|
+
export function redirect(location, { status = 302 } = {}) {
|
|
228
|
+
return { __redirect: location, status };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function sendMiddlewareResult(res, result, secHeaders) {
|
|
232
|
+
if (result.__redirect) {
|
|
233
|
+
res.writeHead(result.status || 302, { ...secHeaders, location: result.__redirect });
|
|
234
|
+
return res.end();
|
|
235
|
+
}
|
|
236
|
+
if (result.__response) {
|
|
237
|
+
return send(res, result.status || 200, result.contentType || 'application/json',
|
|
238
|
+
typeof result.body === 'string' ? result.body : JSON.stringify(result.body), { ...secHeaders, ...(result.headers || {}) });
|
|
239
|
+
}
|
|
240
|
+
// A bare object/string from middleware is treated as a JSON/text body.
|
|
241
|
+
if (typeof result === 'string') return send(res, 200, 'text/plain; charset=utf-8', result, secHeaders);
|
|
242
|
+
return send(res, 200, 'application/json', JSON.stringify(result), secHeaders);
|
|
243
|
+
}
|