spark-ssr 0.1.0 → 0.2.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 +50 -4
- package/bin/cli.js +20 -4
- package/package.json +3 -2
- package/src/config.js +5 -0
- package/src/hydrate.js +12 -7
- package/src/parse.js +16 -2
- package/src/render.js +76 -14
- package/src/server.js +308 -57
package/README.md
CHANGED
|
@@ -47,13 +47,16 @@ No `<script>`. No SQL. No ORM. No server file. No build step.
|
|
|
47
47
|
{
|
|
48
48
|
"db": "postgres://localhost:5432/myapp",
|
|
49
49
|
"auth": { "table": "users", "identity": "email", "secret": "ENV.SESSION_SECRET" },
|
|
50
|
-
"cors": true
|
|
50
|
+
"cors": true,
|
|
51
|
+
"fonts": [{ "family": "Inter", "google": true, "weights": [400, 700] }]
|
|
51
52
|
}
|
|
52
53
|
```
|
|
53
54
|
|
|
54
55
|
`sqlite://./dev.db` works too (Bun ships both drivers). `ENV.*` values resolve
|
|
55
56
|
from the environment at startup. `cors: true` allows all origins on `/api/*`;
|
|
56
|
-
an array allows specific ones.
|
|
57
|
+
an array allows specific ones. `fonts` renders spark-html-font's head tags
|
|
58
|
+
(preloads, `@font-face` with a size-adjusted no-shift fallback, a
|
|
59
|
+
`--font-<slug>` var) into every page — same shapes as its build-pipeline step.
|
|
57
60
|
|
|
58
61
|
## Routing
|
|
59
62
|
|
|
@@ -79,6 +82,41 @@ Without a `pages/` folder, `*.html` at the project root serve the same way.
|
|
|
79
82
|
JSON body (`:body.title`), session (`:session.id`), headers
|
|
80
83
|
(`:header.x-forwarded-for`), uploads (`:file.url`).
|
|
81
84
|
|
|
85
|
+
## The page owns its \<head\>
|
|
86
|
+
|
|
87
|
+
Literal `<title>`/`<meta>`/`<link>` tags at the top of a page lift into the
|
|
88
|
+
document head, `{expr}`-interpolated against the page's data
|
|
89
|
+
(spark-html-head's `/ssr` module does the lifting):
|
|
90
|
+
|
|
91
|
+
```html
|
|
92
|
+
<!-- pages/blog/[slug].html -->
|
|
93
|
+
<title>{post.title} · My Blog</title>
|
|
94
|
+
<meta name="description" content="{post.excerpt}">
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Client scripts and the family
|
|
98
|
+
|
|
99
|
+
A page's plain `<script>` runs on the **server** (the escape hatch).
|
|
100
|
+
`<script type="module">` and `<script src>` are **client** scripts — they lift
|
|
101
|
+
into `<head>` after an auto-generated importmap, so bare imports of the Spark
|
|
102
|
+
family just work, no build:
|
|
103
|
+
|
|
104
|
+
```html
|
|
105
|
+
<script type="module" src="/app.js"></script>
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
```js
|
|
109
|
+
// public/app.js
|
|
110
|
+
import { theme } from 'spark-html-theme';
|
|
111
|
+
theme();
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Every `spark-html-*` package in your dependencies is importmap-mapped and
|
|
115
|
+
served at `/@modules/<name>/…`. Depend on **spark-html-theme** and the
|
|
116
|
+
no-flash init snippet is inlined in every head automatically; depend on
|
|
117
|
+
**spark-html-image** and `spark-ssr build` runs its webp/srcset pass over
|
|
118
|
+
`dist/` (options: `"images"` in spark.json).
|
|
119
|
+
|
|
82
120
|
## Custom endpoints — api/
|
|
83
121
|
|
|
84
122
|
`api/stats.html` auto-serves as `GET /api/stats`:
|
|
@@ -104,14 +142,22 @@ scope; the return value becomes the JSON response).
|
|
|
104
142
|
stored URL into your INSERT.
|
|
105
143
|
- **Error pages** — `404.html` / `500.html` at the project root.
|
|
106
144
|
- **Static assets** — `public/` plus co-located page assets, served as-is.
|
|
145
|
+
Project internals (spark.json, package.json, `*.db`, dotfiles) are never
|
|
146
|
+
served.
|
|
107
147
|
- **Hydration** — interactive pages ship fully-rendered HTML plus a generated
|
|
108
148
|
client component; `mount()` takes over with the same spark-html runtime.
|
|
149
|
+
- **Live reload** — in dev, every edit (pages, components, queries,
|
|
150
|
+
middleware, css) refreshes the browser via SSE. No restart, no flags.
|
|
151
|
+
- **Auth-table hygiene** — the auth table's auto CRUD never returns password
|
|
152
|
+
hashes, and PATCH/DELETE are own-account only. Configuring `auth` registers
|
|
153
|
+
the table (login/signup endpoints) without any page declaring it; disable
|
|
154
|
+
public signup in `middleware.html` if the app is invite-only.
|
|
109
155
|
|
|
110
156
|
## Deploy
|
|
111
157
|
|
|
112
158
|
```bash
|
|
113
|
-
bun spark-ssr build # dist/ with a compiled single binary
|
|
114
|
-
bun spark-ssr start # run in production
|
|
159
|
+
bun spark-ssr build # dist/ with a compiled single binary (public/ flattens into dist root)
|
|
160
|
+
bun spark-ssr start # run in production (watch + live reload off)
|
|
115
161
|
```
|
|
116
162
|
|
|
117
163
|
MIT
|
package/bin/cli.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { join, resolve } from 'node:path';
|
|
17
17
|
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync, readdirSync, statSync } from 'node:fs';
|
|
18
|
-
import { serve } from '../src/index.js';
|
|
18
|
+
import { serve, loadConfig } from '../src/index.js';
|
|
19
19
|
|
|
20
20
|
function parseArgs(argv) {
|
|
21
21
|
const opts = { cmd: 'serve', compile: true };
|
|
@@ -41,7 +41,10 @@ Usage:
|
|
|
41
41
|
|
|
42
42
|
// The project files a deployment needs — pages, components, api, public,
|
|
43
43
|
// error pages, middleware, config. node_modules/dist/uploads stay behind.
|
|
44
|
-
|
|
44
|
+
// public/ is FLATTENED into dist root: assets keep the same URLs they had in
|
|
45
|
+
// dev (/style.css, /img/…), and post-build passes (spark-html-image) resolve
|
|
46
|
+
// root-absolute <img> paths against dist directly.
|
|
47
|
+
const SHIP_DIRS = ['pages', 'components', 'api'];
|
|
45
48
|
const SHIP_FILES = ['404.html', '500.html', 'middleware.html', 'spark.json', 'package.json'];
|
|
46
49
|
|
|
47
50
|
async function build(root, compile) {
|
|
@@ -51,6 +54,11 @@ async function build(root, compile) {
|
|
|
51
54
|
for (const d of SHIP_DIRS) {
|
|
52
55
|
if (existsSync(join(root, d))) cpSync(join(root, d), join(dist, d), { recursive: true });
|
|
53
56
|
}
|
|
57
|
+
if (existsSync(join(root, 'public'))) {
|
|
58
|
+
for (const f of readdirSync(join(root, 'public'))) {
|
|
59
|
+
cpSync(join(root, 'public', f), join(dist, f), { recursive: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
54
62
|
for (const f of SHIP_FILES) {
|
|
55
63
|
if (existsSync(join(root, f))) cpSync(join(root, f), join(dist, f));
|
|
56
64
|
}
|
|
@@ -67,8 +75,16 @@ async function build(root, compile) {
|
|
|
67
75
|
}
|
|
68
76
|
writeFileSync(join(dist, '__server.js'),
|
|
69
77
|
"import { serve } from 'spark-ssr';\n" +
|
|
70
|
-
'serve({ root: process.cwd(), port: Number(process.env.PORT) || 3000 });\n');
|
|
78
|
+
'serve({ root: process.cwd(), port: Number(process.env.PORT) || 3000, watch: false });\n');
|
|
71
79
|
console.log(`✓ assembled dist/`);
|
|
80
|
+
// spark-html-image, when the app depends on it: the same pass a
|
|
81
|
+
// spark-html-bun pipeline runs — webp variants + srcset for every local
|
|
82
|
+
// <img> in the assembled pages and components. Options: spark.json "images".
|
|
83
|
+
try {
|
|
84
|
+
const image = (await import('spark-html-image')).default;
|
|
85
|
+
await image(loadConfig(root).images || {}).run({ outDir: dist });
|
|
86
|
+
console.log('✓ images optimized (spark-html-image)');
|
|
87
|
+
} catch { /* not installed — plain assets ship as-is */ }
|
|
72
88
|
if (compile) {
|
|
73
89
|
const r = Bun.spawnSync(
|
|
74
90
|
['bun', 'build', '--compile', join(dist, '__server.js'), '--outfile', join(dist, 'app')],
|
|
@@ -89,7 +105,7 @@ if (opts.cmd === 'build') {
|
|
|
89
105
|
await build(root, opts.compile);
|
|
90
106
|
} else if (opts.cmd === 'start') {
|
|
91
107
|
const dist = join(root, 'dist');
|
|
92
|
-
await serve({ root: existsSync(join(dist, '__server.js')) ? dist : root, port });
|
|
108
|
+
await serve({ root: existsSync(join(dist, '__server.js')) ? dist : root, port, watch: false });
|
|
93
109
|
} else {
|
|
94
110
|
await serve({ root, port });
|
|
95
111
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spark-ssr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Zero-config SSR for spark-html on Bun. The HTML template infers everything: filesystem routing, <spark-ssr> declarative queries, auto CRUD APIs, sessions, uploads, middleware. No build step.",
|
|
5
5
|
"homepage": "https://wilkinnovo.github.io/spark-html",
|
|
6
6
|
"type": "module",
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"linkedom": "^0.18.12",
|
|
33
|
-
"spark-html": "^0.27.
|
|
33
|
+
"spark-html": "^0.27.8",
|
|
34
|
+
"spark-html-head": "^0.3.0"
|
|
34
35
|
},
|
|
35
36
|
"keywords": [
|
|
36
37
|
"spark-html",
|
package/src/config.js
CHANGED
|
@@ -36,5 +36,10 @@ export function loadConfig(root) {
|
|
|
36
36
|
auth: cfg.auth || null,
|
|
37
37
|
cors: cfg.cors ?? false,
|
|
38
38
|
uploads: cfg.uploads || 'uploads',
|
|
39
|
+
// Companion-package config, same shapes their build-pipeline steps take:
|
|
40
|
+
// "fonts" → spark-html-font tags in every <head>; "images" → options for
|
|
41
|
+
// the spark-html-image pass `spark-ssr build` runs when it's installed.
|
|
42
|
+
fonts: cfg.fonts || null,
|
|
43
|
+
images: cfg.images || null,
|
|
39
44
|
};
|
|
40
45
|
}
|
package/src/hydrate.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* becomes onclick={remove(todo)} — the runtime runs it as an inline statement.
|
|
16
16
|
*/
|
|
17
17
|
import { parseHTML } from 'linkedom';
|
|
18
|
+
import { templateKids } from './render.js';
|
|
18
19
|
|
|
19
20
|
// Structural roles from the analysis (names are the author's own).
|
|
20
21
|
export function handlerRoles(analysis) {
|
|
@@ -43,12 +44,7 @@ export function primaryColumn(cols) {
|
|
|
43
44
|
export function clientComponent({ html, analysis, plan, table, cols, key }) {
|
|
44
45
|
const { document } = parseHTML('<!doctype html><html><body>' + html + '</body></html>');
|
|
45
46
|
|
|
46
|
-
|
|
47
|
-
// .content depending on how linkedom parsed them — read both.
|
|
48
|
-
const kids = (node) => [
|
|
49
|
-
...(node.content ? node.content.childNodes : []),
|
|
50
|
-
...node.childNodes,
|
|
51
|
-
];
|
|
47
|
+
const kids = templateKids;
|
|
52
48
|
|
|
53
49
|
(function transform(node, loopVar) {
|
|
54
50
|
if (node.nodeType !== 1) return;
|
|
@@ -74,7 +70,16 @@ export function clientComponent({ html, analysis, plan, table, cols, key }) {
|
|
|
74
70
|
const em = each.match(/^\s*([\w$]+)/);
|
|
75
71
|
if (em) inner = em[1];
|
|
76
72
|
}
|
|
77
|
-
|
|
73
|
+
// Attribute rewrites must reach BOTH of linkedom's template stores —
|
|
74
|
+
// it may hold duplicate copies in .content and .childNodes, and which
|
|
75
|
+
// one the serializer emits varies. (Moves, like the await unwrap above,
|
|
76
|
+
// take just the canonical side since the template is removed after.)
|
|
77
|
+
const seen = new Set();
|
|
78
|
+
for (const c of [...(node.content ? node.content.childNodes : []), ...node.childNodes]) {
|
|
79
|
+
if (seen.has(c)) continue;
|
|
80
|
+
seen.add(c);
|
|
81
|
+
transform(c, inner);
|
|
82
|
+
}
|
|
78
83
|
return;
|
|
79
84
|
}
|
|
80
85
|
if (loopVar && node.attributes) {
|
package/src/parse.js
CHANGED
|
@@ -11,16 +11,30 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { parseHTML } from 'linkedom';
|
|
13
13
|
|
|
14
|
+
// ── comment masking ────────────────────────────────────────────────────
|
|
15
|
+
// Every regex-based extraction here must mask comments first, so prose like
|
|
16
|
+
// <!-- declare data in <spark-ssr>, no <script> needed --> never starts (or
|
|
17
|
+
// ends) an extraction. restore() puts the comments back verbatim.
|
|
18
|
+
export function maskComments(source) {
|
|
19
|
+
const comments = [];
|
|
20
|
+
const masked = String(source).replace(/<!--[\s\S]*?-->/g, (m) => {
|
|
21
|
+
comments.push(m);
|
|
22
|
+
return `\u0000c${comments.length - 1}\u0000`;
|
|
23
|
+
});
|
|
24
|
+
return { masked, restore: (s) => String(s).replace(/\u0000c(\d+)\u0000/g, (_, i) => comments[i]) };
|
|
25
|
+
}
|
|
26
|
+
|
|
14
27
|
// ── <spark-ssr> blocks ─────────────────────────────────────────────────
|
|
15
28
|
export function extractBlocks(source) {
|
|
29
|
+
const { masked, restore } = maskComments(source);
|
|
16
30
|
const blocks = [];
|
|
17
31
|
const re = /<spark-ssr\b([^>]*?)\/>|<spark-ssr\b([^>]*)>([\s\S]*?)<\/spark-ssr>/gi;
|
|
18
|
-
const html =
|
|
32
|
+
const html = restore(masked.replace(re, (m, selfAttrs, attrs, inner) => {
|
|
19
33
|
const attrStr = selfAttrs ?? attrs ?? '';
|
|
20
34
|
const table = (attrStr.match(/\btable\s*=\s*"([^"]+)"/) || [])[1] || null;
|
|
21
35
|
blocks.push({ table, routes: inner ? parseRoutes(inner) : [] });
|
|
22
36
|
return '';
|
|
23
|
-
});
|
|
37
|
+
}));
|
|
24
38
|
return { blocks, html };
|
|
25
39
|
}
|
|
26
40
|
|
package/src/render.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* re-attaches them client-side; static pages don't need them).
|
|
7
7
|
*/
|
|
8
8
|
import { parseHTML } from 'linkedom';
|
|
9
|
+
import { maskComments } from './parse.js';
|
|
9
10
|
|
|
10
11
|
const FN_CACHE = new Map();
|
|
11
12
|
function compile(expr) {
|
|
@@ -37,12 +38,21 @@ export function evalExpr(expr, scope) {
|
|
|
37
38
|
|
|
38
39
|
const str = (v) => (v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v));
|
|
39
40
|
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
];
|
|
41
|
+
// linkedom's template parsing is inconsistent: children can land in .content,
|
|
42
|
+
// in .childNodes, split between the two (whitespace one side, elements the
|
|
43
|
+
// other), or fully DUPLICATED into both. Never merge — pick the side that
|
|
44
|
+
// actually holds elements; on a tie (duplicates) .content is canonical.
|
|
45
|
+
export function templateKids(node) {
|
|
46
|
+
const c = node.content ? [...node.content.childNodes] : [];
|
|
47
|
+
const d = [...node.childNodes];
|
|
48
|
+
if (!c.length) return d;
|
|
49
|
+
if (!d.length) return c;
|
|
50
|
+
const hasEl = (a) => a.some((n) => n.nodeType === 1);
|
|
51
|
+
if (hasEl(c)) return c;
|
|
52
|
+
if (hasEl(d)) return d;
|
|
53
|
+
return c;
|
|
54
|
+
}
|
|
55
|
+
const kids = templateKids;
|
|
46
56
|
const interpolate = (text, scope) =>
|
|
47
57
|
String(text).replace(/\{([^{}]+)\}/g, (_, e) => str(evalExpr(e, scope)));
|
|
48
58
|
|
|
@@ -188,11 +198,45 @@ async function renderIfChain(node, scope, ctx, depth) {
|
|
|
188
198
|
for (const link of chain) link.node.remove();
|
|
189
199
|
}
|
|
190
200
|
|
|
201
|
+
// Round-trip an evaluated prop back to an attribute string the runtime's
|
|
202
|
+
// coerce() understands ('' = true, JSON for objects, …) — same contract as
|
|
203
|
+
// spark-prerender's serializeProp.
|
|
204
|
+
function serializeProp(v) {
|
|
205
|
+
if (v === true) return '';
|
|
206
|
+
if (v === false) return 'false';
|
|
207
|
+
if (v === null || v === undefined) return 'null';
|
|
208
|
+
if (typeof v === 'string') return v;
|
|
209
|
+
if (typeof v === 'number') return String(v);
|
|
210
|
+
return JSON.stringify(v);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Literal top-level defaults from a component <script> (let count = 0;
|
|
214
|
+
// let greeting = 'hi') so the SSR output shows initial values instead of
|
|
215
|
+
// blanks. Anything non-literal is skipped — the client boot computes it.
|
|
216
|
+
export function scriptLiterals(code) {
|
|
217
|
+
const out = {};
|
|
218
|
+
for (const m of String(code).matchAll(/^\s*(?:let|var|const)\s+([a-zA-Z_$][\w$]*)\s*=\s*(.+?);?\s*$/gm)) {
|
|
219
|
+
const raw = m[2].trim();
|
|
220
|
+
try {
|
|
221
|
+
out[m[1]] = JSON.parse(raw.replace(/^'([^'\\]*)'$/, '"$1"'));
|
|
222
|
+
} catch { /* not a literal — client-side only */ }
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
191
227
|
async function renderImport(node, scope, ctx, depth) {
|
|
192
228
|
const spec = node.getAttribute('import');
|
|
193
|
-
node.removeAttribute('import');
|
|
194
229
|
if (depth >= (ctx.maxDepth || 20)) { node.innerHTML = ''; return; }
|
|
195
230
|
|
|
231
|
+
// A top-level host on a page that will client-mount keeps its import (plus
|
|
232
|
+
// a `name` and its evaluated props) so the runtime's flash-free hydrate
|
|
233
|
+
// path re-resolves it and the component comes alive — exactly the contract
|
|
234
|
+
// spark-prerender's makeHydratable establishes. Nested hosts are inlined;
|
|
235
|
+
// their parent rebuilds them on the client.
|
|
236
|
+
const keepHost = !!ctx.keepImports && depth === 0;
|
|
237
|
+
if (!keepHost) node.removeAttribute('import');
|
|
238
|
+
else node.setAttribute('name', String(spec).split(/[?#]/)[0].replace(/\/+$/, '').replace(/.*\//, '').replace(/\.html$/, ''));
|
|
239
|
+
|
|
196
240
|
// Slot content renders in the CALLER's scope, before the component swaps in.
|
|
197
241
|
await walkChildren(node, scope, ctx, depth);
|
|
198
242
|
const slotHtml = node.innerHTML;
|
|
@@ -203,25 +247,43 @@ async function renderImport(node, scope, ctx, depth) {
|
|
|
203
247
|
for (const attr of [...node.attributes]) {
|
|
204
248
|
const n = attr.name;
|
|
205
249
|
const v = String(attr.value || '');
|
|
250
|
+
if (n === 'import' || n === 'name' || n.startsWith('data-spark')) continue;
|
|
206
251
|
if (n === 'class' || n === 'id') { if (v.includes('{')) attr.value = interpolate(v, scope); continue; }
|
|
207
252
|
const exact = v.trim().match(/^\{([\s\S]+)\}$/);
|
|
208
253
|
props[n] = exact ? evalExpr(exact[1], scope) : v.includes('{') ? interpolate(v, scope) : v;
|
|
209
|
-
|
|
254
|
+
// Kept hosts re-serialize the evaluated value so the client re-resolve
|
|
255
|
+
// receives the same props; inlined hosts drop them.
|
|
256
|
+
if (keepHost) attr.value = serializeProp(props[n]);
|
|
257
|
+
else node.removeAttribute(n);
|
|
210
258
|
}
|
|
211
259
|
|
|
212
260
|
const source = ctx.loadComponent ? await ctx.loadComponent(spec) : null;
|
|
213
261
|
if (source == null) { node.innerHTML = ''; return; }
|
|
214
|
-
// Components are pure UI: strip
|
|
215
|
-
|
|
262
|
+
// Components are pure UI on the server: strip <spark-ssr>/<script> from the
|
|
263
|
+
// output, but read literal script defaults so {count} renders as 0.
|
|
264
|
+
// Comments are masked so prose mentioning those tags never truncates one.
|
|
265
|
+
let script = '';
|
|
266
|
+
const { masked, restore } = maskComments(source);
|
|
267
|
+
const clean = restore(masked
|
|
216
268
|
.replace(/<spark-ssr\b[^>]*?\/>|<spark-ssr\b[^>]*>[\s\S]*?<\/spark-ssr>/gi, '')
|
|
217
|
-
.replace(/<script\b[^>]*>[\s\S]
|
|
269
|
+
.replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gi, (m, body) => { script += body + '\n'; return ''; }));
|
|
218
270
|
node.innerHTML = clean;
|
|
219
|
-
|
|
271
|
+
const compScope = Object.assign(Object.create(null), scriptLiterals(script), props);
|
|
272
|
+
await walkChildren(node, compScope, ctx, depth + 1);
|
|
220
273
|
|
|
221
274
|
// Default slot: replace <slot> with the caller's rendered content.
|
|
222
275
|
for (const slot of [...node.querySelectorAll('slot')]) {
|
|
223
|
-
const holder = ctx.document.createElement('
|
|
276
|
+
const holder = ctx.document.createElement('div');
|
|
224
277
|
holder.innerHTML = slotHtml;
|
|
225
|
-
slot.replaceWith(...
|
|
278
|
+
slot.replaceWith(...holder.childNodes);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Stash the rendered slot content for the client's hydrate path
|
|
282
|
+
// (<template data-spark-slots>, read by the runtime on re-resolve).
|
|
283
|
+
if (keepHost && slotHtml.trim()) {
|
|
284
|
+
const stash = ctx.document.createElement('template');
|
|
285
|
+
stash.setAttribute('data-spark-slots', '');
|
|
286
|
+
stash.innerHTML = slotHtml;
|
|
287
|
+
node.appendChild(stash);
|
|
226
288
|
}
|
|
227
289
|
}
|
package/src/server.js
CHANGED
|
@@ -10,9 +10,14 @@ import { existsSync, readFileSync, readdirSync, statSync, mkdirSync } from 'node
|
|
|
10
10
|
import { createHmac, timingSafeEqual, randomBytes, randomUUID } from 'node:crypto';
|
|
11
11
|
import { loadConfig } from './config.js';
|
|
12
12
|
import { connect } from './db.js';
|
|
13
|
-
import { extractBlocks, analyze, dataPlan, rewriteParams, singleShaped } from './parse.js';
|
|
14
|
-
import { renderFragment } from './render.js';
|
|
13
|
+
import { extractBlocks, analyze, dataPlan, rewriteParams, singleShaped, maskComments } from './parse.js';
|
|
14
|
+
import { renderFragment, evalExpr } from './render.js';
|
|
15
15
|
import { clientComponent, initModule } from './hydrate.js';
|
|
16
|
+
// Head semantics live in one place for the whole family: spark-html-head owns
|
|
17
|
+
// title/meta on the client (pushState updates); its /ssr module owns them
|
|
18
|
+
// here — pages put literal <title>/<meta>/<link> tags in their markup, we
|
|
19
|
+
// lift them into the document head with {expr} interpolated per request.
|
|
20
|
+
import { liftHead, renderHead } from 'spark-html-head/ssr';
|
|
16
21
|
|
|
17
22
|
const AsyncFunction = (async () => {}).constructor;
|
|
18
23
|
const json = (data, status = 200, headers = {}) =>
|
|
@@ -63,12 +68,18 @@ function matchPage(pages, pathname) {
|
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
// Split the page's <script> (the server-side escape hatch) from its markup.
|
|
71
|
+
// Client scripts — <script src> and inline <script type="module"> — are NOT
|
|
72
|
+
// server code; they stay in the markup and liftHead sends them to the browser.
|
|
66
73
|
function splitScript(html) {
|
|
67
74
|
let code = '';
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
75
|
+
const { masked, restore } = maskComments(html);
|
|
76
|
+
const out = restore(masked.replace(
|
|
77
|
+
/<script\b(?![^>]*\bsrc=)(?![^>]*\btype\s*=\s*["']module["'])[^>]*>([\s\S]*?)<\/script>/gi,
|
|
78
|
+
(m, body) => {
|
|
79
|
+
code += body + '\n';
|
|
80
|
+
return '';
|
|
81
|
+
},
|
|
82
|
+
));
|
|
72
83
|
return { html: out, code: code.trim() };
|
|
73
84
|
}
|
|
74
85
|
|
|
@@ -80,10 +91,13 @@ function pageData(page, cache) {
|
|
|
80
91
|
const source = readFileSync(page.file, 'utf8');
|
|
81
92
|
const { blocks, html } = extractBlocks(source);
|
|
82
93
|
const { html: markup, code } = splitScript(html);
|
|
94
|
+
// Analyze BEFORE lifting the head, so a {var} used only in <title>/<meta>
|
|
95
|
+
// still registers as a data need.
|
|
83
96
|
const analysis = analyze(markup);
|
|
84
97
|
analysis.hasScript = !!code;
|
|
85
98
|
const plan = dataPlan(analysis, blocks);
|
|
86
|
-
const
|
|
99
|
+
const { head, scripts, body } = liftHead(markup);
|
|
100
|
+
const data = { mtime, source, blocks, html: body, head, scripts, code, analysis, plan };
|
|
87
101
|
cache.set(page.file, data);
|
|
88
102
|
return data;
|
|
89
103
|
}
|
|
@@ -120,12 +134,95 @@ export async function serve(options = {}) {
|
|
|
120
134
|
const config = { ...loadConfig(root), ...(options.config || {}) };
|
|
121
135
|
const db = await connect(config.db);
|
|
122
136
|
const secret = (config.auth && config.auth.secret) || randomBytes(32).toString('hex');
|
|
123
|
-
const { pagesDir, pages } = scanPages(root);
|
|
124
137
|
const cache = new Map();
|
|
138
|
+
const pages = [];
|
|
139
|
+
let pagesDir = root;
|
|
125
140
|
const uploadsDir = join(root, config.uploads);
|
|
126
141
|
const quiet = !!options.quiet;
|
|
127
142
|
|
|
128
|
-
const ctx = {
|
|
143
|
+
const ctx = { port: 0 };
|
|
144
|
+
|
|
145
|
+
// ── dev live reload ──
|
|
146
|
+
// The server side already re-reads files per request; this closes the loop
|
|
147
|
+
// on the browser side. A cheap mtime sweep (same walk refreshPages does)
|
|
148
|
+
// feeds an SSE channel, and every HTML response carries a two-line client
|
|
149
|
+
// that reloads the page on a ping. Production (`start` / dist) runs with
|
|
150
|
+
// watch:false and ships none of it.
|
|
151
|
+
const live = options.watch !== false;
|
|
152
|
+
const sseClients = new Set();
|
|
153
|
+
const sseEnc = new TextEncoder();
|
|
154
|
+
let watchTimer = null;
|
|
155
|
+
if (live) {
|
|
156
|
+
const IGNORE = new Set(['node_modules', 'dist', 'uploads']);
|
|
157
|
+
const mtimes = new Map();
|
|
158
|
+
const sweep = () => {
|
|
159
|
+
const seen = new Set();
|
|
160
|
+
let changed = false;
|
|
161
|
+
(function walk(dir) {
|
|
162
|
+
let names;
|
|
163
|
+
try { names = readdirSync(dir); } catch { return; }
|
|
164
|
+
for (const f of names) {
|
|
165
|
+
if (f.startsWith('.') || IGNORE.has(f)) continue;
|
|
166
|
+
const full = join(dir, f);
|
|
167
|
+
let st;
|
|
168
|
+
try { st = statSync(full); } catch { continue; }
|
|
169
|
+
if (st.isDirectory()) { walk(full); continue; }
|
|
170
|
+
if (!/\.(html|css|js|json)$/.test(f)) continue;
|
|
171
|
+
seen.add(full);
|
|
172
|
+
if (mtimes.get(full) !== st.mtimeMs) { mtimes.set(full, st.mtimeMs); changed = true; }
|
|
173
|
+
}
|
|
174
|
+
})(root);
|
|
175
|
+
for (const k of mtimes.keys()) if (!seen.has(k)) { mtimes.delete(k); changed = true; }
|
|
176
|
+
return changed;
|
|
177
|
+
};
|
|
178
|
+
sweep(); // baseline — the first pass records, it doesn't reload anyone
|
|
179
|
+
watchTimer = setInterval(() => {
|
|
180
|
+
if (!sweep()) return;
|
|
181
|
+
for (const c of sseClients) {
|
|
182
|
+
try { c.enqueue(sseEnc.encode('data: reload\n\n')); } catch { sseClients.delete(c); }
|
|
183
|
+
}
|
|
184
|
+
}, 250);
|
|
185
|
+
watchTimer.unref?.();
|
|
186
|
+
}
|
|
187
|
+
// Reconnect-then-reload: after a server restart the EventSource reconnects,
|
|
188
|
+
// and a fresh open following an error means "the server came back" — reload.
|
|
189
|
+
const RELOAD_CLIENT = '<script>(()=>{const e=new EventSource("/__spark/reload");let d=0;'
|
|
190
|
+
+ 'e.onmessage=()=>location.reload();e.onerror=()=>{d=1};e.onopen=()=>{if(d)location.reload()}})()</script>';
|
|
191
|
+
|
|
192
|
+
// ── the Spark family, wired in ──
|
|
193
|
+
// Companion packages the app depends on get an importmap entry and are
|
|
194
|
+
// served at /@modules/<name>, so client scripts import them bare — the same
|
|
195
|
+
// packages a spark-html-bun/prerender build uses, working here unbundled.
|
|
196
|
+
let familyDeps = [];
|
|
197
|
+
try {
|
|
198
|
+
const pj = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
199
|
+
familyDeps = Object.keys({ ...pj.dependencies, ...pj.devDependencies })
|
|
200
|
+
.filter((n) => /^spark-html-[\w-]+$/.test(n));
|
|
201
|
+
} catch { /* no package.json — single-file project */ }
|
|
202
|
+
|
|
203
|
+
// spark-html-theme: inline its no-flash snippet in every <head> (the same
|
|
204
|
+
// one spark-html-theme/bun bakes into prerendered pages) so the saved/OS
|
|
205
|
+
// theme is on <html> before first paint.
|
|
206
|
+
let themeInit = '';
|
|
207
|
+
if (familyDeps.includes('spark-html-theme')) {
|
|
208
|
+
try {
|
|
209
|
+
const { themeInitScript } = await import('spark-html-theme/init');
|
|
210
|
+
themeInit = `<script>${themeInitScript()}</script>`;
|
|
211
|
+
} catch { /* older spark-html-theme without /init — theme() still works, with a flash */ }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// spark-html-font: `"fonts"` in spark.json renders the same head tags the
|
|
215
|
+
// font/bun pipeline step bakes at build time — preloads, @font-face with a
|
|
216
|
+
// size-adjusted fallback face, --font-<slug> vars.
|
|
217
|
+
let fontTags = '';
|
|
218
|
+
if (config.fonts) {
|
|
219
|
+
try {
|
|
220
|
+
const { fontHtml } = await import('spark-html-font');
|
|
221
|
+
fontTags = fontHtml({ fonts: config.fonts });
|
|
222
|
+
} catch (e) {
|
|
223
|
+
if (!quiet) console.warn(`[spark-ssr] "fonts" configured but spark-html-font is not installed — ${e.message}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
129
226
|
|
|
130
227
|
// ── request wrapper ──
|
|
131
228
|
function wrapReq(request, url, params, session, server) {
|
|
@@ -227,7 +324,10 @@ export async function serve(options = {}) {
|
|
|
227
324
|
on('GET', `api/${table}`, async (req) => {
|
|
228
325
|
const { scoped } = await tableInfo(table);
|
|
229
326
|
if (scoped && !req.session) return json({ error: 'unauthorized' }, 401);
|
|
230
|
-
|
|
327
|
+
const rows = await tableRows(table, req);
|
|
328
|
+
// Password hashes never leave the auth table, not even to a session.
|
|
329
|
+
if (isAuthTable) for (const r of rows) delete r.password;
|
|
330
|
+
return json(isAuthTable ? [...rows] : rows);
|
|
231
331
|
});
|
|
232
332
|
|
|
233
333
|
on('POST', `api/${table}`, async (req) => {
|
|
@@ -255,26 +355,38 @@ export async function serve(options = {}) {
|
|
|
255
355
|
return json(row, 201);
|
|
256
356
|
});
|
|
257
357
|
|
|
358
|
+
// Auth-table writes are own-account only: anyone could otherwise reset
|
|
359
|
+
// the author's password or delete their account through the auto CRUD.
|
|
360
|
+
const ownAccountOnly = (req) =>
|
|
361
|
+
isAuthTable && (!req.session || String(req.session.id) !== String(req.params.id));
|
|
362
|
+
|
|
258
363
|
on('PATCH', `api/${table}/:id`, async (req) => {
|
|
259
364
|
const { names, scoped } = await tableInfo(table);
|
|
260
365
|
if (scoped && !req.session) return json({ error: 'unauthorized' }, 401);
|
|
366
|
+
if (ownAccountOnly(req)) return json({ error: 'unauthorized' }, 401);
|
|
261
367
|
const { fields } = await req.body();
|
|
262
368
|
const data = {};
|
|
263
369
|
for (const [k, v] of Object.entries(fields)) {
|
|
264
370
|
if (names.includes(k) && k !== 'id' && k !== 'user_id') data[k] = v;
|
|
265
371
|
}
|
|
372
|
+
if (isAuthTable && typeof data.password === 'string') {
|
|
373
|
+
data.password = await Bun.password.hash(data.password);
|
|
374
|
+
}
|
|
266
375
|
const keys = Object.keys(data);
|
|
267
376
|
if (!keys.length) return json({ error: 'empty body' }, 400);
|
|
268
377
|
let sql = `UPDATE ${table} SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`;
|
|
269
378
|
const values = [...keys.map((k) => data[k]), req.params.id];
|
|
270
379
|
if (scoped) { sql += ' AND user_id = ?'; values.push(req.session.id); }
|
|
271
380
|
const rows = await db.query(sql + ' RETURNING *', values);
|
|
272
|
-
|
|
381
|
+
const row = rows[0];
|
|
382
|
+
if (isAuthTable && row) delete row.password;
|
|
383
|
+
return row ? json(row) : json({ error: 'not found' }, 404);
|
|
273
384
|
});
|
|
274
385
|
|
|
275
386
|
on('DELETE', `api/${table}/:id`, async (req) => {
|
|
276
387
|
const { scoped } = await tableInfo(table);
|
|
277
388
|
if (scoped && !req.session) return json({ error: 'unauthorized' }, 401);
|
|
389
|
+
if (ownAccountOnly(req)) return json({ error: 'unauthorized' }, 401);
|
|
278
390
|
let sql = `DELETE FROM ${table} WHERE id = ?`;
|
|
279
391
|
const values = [req.params.id];
|
|
280
392
|
if (scoped) { sql += ' AND user_id = ?'; values.push(req.session.id); }
|
|
@@ -317,36 +429,56 @@ export async function serve(options = {}) {
|
|
|
317
429
|
}
|
|
318
430
|
|
|
319
431
|
// ── explicit <spark-ssr> query endpoints ──
|
|
320
|
-
|
|
432
|
+
// Defs are mutable so an edited page's SQL takes effect without a restart —
|
|
433
|
+
// the registered handler reads def.sql at call time.
|
|
434
|
+
const queryDefs = new Map();
|
|
321
435
|
function registerQuery(route) {
|
|
322
436
|
const key = route.method + ' ' + route.path;
|
|
323
|
-
|
|
324
|
-
|
|
437
|
+
const existing = queryDefs.get(key);
|
|
438
|
+
if (existing) { existing.sql = route.sql; return; }
|
|
439
|
+
const def = { sql: route.sql };
|
|
440
|
+
queryDefs.set(key, def);
|
|
325
441
|
const segs = route.path.split('/').filter(Boolean)
|
|
326
442
|
.map((s) => s.replace(/^\[(\w+)\]$/, ':$1'));
|
|
327
443
|
apiRoutes.push({
|
|
328
444
|
method: route.method,
|
|
329
445
|
segs,
|
|
330
446
|
handler: async (req) => {
|
|
331
|
-
const rows = await runSql(
|
|
332
|
-
if (route.method === 'GET') return json(singleShaped(
|
|
447
|
+
const rows = await runSql(def.sql, req);
|
|
448
|
+
if (route.method === 'GET') return json(singleShaped(def.sql) ? rows[0] ?? null : [...rows]);
|
|
333
449
|
if (Array.isArray(rows) && rows.length) return json(rows.length === 1 ? rows[0] : [...rows]);
|
|
334
450
|
return json({ ok: true, changes: rows.changes ?? 0 });
|
|
335
451
|
},
|
|
336
452
|
});
|
|
337
453
|
}
|
|
338
454
|
|
|
339
|
-
//
|
|
455
|
+
// (Re)scan pages/ and register everything they declare. Runs per request —
|
|
456
|
+
// a plain readdir walk plus mtime-cached parses — so new pages, new tables,
|
|
457
|
+
// and edited queries appear without restarting the server.
|
|
340
458
|
const tables = new Set();
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
459
|
+
// Configuring auth IS declaring its table: the login endpoint
|
|
460
|
+
// (POST /api/<table>?auth) and signup exist without any page mentioning
|
|
461
|
+
// them. Single-account apps can turn signup off in middleware.html.
|
|
462
|
+
if (config.auth && config.auth.table && db) {
|
|
463
|
+
tables.add(config.auth.table);
|
|
464
|
+
registerTable(config.auth.table);
|
|
465
|
+
}
|
|
466
|
+
function refreshPages() {
|
|
467
|
+
const scanned = scanPages(root);
|
|
468
|
+
pagesDir = scanned.pagesDir;
|
|
469
|
+
pages.splice(0, pages.length, ...scanned.pages);
|
|
470
|
+
for (const page of pages) {
|
|
471
|
+
let pd;
|
|
472
|
+
try { pd = pageData(page, cache); } catch { continue; }
|
|
473
|
+
for (const b of pd.blocks) {
|
|
474
|
+
if (b.table && !tables.has(b.table)) { tables.add(b.table); registerTable(b.table); }
|
|
475
|
+
for (const r of b.routes) {
|
|
476
|
+
if (r.path) registerQuery(r);
|
|
477
|
+
}
|
|
347
478
|
}
|
|
348
479
|
}
|
|
349
480
|
}
|
|
481
|
+
refreshPages();
|
|
350
482
|
|
|
351
483
|
// ── api/ folder — custom endpoints ──
|
|
352
484
|
function makeAppFetch(req) {
|
|
@@ -368,8 +500,12 @@ export async function serve(options = {}) {
|
|
|
368
500
|
};
|
|
369
501
|
}
|
|
370
502
|
|
|
371
|
-
|
|
372
|
-
|
|
503
|
+
// api/ files re-scan per request too; script handlers hold a mutable def so
|
|
504
|
+
// edits take effect, and registration itself happens once per route.
|
|
505
|
+
const apiDefs = new Map(); // route path → { mtime, fn }
|
|
506
|
+
function refreshApi() {
|
|
507
|
+
const apiDir = join(root, 'api');
|
|
508
|
+
if (!existsSync(apiDir)) return;
|
|
373
509
|
(function scanApi(dir, prefix) {
|
|
374
510
|
for (const f of readdirSync(dir)) {
|
|
375
511
|
if (f.startsWith('.')) continue;
|
|
@@ -377,21 +513,32 @@ export async function serve(options = {}) {
|
|
|
377
513
|
if (statSync(full).isDirectory()) { scanApi(full, prefix + f + '/'); continue; }
|
|
378
514
|
if (!f.endsWith('.html')) continue;
|
|
379
515
|
const route = '/api/' + prefix + f.slice(0, -5);
|
|
516
|
+
const mtime = statSync(full).mtimeMs;
|
|
517
|
+
let def = apiDefs.get(route);
|
|
518
|
+
if (def && def.mtime === mtime) continue;
|
|
519
|
+
if (!def) { def = { mtime: 0, fn: null, registered: false }; apiDefs.set(route, def); }
|
|
520
|
+
def.mtime = mtime;
|
|
380
521
|
const source = readFileSync(full, 'utf8');
|
|
381
522
|
const { blocks, html } = extractBlocks(source);
|
|
382
523
|
const { code } = splitScript(html);
|
|
383
524
|
for (const b of blocks) {
|
|
384
525
|
for (const r of b.routes) registerQuery({ ...r, path: r.path || route });
|
|
385
526
|
}
|
|
527
|
+
def.fn = null;
|
|
386
528
|
if (code) {
|
|
387
|
-
|
|
529
|
+
try { def.fn = new AsyncFunction('req', 'res', 'db', 'fetch', code); }
|
|
530
|
+
catch (e) { if (!quiet) console.warn(`[spark-ssr] ${route} <script> — ${e.message}`); }
|
|
531
|
+
}
|
|
532
|
+
if (def.fn && !def.registered) {
|
|
533
|
+
def.registered = true;
|
|
388
534
|
const segs = route.split('/').filter(Boolean);
|
|
389
535
|
for (const method of ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']) {
|
|
390
536
|
apiRoutes.push({
|
|
391
537
|
method,
|
|
392
538
|
segs,
|
|
393
539
|
handler: async (req, res) => {
|
|
394
|
-
|
|
540
|
+
if (!def.fn) return json({ error: 'not found' }, 404);
|
|
541
|
+
const out = await def.fn(req, res, db, makeAppFetch(req));
|
|
395
542
|
if (out instanceof Response) return out;
|
|
396
543
|
if (out && typeof out === 'object' && 'status' in out && 'body' in out) {
|
|
397
544
|
return new Response(typeof out.body === 'string' ? out.body : JSON.stringify(out.body), { status: out.status });
|
|
@@ -404,6 +551,7 @@ export async function serve(options = {}) {
|
|
|
404
551
|
}
|
|
405
552
|
})(apiDir, '');
|
|
406
553
|
}
|
|
554
|
+
refreshApi();
|
|
407
555
|
|
|
408
556
|
function matchApi(method, pathname) {
|
|
409
557
|
const parts = pathname.split('/').filter(Boolean);
|
|
@@ -419,14 +567,24 @@ export async function serve(options = {}) {
|
|
|
419
567
|
return null;
|
|
420
568
|
}
|
|
421
569
|
|
|
422
|
-
// ── middleware.html ──
|
|
570
|
+
// ── middleware.html (reloaded when the file changes) ──
|
|
423
571
|
let middleware = null;
|
|
572
|
+
let mwMtime = -1;
|
|
424
573
|
const mwState = { rateLimit: new Map(), state: {} };
|
|
425
|
-
|
|
426
|
-
|
|
574
|
+
function refreshMiddleware() {
|
|
575
|
+
const mwFile = join(root, 'middleware.html');
|
|
576
|
+
if (!existsSync(mwFile)) { middleware = null; mwMtime = -1; return; }
|
|
577
|
+
const mtime = statSync(mwFile).mtimeMs;
|
|
578
|
+
if (mtime === mwMtime) return;
|
|
579
|
+
mwMtime = mtime;
|
|
580
|
+
middleware = null;
|
|
427
581
|
const { code } = splitScript(readFileSync(mwFile, 'utf8'));
|
|
428
|
-
if (code)
|
|
582
|
+
if (code) {
|
|
583
|
+
try { middleware = new AsyncFunction('req', 'res', 'rateLimit', 'state', 'fetch', code); }
|
|
584
|
+
catch (e) { if (!quiet) console.warn(`[spark-ssr] middleware.html — ${e.message}`); }
|
|
585
|
+
}
|
|
429
586
|
}
|
|
587
|
+
refreshMiddleware();
|
|
430
588
|
|
|
431
589
|
// ── CORS ──
|
|
432
590
|
function corsHeaders(origin) {
|
|
@@ -460,9 +618,15 @@ export async function serve(options = {}) {
|
|
|
460
618
|
if (!rel || rel.includes('..')) return null;
|
|
461
619
|
const candidates = [join(root, 'public', rel)];
|
|
462
620
|
const ext = extname(rel);
|
|
463
|
-
|
|
621
|
+
// The root fallback exists for co-located assets (pages/x.css, img/…) —
|
|
622
|
+
// it must never serve project internals: config (may hold secrets),
|
|
623
|
+
// lockfiles, databases, dotfiles. public/ stays the intentional space.
|
|
624
|
+
const internal = rel.startsWith('.') || rel.includes('/.')
|
|
625
|
+
|| ['spark.json', 'package.json', 'bun.lock', 'bun.lockb', 'package-lock.json'].includes(rel)
|
|
626
|
+
|| ['.db', '.sqlite', '.sqlite3'].includes(ext);
|
|
627
|
+
if (!internal && ext && ext !== '.html') {
|
|
464
628
|
candidates.push(join(root, rel), join(pagesDir, rel));
|
|
465
|
-
} else if (rel.startsWith('components/')) {
|
|
629
|
+
} else if (!internal && rel.startsWith('components/')) {
|
|
466
630
|
candidates.push(join(root, rel));
|
|
467
631
|
}
|
|
468
632
|
for (const file of candidates) {
|
|
@@ -492,23 +656,51 @@ export async function serve(options = {}) {
|
|
|
492
656
|
&& pd.blocks.some((b) => b.table) && !!db;
|
|
493
657
|
}
|
|
494
658
|
|
|
495
|
-
function shell(page, body, { hydrate }) {
|
|
659
|
+
function shell(page, body, { hydrate, mount, headExtra = '', scripts = '' }) {
|
|
496
660
|
const title = page.key === 'index' ? 'Spark' : page.key.split('/').pop().replace(/\[|\]/g, '');
|
|
497
661
|
const cssRel = page.key + '.css';
|
|
498
662
|
const hasCss = existsSync(join(pagesDir, cssRel));
|
|
663
|
+
// The importmap must precede EVERY module script in document order (a
|
|
664
|
+
// later one is ignored), so the whole module story lives in <head>:
|
|
665
|
+
// importmap → the page's own client scripts → mount. Page scripts are
|
|
666
|
+
// the app's bootstrap (store()/theme() setup) and modules execute in
|
|
667
|
+
// document order, so they run before components boot — same contract as
|
|
668
|
+
// a hand-written main.js that ends with mount().
|
|
669
|
+
const needModules = mount || scripts.includes('<script');
|
|
670
|
+
const imports = {};
|
|
671
|
+
for (const dep of ['spark-html', ...familyDeps]) {
|
|
672
|
+
const info = moduleEntry(dep);
|
|
673
|
+
if (info) imports[dep] = `/@modules/${dep}/${info.entry}`;
|
|
674
|
+
}
|
|
675
|
+
const importmap = needModules
|
|
676
|
+
? `<script type="importmap">${JSON.stringify({ imports })}</script>\n`
|
|
677
|
+
: '';
|
|
678
|
+
const mountJs = mount
|
|
679
|
+
? `<script type="module">import { mount } from 'spark-html'; mount();</script>\n`
|
|
680
|
+
: '';
|
|
499
681
|
const head =
|
|
500
682
|
'<meta charset="utf-8">\n' +
|
|
501
683
|
'<meta name="viewport" content="width=device-width, initial-scale=1">\n' +
|
|
502
|
-
|
|
684
|
+
(themeInit ? themeInit + '\n' : '') +
|
|
685
|
+
(/<title\b/i.test(headExtra) ? '' : `<title>${title}</title>\n`) +
|
|
686
|
+
(headExtra ? headExtra + '\n' : '') +
|
|
687
|
+
(fontTags ? fontTags + '\n' : '') +
|
|
688
|
+
importmap +
|
|
689
|
+
(scripts ? scripts + '\n' : '') +
|
|
690
|
+
mountJs +
|
|
503
691
|
(hasCss ? `<link rel="stylesheet" href="/${cssRel}">\n` : '');
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
692
|
+
// A hydrating page host carries BOTH `import` and `name` — that is the
|
|
693
|
+
// runtime's flash-free hydrate contract (same as spark-prerender's
|
|
694
|
+
// makeHydratable): the pre-rendered content stays visible while the
|
|
695
|
+
// component is fetched and booted detached, then swaps in atomically.
|
|
696
|
+
// `name` missing would make the runtime treat the rendered HTML as SLOT
|
|
697
|
+
// content and project it next to the fresh render — duplicated live UI.
|
|
698
|
+
const compName = page.key.replace(/.*\//, '');
|
|
508
699
|
const host = hydrate
|
|
509
|
-
? `<div import="/__spark/page/${page.key}" data-spark-ssr>${body}</div>`
|
|
700
|
+
? `<div import="/__spark/page/${page.key}" name="${compName}" data-spark-ssr>${body}</div>`
|
|
510
701
|
: `<div data-spark-ssr>${body}</div>`;
|
|
511
|
-
|
|
702
|
+
const reload = live ? RELOAD_CLIENT + '\n' : '';
|
|
703
|
+
return `<!doctype html>\n<html>\n<head>\n${head}</head>\n<body>\n${host}\n${reload}</body>\n</html>\n`;
|
|
512
704
|
}
|
|
513
705
|
|
|
514
706
|
async function buildScope(pd, req) {
|
|
@@ -529,8 +721,16 @@ export async function serve(options = {}) {
|
|
|
529
721
|
async function servePage(page, req) {
|
|
530
722
|
const pd = pageData(page, cache);
|
|
531
723
|
const scope = await buildScope(pd, req);
|
|
532
|
-
const
|
|
533
|
-
|
|
724
|
+
const hydrate = shouldHydrate(pd);
|
|
725
|
+
// Component imports keep their host (import + name + props) on pages the
|
|
726
|
+
// page host won't rebuild wholesale, so a client mount re-resolves them
|
|
727
|
+
// and their own <script> comes alive (counters, demos, …).
|
|
728
|
+
const hasComponents = /\bimport\s*=\s*"/.test(pd.html);
|
|
729
|
+
const body = await renderFragment(pd.html, scope, { loadComponent, keepImports: !hydrate });
|
|
730
|
+
const headExtra = pd.head ? renderHead(pd.head, (e) => evalExpr(e, scope)) : '';
|
|
731
|
+
return new Response(shell(page, body, {
|
|
732
|
+
hydrate, mount: hydrate || hasComponents, headExtra, scripts: pd.scripts,
|
|
733
|
+
}), {
|
|
534
734
|
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
535
735
|
});
|
|
536
736
|
}
|
|
@@ -538,22 +738,37 @@ export async function serve(options = {}) {
|
|
|
538
738
|
function errorPage(status) {
|
|
539
739
|
const file = join(root, `${status}.html`);
|
|
540
740
|
if (existsSync(file)) {
|
|
541
|
-
|
|
741
|
+
// The reload client rides along so fixing the page un-sticks the browser.
|
|
742
|
+
const body = readFileSync(file, 'utf8') + (live ? '\n' + RELOAD_CLIENT : '');
|
|
743
|
+
return new Response(body, { status, headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
542
744
|
}
|
|
543
745
|
return new Response(status === 404 ? 'Not found' : 'Server error', { status });
|
|
544
746
|
}
|
|
545
747
|
|
|
546
|
-
// spark-html
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
748
|
+
// spark-html + family packages, served as browser modules. The importmap
|
|
749
|
+
// maps each package name to /@modules/<pkg>/<entry>, and sibling files in
|
|
750
|
+
// the package resolve as relative imports under the same prefix (theme's
|
|
751
|
+
// ./init.js, say). Bun's resolver falls back to its GLOBAL install cache
|
|
752
|
+
// when a dir has no node_modules — that can be a different version than
|
|
753
|
+
// the app's, so cache hits only count when nothing real resolves.
|
|
754
|
+
const moduleInfo = new Map(); // pkg → { dir, entry } | null
|
|
755
|
+
function moduleEntry(pkg) {
|
|
756
|
+
if (moduleInfo.has(pkg)) return moduleInfo.get(pkg);
|
|
757
|
+
let lastResort = null;
|
|
550
758
|
for (const dir of [root, dirname(new URL(import.meta.url).pathname)]) {
|
|
551
759
|
try {
|
|
552
|
-
|
|
553
|
-
|
|
760
|
+
const file = Bun.resolveSync(pkg, dir);
|
|
761
|
+
if (file.includes('/install/cache/')) { lastResort = lastResort || file; continue; }
|
|
762
|
+
const info = { dir: dirname(file), entry: file.slice(file.lastIndexOf('/') + 1) };
|
|
763
|
+
moduleInfo.set(pkg, info);
|
|
764
|
+
return info;
|
|
554
765
|
} catch { /* next */ }
|
|
555
766
|
}
|
|
556
|
-
|
|
767
|
+
const info = lastResort
|
|
768
|
+
? { dir: dirname(lastResort), entry: lastResort.slice(lastResort.lastIndexOf('/') + 1) }
|
|
769
|
+
: null;
|
|
770
|
+
moduleInfo.set(pkg, info);
|
|
771
|
+
return info;
|
|
557
772
|
}
|
|
558
773
|
|
|
559
774
|
// ── the server ──
|
|
@@ -564,10 +779,27 @@ export async function serve(options = {}) {
|
|
|
564
779
|
let pathname;
|
|
565
780
|
try { pathname = decodeURIComponent(url.pathname); } catch { pathname = url.pathname; }
|
|
566
781
|
if (pathname.includes('..')) return errorPage(404);
|
|
782
|
+
|
|
783
|
+
// Dev reload channel — before middleware; it's the harness, not the app.
|
|
784
|
+
if (live && pathname === '/__spark/reload') {
|
|
785
|
+
let ctrl;
|
|
786
|
+
const stream = new ReadableStream({
|
|
787
|
+
start(c) { ctrl = c; c.enqueue(sseEnc.encode(': connected\n\n')); sseClients.add(c); },
|
|
788
|
+
cancel() { sseClients.delete(ctrl); },
|
|
789
|
+
});
|
|
790
|
+
return new Response(stream, {
|
|
791
|
+
headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-store' },
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
567
795
|
const session = readSession(request.headers.get('cookie'), secret);
|
|
568
796
|
const extraHeaders = {};
|
|
569
797
|
|
|
570
798
|
try {
|
|
799
|
+
// Pick up new/edited pages, api files, and middleware without a
|
|
800
|
+
// restart (readdir walk + mtime-cached parses — cheap).
|
|
801
|
+
if (options.watch !== false) { refreshPages(); refreshApi(); refreshMiddleware(); }
|
|
802
|
+
|
|
571
803
|
// middleware.html runs first, on every request.
|
|
572
804
|
if (middleware) {
|
|
573
805
|
const req = wrapReq(request, url, {}, session, srv);
|
|
@@ -585,11 +817,24 @@ export async function serve(options = {}) {
|
|
|
585
817
|
return res;
|
|
586
818
|
};
|
|
587
819
|
|
|
588
|
-
if (pathname
|
|
589
|
-
const
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
820
|
+
if (pathname.startsWith('/@modules/')) {
|
|
821
|
+
const rest = pathname.slice('/@modules/'.length);
|
|
822
|
+
const slash = rest.indexOf('/');
|
|
823
|
+
const pkg = slash === -1 ? rest : rest.slice(0, slash);
|
|
824
|
+
const subpath = slash === -1 ? '' : rest.slice(slash + 1);
|
|
825
|
+
let mod = null;
|
|
826
|
+
if (/^spark-html(-[\w-]+)?$/.test(pkg)) {
|
|
827
|
+
const info = moduleEntry(pkg);
|
|
828
|
+
if (info) {
|
|
829
|
+
const file = resolve(info.dir, subpath || info.entry);
|
|
830
|
+
if (file.startsWith(info.dir + '/') && existsSync(file) && statSync(file).isFile()) {
|
|
831
|
+
mod = new Response(readFileSync(file, 'utf8'), {
|
|
832
|
+
headers: { 'content-type': 'text/javascript', 'cache-control': 'no-cache' },
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
return finish(mod || errorPage(404));
|
|
593
838
|
}
|
|
594
839
|
|
|
595
840
|
if (pathname.startsWith('/__spark/page/')) {
|
|
@@ -669,6 +914,12 @@ export async function serve(options = {}) {
|
|
|
669
914
|
root,
|
|
670
915
|
config,
|
|
671
916
|
db,
|
|
672
|
-
stop(force) {
|
|
917
|
+
stop(force) {
|
|
918
|
+
if (watchTimer) clearInterval(watchTimer);
|
|
919
|
+
for (const c of sseClients) { try { c.close(); } catch { /* gone */ } }
|
|
920
|
+
sseClients.clear();
|
|
921
|
+
server.stop(force);
|
|
922
|
+
return db && db.close();
|
|
923
|
+
},
|
|
673
924
|
};
|
|
674
925
|
}
|