spark-ssr 0.1.1 → 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 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
- const SHIP_DIRS = ['pages', 'components', 'api', 'public'];
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.1.1",
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.7"
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/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 = String(source).replace(re, (m, selfAttrs, attrs, inner) => {
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) {
@@ -260,10 +261,12 @@ async function renderImport(node, scope, ctx, depth) {
260
261
  if (source == null) { node.innerHTML = ''; return; }
261
262
  // Components are pure UI on the server: strip <spark-ssr>/<script> from the
262
263
  // output, but read literal script defaults so {count} renders as 0.
264
+ // Comments are masked so prose mentioning those tags never truncates one.
263
265
  let script = '';
264
- const clean = String(source)
266
+ const { masked, restore } = maskComments(source);
267
+ const clean = restore(masked
265
268
  .replace(/<spark-ssr\b[^>]*?\/>|<spark-ssr\b[^>]*>[\s\S]*?<\/spark-ssr>/gi, '')
266
- .replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gi, (m, body) => { script += body + '\n'; return ''; });
269
+ .replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gi, (m, body) => { script += body + '\n'; return ''; }));
267
270
  node.innerHTML = clean;
268
271
  const compScope = Object.assign(Object.create(null), scriptLiterals(script), props);
269
272
  await walkChildren(node, compScope, ctx, depth + 1);
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 out = String(html).replace(/<script\b(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi, (m, body) => {
69
- code += body + '\n';
70
- return '';
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 data = { mtime, source, blocks, html: markup, code, analysis, plan };
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
  }
@@ -128,6 +142,88 @@ export async function serve(options = {}) {
128
142
 
129
143
  const ctx = { port: 0 };
130
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
+ }
226
+
131
227
  // ── request wrapper ──
132
228
  function wrapReq(request, url, params, session, server) {
133
229
  const headers = {};
@@ -228,7 +324,10 @@ export async function serve(options = {}) {
228
324
  on('GET', `api/${table}`, async (req) => {
229
325
  const { scoped } = await tableInfo(table);
230
326
  if (scoped && !req.session) return json({ error: 'unauthorized' }, 401);
231
- return json(await tableRows(table, req));
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);
232
331
  });
233
332
 
234
333
  on('POST', `api/${table}`, async (req) => {
@@ -256,26 +355,38 @@ export async function serve(options = {}) {
256
355
  return json(row, 201);
257
356
  });
258
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
+
259
363
  on('PATCH', `api/${table}/:id`, async (req) => {
260
364
  const { names, scoped } = await tableInfo(table);
261
365
  if (scoped && !req.session) return json({ error: 'unauthorized' }, 401);
366
+ if (ownAccountOnly(req)) return json({ error: 'unauthorized' }, 401);
262
367
  const { fields } = await req.body();
263
368
  const data = {};
264
369
  for (const [k, v] of Object.entries(fields)) {
265
370
  if (names.includes(k) && k !== 'id' && k !== 'user_id') data[k] = v;
266
371
  }
372
+ if (isAuthTable && typeof data.password === 'string') {
373
+ data.password = await Bun.password.hash(data.password);
374
+ }
267
375
  const keys = Object.keys(data);
268
376
  if (!keys.length) return json({ error: 'empty body' }, 400);
269
377
  let sql = `UPDATE ${table} SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`;
270
378
  const values = [...keys.map((k) => data[k]), req.params.id];
271
379
  if (scoped) { sql += ' AND user_id = ?'; values.push(req.session.id); }
272
380
  const rows = await db.query(sql + ' RETURNING *', values);
273
- return rows[0] ? json(rows[0]) : json({ error: 'not found' }, 404);
381
+ const row = rows[0];
382
+ if (isAuthTable && row) delete row.password;
383
+ return row ? json(row) : json({ error: 'not found' }, 404);
274
384
  });
275
385
 
276
386
  on('DELETE', `api/${table}/:id`, async (req) => {
277
387
  const { scoped } = await tableInfo(table);
278
388
  if (scoped && !req.session) return json({ error: 'unauthorized' }, 401);
389
+ if (ownAccountOnly(req)) return json({ error: 'unauthorized' }, 401);
279
390
  let sql = `DELETE FROM ${table} WHERE id = ?`;
280
391
  const values = [req.params.id];
281
392
  if (scoped) { sql += ' AND user_id = ?'; values.push(req.session.id); }
@@ -345,6 +456,13 @@ export async function serve(options = {}) {
345
456
  // a plain readdir walk plus mtime-cached parses — so new pages, new tables,
346
457
  // and edited queries appear without restarting the server.
347
458
  const tables = new Set();
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
+ }
348
466
  function refreshPages() {
349
467
  const scanned = scanPages(root);
350
468
  pagesDir = scanned.pagesDir;
@@ -500,9 +618,15 @@ export async function serve(options = {}) {
500
618
  if (!rel || rel.includes('..')) return null;
501
619
  const candidates = [join(root, 'public', rel)];
502
620
  const ext = extname(rel);
503
- if (ext && ext !== '.html') {
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') {
504
628
  candidates.push(join(root, rel), join(pagesDir, rel));
505
- } else if (rel.startsWith('components/')) {
629
+ } else if (!internal && rel.startsWith('components/')) {
506
630
  candidates.push(join(root, rel));
507
631
  }
508
632
  for (const file of candidates) {
@@ -532,19 +656,39 @@ export async function serve(options = {}) {
532
656
  && pd.blocks.some((b) => b.table) && !!db;
533
657
  }
534
658
 
535
- function shell(page, body, { hydrate, mount }) {
659
+ function shell(page, body, { hydrate, mount, headExtra = '', scripts = '' }) {
536
660
  const title = page.key === 'index' ? 'Spark' : page.key.split('/').pop().replace(/\[|\]/g, '');
537
661
  const cssRel = page.key + '.css';
538
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
+ : '';
539
681
  const head =
540
682
  '<meta charset="utf-8">\n' +
541
683
  '<meta name="viewport" content="width=device-width, initial-scale=1">\n' +
542
- `<title>${title}</title>\n` +
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 +
543
691
  (hasCss ? `<link rel="stylesheet" href="/${cssRel}">\n` : '');
544
- const hydration = mount
545
- ? `\n<script type="importmap">{"imports":{"spark-html":"/@modules/spark-html"}}</script>\n` +
546
- `<script type="module">import { mount } from 'spark-html'; mount();</script>\n`
547
- : '\n';
548
692
  // A hydrating page host carries BOTH `import` and `name` — that is the
549
693
  // runtime's flash-free hydrate contract (same as spark-prerender's
550
694
  // makeHydratable): the pre-rendered content stays visible while the
@@ -555,7 +699,8 @@ export async function serve(options = {}) {
555
699
  const host = hydrate
556
700
  ? `<div import="/__spark/page/${page.key}" name="${compName}" data-spark-ssr>${body}</div>`
557
701
  : `<div data-spark-ssr>${body}</div>`;
558
- return `<!doctype html>\n<html>\n<head>\n${head}</head>\n<body>\n${host}${hydration}</body>\n</html>\n`;
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`;
559
704
  }
560
705
 
561
706
  async function buildScope(pd, req) {
@@ -582,7 +727,10 @@ export async function serve(options = {}) {
582
727
  // and their own <script> comes alive (counters, demos, …).
583
728
  const hasComponents = /\bimport\s*=\s*"/.test(pd.html);
584
729
  const body = await renderFragment(pd.html, scope, { loadComponent, keepImports: !hydrate });
585
- return new Response(shell(page, body, { hydrate, mount: hydrate || hasComponents }), {
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
+ }), {
586
734
  headers: { 'content-type': 'text/html; charset=utf-8' },
587
735
  });
588
736
  }
@@ -590,22 +738,37 @@ export async function serve(options = {}) {
590
738
  function errorPage(status) {
591
739
  const file = join(root, `${status}.html`);
592
740
  if (existsSync(file)) {
593
- return new Response(readFileSync(file, 'utf8'), { status, headers: { 'content-type': 'text/html; charset=utf-8' } });
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' } });
594
744
  }
595
745
  return new Response(status === 404 ? 'Not found' : 'Server error', { status });
596
746
  }
597
747
 
598
- // spark-html runtime, served for hydration (importmap target).
599
- let runtimeJs = null;
600
- function runtimeFile() {
601
- if (runtimeJs) return runtimeJs;
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;
602
758
  for (const dir of [root, dirname(new URL(import.meta.url).pathname)]) {
603
759
  try {
604
- runtimeJs = readFileSync(Bun.resolveSync('spark-html', dir), 'utf8');
605
- return runtimeJs;
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;
606
765
  } catch { /* next */ }
607
766
  }
608
- return null;
767
+ const info = lastResort
768
+ ? { dir: dirname(lastResort), entry: lastResort.slice(lastResort.lastIndexOf('/') + 1) }
769
+ : null;
770
+ moduleInfo.set(pkg, info);
771
+ return info;
609
772
  }
610
773
 
611
774
  // ── the server ──
@@ -616,6 +779,19 @@ export async function serve(options = {}) {
616
779
  let pathname;
617
780
  try { pathname = decodeURIComponent(url.pathname); } catch { pathname = url.pathname; }
618
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
+
619
795
  const session = readSession(request.headers.get('cookie'), secret);
620
796
  const extraHeaders = {};
621
797
 
@@ -641,11 +817,24 @@ export async function serve(options = {}) {
641
817
  return res;
642
818
  };
643
819
 
644
- if (pathname === '/@modules/spark-html') {
645
- const js = runtimeFile();
646
- return finish(js
647
- ? new Response(js, { headers: { 'content-type': 'text/javascript', 'cache-control': 'no-cache' } })
648
- : errorPage(404));
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));
649
838
  }
650
839
 
651
840
  if (pathname.startsWith('/__spark/page/')) {
@@ -725,6 +914,12 @@ export async function serve(options = {}) {
725
914
  root,
726
915
  config,
727
916
  db,
728
- stop(force) { server.stop(force); return db && db.close(); },
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
+ },
729
924
  };
730
925
  }