@invisibleloop/pulse 0.20.33 → 0.20.35

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": "@invisibleloop/pulse",
3
- "version": "0.20.33",
3
+ "version": "0.20.35",
4
4
  "type": "module",
5
5
  "description": "AI-first frontend framework. The spec is the source of truth.",
6
6
  "license": "MIT",
@@ -1 +1 @@
1
- 0.20.33
1
+ 0.20.35
@@ -216,7 +216,7 @@ ${port !== 3000 ? ` port: ${port},\n` : ''}${agent && agent !== 'claude' ? ` a
216
216
  },
217
217
  {
218
218
  type: 'command',
219
- command: `node .claude/coverage-check.js`,
219
+ command: `cd "$CLAUDE_PROJECT_DIR" && node .claude/coverage-check.js`,
220
220
  statusMessage: 'Checking test coverage...',
221
221
  },
222
222
  {
@@ -417,7 +417,6 @@ export function wrapDocument({ content, spec = {}, serverState = {}, storeState
417
417
  ${styleLinks}
418
418
  ${deferredStyleLinks}
419
419
  ${schemaScript}
420
- <script defer src="https://quietlytics.app/tracker.js"></script>
421
420
  </head>
422
421
  <body${bodyAttr}>
423
422
  <a href="#main-content" class="pulse-skip-link">Skip to main content</a>
@@ -602,7 +602,11 @@ export async function createServer(entries, options = {}) {
602
602
  maxBody = 1024 * 1024, // max request body size in bytes (default 1 MB)
603
603
  shutdownTimeout = 30000, // ms to wait for in-flight requests before force-exit
604
604
  healthCheck = '/healthz', // path for health check endpoint, or false to disable
605
- csp = {}, // extra CSP sources: { 'style-src': ['https://fonts.googleapis.com'] }
605
+ csp: cspOption = {}, // extra CSP sources: { 'style-src': ['https://fonts.googleapis.com'] }
606
+ analytics = null, // opt-in analytics script URL, e.g. 'https://quietlytics.app/tracker.js'.
607
+ // Injected as <script defer> in every page head; its origin is added
608
+ // to script-src + connect-src. Off by default — Pulse never injects
609
+ // third-party scripts unless asked.
606
610
  secret = null, // HMAC secret for CSRF tokens — set a stable value when running
607
611
  // multiple instances; defaults to a random per-boot secret
608
612
  sitemap = false, // true | { origin } — auto-serve /sitemap.xml + robots.txt from
@@ -714,6 +718,14 @@ export async function createServer(entries, options = {}) {
714
718
  // Live store push — SSE clients and the broadcast function. The heartbeat
715
719
  // comment keeps proxies from reaping idle connections; unref'd so it never
716
720
  // holds the process open.
721
+ const analyticsSrc = analytics ? (typeof analytics === 'string' ? analytics : analytics.src) : null
722
+ let csp = cspOption
723
+ if (analyticsSrc) {
724
+ const origin = new URL(analyticsSrc).origin
725
+ csp = { ...cspOption }
726
+ for (const k of ['script-src', 'connect-src']) csp[k] = [...(cspOption[k] || []), origin]
727
+ }
728
+ const analyticsTag = analyticsSrc ? `<script defer src="${escHtml(analyticsSrc)}"></script>` : ''
717
729
  const livePath = live ? ((typeof live === 'object' && live.path) || '/__pulse/live') : null
718
730
  const liveClients = new Set()
719
731
  const liveHeartbeat = livePath
@@ -1102,11 +1114,11 @@ export async function createServer(entries, options = {}) {
1102
1114
  // string path — they are small, must carry an explicit status, and POST
1103
1115
  // bodies are already consumed.
1104
1116
  if (responseStatus !== 200 || req.method === 'POST') {
1105
- await handleStringResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, storeDef, csp, faviconPath, responseStatus, livePath)
1117
+ await handleStringResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, storeDef, csp, faviconPath, responseStatus, livePath, analyticsTag)
1106
1118
  } else if (stream) {
1107
- await handleStreamResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, storeDef, csp, faviconPath, livePath)
1119
+ await handleStreamResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, storeDef, csp, faviconPath, livePath, analyticsTag)
1108
1120
  } else {
1109
- await handleStringResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, storeDef, csp, faviconPath, 200, livePath)
1121
+ await handleStringResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, storeDef, csp, faviconPath, 200, livePath, analyticsTag)
1110
1122
  }
1111
1123
 
1112
1124
  } catch (err) {
@@ -1291,7 +1303,7 @@ async function handleNavStreamResponse(spec, ctx, req, res) {
1291
1303
  * Render to a complete string then send — simpler, easier to cache.
1292
1304
  * Checks the in-process page cache before rendering; stores result after.
1293
1305
  */
1294
- async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalBase = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}, faviconPath = null, status = 200, livePath = '') {
1306
+ async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalBase = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}, faviconPath = null, status = 200, livePath = '', analyticsTag = '') {
1295
1307
  const cacheKey = buildCacheKey(spec, ctx)
1296
1308
  // Never cache POST re-renders or non-200 responses (custom 404s) — POST output
1297
1309
  // varies per submission and a cached 404 body would be served with a 200 later.
@@ -1315,7 +1327,7 @@ async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = f
1315
1327
  const canonicalTag = canonicalUrl ? `<link rel="canonical" href="${escHtml(canonicalUrl)}">` : ''
1316
1328
  const resolvedSpec = { ...spec, meta: await resolveMeta(spec.meta, ctx) }
1317
1329
  const resolvedExtraBody = typeof extraBody === 'function' ? extraBody(nonce) : extraBody
1318
- const wrapped = wrapDocument({ content, spec: resolvedSpec, serverState, storeState: ctx.store || null, storeDef: store || null, timing, extraBody: resolvedExtraBody, extraHead: (dev ? devImportMap(nonce) + '\n ' : '') + canonicalTag, nonce, runtimeBundle, faviconHref: faviconPath || '', livePath })
1330
+ const wrapped = wrapDocument({ content, spec: resolvedSpec, serverState, storeState: ctx.store || null, storeDef: store || null, timing, extraBody: resolvedExtraBody, extraHead: (dev ? devImportMap(nonce) + '\n ' : '') + canonicalTag + analyticsTag, nonce, runtimeBundle, faviconHref: faviconPath || '', livePath })
1319
1331
  html = wrapped.html
1320
1332
  serverTimingValue = wrapped.serverTimingValue
1321
1333
  if (ttl > 0) pageHtmlCache.set(cacheKey, { html }, ttl)
@@ -1343,7 +1355,7 @@ async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = f
1343
1355
  * Stream the response — shell first, deferred segments follow.
1344
1356
  * On a page-cache hit, serves the buffered HTML as a string (no streaming needed).
1345
1357
  */
1346
- async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalBase = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}, faviconPath = null, livePath = '') {
1358
+ async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalBase = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}, faviconPath = null, livePath = '', analyticsTag = '') {
1347
1359
  // Serve from in-process page cache when available — skip streaming overhead.
1348
1360
  const cacheKey = buildCacheKey(spec, ctx)
1349
1361
  const ttl = isCacheablePage(spec) ? pageCacheTtl(spec, dev, defaultCache) : 0
@@ -1425,8 +1437,7 @@ async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = f
1425
1437
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1426
1438
  <link rel="icon" href="${faviconPath || 'data:,'}">
1427
1439
  <title>${escHtml(title)}</title>
1428
- ${stylePreconnects ? stylePreconnects + '\n' : ''}${stylePreloads ? stylePreloads + '\n' : ''}${runtimePreload ? runtimePreload + '\n' : ''}${dev ? devImportMap(nonce) + '\n' : ''}${metaTags} <script defer src="https://quietlytics.app/tracker.js"></script>
1429
- </head>
1440
+ ${stylePreconnects ? stylePreconnects + '\n' : ''}${stylePreloads ? stylePreloads + '\n' : ''}${runtimePreload ? runtimePreload + '\n' : ''}${dev ? devImportMap(nonce) + '\n' : ''}${metaTags}${analyticsTag ? ' ' + analyticsTag + '\n' : ''}</head>
1430
1441
  <body${bodyAttr}>
1431
1442
  <div id="pulse-root">`
1432
1443
 
@@ -2444,6 +2444,31 @@ await test('ctx.formData() still handles URL-encoded body correctly', async () =
2444
2444
 
2445
2445
  // ---------------------------------------------------------------------------
2446
2446
 
2447
+ console.log('\nAnalytics (opt-in)\n')
2448
+
2449
+ const TRACKER = 'https://example-analytics.test/tracker.js'
2450
+
2451
+ for (const stream of [false, true]) {
2452
+ await test(`no third-party script is injected by default (stream: ${stream})`, async () => {
2453
+ await withServer([helloSpec], { stream }, async (port) => {
2454
+ const { body } = await get(port, '/hello')
2455
+ assert(!body.includes('<script defer'), 'default pages must not carry an analytics script')
2456
+ })
2457
+ })
2458
+
2459
+ await test(`analytics option injects the script and widens CSP (stream: ${stream})`, async () => {
2460
+ await withServer([helloSpec], { stream, analytics: TRACKER }, async (port) => {
2461
+ const res = await get(port, '/hello')
2462
+ assert(res.body.includes(`<script defer src="${TRACKER}"></script>`), 'script tag missing from head')
2463
+ const csp = String(res.headers['content-security-policy'])
2464
+ assert(/script-src[^;]*https:\/\/example-analytics\.test/.test(csp), 'script-src should allow the origin')
2465
+ assert(/connect-src[^;]*https:\/\/example-analytics\.test/.test(csp), 'connect-src should allow the origin')
2466
+ })
2467
+ })
2468
+ }
2469
+
2470
+ // ---------------------------------------------------------------------------
2471
+
2447
2472
  console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
2448
2473
  finished = true
2449
2474
  if (failed > 0) process.exit(1)
package/types/server.d.ts CHANGED
@@ -127,6 +127,14 @@ export interface ServerOptions {
127
127
  */
128
128
  csp?: Record<string, string[]>
129
129
 
130
+ /**
131
+ * Opt-in analytics script URL. Injected as `<script defer src>` in every page head,
132
+ * and its origin is added to `script-src` and `connect-src`. Off by default —
133
+ * Pulse never injects third-party scripts unless asked.
134
+ * @example analytics: 'https://quietlytics.app/tracker.js'
135
+ */
136
+ analytics?: string | { src: string }
137
+
130
138
  /** Called on unhandled errors. Receives (err, req, res). */
131
139
  onError?: (err: Error, req: IncomingMessage, res: ServerResponse) => void
132
140