glashjs 0.0.2 → 0.1.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/LICENSE +21 -0
- package/README.md +37 -3
- package/bin/glash.mjs +23 -0
- package/package.json +9 -4
- package/src/config.mjs +3 -0
- package/src/index.mjs +3 -0
- package/src/server/html.mjs +68 -0
- package/src/server/router.mjs +76 -0
- package/src/server/server.mjs +147 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GlashDB
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -49,6 +49,36 @@ Nothing is unhackable. glashjs ships strong, opinionated defaults so you're secu
|
|
|
49
49
|
- **Subresource Integrity** helper (`sri()`) for build assets
|
|
50
50
|
- Emitted to `glash-headers.json` for the edge, plus a `glashSecurity()` Express middleware
|
|
51
51
|
|
|
52
|
+
## Routing, SSR & API (new in 0.1)
|
|
53
|
+
glashjs now has a real server runtime — **file-based routing**, **server-side rendering**, and **API routes** — on Node built-ins, zero deps.
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
routes/
|
|
57
|
+
index.mjs -> / (SSR page)
|
|
58
|
+
about.mjs -> /about
|
|
59
|
+
blog/[slug].mjs -> /blog/:slug (dynamic segment -> ctx.params.slug)
|
|
60
|
+
docs/[...path].mjs -> /docs/* (catch-all)
|
|
61
|
+
api/hello.mjs -> /api/hello (API: export GET/POST/…)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```js
|
|
65
|
+
// routes/index.mjs — a server-rendered page (XSS-safe `html` template)
|
|
66
|
+
import { html } from 'glashjs';
|
|
67
|
+
export default (ctx) => ({ title: 'Home', body: html`<h1>Hello ${ctx.query.name}</h1>` });
|
|
68
|
+
|
|
69
|
+
// routes/api/hello.mjs — an API route
|
|
70
|
+
import { json } from 'glashjs';
|
|
71
|
+
export const GET = (ctx) => ({ ok: true, name: ctx.query.name });
|
|
72
|
+
export const POST = (ctx) => json({ created: ctx.body }, { status: 201 });
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
glash dev # dev server: routing + SSR + API, live route reload
|
|
77
|
+
glash serve # production server over routes/ + built assets (Brotli-negotiated)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Every response carries the secure-by-default headers; static files are served from the build with Brotli negotiation. **Honest scope:** this is real SSR of HTML (render functions) — React/JSX components with client hydration, layouts/nested routing, client-JS bundling, and HMR are the next milestones (below).
|
|
81
|
+
|
|
52
82
|
## Usage
|
|
53
83
|
|
|
54
84
|
```js
|
|
@@ -92,10 +122,14 @@ animatedFavicon: true, // bundled animated glash mark (d
|
|
|
92
122
|
- [x] Asset optimizer (Brotli/Gzip real; AVIF/WebP/AV1 via optional sharp/ffmpeg)
|
|
93
123
|
- [x] Offline Service Worker + PWA manifest
|
|
94
124
|
- [x] Secure-by-default headers + CSP + SRI
|
|
125
|
+
- [x] File-based routing (pages + `api/`, dynamic `[param]` & catch-all `[...path]`)
|
|
126
|
+
- [x] Server-side rendering (XSS-safe `html` templates) + full-document runtime
|
|
127
|
+
- [x] API routes (per-method handlers, JSON body parsing, typed `json()` responses)
|
|
128
|
+
- [x] Dev/prod server with live route reload + Brotli-negotiated static serving
|
|
129
|
+
- [ ] React/JSX components + client hydration, nested layouts, streaming SSR
|
|
130
|
+
- [ ] Client-JS bundling + HMR (on esbuild/Vite)
|
|
95
131
|
- [ ] `<Image>` / `<Video>` components that emit `<picture>`/`<source>` from the manifest
|
|
96
|
-
- [ ]
|
|
97
|
-
- [ ] SSR / streaming + edge adapter for the glashdb Worker (serve `.br`/`.avif` by `Accept`)
|
|
98
|
-
- [ ] Signed, immutable asset URLs + automatic SRI injection into HTML
|
|
132
|
+
- [ ] Edge adapter for the glashdb Worker (serve `.br`/`.avif` by `Accept`)
|
|
99
133
|
- [ ] `glash deploy` → glashdb hosting in one command
|
|
100
134
|
|
|
101
135
|
## Design stance
|
package/bin/glash.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { readFileSync } from 'node:fs';
|
|
4
4
|
import { build } from '../src/build.mjs';
|
|
5
5
|
import { optimizeAssets } from '../src/assets/optimize.mjs';
|
|
6
|
+
import { createGlashServer } from '../src/server/server.mjs';
|
|
6
7
|
|
|
7
8
|
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
8
9
|
const [, , cmd, ...rest] = process.argv;
|
|
@@ -12,12 +13,32 @@ function arg(name, fallback) {
|
|
|
12
13
|
return i >= 0 && rest[i + 1] ? rest[i + 1] : fallback;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
async function serve(dev) {
|
|
17
|
+
const root = arg('--root', process.cwd());
|
|
18
|
+
const { listen, cfg, routes } = await createGlashServer({ root, dev });
|
|
19
|
+
const port = Number(arg('--port', cfg.port || 3000));
|
|
20
|
+
const { host } = await listen(port);
|
|
21
|
+
const pages = routes.filter((r) => !r.isApi).length;
|
|
22
|
+
const apis = routes.filter((r) => r.isApi).length;
|
|
23
|
+
console.log(`\nglashjs ${dev ? 'dev' : 'serve'} — "${cfg.name}"`);
|
|
24
|
+
console.log(` ${pages} page route(s), ${apis} api route(s)`);
|
|
25
|
+
routes.forEach((r) => console.log(` ${r.isApi ? 'api ' : 'page'} ${r.pattern}`));
|
|
26
|
+
console.log(`\n ▶ http://localhost:${port}${dev ? ' (live route reload)' : ''}\n`);
|
|
27
|
+
}
|
|
28
|
+
|
|
15
29
|
async function main() {
|
|
16
30
|
switch (cmd) {
|
|
17
31
|
case 'build': {
|
|
18
32
|
await build({ root: arg('--root', process.cwd()) });
|
|
19
33
|
break;
|
|
20
34
|
}
|
|
35
|
+
case 'dev':
|
|
36
|
+
await serve(true);
|
|
37
|
+
break;
|
|
38
|
+
case 'serve':
|
|
39
|
+
case 'start':
|
|
40
|
+
await serve(false);
|
|
41
|
+
break;
|
|
21
42
|
case 'optimize': {
|
|
22
43
|
const dir = rest[0] && !rest[0].startsWith('--') ? rest[0] : 'public';
|
|
23
44
|
console.log(`glashjs optimize — ${dir}\n`);
|
|
@@ -35,6 +56,8 @@ async function main() {
|
|
|
35
56
|
console.log(`glashjs — fast, offline-capable, hard-to-hack sites
|
|
36
57
|
|
|
37
58
|
Usage:
|
|
59
|
+
glash dev [--port 3000] Run the dev server (file-based routing, SSR, API, live reload)
|
|
60
|
+
glash serve [--port 3000] Run the production server over routes/ + built assets
|
|
38
61
|
glash build [--root <dir>] Optimize assets, generate offline SW + PWA + security manifests
|
|
39
62
|
glash optimize [<dir>] Just run the asset optimizer over a directory
|
|
40
63
|
glash version Print version
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "glashjs",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "glashjs — a
|
|
3
|
+
"version": "0.1.0",
|
|
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": {
|
|
7
7
|
"glash": "bin/glash.mjs"
|
|
@@ -37,7 +37,12 @@
|
|
|
37
37
|
"asset-optimization",
|
|
38
38
|
"avif",
|
|
39
39
|
"brotli",
|
|
40
|
-
"security"
|
|
40
|
+
"security",
|
|
41
|
+
"routing",
|
|
42
|
+
"ssr",
|
|
43
|
+
"api",
|
|
44
|
+
"server",
|
|
45
|
+
"web-framework"
|
|
41
46
|
],
|
|
42
|
-
"license": "
|
|
47
|
+
"license": "MIT"
|
|
43
48
|
}
|
package/src/config.mjs
CHANGED
|
@@ -17,6 +17,9 @@ export const DEFAULT_CONFIG = {
|
|
|
17
17
|
// false disables it. Emits `glash-favicon.mjs` — call startGlashFavicon() once.
|
|
18
18
|
animatedFavicon: true,
|
|
19
19
|
publicDir: 'public',
|
|
20
|
+
// File-based routes (pages + api/) served by `glash dev` / `glash serve`.
|
|
21
|
+
routesDir: 'routes',
|
|
22
|
+
port: 3000,
|
|
20
23
|
outDir: '.glash/out',
|
|
21
24
|
themeColor: '#0b0d12',
|
|
22
25
|
offline: true,
|
package/src/index.mjs
CHANGED
|
@@ -5,3 +5,6 @@ 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';
|
|
10
|
+
export { html, raw, escapeHtml, renderDocument } from './server/html.mjs';
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// glashjs SSR HTML layer
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// A secure-by-default templating helper + full-document renderer. The `html`
|
|
4
|
+
// tagged template ESCAPES every interpolation (so user data can't inject
|
|
5
|
+
// markup — XSS-safe by default, matching the security pillar). Use `raw()` to
|
|
6
|
+
// deliberately embed already-rendered HTML (e.g. a nested component).
|
|
7
|
+
export function escapeHtml(value) {
|
|
8
|
+
return String(value).replace(/[&<>"']/g, (c) => ({
|
|
9
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
10
|
+
}[c]));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function raw(value) {
|
|
14
|
+
return { __raw: String(value) };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function interpolate(v) {
|
|
18
|
+
if (v == null || v === false) return '';
|
|
19
|
+
if (Array.isArray(v)) return v.map(interpolate).join('');
|
|
20
|
+
if (typeof v === 'object' && '__raw' in v) return v.__raw;
|
|
21
|
+
return escapeHtml(v);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function html(strings, ...values) {
|
|
25
|
+
let out = '';
|
|
26
|
+
strings.forEach((str, i) => { out += str + (i < values.length ? interpolate(values[i]) : ''); });
|
|
27
|
+
return raw(out);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Wrap a page body into a full HTML document with the glashjs runtime wired in:
|
|
32
|
+
* favicon, animated-favicon runtime, and offline service-worker registration.
|
|
33
|
+
* The runtime imports are resilient (try/catch) so pages still render in `dev`
|
|
34
|
+
* before a `glash build` has produced those files.
|
|
35
|
+
*/
|
|
36
|
+
export function renderDocument({
|
|
37
|
+
title = 'glashjs',
|
|
38
|
+
head = '',
|
|
39
|
+
body = '',
|
|
40
|
+
lang = 'en',
|
|
41
|
+
favicon = '/favicon.svg',
|
|
42
|
+
offline = true,
|
|
43
|
+
animatedFavicon = true,
|
|
44
|
+
} = {}) {
|
|
45
|
+
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
|
+
return `<!doctype html>
|
|
54
|
+
<html lang="${escapeHtml(lang)}">
|
|
55
|
+
<head>
|
|
56
|
+
<meta charset="utf-8">
|
|
57
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
58
|
+
<title>${escapeHtml(title)}</title>
|
|
59
|
+
<link rel="icon" href="${escapeHtml(favicon)}" type="image/svg+xml">
|
|
60
|
+
<link rel="manifest" href="/manifest.webmanifest">
|
|
61
|
+
${headHtml}
|
|
62
|
+
</head>
|
|
63
|
+
<body>
|
|
64
|
+
${bodyHtml}
|
|
65
|
+
${fav}${off}
|
|
66
|
+
</body>
|
|
67
|
+
</html>`;
|
|
68
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// glashjs file-based router
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Maps files under the routes/ dir to URL patterns, Next-style:
|
|
4
|
+
// routes/index.mjs -> /
|
|
5
|
+
// routes/about.mjs -> /about
|
|
6
|
+
// routes/blog/[slug].mjs -> /blog/:slug (dynamic segment)
|
|
7
|
+
// routes/docs/[...path].mjs -> /docs/*path (catch-all)
|
|
8
|
+
// routes/api/hello.mjs -> /api/hello (API route — exports GET/POST/…)
|
|
9
|
+
// Anything under routes/api/ is an API route; everything else is a page (SSR).
|
|
10
|
+
import { promises as fs } from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
export async function discoverRoutes(routesDir) {
|
|
14
|
+
const root = path.resolve(routesDir);
|
|
15
|
+
const files = [];
|
|
16
|
+
await walk(root, root, files);
|
|
17
|
+
const routes = files
|
|
18
|
+
.filter((f) => /\.(mjs|js)$/.test(f.rel))
|
|
19
|
+
.map((f) => toRoute(f.rel, f.file));
|
|
20
|
+
// Most specific first: static segments beat params beat catch-all.
|
|
21
|
+
routes.sort((a, b) => b.score - a.score);
|
|
22
|
+
return routes;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function walk(root, dir, out) {
|
|
26
|
+
let entries;
|
|
27
|
+
try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
28
|
+
for (const e of entries) {
|
|
29
|
+
const full = path.join(dir, e.name);
|
|
30
|
+
if (e.isDirectory()) await walk(root, full, out);
|
|
31
|
+
else if (e.isFile()) out.push({ file: full, rel: path.relative(root, full).split(path.sep).join('/') });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function toRoute(rel, file) {
|
|
36
|
+
const clean = rel.replace(/\.(mjs|js)$/, '');
|
|
37
|
+
const isApi = clean === 'api' || clean.startsWith('api/');
|
|
38
|
+
const segs = [];
|
|
39
|
+
for (const part of clean.split('/').filter(Boolean)) {
|
|
40
|
+
if (part === 'index') continue;
|
|
41
|
+
if (part.startsWith('[...') && part.endsWith(']')) segs.push({ type: 'catchall', name: part.slice(4, -1) });
|
|
42
|
+
else if (part.startsWith('[') && part.endsWith(']')) segs.push({ type: 'param', name: part.slice(1, -1) });
|
|
43
|
+
else segs.push({ type: 'static', value: part });
|
|
44
|
+
}
|
|
45
|
+
const pattern = '/' + segs.map((s) => (s.type === 'static' ? s.value : s.type === 'param' ? `:${s.name}` : `*${s.name}`)).join('/');
|
|
46
|
+
const score = segs.reduce((acc, s) => acc + (s.type === 'static' ? 3 : s.type === 'param' ? 2 : 1), 0) * 10 + segs.length;
|
|
47
|
+
return { file, isApi, segs, pattern: pattern === '/' ? '/' : pattern, score };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function matchRoute(routes, pathname) {
|
|
51
|
+
const reqSegs = pathname.replace(/\/+$/, '').split('/').filter(Boolean);
|
|
52
|
+
for (const route of routes) {
|
|
53
|
+
const params = matchSegs(route.segs, reqSegs);
|
|
54
|
+
if (params) return { route, params };
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function matchSegs(segs, reqSegs) {
|
|
60
|
+
const params = {};
|
|
61
|
+
let i = 0;
|
|
62
|
+
for (const seg of segs) {
|
|
63
|
+
if (seg.type === 'catchall') {
|
|
64
|
+
params[seg.name] = reqSegs.slice(i).map(decodeURIComponent).join('/');
|
|
65
|
+
return params;
|
|
66
|
+
}
|
|
67
|
+
if (i >= reqSegs.length) return null;
|
|
68
|
+
if (seg.type === 'static') {
|
|
69
|
+
if (reqSegs[i] !== seg.value) return null;
|
|
70
|
+
} else {
|
|
71
|
+
params[seg.name] = decodeURIComponent(reqSegs[i]);
|
|
72
|
+
}
|
|
73
|
+
i++;
|
|
74
|
+
}
|
|
75
|
+
return i === reqSegs.length ? params : null;
|
|
76
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// glashjs server — routing, SSR, and API in one Node http server.
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// `dev: true` -> re-discovers routes and re-imports modules each request, so
|
|
4
|
+
// edits show up without a restart (live reload of route code).
|
|
5
|
+
// `dev: false` -> caches routes; serves the built `outDir` (optimized assets,
|
|
6
|
+
// service worker, favicons) with Brotli negotiation.
|
|
7
|
+
// Every response carries the secure-by-default headers.
|
|
8
|
+
import http from 'node:http';
|
|
9
|
+
import { promises as fs, existsSync, statSync } from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { pathToFileURL } from 'node:url';
|
|
12
|
+
import { discoverRoutes, matchRoute } from './router.mjs';
|
|
13
|
+
import { renderDocument } from './html.mjs';
|
|
14
|
+
import { securityHeaders } from '../security/headers.mjs';
|
|
15
|
+
import { loadConfig } from '../config.mjs';
|
|
16
|
+
|
|
17
|
+
const MIME = {
|
|
18
|
+
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.mjs': 'text/javascript',
|
|
19
|
+
'.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml',
|
|
20
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp',
|
|
21
|
+
'.avif': 'image/avif', '.webmanifest': 'application/manifest+json', '.ico': 'image/x-icon',
|
|
22
|
+
'.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8',
|
|
23
|
+
};
|
|
24
|
+
const mime = (file) => MIME[path.extname(file).toLowerCase()] || 'application/octet-stream';
|
|
25
|
+
|
|
26
|
+
/** Build the glashjs server. Returns { server, listen, cfg }. */
|
|
27
|
+
export async function createGlashServer({ root = process.cwd(), dev = false } = {}) {
|
|
28
|
+
const cfg = await loadConfig(root);
|
|
29
|
+
const routesDir = path.resolve(root, cfg.routesDir || 'routes');
|
|
30
|
+
const outDir = path.resolve(root, cfg.outDir);
|
|
31
|
+
const secHeaders = securityHeaders(cfg.security);
|
|
32
|
+
let routes = await discoverRoutes(routesDir);
|
|
33
|
+
|
|
34
|
+
const importRoute = (file) =>
|
|
35
|
+
import(pathToFileURL(file).href + (dev ? `?t=${Date.now()}` : ''));
|
|
36
|
+
|
|
37
|
+
const server = http.createServer(async (req, res) => {
|
|
38
|
+
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
39
|
+
const pathname = safeDecode(url.pathname);
|
|
40
|
+
try {
|
|
41
|
+
if (await serveStatic(res, outDir, pathname, req, secHeaders)) return;
|
|
42
|
+
if (dev) routes = await discoverRoutes(routesDir);
|
|
43
|
+
const match = matchRoute(routes, pathname);
|
|
44
|
+
if (!match) return send(res, 404, 'text/plain; charset=utf-8', 'Not found', secHeaders);
|
|
45
|
+
const mod = await importRoute(match.route.file);
|
|
46
|
+
const ctx = makeCtx(req, url, match.params);
|
|
47
|
+
return match.route.isApi
|
|
48
|
+
? await handleApi(res, mod, req, ctx, secHeaders)
|
|
49
|
+
: await handlePage(res, mod, ctx, cfg, secHeaders);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
const msg = dev ? `glashjs error:\n${err?.stack || err}` : 'Internal Server Error';
|
|
52
|
+
send(res, 500, 'text/plain; charset=utf-8', msg, secHeaders);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const listen = (port = cfg.port || 3000, host = '0.0.0.0') =>
|
|
57
|
+
new Promise((resolve) => server.listen(port, host, () => resolve({ port, host })));
|
|
58
|
+
|
|
59
|
+
return { server, listen, cfg, routes, routesDir, outDir };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function handleApi(res, mod, req, ctx, secHeaders) {
|
|
63
|
+
const method = req.method.toUpperCase();
|
|
64
|
+
const handler = mod[method] || (method === 'GET' && mod.default) || mod.handler;
|
|
65
|
+
if (typeof handler !== 'function') {
|
|
66
|
+
return send(res, 405, 'application/json', JSON.stringify({ error: 'method not allowed' }), secHeaders);
|
|
67
|
+
}
|
|
68
|
+
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) ctx.body = await readJson(req);
|
|
69
|
+
const result = await handler(ctx);
|
|
70
|
+
if (result && result.__response) {
|
|
71
|
+
return send(res, result.status || 200, result.contentType || 'application/json',
|
|
72
|
+
typeof result.body === 'string' ? result.body : JSON.stringify(result.body), { ...secHeaders, ...(result.headers || {}) });
|
|
73
|
+
}
|
|
74
|
+
send(res, 200, 'application/json', JSON.stringify(result ?? null), secHeaders);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function handlePage(res, mod, ctx, cfg, secHeaders) {
|
|
78
|
+
const render = mod.default;
|
|
79
|
+
if (typeof render !== 'function') return send(res, 500, 'text/plain', 'route has no default export', secHeaders);
|
|
80
|
+
const out = await render(ctx);
|
|
81
|
+
const page = typeof out === 'string' || (out && out.__raw) ? { body: out } : (out || {});
|
|
82
|
+
const docHtml = renderDocument({
|
|
83
|
+
title: page.title || mod.title || cfg.name,
|
|
84
|
+
head: page.head || '',
|
|
85
|
+
body: page.body ?? '',
|
|
86
|
+
offline: cfg.offline,
|
|
87
|
+
animatedFavicon: !!cfg.animatedFavicon,
|
|
88
|
+
});
|
|
89
|
+
send(res, page.status || 200, 'text/html; charset=utf-8', docHtml, { ...secHeaders, ...(page.headers || {}) });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function serveStatic(res, outDir, pathname, req, secHeaders) {
|
|
93
|
+
if (pathname === '/') return false; // let the index page route render
|
|
94
|
+
const rel = pathname.replace(/^\/+/, '');
|
|
95
|
+
const file = path.join(outDir, rel);
|
|
96
|
+
if (!file.startsWith(outDir + path.sep)) return false; // path traversal guard
|
|
97
|
+
const ae = String(req.headers['accept-encoding'] || '');
|
|
98
|
+
if (ae.includes('br') && existsSync(file + '.br')) {
|
|
99
|
+
const buf = await fs.readFile(file + '.br');
|
|
100
|
+
res.writeHead(200, { ...secHeaders, 'content-type': mime(file), 'content-encoding': 'br', 'cache-control': 'public, max-age=31536000, immutable' });
|
|
101
|
+
res.end(buf);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
if (existsSync(file) && statSync(file).isFile()) {
|
|
105
|
+
const buf = await fs.readFile(file);
|
|
106
|
+
res.writeHead(200, { ...secHeaders, 'content-type': mime(file), 'cache-control': 'public, max-age=3600' });
|
|
107
|
+
res.end(buf);
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function makeCtx(req, url, params) {
|
|
114
|
+
return {
|
|
115
|
+
req,
|
|
116
|
+
method: req.method,
|
|
117
|
+
url,
|
|
118
|
+
path: url.pathname,
|
|
119
|
+
params,
|
|
120
|
+
query: Object.fromEntries(url.searchParams),
|
|
121
|
+
headers: req.headers,
|
|
122
|
+
body: undefined,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function readJson(req) {
|
|
127
|
+
return new Promise((resolve) => {
|
|
128
|
+
let data = '';
|
|
129
|
+
req.on('data', (c) => { data += c; if (data.length > 2_000_000) req.destroy(); });
|
|
130
|
+
req.on('end', () => { try { resolve(data ? JSON.parse(data) : {}); } catch { resolve({}); } });
|
|
131
|
+
req.on('error', () => resolve({}));
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function send(res, status, contentType, body, headers = {}) {
|
|
136
|
+
res.writeHead(status, { ...headers, 'content-type': contentType });
|
|
137
|
+
res.end(body);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function safeDecode(p) {
|
|
141
|
+
try { return decodeURIComponent(p); } catch { return p; }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** API helper: return a typed Response from a handler. */
|
|
145
|
+
export function json(body, { status = 200, headers } = {}) {
|
|
146
|
+
return { __response: true, status, contentType: 'application/json', body, headers };
|
|
147
|
+
}
|