spark-ssr 0.1.0 → 0.1.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spark-ssr",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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,7 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "linkedom": "^0.18.12",
33
- "spark-html": "^0.27.0"
33
+ "spark-html": "^0.27.7"
34
34
  },
35
35
  "keywords": [
36
36
  "spark-html",
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/render.js CHANGED
@@ -37,12 +37,21 @@ export function evalExpr(expr, scope) {
37
37
 
38
38
  const str = (v) => (v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v));
39
39
 
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
- ];
40
+ // linkedom's template parsing is inconsistent: children can land in .content,
41
+ // in .childNodes, split between the two (whitespace one side, elements the
42
+ // other), or fully DUPLICATED into both. Never merge — pick the side that
43
+ // actually holds elements; on a tie (duplicates) .content is canonical.
44
+ export function templateKids(node) {
45
+ const c = node.content ? [...node.content.childNodes] : [];
46
+ const d = [...node.childNodes];
47
+ if (!c.length) return d;
48
+ if (!d.length) return c;
49
+ const hasEl = (a) => a.some((n) => n.nodeType === 1);
50
+ if (hasEl(c)) return c;
51
+ if (hasEl(d)) return d;
52
+ return c;
53
+ }
54
+ const kids = templateKids;
46
55
  const interpolate = (text, scope) =>
47
56
  String(text).replace(/\{([^{}]+)\}/g, (_, e) => str(evalExpr(e, scope)));
48
57
 
@@ -188,11 +197,45 @@ async function renderIfChain(node, scope, ctx, depth) {
188
197
  for (const link of chain) link.node.remove();
189
198
  }
190
199
 
