@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 +1 -1
- package/public/.pulse-ui-version +1 -1
- package/src/cli/scaffold.js +1 -1
- package/src/runtime/ssr.js +0 -1
- package/src/server/index.js +20 -9
- package/src/server/server.test.js +25 -0
- package/types/server.d.ts +8 -0
package/package.json
CHANGED
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.20.
|
|
1
|
+
0.20.35
|
package/src/cli/scaffold.js
CHANGED
|
@@ -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
|
{
|
package/src/runtime/ssr.js
CHANGED
|
@@ -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>
|
package/src/server/index.js
CHANGED
|
@@ -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
|
|
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}
|
|
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
|
|