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 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.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.0"
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
- // Nested templates may keep their children in .childNodes rather than
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
- for (const c of kids(node)) transform(c, inner);
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 = 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) {
@@ -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
- // Template children may live in .content or .childNodes depending on how
41
- // linkedom parsed the (possibly nested) template read both.
42
- const kids = (node) => [
43
- ...(node.content ? node.content.childNodes : []),
44
- ...node.childNodes,
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
- node.removeAttribute(n);
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 their <spark-ssr>/<script>, keep markup+style.
215
- const clean = String(source)
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]*?<\/script>/gi, '');
269
+ .replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gi, (m, body) => { script += body + '\n'; return ''; }));
218
270
  node.innerHTML = clean;
219
- await walkChildren(node, props, ctx, depth + 1);
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('template');
276
+ const holder = ctx.document.createElement('div');
224
277
  holder.innerHTML = slotHtml;
225
- slot.replaceWith(...(holder.content || holder).childNodes);
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 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
  }
@@ -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 = { root, config, db, secret, pagesDir, pages, cache, uploadsDir, port: 0 };
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
- 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);
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
- 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);
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
- const registered = new Set();
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
- if (registered.has(key)) return;
324
- registered.add(key);
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(route.sql, req);
332
- if (route.method === 'GET') return json(singleShaped(route.sql) ? rows[0] ?? null : [...rows]);
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
- // Register everything the pages declare.
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
- for (const page of pages) {
342
- const pd = pageData(page, cache);
343
- for (const b of pd.blocks) {
344
- if (b.table && !tables.has(b.table)) { tables.add(b.table); registerTable(b.table); }
345
- for (const r of b.routes) {
346
- if (r.path) registerQuery(r);
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
- const apiDir = join(root, 'api');
372
- if (existsSync(apiDir)) {
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
- const fn = new AsyncFunction('req', 'res', 'db', 'fetch', code);
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
- const out = await fn(req, res, db, makeAppFetch(req));
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
- const mwFile = join(root, 'middleware.html');
426
- if (existsSync(mwFile)) {
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) middleware = new AsyncFunction('req', 'res', 'rateLimit', 'state', 'fetch', 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
- 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') {
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
- `<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 +
503
691
  (hasCss ? `<link rel="stylesheet" href="/${cssRel}">\n` : '');
504
- const hydration = hydrate
505
- ? `\n<script type="importmap">{"imports":{"spark-html":"/@modules/spark-html"}}</script>\n` +
506
- `<script type="module">import { mount } from 'spark-html'; mount();</script>\n`
507
- : '\n';
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
- 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`;
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 body = await renderFragment(pd.html, scope, { loadComponent });
533
- return new Response(shell(page, body, { hydrate: shouldHydrate(pd) }), {
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
- 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' } });
542
744
  }
543
745
  return new Response(status === 404 ? 'Not found' : 'Server error', { status });
544
746
  }
545
747
 
546
- // spark-html runtime, served for hydration (importmap target).
547
- let runtimeJs = null;
548
- function runtimeFile() {
549
- 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;
550
758
  for (const dir of [root, dirname(new URL(import.meta.url).pathname)]) {
551
759
  try {
552
- runtimeJs = readFileSync(Bun.resolveSync('spark-html', dir), 'utf8');
553
- 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;
554
765
  } catch { /* next */ }
555
766
  }
556
- 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;
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 === '/@modules/spark-html') {
589
- const js = runtimeFile();
590
- return finish(js
591
- ? new Response(js, { headers: { 'content-type': 'text/javascript', 'cache-control': 'no-cache' } })
592
- : 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));
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) { 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
+ },
673
924
  };
674
925
  }