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 +2 -2
- package/src/hydrate.js +12 -7
- package/src/render.js +72 -13
- package/src/server.js +83 -27
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spark-ssr",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
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
|
-
|
|
47
|
-
// .content depending on how linkedom parsed them — read both.
|
|
48
|
-
const kids = (node) => [
|
|
49
|
-
...(node.content ? node.content.childNodes : []),
|
|
50
|
-
...node.childNodes,
|
|
51
|
-
];
|
|
47
|
+
const kids = templateKids;
|
|
52
48
|
|
|
53
49
|
(function transform(node, loopVar) {
|
|
54
50
|
if (node.nodeType !== 1) return;
|
|
@@ -74,7 +70,16 @@ export function clientComponent({ html, analysis, plan, table, cols, key }) {
|
|
|
74
70
|
const em = each.match(/^\s*([\w$]+)/);
|
|
75
71
|
if (em) inner = em[1];
|
|
76
72
|
}
|
|
77
|
-
|
|
73
|
+
// Attribute rewrites must reach BOTH of linkedom's template stores —
|
|
74
|
+
// it may hold duplicate copies in .content and .childNodes, and which
|
|
75
|
+
// one the serializer emits varies. (Moves, like the await unwrap above,
|
|
76
|
+
// take just the canonical side since the template is removed after.)
|
|
77
|
+
const seen = new Set();
|
|
78
|
+
for (const c of [...(node.content ? node.content.childNodes : []), ...node.childNodes]) {
|
|
79
|
+
if (seen.has(c)) continue;
|
|
80
|
+
seen.add(c);
|
|
81
|
+
transform(c, inner);
|
|
82
|
+
}
|
|
78
83
|
return;
|
|
79
84
|
}
|
|
80
85
|
if (loopVar && node.attributes) {
|
package/src/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
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
|
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]
|
|
266
|
+
.replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gi, (m, body) => { script += body + '\n'; return ''; });
|
|
218
267
|
node.innerHTML = clean;
|
|
219
|
-
|
|
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('
|
|
273
|
+
const holder = ctx.document.createElement('div');
|
|
224
274
|
holder.innerHTML = slotHtml;
|
|
225
|
-
slot.replaceWith(...
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
324
|
-
|
|
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(
|
|
332
|
-
if (route.method === 'GET') return json(singleShaped(
|
|
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
|
-
//
|
|
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
|
-
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
426
|
-
|
|
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)
|
|
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 =
|
|
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
|
|
533
|
-
|
|
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);
|