200
+ // Round-trip an evaluated prop back to an attribute string the runtime's
201
+ // coerce() understands ('' = true, JSON for objects, …) — same contract as
202
+ // spark-prerender's serializeProp.
203
+ function serializeProp(v) {
204
+ if (v === true) return '';
205
+ if (v === false) return 'false';
206
+ if (v === null || v === undefined) return 'null';
207
+ if (typeof v === 'string') return v;
208
+ if (typeof v === 'number') return String(v);
209
+ return JSON.stringify(v);
210
+ }
211
+
212
+ // Literal top-level defaults from a component <script> (let count = 0;
213
+ // let greeting = 'hi') so the SSR output shows initial values instead of
214
+ // blanks. Anything non-literal is skipped — the client boot computes it.
215
+ export function scriptLiterals(code) {
216
+ const out = {};
217
+ for (const m of String(code).matchAll(/^\s*(?:let|var|const)\s+([a-zA-Z_$][\w$]*)\s*=\s*(.+?);?\s*$/gm)) {
218
+ const raw = m[2].trim();
219
+ try {
220
+ out[m[1]] = JSON.parse(raw.replace(/^'([^'\\]*)'$/, '"$1"'));
221
+ } catch { /* not a literal — client-side only */ }
222
+ }
223
+ return out;
224
+ }
225
+
191
226
  async function renderImport(node, scope, ctx, depth) {
192
227
  const spec = node.getAttribute('import');
193
- node.removeAttribute('import');
194
228
  if (depth >= (ctx.maxDepth || 20)) { node.innerHTML = ''; return; }
195
229
 
230
+ // A top-level host on a page that will client-mount keeps its import (plus
231
+ // a `name` and its evaluated props) so the runtime's flash-free hydrate
232
+ // path re-resolves it and the component comes alive — exactly the contract
233
+ // spark-prerender's makeHydratable establishes. Nested hosts are inlined;
234
+ // their parent rebuilds them on the client.
235
+ const keepHost = !!ctx.keepImports && depth === 0;
236
+ if (!keepHost) node.removeAttribute('import');
237
+ else node.setAttribute('name', String(spec).split(/[?#]/)[0].replace(/\/+$/, '').replace(/.*\//, '').replace(/\.html$/, ''));
238
+
196
239
  // Slot content renders in the CALLER's scope, before the component swaps in.
197
240
  await walkChildren(node, scope, ctx, depth);
198
241
  const slotHtml = node.innerHTML;
@@ -203,25 +246,41 @@ async function renderImport(node, scope, ctx, depth) {
203
246
  for (const attr of [...node.attributes]) {
204
247
  const n = attr.name;
205
248
  const v = String(attr.value || '');
249
+ if (n === 'import' || n === 'name' || n.startsWith('data-spark')) continue;
206
250
  if (n === 'class' || n === 'id') { if (v.includes('{')) attr.value = interpolate(v, scope); continue; }
207
251
  const exact = v.trim().match(/^\{([\s\S]+)\}$/);
208
252
  props[n] = exact ? evalExpr(exact[1], scope) : v.includes('{') ? interpolate(v, scope) : v;
209
- node.removeAttribute(n);
253
+ // Kept hosts re-serialize the evaluated value so the client re-resolve
254
+ // receives the same props; inlined hosts drop them.
255
+ if (keepHost) attr.value = serializeProp(props[n]);
256
+ else node.removeAttribute(n);
210
257
  }
211
258
 
212
259
  const source = ctx.loadComponent ? await ctx.loadComponent(spec) : null;
213
260
  if (source == null) { node.innerHTML = ''; return; }
214
- // Components are pure UI: strip their <spark-ssr>/<script>, keep markup+style.
261
+ // Components are pure UI on the server: strip <spark-ssr>/<script> from the
262
+ // output, but read literal script defaults so {count} renders as 0.
263
+ let script = '';
215
264
  const clean = String(source)
216
265
  .replace(/<spark-ssr\b[^>]*?\/>|<spark-ssr\b[^>]*>[\s\S]*?<\/spark-ssr>/gi, '')
217
- .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
266
+ .replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gi, (m, body) => { script += body + '\n'; return ''; });
218
267
  node.innerHTML = clean;
219
- await walkChildren(node, props, ctx, depth + 1);
268
+ const compScope = Object.assign(Object.create(null), scriptLiterals(script), props);
269
+ await walkChildren(node, compScope, ctx, depth + 1);
220
270
 
221
271
  // Default slot: replace <slot> with the caller's rendered content.
222
272
  for (const slot of [...node.querySelectorAll('slot')]) {
223
- const holder = ctx.document.createElement('template');
273
+ const holder = ctx.document.createElement('div');
224
274
  holder.innerHTML = slotHtml;
225
- slot.replaceWith(...(holder.content || holder).childNodes);
275
+ slot.replaceWith(...holder.childNodes);
276
+ }
277
+
278
+ // Stash the rendered slot content for the client's hydrate path
279
+ // (<template data-spark-slots>, read by the runtime on re-resolve).
280
+ if (keepHost && slotHtml.trim()) {
281
+ const stash = ctx.document.createElement('template');
282
+ stash.setAttribute('data-spark-slots', '');
283
+ stash.innerHTML = slotHtml;
284
+ node.appendChild(stash);
226
285
  }
227
286
  }
package/src/server.js CHANGED
@@ -120,12 +120,13 @@ export async function serve(options = {}) {
120
120
  const config = { ...loadConfig(root), ...(options.config || {}) };
121
121
  const db = await connect(config.db);
122
122
  const secret = (config.auth && config.auth.secret) || randomBytes(32).toString('hex');
123
- const { pagesDir, pages } = scanPages(root);
124
123
  const cache = new Map();
124
+ const pages = [];
125
+ let pagesDir = root;
125
126
  const uploadsDir = join(root, config.uploads);
126
127
  const quiet = !!options.quiet;
127
128
 
128
- const ctx = { root, config, db, secret, pagesDir, pages, cache, uploadsDir, port: 0 };
129
+ const ctx = { port: 0 };
129
130
 
130
131
  // ── request wrapper ──
131
132
  function wrapReq(request, url, params, session, server) {
@@ -317,36 +318,49 @@ export async function serve(options = {}) {
317
318
  }
318
319
 
319
320
  // ── explicit <spark-ssr> query endpoints ──
320
- const registered = new Set();
321
+ // Defs are mutable so an edited page's SQL takes effect without a restart —
322
+ // the registered handler reads def.sql at call time.
323
+ const queryDefs = new Map();
321
324
  function registerQuery(route) {
322
325
  const key = route.method + ' ' + route.path;
323
- if (registered.has(key)) return;
324
- registered.add(key);
326
+ const existing = queryDefs.get(key);
327
+ if (existing) { existing.sql = route.sql; return; }
328
+ const def = { sql: route.sql };
329
+ queryDefs.set(key, def);
325
330
  const segs = route.path.split('/').filter(Boolean)
326
331
  .map((s) => s.replace(/^\[(\w+)\]$/, ':$1'));
327
332
  apiRoutes.push({
328
333
  method: route.method,
329
334
  segs,
330
335
  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]);
336
+ const rows = await runSql(def.sql, req);
337
+ if (route.method === 'GET') return json(singleShaped(def.sql) ? rows[0] ?? null : [...rows]);
333
338
  if (Array.isArray(rows) && rows.length) return json(rows.length === 1 ? rows[0] : [...rows]);
334
339
  return json({ ok: true, changes: rows.changes ?? 0 });
335
340
  },
336
341
  });
337
342
  }
338
343
 
339
- // Register everything the pages declare.
344
+ // (Re)scan pages/ and register everything they declare. Runs per request —
345
+ // a plain readdir walk plus mtime-cached parses — so new pages, new tables,
346
+ // and edited queries appear without restarting the server.
340
347
  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);
348
+ function refreshPages() {
349
+ const scanned = scanPages(root);
350
+ pagesDir = scanned.pagesDir;
351
+ pages.splice(0, pages.length, ...scanned.pages);
352
+ for (const page of pages) {
353
+ let pd;
354
+ try { pd = pageData(page, cache); } catch { continue; }
355
+ for (const b of pd.blocks) {
356
+ if (b.table && !tables.has(b.table)) { tables.add(b.table); registerTable(b.table); }
357
+ for (const r of b.routes) {
358
+ if (r.path) registerQuery(r);
359
+ }
347
360
  }
348
361
  }
349
362
  }
363
+ refreshPages();
350
364
 
351
365
  // ── api/ folder — custom endpoints ──
352
366
  function makeAppFetch(req) {
@@ -368,8 +382,12 @@ export async function serve(options = {}) {
368
382
  };
369
383
  }
370
384
 
371
- const apiDir = join(root, 'api');
372
- if (existsSync(apiDir)) {
385
+ // api/ files re-scan per request too; script handlers hold a mutable def so
386
+ // edits take effect, and registration itself happens once per route.
387
+ const apiDefs = new Map(); // route path → { mtime, fn }
388
+ function refreshApi() {
389
+ const apiDir = join(root, 'api');
390
+ if (!existsSync(apiDir)) return;
373
391
  (function scanApi(dir, prefix) {
374
392
  for (const f of readdirSync(dir)) {
375
393
  if (f.startsWith('.')) continue;
@@ -377,21 +395,32 @@ export async function serve(options = {}) {
377
395
  if (statSync(full).isDirectory()) { scanApi(full, prefix + f + '/'); continue; }
378
396
  if (!f.endsWith('.html')) continue;
379
397
  const route = '/api/' + prefix + f.slice(0, -5);
398
+ const mtime = statSync(full).mtimeMs;
399
+ let def = apiDefs.get(route);
400
+ if (def && def.mtime === mtime) continue;
401
+ if (!def) { def = { mtime: 0, fn: null, registered: false }; apiDefs.set(route, def); }
402
+ def.mtime = mtime;
380
403
  const source = readFileSync(full, 'utf8');
381
404
  const { blocks, html } = extractBlocks(source);
382
405
  const { code } = splitScript(html);
383
406
  for (const b of blocks) {
384
407
  for (const r of b.routes) registerQuery({ ...r, path: r.path || route });
385
408
  }
409
+ def.fn = null;
386
410
  if (code) {
387
- const fn = new AsyncFunction('req', 'res', 'db', 'fetch', code);
411
+ try { def.fn = new AsyncFunction('req', 'res', 'db', 'fetch', code); }
412
+ catch (e) { if (!quiet) console.warn(`[spark-ssr] ${route} <script> — ${e.message}`); }
413
+ }
414
+ if (def.fn && !def.registered) {
415
+ def.registered = true;
388
416
  const segs = route.split('/').filter(Boolean);
389
417
  for (const method of ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']) {
390
418
  apiRoutes.push({
391
419
  method,
392
420
  segs,
393
421
  handler: async (req, res) => {
394
- const out = await fn(req, res, db, makeAppFetch(req));
422
+ if (!def.fn) return json({ error: 'not found' }, 404);
423
+ const out = await def.fn(req, res, db, makeAppFetch(req));
395
424
  if (out instanceof Response) return out;
396
425
  if (out && typeof out === 'object' && 'status' in out && 'body' in out) {
397
426
  return new Response(typeof out.body === 'string' ? out.body : JSON.stringify(out.body), { status: out.status });
@@ -404,6 +433,7 @@ export async function serve(options = {}) {
404
433
  }
405
434
  })(apiDir, '');
406
435
  }
436
+ refreshApi();
407
437
 
408
438
  function matchApi(method, pathname) {
409
439
  const parts = pathname.split('/').filter(Boolean);
@@ -419,14 +449,24 @@ export async function serve(options = {}) {
419
449
  return null;
420
450
  }
421
451
 
422
- // ── middleware.html ──
452
+ // ── middleware.html (reloaded when the file changes) ──
423
453
  let middleware = null;
454
+ let mwMtime = -1;
424
455
  const mwState = { rateLimit: new Map(), state: {} };
425
- const mwFile = join(root, 'middleware.html');
426
- if (existsSync(mwFile)) {
456
+ function refreshMiddleware() {
457
+ const mwFile = join(root, 'middleware.html');
458
+ if (!existsSync(mwFile)) { middleware = null; mwMtime = -1; return; }
459
+ const mtime = statSync(mwFile).mtimeMs;
460
+ if (mtime === mwMtime) return;
461
+ mwMtime = mtime;
462
+ middleware = null;
427
463
  const { code } = splitScript(readFileSync(mwFile, 'utf8'));
428
- if (code) middleware = new AsyncFunction('req', 'res', 'rateLimit', 'state', 'fetch', code);
464
+ if (code) {
465
+ try { middleware = new AsyncFunction('req', 'res', 'rateLimit', 'state', 'fetch', code); }
466
+ catch (e) { if (!quiet) console.warn(`[spark-ssr] middleware.html — ${e.message}`); }
467
+ }
429
468
  }
469
+ refreshMiddleware();
430
470
 
431
471
  // ── CORS ──
432
472
  function corsHeaders(origin) {
@@ -492,7 +532,7 @@ export async function serve(options = {}) {
492
532
  && pd.blocks.some((b) => b.table) && !!db;
493
533
  }
494
534
 
495
- function shell(page, body, { hydrate }) {
535
+ function shell(page, body, { hydrate, mount }) {
496
536
  const title = page.key === 'index' ? 'Spark' : page.key.split('/').pop().replace(/\[|\]/g, '');
497
537
  const cssRel = page.key + '.css';
498
538
  const hasCss = existsSync(join(pagesDir, cssRel));
@@ -501,12 +541,19 @@ export async function serve(options = {}) {
501
541
  '<meta name="viewport" content="width=device-width, initial-scale=1">\n' +
502
542
  `<title>${title}</title>\n` +
503
543
  (hasCss ? `<link rel="stylesheet" href="/${cssRel}">\n` : '');
504
- const hydration = hydrate
544
+ const hydration = mount
505
545
  ? `\n<script type="importmap">{"imports":{"spark-html":"/@modules/spark-html"}}</script>\n` +
506
546
  `<script type="module">import { mount } from 'spark-html'; mount();</script>\n`
507
547
  : '\n';
548
+ // A hydrating page host carries BOTH `import` and `name` — that is the
549
+ // runtime's flash-free hydrate contract (same as spark-prerender's
550
+ // makeHydratable): the pre-rendered content stays visible while the
551
+ // component is fetched and booted detached, then swaps in atomically.
552
+ // `name` missing would make the runtime treat the rendered HTML as SLOT
553
+ // content and project it next to the fresh render — duplicated live UI.
554
+ const compName = page.key.replace(/.*\//, '');
508
555
  const host = hydrate
509
- ? `<div import="/__spark/page/${page.key}" data-spark-ssr>${body}</div>`
556
+ ? `<div import="/__spark/page/${page.key}" name="${compName}" data-spark-ssr>${body}</div>`
510
557
  : `<div data-spark-ssr>${body}</div>`;
511
558
  return `<!doctype html>\n<html>\n<head>\n${head}</head>\n<body>\n${host}${hydration}</body>\n</html>\n`;
512
559
  }
@@ -529,8 +576,13 @@ export async function serve(options = {}) {
529
576
  async function servePage(page, req) {
530
577
  const pd = pageData(page, cache);
531
578
  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) }), {
579
+ const hydrate = shouldHydrate(pd);
580
+ // Component imports keep their host (import + name + props) on pages the
581
+ // page host won't rebuild wholesale, so a client mount re-resolves them
582
+ // and their own <script> comes alive (counters, demos, …).
583
+ const hasComponents = /\bimport\s*=\s*"/.test(pd.html);
584
+ const body = await renderFragment(pd.html, scope, { loadComponent, keepImports: !hydrate });
585
+ return new Response(shell(page, body, { hydrate, mount: hydrate || hasComponents }), {
534
586
  headers: { 'content-type': 'text/html; charset=utf-8' },
535
587
  });
536
588
  }
@@ -568,6 +620,10 @@ export async function serve(options = {}) {
568
620
  const extraHeaders = {};
569
621
 
570
622
  try {
623
+ // Pick up new/edited pages, api files, and middleware without a
624
+ // restart (readdir walk + mtime-cached parses — cheap).
625
+ if (options.watch !== false) { refreshPages(); refreshApi(); refreshMiddleware(); }
626
+
571
627
  // middleware.html runs first, on every request.
572
628
  if (middleware) {
573
629
  const req = wrapReq(request, url, {}, session, srv);