@invisibleloop/pulse 0.20.10 → 0.20.19
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 +6 -5
- package/public/.pulse-ui-version +1 -1
- package/scripts/build.js +26 -0
- package/scripts/release-version.js +17 -8
- package/scripts/strip-server.test.js +60 -0
- package/src/agent/checklist.md +8 -12
- package/src/agent/commands/pulse-start.md +2 -0
- package/src/agent/commands/verify.md +30 -8
- package/src/agent/guide-components.md +7 -25
- package/src/agent/guide-routing.md +0 -12
- package/src/agent/guide-server.md +102 -1
- package/src/agent/guide-spec.md +8 -1
- package/src/agent/guide-styles.md +23 -0
- package/src/agent/identity.md +9 -0
- package/src/agent/workflow.md +4 -23
- package/src/cli/check-bundles.js +113 -0
- package/src/cli/check-bundles.test.js +137 -0
- package/src/cli/cli.test.js +105 -0
- package/src/cli/dev.js +48 -3
- package/src/cli/diagnose.js +105 -0
- package/src/cli/diagnose.test.js +124 -0
- package/src/cli/discover.js +9 -2
- package/src/cli/index.js +171 -9
- package/src/cli/process-utils.js +57 -0
- package/src/cli/process-utils.test.js +114 -0
- package/src/cli/review.js +391 -0
- package/src/cli/review.test.js +143 -0
- package/src/cli/scaffold.js +9 -4
- package/src/cli/stamp.js +102 -0
- package/src/cli/stamp.test.js +149 -0
- package/src/cli/start.js +15 -2
- package/src/cli/validate.js +184 -0
- package/src/cli/validate.test.js +153 -0
- package/src/mcp/mcp.test.js +261 -30
- package/src/mcp/server.js +207 -515
- package/src/mcp/validate-worker.js +18 -1
- package/src/runtime/index.js +144 -11
- package/src/runtime/runtime.test.js +199 -0
- package/src/runtime/ssr.js +14 -7
- package/src/runtime/ssr.test.js +31 -0
- package/src/server/index.js +163 -10
- package/src/server/server.test.js +225 -2
- package/src/spec/schema.js +1 -1
- package/src/ui/index.js +0 -5
- package/src/ui/ui.test.js +0 -75
- package/types/ui.d.ts +0 -21
- package/.claude/commands/build-page.md +0 -63
- package/.claude/commands/new-doc-page.md +0 -45
- package/.claude/commands/pulse-start.md +0 -11
- package/.claude/commands/pulse-stop.md +0 -5
- package/.claude/commands/verify.md +0 -118
- package/.claude/pulse-checklist.md +0 -189
- package/.github/workflows/ci.yml +0 -25
- package/.github/workflows/publish.yml +0 -45
- package/.pulse/load-reports/home/1773432711417.json +0 -22
- package/.pulse-verified +0 -1
- package/AGENTS.md +0 -697
- package/CLAUDE.md +0 -697
- package/README.md +0 -95
- package/benchmark/public/dist/counter.boot-PO6SNSJZ.js +0 -8
- package/benchmark/public/dist/home.boot-YK6VAEQI.js +0 -6
- package/benchmark/public/dist/manifest.json +0 -5
- package/benchmark/public/dist/runtime-5Y7ESAIB.js +0 -2
- package/benchmark/public/dist/runtime-KO4BHUQ3.js +0 -49
- package/benchmark/results/2026-03-27-09-20.json +0 -188
- package/benchmark/results/2026-03-27-09-21.json +0 -188
- package/benchmark/results/2026-03-27-09-29.json +0 -188
- package/benchmark/results/2026-03-27-10-14.json +0 -188
- package/benchmark/results/2026-03-27-10-18.json +0 -232
- package/benchmark/src/pages/counter.js +0 -37
- package/benchmark/src/pages/home.js +0 -21
- package/docs/.claude/commands/pulse-build.md +0 -7
- package/docs/.claude/commands/pulse-dev.md +0 -7
- package/docs/.claude/commands/pulse-start.md +0 -9
- package/docs/.claude/commands/pulse-stop.md +0 -5
- package/docs/.claude/commands/verify.md +0 -84
- package/docs/.claude/coverage-check.js +0 -196
- package/docs/.claude/pulse-checklist.md +0 -189
- package/docs/public/.pulse-ui-version +0 -1
- package/docs/public/andypixel.css +0 -237
- package/docs/public/bookworms.css +0 -422
- package/docs/public/coutumes.css +0 -561
- package/docs/public/docs.css +0 -2388
- package/docs/public/favicon.svg +0 -4
- package/docs/public/home-brut.css +0 -982
- package/docs/public/images/bookworms/hero-books.jpg +0 -0
- package/docs/public/images/bookworms/how-we-read-now.jpg +0 -0
- package/docs/public/images/bookworms/lantern-keepers.jpg +0 -0
- package/docs/public/images/bookworms/provincial-bookseller.jpg +0 -0
- package/docs/public/images/journey/178.jpg +0 -0
- package/docs/public/images/journey/29.jpg +0 -0
- package/docs/public/images/retrowheels/hero.jpg +0 -0
- package/docs/public/images/shop/devbook-pro.jpg +0 -0
- package/docs/public/images/suzukibento/1015.jpg +0 -0
- package/docs/public/images/suzukibento/1025.jpg +0 -0
- package/docs/public/images/suzukibento/1043.jpg +0 -0
- package/docs/public/images/suzukibento/1059.jpg +0 -0
- package/docs/public/images/suzukibento/1062.jpg +0 -0
- package/docs/public/images/suzukibento/1084.jpg +0 -0
- package/docs/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
- package/docs/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
- package/docs/public/images/swish/1485518882345-15568b007407.jpg +0 -0
- package/docs/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
- package/docs/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
- package/docs/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
- package/docs/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
- package/docs/public/images/swish/1551803091-e20673f15770.jpg +0 -0
- package/docs/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
- package/docs/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
- package/docs/public/img/heic1501a.jpg +0 -0
- package/docs/public/img/placeholder-landscape.svg +0 -18
- package/docs/public/img/placeholder-square.svg +0 -14
- package/docs/public/img/placeholder-tall.svg +0 -22
- package/docs/public/journey.css +0 -528
- package/docs/public/menu.js +0 -327
- package/docs/public/pulse-ui.css +0 -3028
- package/docs/public/pulse-ui.js +0 -257
- package/docs/public/retrowheels.css +0 -425
- package/docs/public/shop.css +0 -269
- package/docs/public/sparkys.css +0 -382
- package/docs/public/suzukibento.css +0 -517
- package/docs/public/swish.css +0 -394
- package/docs/public/theme.css +0 -52
- package/docs/public/themes/lumio.css +0 -50
- package/docs/public/themes/observatory.css +0 -104
- package/docs/server.js +0 -161
- package/docs/src/lib/component-page.js +0 -49
- package/docs/src/lib/highlight.js +0 -2
- package/docs/src/lib/layout.js +0 -190
- package/docs/src/lib/metrics-store.js +0 -6
- package/docs/src/lib/nav.js +0 -171
- package/docs/src/lib/stats.js +0 -81
- package/docs/src/pages/accessibility.js +0 -157
- package/docs/src/pages/actions.js +0 -194
- package/docs/src/pages/auth.js +0 -176
- package/docs/src/pages/caching.js +0 -95
- package/docs/src/pages/components/accordion.js +0 -48
- package/docs/src/pages/components/alert.js +0 -35
- package/docs/src/pages/components/app-badge.js +0 -41
- package/docs/src/pages/components/avatar.js +0 -35
- package/docs/src/pages/components/badge.js +0 -36
- package/docs/src/pages/components/breadcrumbs.js +0 -94
- package/docs/src/pages/components/button.js +0 -84
- package/docs/src/pages/components/card.js +0 -225
- package/docs/src/pages/components/charts.js +0 -283
- package/docs/src/pages/components/checkbox.js +0 -129
- package/docs/src/pages/components/cluster.js +0 -47
- package/docs/src/pages/components/container.js +0 -40
- package/docs/src/pages/components/cta.js +0 -54
- package/docs/src/pages/components/divider.js +0 -37
- package/docs/src/pages/components/empty.js +0 -36
- package/docs/src/pages/components/feature.js +0 -60
- package/docs/src/pages/components/fieldset.js +0 -65
- package/docs/src/pages/components/fileupload.js +0 -128
- package/docs/src/pages/components/footer.js +0 -58
- package/docs/src/pages/components/grid.js +0 -169
- package/docs/src/pages/components/heading.js +0 -107
- package/docs/src/pages/components/hero.js +0 -123
- package/docs/src/pages/components/icons.js +0 -410
- package/docs/src/pages/components/image.js +0 -71
- package/docs/src/pages/components/input.js +0 -51
- package/docs/src/pages/components/media.js +0 -51
- package/docs/src/pages/components/modal.js +0 -111
- package/docs/src/pages/components/nav.js +0 -136
- package/docs/src/pages/components/phone-frame.js +0 -74
- package/docs/src/pages/components/progress.js +0 -102
- package/docs/src/pages/components/prose.js +0 -111
- package/docs/src/pages/components/pullquote.js +0 -71
- package/docs/src/pages/components/radio.js +0 -194
- package/docs/src/pages/components/rating.js +0 -106
- package/docs/src/pages/components/search.js +0 -61
- package/docs/src/pages/components/section.js +0 -98
- package/docs/src/pages/components/segmented.js +0 -121
- package/docs/src/pages/components/select.js +0 -45
- package/docs/src/pages/components/slider.js +0 -115
- package/docs/src/pages/components/spinner.js +0 -73
- package/docs/src/pages/components/stack.js +0 -48
- package/docs/src/pages/components/stat.js +0 -55
- package/docs/src/pages/components/stepper.js +0 -66
- package/docs/src/pages/components/table.js +0 -45
- package/docs/src/pages/components/textarea.js +0 -31
- package/docs/src/pages/components/toggle.js +0 -84
- package/docs/src/pages/components/tooltip.js +0 -48
- package/docs/src/pages/components.js +0 -200
- package/docs/src/pages/config.js +0 -193
- package/docs/src/pages/constraints.js +0 -100
- package/docs/src/pages/deploy.js +0 -233
- package/docs/src/pages/error-pages.js +0 -99
- package/docs/src/pages/extending.js +0 -198
- package/docs/src/pages/faq.js +0 -99
- package/docs/src/pages/forms.js +0 -129
- package/docs/src/pages/getting-started.js +0 -147
- package/docs/src/pages/guard.js +0 -120
- package/docs/src/pages/home.js +0 -345
- package/docs/src/pages/how-it-works.js +0 -244
- package/docs/src/pages/hydration.js +0 -108
- package/docs/src/pages/images.js +0 -120
- package/docs/src/pages/markdown.js +0 -156
- package/docs/src/pages/meta.js +0 -193
- package/docs/src/pages/mutations.js +0 -107
- package/docs/src/pages/navigation.js +0 -85
- package/docs/src/pages/performance.js +0 -157
- package/docs/src/pages/persist.js +0 -89
- package/docs/src/pages/project-structure.js +0 -91
- package/docs/src/pages/prompt-examples.js +0 -186
- package/docs/src/pages/raw-responses.js +0 -122
- package/docs/src/pages/routing.js +0 -120
- package/docs/src/pages/server-api.js +0 -291
- package/docs/src/pages/server-data.js +0 -203
- package/docs/src/pages/slash-commands.js +0 -55
- package/docs/src/pages/spec.js +0 -207
- package/docs/src/pages/state.js +0 -102
- package/docs/src/pages/store.js +0 -203
- package/docs/src/pages/streaming.js +0 -107
- package/docs/src/pages/stripe.js +0 -194
- package/docs/src/pages/supabase.js +0 -324
- package/docs/src/pages/templates/andypixel.js +0 -260
- package/docs/src/pages/templates/blog-post.js +0 -226
- package/docs/src/pages/templates/bookworms.js +0 -227
- package/docs/src/pages/templates/coutumes.js +0 -237
- package/docs/src/pages/templates/journey.js +0 -186
- package/docs/src/pages/templates/mobile-app.js +0 -371
- package/docs/src/pages/templates/retrowheels.js +0 -355
- package/docs/src/pages/templates/shop.js +0 -268
- package/docs/src/pages/templates/sparkys.js +0 -264
- package/docs/src/pages/templates/suzukibento.js +0 -200
- package/docs/src/pages/templates/swish.js +0 -313
- package/docs/src/pages/templates.js +0 -534
- package/docs/src/pages/testing.js +0 -199
- package/docs/src/pages/validation.js +0 -138
- package/examples/.claude/commands/pulse-build.md +0 -7
- package/examples/.claude/commands/pulse-dev.md +0 -7
- package/examples/.claude/commands/pulse-start.md +0 -9
- package/examples/.claude/commands/pulse-stop.md +0 -5
- package/examples/.claude/commands/verify.md +0 -84
- package/examples/.claude/coverage-check.js +0 -196
- package/examples/.claude/pulse-checklist.md +0 -189
- package/examples/andypixel.css +0 -237
- package/examples/andypixel.js +0 -258
- package/examples/bookworms.css +0 -422
- package/examples/bookworms.js +0 -225
- package/examples/contact.js +0 -166
- package/examples/counter.js +0 -94
- package/examples/dev.server.js +0 -110
- package/examples/examples.test.js +0 -394
- package/examples/journey.css +0 -528
- package/examples/journey.js +0 -186
- package/examples/pricing.js +0 -262
- package/examples/products.js +0 -190
- package/examples/public/.pulse-ui-version +0 -1
- package/examples/public/pulse-ui.css +0 -3028
- package/examples/public/pulse-ui.js +0 -257
- package/examples/quiz.js +0 -208
- package/examples/retrowheels.css +0 -425
- package/examples/retrowheels.js +0 -353
- package/examples/shared.js +0 -84
- package/examples/suzukibento.css +0 -513
- package/examples/suzukibento.js +0 -200
- package/examples/swish.css +0 -394
- package/examples/swish.js +0 -311
- package/examples/todos.js +0 -162
- package/src/ui/fieldset.js +0 -37
- package/src/ui/phone-frame.js +0 -40
- package/src/ui/search.js +0 -78
- package/src/ui/segmented.js +0 -50
- package/src/ui/tooltip.js +0 -28
package/src/server/index.js
CHANGED
|
@@ -331,15 +331,36 @@ function resolveCache(value) {
|
|
|
331
331
|
*
|
|
332
332
|
* spec.cache / defaultCache:
|
|
333
333
|
* true → public, max-age=3600, stale-while-revalidate=86400
|
|
334
|
-
* number
|
|
334
|
+
* number → public, max-age={n}
|
|
335
335
|
* { public, maxAge, staleWhileRevalidate }
|
|
336
|
+
*
|
|
337
|
+
* Pages with no cache config get 'no-cache', not 'no-store' — this is a
|
|
338
|
+
* deliberate choice, not a lesser default. 'no-store' unconditionally blocks
|
|
339
|
+
* the browser's back/forward cache (confirmed against a real Lighthouse
|
|
340
|
+
* audit: "Page prevented back/forward cache restoration ... cache-control:
|
|
341
|
+
* no-store header"), which without this fix meant every Pulse page failed
|
|
342
|
+
* bfcache eligibility unless it explicitly opted into caching. 'no-cache'
|
|
343
|
+
* still forces revalidation on every direct load/refresh — it does NOT
|
|
344
|
+
* serve a stale copy without checking first — it just permits the browser
|
|
345
|
+
* to keep an in-memory snapshot for back/forward navigation, which never
|
|
346
|
+
* touches the network at all. No risk of stale dynamic content on a normal
|
|
347
|
+
* page load; this only affects the back/forward navigation path.
|
|
336
348
|
*/
|
|
337
349
|
function buildCacheControl(spec, dev, defaultCache = null) {
|
|
338
350
|
if (dev) return 'no-store'
|
|
339
351
|
|
|
340
352
|
const cfg = resolveCache(spec?.cache) ?? resolveCache(defaultCache)
|
|
341
|
-
if (!cfg) return 'no-
|
|
353
|
+
if (!cfg) return 'no-cache'
|
|
354
|
+
|
|
355
|
+
return buildCacheControlFromConfig(cfg)
|
|
356
|
+
}
|
|
342
357
|
|
|
358
|
+
/**
|
|
359
|
+
* Format an already-resolved cache config (see resolveCache) into a
|
|
360
|
+
* Cache-Control header value. Split out of buildCacheControl so static-file
|
|
361
|
+
* serving can reuse the same formatting without a spec object.
|
|
362
|
+
*/
|
|
363
|
+
function buildCacheControlFromConfig(cfg) {
|
|
343
364
|
const { public: isPublic = false, maxAge = 0, staleWhileRevalidate } = cfg
|
|
344
365
|
const parts = [isPublic ? 'public' : 'private']
|
|
345
366
|
if (maxAge > 0) parts.push(`max-age=${maxAge}`)
|
|
@@ -492,12 +513,24 @@ const MIME_TYPES = {
|
|
|
492
513
|
|
|
493
514
|
/**
|
|
494
515
|
* Returns true if the spec needs a client-side hydration script.
|
|
495
|
-
* Specs with no mutations, actions, or
|
|
516
|
+
* Specs with no mutations, actions, persist, or store are purely
|
|
517
|
+
* server-rendered.
|
|
518
|
+
*
|
|
519
|
+
* spec.store alone counts even with no local mutations/actions: mount() is
|
|
520
|
+
* what wires up data-store-event dispatch AND the live SSE store-push
|
|
521
|
+
* subscription (initLiveStore) — a store-only page with no hydration script
|
|
522
|
+
* would silently never receive pushStore() broadcasts and have a dead
|
|
523
|
+
* data-store-event button, with no error surfaced anywhere (server-rendered
|
|
524
|
+
* HTML looks correct). Mirrors the same fix in discover.js's needsHydration
|
|
525
|
+
* and build.js's needsClientBundle — this is the third, independent copy of
|
|
526
|
+
* this check (the one createServer itself uses when auto-deriving
|
|
527
|
+
* spec.hydrate for URL entries) and previously had the same gap.
|
|
496
528
|
*/
|
|
497
529
|
function needsHydration(spec) {
|
|
498
530
|
return !!(spec.mutations && Object.keys(spec.mutations).length) ||
|
|
499
531
|
!!(spec.actions && Object.keys(spec.actions).length) ||
|
|
500
|
-
!!spec.persist
|
|
532
|
+
!!spec.persist ||
|
|
533
|
+
!!(spec.store && spec.store.length)
|
|
501
534
|
}
|
|
502
535
|
|
|
503
536
|
/**
|
|
@@ -562,6 +595,9 @@ export async function createServer(entries, options = {}) {
|
|
|
562
595
|
root = null, // URL/string — project root for deriving browser paths from file: URLs
|
|
563
596
|
resolveBrand = null, // async (host) => brandConfig — keyed by domain
|
|
564
597
|
defaultCache = null, // default page cache: true | seconds | { public, maxAge, swr }
|
|
598
|
+
staticCache = null, // cache for static files served from staticDir (excludes /dist/,
|
|
599
|
+
// which is always immutable/1yr): true | seconds | { public, maxAge, swr }
|
|
600
|
+
// — default without this option is public, max-age=3600
|
|
565
601
|
fetcherTimeout = null, // ms before any server fetcher times out (null = no limit)
|
|
566
602
|
maxBody = 1024 * 1024, // max request body size in bytes (default 1 MB)
|
|
567
603
|
shutdownTimeout = 30000, // ms to wait for in-flight requests before force-exit
|
|
@@ -582,7 +618,7 @@ export async function createServer(entries, options = {}) {
|
|
|
582
618
|
// clients; pages subscribed via spec.store re-render immediately.
|
|
583
619
|
// Broadcast = SHARED data only (stock, announcements), never
|
|
584
620
|
// per-user data. Default path: /__pulse/live
|
|
585
|
-
onError = (err, req, res) => defaultErrorHandler(err, req, res, dev),
|
|
621
|
+
onError = (err, req, res) => defaultErrorHandler(err, req, res, dev, errorJournalPath),
|
|
586
622
|
onRequest,
|
|
587
623
|
agentMode = false, // show agent-active indicator in banner
|
|
588
624
|
quiet = false, // suppress request logging
|
|
@@ -593,6 +629,10 @@ export async function createServer(entries, options = {}) {
|
|
|
593
629
|
|
|
594
630
|
const healthPath = healthCheck === true ? '/healthz' : (healthCheck || null)
|
|
595
631
|
|
|
632
|
+
// Error journal path — resolved once per createServer call from this
|
|
633
|
+
// instance's real root, not cached at module load. See writeErrorEntry.
|
|
634
|
+
const errorJournalPath = resolveErrorJournalPath(root)
|
|
635
|
+
|
|
596
636
|
// Store definition — `let` so the dev server can hot-swap it via updateStore()
|
|
597
637
|
// when pulse.store.js changes, the same way updateSpecs() swaps page specs.
|
|
598
638
|
let storeDef = store
|
|
@@ -665,6 +705,7 @@ export async function createServer(entries, options = {}) {
|
|
|
665
705
|
// Build route table — let so it can be swapped on hot reload.
|
|
666
706
|
// The '*' spec is excluded: it is not a route, it is the fallback.
|
|
667
707
|
let router = buildRouter(specs.filter(s => s.route !== '*'))
|
|
708
|
+
warnOnRouteShadowing(router)
|
|
668
709
|
|
|
669
710
|
// CSRF secret — per-boot random unless pinned via options.secret. Tokens are
|
|
670
711
|
// tied to a cookie, so a restart only means in-flight forms re-render once.
|
|
@@ -775,6 +816,25 @@ export async function createServer(entries, options = {}) {
|
|
|
775
816
|
return
|
|
776
817
|
}
|
|
777
818
|
|
|
819
|
+
// Client-side error reporting — dev only. The runtime POSTs view/action
|
|
820
|
+
// failures here so they land in the same journal as server-side errors;
|
|
821
|
+
// pulse_diagnose reads one file regardless of where the error occurred.
|
|
822
|
+
// Not registered in production — a stray POST there just 404s harmlessly,
|
|
823
|
+
// so the client runtime can call it unconditionally with no dev check.
|
|
824
|
+
if (dev && pathname === '/__pulse/error' && req.method === 'POST') {
|
|
825
|
+
let body = ''
|
|
826
|
+
req.on('data', chunk => { body += chunk })
|
|
827
|
+
req.on('end', () => {
|
|
828
|
+
try {
|
|
829
|
+
const { route, phase, message, stack } = JSON.parse(body || '{}')
|
|
830
|
+
writeErrorEntry(errorJournalPath, { route, phase: phase === 'action' ? 'action' : 'view', message, stack })
|
|
831
|
+
} catch { /* malformed payload — drop it, this is best-effort */ }
|
|
832
|
+
res.writeHead(204, SECURITY_HEADERS)
|
|
833
|
+
res.end()
|
|
834
|
+
})
|
|
835
|
+
return
|
|
836
|
+
}
|
|
837
|
+
|
|
778
838
|
// Request hook — allows middleware-like behaviour
|
|
779
839
|
if (onRequest) {
|
|
780
840
|
const result = onRequest(req, res)
|
|
@@ -783,7 +843,7 @@ export async function createServer(entries, options = {}) {
|
|
|
783
843
|
|
|
784
844
|
// Static file serving — GET/HEAD only
|
|
785
845
|
if (staticDir && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
786
|
-
const served = serveStatic(req, res, staticDir, dev)
|
|
846
|
+
const served = serveStatic(req, res, staticDir, dev, staticCache)
|
|
787
847
|
if (served) return
|
|
788
848
|
}
|
|
789
849
|
|
|
@@ -1322,7 +1382,7 @@ async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = f
|
|
|
1322
1382
|
try { res.writeEarlyHints({ link: earlyLinks }) } catch {}
|
|
1323
1383
|
}
|
|
1324
1384
|
|
|
1325
|
-
const stylePreconnects = buildStylePreconnects(meta.styles || [])
|
|
1385
|
+
const stylePreconnects = buildStylePreconnects([...(meta.styles || []), ...(meta.deferredStyles || [])])
|
|
1326
1386
|
|
|
1327
1387
|
const stylePreloads = (meta.styles || [])
|
|
1328
1388
|
.map(href => ` <link rel="preload" as="style" href="${escHtml(href)}">`)
|
|
@@ -1513,6 +1573,37 @@ function buildRouter(specs) {
|
|
|
1513
1573
|
})
|
|
1514
1574
|
}
|
|
1515
1575
|
|
|
1576
|
+
/**
|
|
1577
|
+
* Route matching is first-match-wins in registration order (see matchRoute) —
|
|
1578
|
+
* there is no path-specificity ranking. Registration order comes from file
|
|
1579
|
+
* discovery order, which is not guaranteed alphabetical across platforms. A
|
|
1580
|
+
* dynamic route registered before a same-depth static route will silently
|
|
1581
|
+
* swallow every request to that static route — no error, no warning, just a
|
|
1582
|
+
* 404 or the wrong page rendering, discovered only by noticing a route never
|
|
1583
|
+
* gets hit. This is a static, best-effort check: it tests each dynamic
|
|
1584
|
+
* route's pattern against a synthetic literal built from each later static
|
|
1585
|
+
* route's own path segments, so it only catches routes actually registered
|
|
1586
|
+
* (nothing exhaustive about every possible URL).
|
|
1587
|
+
*/
|
|
1588
|
+
function warnOnRouteShadowing(router) {
|
|
1589
|
+
for (let i = 0; i < router.length; i++) {
|
|
1590
|
+
const earlier = router[i]
|
|
1591
|
+
if (earlier.params.length === 0) continue // static routes can't shadow anything
|
|
1592
|
+
for (let j = i + 1; j < router.length; j++) {
|
|
1593
|
+
const later = router[j]
|
|
1594
|
+
if (later.params.length > 0) continue // only static routes can be shadowed this way
|
|
1595
|
+
if (earlier.pattern.test(later.spec.route)) {
|
|
1596
|
+
console.warn(
|
|
1597
|
+
`⚠ Pulse: route "${earlier.spec.route}" is registered before "${later.spec.route}" and matches it — ` +
|
|
1598
|
+
`"${later.spec.route}" will never be reached. Route matching is first-match-wins with no ` +
|
|
1599
|
+
`specificity ranking; rename the file backing "${earlier.spec.route}" so it sorts after ` +
|
|
1600
|
+
`"${later.spec.route}", or give one of them an explicit route that no longer overlaps.`
|
|
1601
|
+
)
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1516
1607
|
/**
|
|
1517
1608
|
* Match a pathname against the router.
|
|
1518
1609
|
* Returns the first match with extracted params, or null.
|
|
@@ -1754,9 +1845,12 @@ function resolveSpec(spec, hydrateMap) {
|
|
|
1754
1845
|
* @param {http.IncomingMessage} req
|
|
1755
1846
|
* @param {http.ServerResponse} res
|
|
1756
1847
|
* @param {string} staticDir - Absolute path to the static files directory
|
|
1848
|
+
* @param {boolean} dev
|
|
1849
|
+
* @param {*} staticCache - true | seconds | { public, maxAge, staleWhileRevalidate };
|
|
1850
|
+
* overrides the default public, max-age=3600 for non-/dist/ static files
|
|
1757
1851
|
* @returns {boolean}
|
|
1758
1852
|
*/
|
|
1759
|
-
function serveStatic(req, res, staticDir, dev = false) {
|
|
1853
|
+
function serveStatic(req, res, staticDir, dev = false, staticCache = null) {
|
|
1760
1854
|
const url = new URL(req.url, 'http://localhost')
|
|
1761
1855
|
let pathname
|
|
1762
1856
|
try { pathname = decodeURIComponent(url.pathname) }
|
|
@@ -1775,10 +1869,15 @@ function serveStatic(req, res, staticDir, dev = false) {
|
|
|
1775
1869
|
|
|
1776
1870
|
// Content-hashed bundles under /dist/ can be cached indefinitely.
|
|
1777
1871
|
// In dev, all other files get no-store so CSS/JS changes are reflected immediately.
|
|
1872
|
+
// staticCache lets a project raise the default 3600s for its own unhashed
|
|
1873
|
+
// static assets (e.g. /images/*) without pretending they're immutable.
|
|
1778
1874
|
const isImmutable = pathname.startsWith('/dist/')
|
|
1875
|
+
const staticCacheCfg = resolveCache(staticCache)
|
|
1779
1876
|
const cache = isImmutable
|
|
1780
1877
|
? 'public, max-age=31536000, immutable'
|
|
1781
|
-
: dev ? 'no-store'
|
|
1878
|
+
: dev ? 'no-store'
|
|
1879
|
+
: staticCacheCfg ? buildCacheControlFromConfig(staticCacheCfg)
|
|
1880
|
+
: 'public, max-age=3600'
|
|
1782
1881
|
|
|
1783
1882
|
// Only compress compressible text types
|
|
1784
1883
|
const compressible = ['.js', '.css', '.html', '.json', '.svg'].includes(ext)
|
|
@@ -2003,8 +2102,62 @@ function notFoundHtml(pathname) {
|
|
|
2003
2102
|
</html>`
|
|
2004
2103
|
}
|
|
2005
2104
|
|
|
2006
|
-
|
|
2105
|
+
// ---------------------------------------------------------------------------
|
|
2106
|
+
// Error journal — dev-only diagnostic log an agent can read via pulse_diagnose.
|
|
2107
|
+
// Captures the failure modes that previously dead-ended at console.error:
|
|
2108
|
+
// server errors, SSR view throws, sitemap enumerator failures, and (via the
|
|
2109
|
+
// /__pulse/error endpoint) post-hydration client view/action failures.
|
|
2110
|
+
// Ring-buffered at 50 entries — this is a build/iterate-time tool, not a
|
|
2111
|
+
// production error tracker, so it is only written when dev === true.
|
|
2112
|
+
// ---------------------------------------------------------------------------
|
|
2113
|
+
|
|
2114
|
+
const ERROR_JOURNAL_LIMIT = 50
|
|
2115
|
+
|
|
2116
|
+
// journalPath is computed per-createServer-call from the real project root
|
|
2117
|
+
// (see resolveErrorJournalPath below) rather than cached at module load —
|
|
2118
|
+
// each createServer instance (e.g. concurrent test servers) gets its own
|
|
2119
|
+
// correct path instead of a stale process.cwd() snapshot from import time.
|
|
2120
|
+
function resolveErrorJournalPath(root) {
|
|
2121
|
+
const dir = root
|
|
2122
|
+
? (typeof root === 'string' ? root : root.pathname || process.cwd())
|
|
2123
|
+
: process.cwd()
|
|
2124
|
+
return path.join(dir, '.pulse', 'errors.json')
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
function readErrorJournal(journalPath) {
|
|
2128
|
+
try {
|
|
2129
|
+
return JSON.parse(fs.readFileSync(journalPath, 'utf8'))
|
|
2130
|
+
} catch {
|
|
2131
|
+
return []
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
function writeErrorEntry(journalPath, { route, phase, message, stack, spec = null }) {
|
|
2136
|
+
try {
|
|
2137
|
+
const entries = readErrorJournal(journalPath)
|
|
2138
|
+
entries.push({
|
|
2139
|
+
id: crypto.randomUUID(),
|
|
2140
|
+
ts: new Date().toISOString(),
|
|
2141
|
+
route: route || null,
|
|
2142
|
+
phase, // 'view' | 'action' | 'server' | 'guard' | 'sitemap'
|
|
2143
|
+
message: String(message || '').slice(0, 2000),
|
|
2144
|
+
stack: String(stack || '').slice(0, 4000),
|
|
2145
|
+
spec,
|
|
2146
|
+
resolved: false,
|
|
2147
|
+
})
|
|
2148
|
+
// Ring buffer — keep only the most recent entries
|
|
2149
|
+
const trimmed = entries.slice(-ERROR_JOURNAL_LIMIT)
|
|
2150
|
+
fs.mkdirSync(path.dirname(journalPath), { recursive: true })
|
|
2151
|
+
fs.writeFileSync(journalPath, JSON.stringify(trimmed, null, 2), 'utf8')
|
|
2152
|
+
} catch (journalErr) {
|
|
2153
|
+
// Never let journal-write failures affect the actual error response
|
|
2154
|
+
console.error('[Pulse] failed to write error journal:', journalErr.message)
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
function defaultErrorHandler(err, _req, res, dev = false, journalPath = null) {
|
|
2007
2159
|
console.error('[Pulse] Server error:', err)
|
|
2160
|
+
if (dev && journalPath) writeErrorEntry(journalPath, { route: _req?.url || null, phase: 'server', message: err.message, stack: err.stack })
|
|
2008
2161
|
if (res.headersSent) return
|
|
2009
2162
|
|
|
2010
2163
|
const html = dev ? errorPage(err) : errorPage500()
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import http from 'http'
|
|
7
|
+
import fs from 'fs'
|
|
8
|
+
import os from 'os'
|
|
9
|
+
import path from 'path'
|
|
7
10
|
import { createServer, TtlCache } from './index.js'
|
|
8
11
|
|
|
9
12
|
// ---------------------------------------------------------------------------
|
|
@@ -543,13 +546,17 @@ const throwingSpec = {
|
|
|
543
546
|
}
|
|
544
547
|
|
|
545
548
|
await test('dev mode returns HTML error page on render error (string mode)', async () => {
|
|
546
|
-
|
|
549
|
+
// dev: true writes to the error journal at <root>/.pulse/errors.json — pass an
|
|
550
|
+
// isolated tmp root so this doesn't leave a file behind in the package directory.
|
|
551
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-boom-'))
|
|
552
|
+
await withServer([throwingSpec], { stream: false, dev: true, root: tmpRoot }, async (port) => {
|
|
547
553
|
const { status, headers, body } = await get(port, '/boom')
|
|
548
554
|
assert(status === 500, `Expected 500, got ${status}`)
|
|
549
555
|
assert(headers['content-type']?.includes('text/html'), `Expected HTML, got ${headers['content-type']}`)
|
|
550
556
|
assert(body.includes('view exploded'), `Expected error message in page: ${body.slice(0, 200)}`)
|
|
551
557
|
assert(body.includes('Stack trace'), `Expected stack trace section in page`)
|
|
552
558
|
})
|
|
559
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true })
|
|
553
560
|
})
|
|
554
561
|
|
|
555
562
|
await test('production mode returns generic error page (no stack trace)', async () => {
|
|
@@ -562,12 +569,14 @@ await test('production mode returns generic error page (no stack trace)', async
|
|
|
562
569
|
})
|
|
563
570
|
|
|
564
571
|
await test('dev mode injects error into stream when render throws (stream mode)', async () => {
|
|
565
|
-
|
|
572
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-boom-'))
|
|
573
|
+
await withServer([throwingSpec], { stream: true, dev: true, root: tmpRoot }, async (port) => {
|
|
566
574
|
const { status, body } = await get(port, '/boom')
|
|
567
575
|
assert(status === 200, `Stream mode commits 200 before error, got ${status}`)
|
|
568
576
|
assert(body.includes('view exploded'), `Expected error message injected into stream: ${body.slice(0, 400)}`)
|
|
569
577
|
assert(body.includes('pulse-root'), `Expected pulse-root in partial document`)
|
|
570
578
|
})
|
|
579
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true })
|
|
571
580
|
})
|
|
572
581
|
|
|
573
582
|
await test('production stream mode hides error details', async () => {
|
|
@@ -986,6 +995,21 @@ await test('CSP nonce differs between requests', async () => {
|
|
|
986
995
|
})
|
|
987
996
|
})
|
|
988
997
|
|
|
998
|
+
await test('csp option extends trusted-types, merging with the base pulse policy', async () => {
|
|
999
|
+
// Regression: an agent assumed trusted-types/require-trusted-types-for
|
|
1000
|
+
// weren't extendable via createServer's csp option (unlike other
|
|
1001
|
+
// directives like img-src/style-src) because no guide demonstrated it —
|
|
1002
|
+
// buildCsp's merge loop is actually fully generic across all directive
|
|
1003
|
+
// keys, this was a documentation gap, not a real framework limitation.
|
|
1004
|
+
// Pin the real behavior so it can't silently regress.
|
|
1005
|
+
await withServer([canonicalSpec], { stream: false, csp: { 'trusted-types': ['google'] } }, async (port) => {
|
|
1006
|
+
const { headers } = await request(port, 'GET', '/about')
|
|
1007
|
+
const csp = headers['content-security-policy']
|
|
1008
|
+
assert(csp.includes('trusted-types pulse google'),
|
|
1009
|
+
`Expected trusted-types to merge with the base 'pulse' policy, got: ${csp}`)
|
|
1010
|
+
})
|
|
1011
|
+
})
|
|
1012
|
+
|
|
989
1013
|
await test('CSP header present on streaming page response', async () => {
|
|
990
1014
|
await withServer([canonicalSpec], { stream: true }, async (port) => {
|
|
991
1015
|
const { headers } = await request(port, 'GET', '/about')
|
|
@@ -994,6 +1018,38 @@ await test('CSP header present on streaming page response', async () => {
|
|
|
994
1018
|
})
|
|
995
1019
|
})
|
|
996
1020
|
|
|
1021
|
+
// Cache-Control
|
|
1022
|
+
await test('HTML with no spec.cache/defaultCache gets no-cache, not no-store', async () => {
|
|
1023
|
+
// Regression: a real Lighthouse audit failed "Page prevented back/forward
|
|
1024
|
+
// cache restoration" with reason "cache-control:no-store" on a page that
|
|
1025
|
+
// had no cache config at all — the framework's old default was no-store,
|
|
1026
|
+
// which unconditionally blocks the browser's bfcache. no-cache still
|
|
1027
|
+
// forces revalidation on every direct load (no risk of serving stale
|
|
1028
|
+
// content) but does not block bfcache, since bfcache restoration never
|
|
1029
|
+
// touches the network.
|
|
1030
|
+
await withServer([canonicalSpec], { stream: false }, async (port) => {
|
|
1031
|
+
const { headers } = await request(port, 'GET', '/about')
|
|
1032
|
+
assert(headers['cache-control'] === 'no-cache',
|
|
1033
|
+
`Expected no-cache, got: ${headers['cache-control']}`)
|
|
1034
|
+
})
|
|
1035
|
+
})
|
|
1036
|
+
|
|
1037
|
+
await test('HTML respects spec.cache / defaultCache when configured', async () => {
|
|
1038
|
+
await withServer([helloSpec], { stream: false, defaultCache: 60 }, async (port) => {
|
|
1039
|
+
const { headers } = await request(port, 'GET', '/hello')
|
|
1040
|
+
assert(headers['cache-control']?.includes('max-age=60'),
|
|
1041
|
+
`Expected max-age=60, got: ${headers['cache-control']}`)
|
|
1042
|
+
})
|
|
1043
|
+
})
|
|
1044
|
+
|
|
1045
|
+
await test('dev mode always returns no-store regardless of cache config', async () => {
|
|
1046
|
+
await withServer([canonicalSpec], { stream: false, dev: true, defaultCache: 3600 }, async (port) => {
|
|
1047
|
+
const { headers } = await request(port, 'GET', '/about')
|
|
1048
|
+
assert(headers['cache-control'] === 'no-store',
|
|
1049
|
+
`Expected no-store in dev mode, got: ${headers['cache-control']}`)
|
|
1050
|
+
})
|
|
1051
|
+
})
|
|
1052
|
+
|
|
997
1053
|
// HSTS
|
|
998
1054
|
await test('HSTS header absent on plain HTTP', async () => {
|
|
999
1055
|
await withServer([canonicalSpec], { stream: false }, async (port) => {
|
|
@@ -1800,6 +1856,32 @@ await test('hydrated pages receive the __PULSE_LIVE__ flag; disabled servers do
|
|
|
1800
1856
|
})
|
|
1801
1857
|
})
|
|
1802
1858
|
|
|
1859
|
+
await test('a store-only URL-entry spec (no local mutations/actions/persist, no explicit hydrate) still auto-derives a hydrate URL — regression: mount() is what wires up data-store-event dispatch AND the live SSE store-push subscription; without this a store-only page silently ships zero JS and never receives pushStore() broadcasts', async () => {
|
|
1860
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-needshydration-store-test-'))
|
|
1861
|
+
const specPath = path.join(dir, 'storey.js')
|
|
1862
|
+
fs.writeFileSync(
|
|
1863
|
+
specPath,
|
|
1864
|
+
`export default {\n route: '/storey',\n store: ['count'],\n state: {},\n view: (state, server) => \`\${server.count ?? 0}\`,\n}\n`
|
|
1865
|
+
)
|
|
1866
|
+
try {
|
|
1867
|
+
const port = nextPort++
|
|
1868
|
+
const { server } = await createServer([new URL('file://' + specPath)], { stream: false, live: true, port, root: new URL('file://' + dir + '/') })
|
|
1869
|
+
await new Promise(resolve => server.once('listening', resolve))
|
|
1870
|
+
try {
|
|
1871
|
+
const { body } = await get(port, '/storey')
|
|
1872
|
+
// A store-only page with no auto-derived hydrate would render with no
|
|
1873
|
+
// module script and no __PULSE_LIVE__ flag at all — both must be present.
|
|
1874
|
+
assert(body.includes('<script type="module"'), `Expected a hydration script tag, got: ${body.slice(0, 800)}`)
|
|
1875
|
+
assert(body.includes('__PULSE_LIVE__'), `Expected the live-store flag (only emitted for hydrated pages), got: ${body.slice(0, 800)}`)
|
|
1876
|
+
} finally {
|
|
1877
|
+
server.closeAllConnections?.()
|
|
1878
|
+
await new Promise(resolve => server.close(resolve))
|
|
1879
|
+
}
|
|
1880
|
+
} finally {
|
|
1881
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
1882
|
+
}
|
|
1883
|
+
})
|
|
1884
|
+
|
|
1803
1885
|
// ---------------------------------------------------------------------------
|
|
1804
1886
|
|
|
1805
1887
|
console.log('\nMalformed request handling\n')
|
|
@@ -1906,6 +1988,147 @@ await test('serves brotli-compressed HTML when br is accepted', async () => {
|
|
|
1906
1988
|
})
|
|
1907
1989
|
|
|
1908
1990
|
// ---------------------------------------------------------------------------
|
|
1991
|
+
// Route shadowing warning
|
|
1992
|
+
// ---------------------------------------------------------------------------
|
|
1993
|
+
// Regression: found via dogfooding. Route matching is first-match-wins in
|
|
1994
|
+
// registration order with no specificity ranking (see matchRoute) — a dynamic
|
|
1995
|
+
// route registered before a same-depth static route silently swallows every
|
|
1996
|
+
// request to that static route, with no error and no warning. discoverPages'
|
|
1997
|
+
// file-order is not guaranteed alphabetical across platforms, so this is a
|
|
1998
|
+
// real, easy-to-hit trap for any project mixing a catch-all-ish :param route
|
|
1999
|
+
// with static pages at the same path depth.
|
|
2000
|
+
|
|
2001
|
+
console.log('\nRoute shadowing\n')
|
|
2002
|
+
|
|
2003
|
+
const dynamicFirstSpec = { route: '/:code', state: {}, view: () => '' }
|
|
2004
|
+
const staticShadowedSpec = { route: '/settings', state: {}, view: () => '' }
|
|
2005
|
+
const unrelatedSpec = { route: '/about', state: {}, view: () => '' }
|
|
2006
|
+
|
|
2007
|
+
// The warning fires synchronously inside createServer, before it starts
|
|
2008
|
+
// listening — capture around the whole createServer call, then tear the
|
|
2009
|
+
// server down immediately (nothing needs to actually run against it).
|
|
2010
|
+
async function warningsFromCreateServer(specs) {
|
|
2011
|
+
const original = console.warn
|
|
2012
|
+
const messages = []
|
|
2013
|
+
console.warn = (msg) => messages.push(msg)
|
|
2014
|
+
const port = nextPort++
|
|
2015
|
+
let server
|
|
2016
|
+
try {
|
|
2017
|
+
;({ server } = await createServer(specs, { port, stream: false, quiet: true }))
|
|
2018
|
+
await new Promise(resolve => server.once('listening', resolve))
|
|
2019
|
+
} finally {
|
|
2020
|
+
console.warn = original
|
|
2021
|
+
if (server) {
|
|
2022
|
+
server.closeAllConnections?.()
|
|
2023
|
+
await new Promise(resolve => server.close(resolve))
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
return messages
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
await test('warns when a dynamic route is registered before a static route it shadows', async () => {
|
|
2030
|
+
const warnings = await warningsFromCreateServer([dynamicFirstSpec, staticShadowedSpec])
|
|
2031
|
+
assert(warnings.some(w => w.includes('/:code') && w.includes('/settings') && w.includes('never be reached')),
|
|
2032
|
+
`Expected a shadowing warning naming both routes, got: ${JSON.stringify(warnings)}`)
|
|
2033
|
+
})
|
|
2034
|
+
|
|
2035
|
+
await test('no warning when the static route is registered first (correct order)', async () => {
|
|
2036
|
+
const warnings = await warningsFromCreateServer([staticShadowedSpec, dynamicFirstSpec])
|
|
2037
|
+
assert(warnings.length === 0, `Expected no warnings, got: ${JSON.stringify(warnings)}`)
|
|
2038
|
+
})
|
|
2039
|
+
|
|
2040
|
+
await test('no warning between two unrelated static routes', async () => {
|
|
2041
|
+
const warnings = await warningsFromCreateServer([staticShadowedSpec, unrelatedSpec])
|
|
2042
|
+
assert(warnings.length === 0, `Expected no warnings, got: ${JSON.stringify(warnings)}`)
|
|
2043
|
+
})
|
|
2044
|
+
|
|
2045
|
+
// ---------------------------------------------------------------------------
|
|
2046
|
+
|
|
2047
|
+
console.log('\nError journal\n')
|
|
2048
|
+
|
|
2049
|
+
function makeTmpRoot() {
|
|
2050
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-error-journal-'))
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
function readJournal(root) {
|
|
2054
|
+
const p = path.join(root, '.pulse', 'errors.json')
|
|
2055
|
+
return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : null
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
const journalThrowingSpec = {
|
|
2059
|
+
route: '/boom-journal',
|
|
2060
|
+
state: {},
|
|
2061
|
+
view: () => { throw new Error('deliberate view failure') },
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
await test('server error in dev mode writes an entry to the journal', async () => {
|
|
2065
|
+
const root = makeTmpRoot()
|
|
2066
|
+
await withServer([journalThrowingSpec], { stream: false, dev: true, root }, async (port) => {
|
|
2067
|
+
await get(port, '/boom-journal')
|
|
2068
|
+
const entries = readJournal(root)
|
|
2069
|
+
assert(entries && entries.length === 1, `Expected 1 journal entry, got ${JSON.stringify(entries)}`)
|
|
2070
|
+
assert(entries[0].phase === 'server', `Expected phase "server", got ${entries[0].phase}`)
|
|
2071
|
+
assert(entries[0].message.includes('deliberate view failure'), `Message missing detail: ${entries[0].message}`)
|
|
2072
|
+
assert(entries[0].resolved === false, 'New entry should be unresolved')
|
|
2073
|
+
})
|
|
2074
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
2075
|
+
})
|
|
2076
|
+
|
|
2077
|
+
await test('server error NOT in dev mode does not write a journal entry', async () => {
|
|
2078
|
+
const root = makeTmpRoot()
|
|
2079
|
+
await withServer([journalThrowingSpec], { stream: false, dev: false, root }, async (port) => {
|
|
2080
|
+
await get(port, '/boom-journal')
|
|
2081
|
+
const entries = readJournal(root)
|
|
2082
|
+
assert(entries === null, `Expected no journal file in prod mode, got ${JSON.stringify(entries)}`)
|
|
2083
|
+
})
|
|
2084
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
2085
|
+
})
|
|
2086
|
+
|
|
2087
|
+
await test('POST /__pulse/error in dev mode writes a client-reported entry', async () => {
|
|
2088
|
+
const root = makeTmpRoot()
|
|
2089
|
+
await withServer([helloSpec], { stream: false, dev: true, root }, async (port) => {
|
|
2090
|
+
const { status } = await requestWithBody(
|
|
2091
|
+
port, 'POST', '/__pulse/error',
|
|
2092
|
+
JSON.stringify({ route: '/dashboard', phase: 'action', message: 'action "save" failed: boom', stack: 'Error: boom\n at save' }),
|
|
2093
|
+
'application/json'
|
|
2094
|
+
)
|
|
2095
|
+
assert(status === 204, `Expected 204, got ${status}`)
|
|
2096
|
+
const entries = readJournal(root)
|
|
2097
|
+
assert(entries && entries.length === 1, `Expected 1 journal entry, got ${JSON.stringify(entries)}`)
|
|
2098
|
+
assert(entries[0].route === '/dashboard', `Expected route /dashboard, got ${entries[0].route}`)
|
|
2099
|
+
assert(entries[0].phase === 'action', `Expected phase action, got ${entries[0].phase}`)
|
|
2100
|
+
})
|
|
2101
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
2102
|
+
})
|
|
2103
|
+
|
|
2104
|
+
await test('POST /__pulse/error in production returns 404 — no dev-only endpoint exposed', async () => {
|
|
2105
|
+
const root = makeTmpRoot()
|
|
2106
|
+
await withServer([helloSpec], { stream: false, dev: false, root }, async (port) => {
|
|
2107
|
+
const { status } = await requestWithBody(port, 'POST', '/__pulse/error', JSON.stringify({}), 'application/json')
|
|
2108
|
+
assert(status === 404, `Expected 404 in production, got ${status}`)
|
|
2109
|
+
assert(readJournal(root) === null, 'No journal file should exist in production')
|
|
2110
|
+
})
|
|
2111
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
2112
|
+
})
|
|
2113
|
+
|
|
2114
|
+
await test('error journal is capped at 50 entries (ring buffer)', async () => {
|
|
2115
|
+
const root = makeTmpRoot()
|
|
2116
|
+
await withServer([helloSpec], { stream: false, dev: true, root }, async (port) => {
|
|
2117
|
+
for (let i = 0; i < 55; i++) {
|
|
2118
|
+
await requestWithBody(
|
|
2119
|
+
port, 'POST', '/__pulse/error',
|
|
2120
|
+
JSON.stringify({ route: '/x', phase: 'view', message: `error ${i}`, stack: '' }),
|
|
2121
|
+
'application/json'
|
|
2122
|
+
)
|
|
2123
|
+
}
|
|
2124
|
+
const entries = readJournal(root)
|
|
2125
|
+
assert(entries.length === 50, `Expected 50 entries (capped), got ${entries.length}`)
|
|
2126
|
+
// Oldest entries should have been dropped — the most recent should remain
|
|
2127
|
+
assert(entries[entries.length - 1].message === 'error 54', `Expected newest entry to survive, got ${entries[entries.length - 1].message}`)
|
|
2128
|
+
assert(entries[0].message === 'error 5', `Expected oldest surviving entry to be "error 5", got ${entries[0].message}`)
|
|
2129
|
+
})
|
|
2130
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
2131
|
+
})
|
|
1909
2132
|
|
|
1910
2133
|
console.log('\nGraceful shutdown\n')
|
|
1911
2134
|
|
package/src/spec/schema.js
CHANGED
|
@@ -428,7 +428,7 @@ export function validateSpec(spec) {
|
|
|
428
428
|
// the framework reading spec.meta.vibe directly — this check catches unknown keys)
|
|
429
429
|
if (spec.meta && typeof spec.meta === 'object' && !Array.isArray(spec.meta)) {
|
|
430
430
|
const KNOWN_META_KEYS = new Set([
|
|
431
|
-
'title', 'description', 'styles', 'scripts', 'theme', 'vibe',
|
|
431
|
+
'title', 'description', 'styles', 'deferredStyles', 'scripts', 'theme', 'vibe',
|
|
432
432
|
'ogTitle', 'ogDescription', 'ogImage', 'canonical', 'schema',
|
|
433
433
|
'robots', 'viewport', 'charset', 'lang',
|
|
434
434
|
])
|
package/src/ui/index.js
CHANGED
|
@@ -22,8 +22,6 @@ export { button } from './button.js'
|
|
|
22
22
|
export { badge } from './badge.js'
|
|
23
23
|
export { card } from './card.js'
|
|
24
24
|
export { input } from './input.js'
|
|
25
|
-
export { search } from './search.js'
|
|
26
|
-
export { fieldset } from './fieldset.js'
|
|
27
25
|
export { select } from './select.js'
|
|
28
26
|
export { textarea } from './textarea.js'
|
|
29
27
|
export { alert } from './alert.js'
|
|
@@ -32,7 +30,6 @@ export { avatar } from './avatar.js'
|
|
|
32
30
|
export { empty } from './empty.js'
|
|
33
31
|
export { table } from './table.js'
|
|
34
32
|
export { hero } from './hero.js'
|
|
35
|
-
export { phoneFrame } from './phone-frame.js'
|
|
36
33
|
export { feature } from './feature.js'
|
|
37
34
|
export { accordion } from './accordion.js'
|
|
38
35
|
export { nav } from './nav.js'
|
|
@@ -44,7 +41,6 @@ export { stack } from './stack.js'
|
|
|
44
41
|
export { cluster } from './cluster.js'
|
|
45
42
|
export { divider } from './divider.js'
|
|
46
43
|
export { media } from './media.js'
|
|
47
|
-
export { tooltip } from './tooltip.js'
|
|
48
44
|
export { modal, modalTrigger } from './modal.js'
|
|
49
45
|
export { cta } from './cta.js'
|
|
50
46
|
export { footer } from './footer.js'
|
|
@@ -55,7 +51,6 @@ export { rating } from './rating.js'
|
|
|
55
51
|
export { spinner } from './spinner.js'
|
|
56
52
|
export { progress } from './progress.js'
|
|
57
53
|
export { slider } from './slider.js'
|
|
58
|
-
export { segmented } from './segmented.js'
|
|
59
54
|
export { fileUpload } from './fileupload.js'
|
|
60
55
|
export { breadcrumbs } from './breadcrumbs.js'
|
|
61
56
|
export { stepper } from './stepper.js'
|