@invisibleloop/pulse 0.20.51 → 0.20.53

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.51",
3
+ "version": "0.20.53",
4
4
  "type": "module",
5
5
  "description": "AI-first frontend framework. The spec is the source of truth.",
6
6
  "license": "MIT",
@@ -76,7 +76,7 @@
76
76
  "scripts": {
77
77
  "dev": "node --watch src/cli/dev.js",
78
78
  "build": "node scripts/build.js",
79
- "test": "node src/spec/schema.test.js && node src/runtime/runtime.test.js && node src/runtime/navigate.test.js && node src/runtime/morph.test.js && node src/runtime/ssr.test.js && node src/runtime/image.test.js && node src/server/server.test.js && node src/cli/cli.test.js && node src/cli/validate.test.js && node src/cli/diagnose.test.js && node src/cli/check-bundles.test.js && node src/cli/review.test.js && node src/cli/process-utils.test.js && node src/cli/stamp.test.js && node src/cli/feedback.test.js && node src/cli/update.test.js && node src/ui/ui.test.js && node src/testing/testing.test.js && node src/store/store.test.js && node src/md/md.test.js && node scripts/strip-server.test.js && node scripts/css-purge.test.js && node src/mcp/mcp.test.js && node src/mcp/guards.test.js",
79
+ "test": "node src/spec/schema.test.js && node src/runtime/runtime.test.js && node src/runtime/navigate.test.js && node src/runtime/morph.test.js && node src/runtime/ssr.test.js && node src/runtime/image.test.js && node src/server/server.test.js && node src/cli/cli.test.js && node src/cli/validate.test.js && node src/cli/diagnose.test.js && node src/cli/check-bundles.test.js && node src/cli/review.test.js && node src/cli/process-utils.test.js && node src/cli/stamp.test.js && node src/cli/feedback.test.js && node src/cli/update.test.js && node src/cli/dev-host.test.js && node src/ui/ui.test.js && node src/testing/testing.test.js && node src/store/store.test.js && node src/md/md.test.js && node scripts/strip-server.test.js && node scripts/css-purge.test.js && node src/mcp/mcp.test.js && node src/mcp/guards.test.js",
80
80
  "typecheck": "tsc --noEmit",
81
81
  "docs": "node --watch src/cli/dev.js --root ../../docs --port 4000",
82
82
  "docs:start": "node ../../docs/server.js"
@@ -1 +1 @@
1
- 0.20.51
1
+ 0.20.53
@@ -371,6 +371,19 @@ export default {
371
371
  }
372
372
  ```
373
373
 
374
+ ## Production behind a proxy — pin the public origin
375
+
376
+ By default Pulse builds `<link rel="canonical">`, `sitemap.xml` and `robots.txt` from the request's `Host`, `X-Forwarded-Host` and `X-Forwarded-Proto` headers. Anyone can send those headers, and a CDN that ignores `Vary` will cache the result for everyone — one forged request can point every visitor's canonical URL at another domain. For a real deployment, pin the origin:
377
+
378
+ ```js
379
+ // pulse.config.js
380
+ export default {
381
+ origin: 'https://example.com', // canonical + sitemap + robots use this; headers are ignored
382
+ }
383
+ ```
384
+
385
+ It must be an absolute http(s) URL (a bad value fails at startup). `sitemap: { origin }` still wins for the sitemap if you set both. Also set `host: '127.0.0.1'` in `pulse.config.js` if a reverse proxy on the same machine is the only thing that should reach the server. `pulse dev` already listens on `127.0.0.1`; use `pulse dev --host 0.0.0.0` for phone testing or a container.
386
+
374
387
  ## Health check endpoint
375
388
 
376
389
  Pulse exposes a built-in health check at `/healthz` by default. It responds **before** `onRequest`, static files, and route matching — so load balancers always get a response.
package/src/cli/dev.js CHANGED
@@ -21,6 +21,7 @@ import * as log from './logger.js'
21
21
  const args = process.argv.slice(2)
22
22
  const rootArg = args.indexOf('--root')
23
23
  const portArg = args.indexOf('--port')
24
+ const hostArg = args.indexOf('--host')
24
25
 
25
26
  const ROOT = rootArg !== -1
26
27
  ? path.resolve(args[rootArg + 1])
@@ -295,6 +296,10 @@ const reloadScript = (nonce) => `<script nonce="${nonce}">
295
296
 
296
297
  const { updateSpecs, updateStore } = await createServer(specs, {
297
298
  port: PORT,
299
+ // Loopback by default: the dev server has dev-only endpoints and stack-trace error
300
+ // pages, and should not be reachable from the LAN. `--host 0.0.0.0` (or `host` in
301
+ // pulse.config.js) opts in — e.g. for testing on a phone, or inside a container.
302
+ host: (hostArg !== -1 && args[hostArg + 1]) || _config.host || '127.0.0.1',
298
303
  stream: true,
299
304
  staticDir: fs.existsSync(PUBLIC_DIR) ? PUBLIC_DIR : null,
300
305
  manifest: {}, // never use a build manifest in dev — always serve source files
@@ -305,7 +310,7 @@ const { updateSpecs, updateStore } = await createServer(specs, {
305
310
  ...(_config.csp ? { csp: _config.csp } : {}),
306
311
  // Server options forwarded verbatim from pulse.config.js so dev behaves like
307
312
  // production — keep in sync with start.js PASSTHROUGH_OPTIONS
308
- ...(['redirects', 'sitemap', 'robots', 'secret', 'live', 'trailingSlash', 'fetcherTimeout', 'maxBody'].reduce(
313
+ ...(['redirects', 'sitemap', 'robots', 'secret', 'live', 'trailingSlash', 'fetcherTimeout', 'maxBody', 'origin'].reduce(
309
314
  (acc, k) => (_config[k] !== undefined ? { ...acc, [k]: _config[k] } : acc), {})),
310
315
 
311
316
  onRequest(req, res) {
package/src/cli/start.js CHANGED
@@ -48,7 +48,7 @@ let csp = null
48
48
  let passthrough = {}
49
49
 
50
50
  // Server options forwarded verbatim from pulse.config.js — keep in sync with dev.js
51
- const PASSTHROUGH_OPTIONS = ['redirects', 'sitemap', 'robots', 'secret', 'live', 'trailingSlash', 'fetcherTimeout', 'maxBody']
51
+ const PASSTHROUGH_OPTIONS = ['redirects', 'sitemap', 'robots', 'secret', 'live', 'trailingSlash', 'fetcherTimeout', 'maxBody', 'origin', 'host']
52
52
 
53
53
  const configPath = path.join(ROOT, 'pulse.config.js')
54
54
  if (fs.existsSync(configPath)) {
@@ -614,6 +614,11 @@ export async function createServer(entries, options = {}) {
614
614
  staticDir = null,
615
615
  manifest = null, // path to manifest.json or plain object
616
616
  trailingSlash = 'remove', // 'remove' | 'add' | 'allow'
617
+ origin: pinOrigin = null, // pin the public origin, e.g. 'https://example.com': used for
618
+ // <link rel="canonical">, sitemap.xml and robots.txt INSTEAD of the
619
+ // Host / X-Forwarded-* headers, which a client (or a CDN that ignores
620
+ // Vary) can forge. Recommended in production.
621
+ host: listenHost = null, // interface to listen on (default: all). `pulse dev` uses 127.0.0.1.
617
622
  extraBody = '', // extra HTML injected before </body> on every page
618
623
  dev = false, // dev mode — show detailed error pages
619
624
  store = null, // global store definition (pulse.store.js default export)
@@ -653,6 +658,17 @@ export async function createServer(entries, options = {}) {
653
658
  quiet = false, // suppress request logging
654
659
  } = options
655
660
 
661
+ // A pinned origin must be a bare http(s) origin; normalise it ("https://example.com/x" -> "https://example.com").
662
+ let pinnedOrigin = null
663
+ if (pinOrigin != null) {
664
+ let u = null
665
+ try { u = new URL(String(pinOrigin)) } catch { /* rejected below */ }
666
+ if (!u || (u.protocol !== 'http:' && u.protocol !== 'https:')) {
667
+ throw new Error(`createServer: origin must be an absolute http(s) URL such as "https://example.com" (got ${JSON.stringify(pinOrigin)})`)
668
+ }
669
+ pinnedOrigin = u.origin
670
+ }
671
+
656
672
  // Resolve URL entries → spec objects, auto-setting hydrate where needed
657
673
  const specs = await resolveSpecEntries(entries, root)
658
674
 
@@ -794,7 +810,7 @@ export async function createServer(entries, options = {}) {
794
810
  // Sitemap — track the live spec list (updated on hot reload) and surface a
795
811
  // startup hint for dynamic routes that can't be enumerated automatically.
796
812
  const sitemapEnabled = !!sitemap
797
- const sitemapOrigin = (typeof sitemap === 'object' && sitemap?.origin) ? String(sitemap.origin).replace(/\/$/, '') : null
813
+ const sitemapOrigin = ((typeof sitemap === 'object' && sitemap?.origin) ? String(sitemap.origin).replace(/\/$/, '') : null) || pinnedOrigin
798
814
  let allSpecs = specs
799
815
  if (sitemapEnabled && !quiet) {
800
816
  const unenumerated = specs.filter(s =>
@@ -1157,7 +1173,7 @@ export async function createServer(entries, options = {}) {
1157
1173
  const canonicalPath = trailingSlash === 'add' && pathname !== '/'
1158
1174
  ? (pathname.endsWith('/') ? pathname : pathname + '/')
1159
1175
  : (pathname !== '/' && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname)
1160
- const canonicalBase = `${proto}://${host}${canonicalPath}`
1176
+ const canonicalBase = pinnedOrigin ? `${pinnedOrigin}${canonicalPath}` : `${proto}://${host}${canonicalPath}`
1161
1177
 
1162
1178
  // Raw content spec (RSS, sitemap, JSON API, webhooks) — bypass HTML pipeline
1163
1179
  if (spec.contentType) {
@@ -1261,7 +1277,7 @@ export async function createServer(entries, options = {}) {
1261
1277
  process.on('SIGTERM', shutdown)
1262
1278
  process.on('SIGINT', shutdown)
1263
1279
 
1264
- server.listen(port, () => {
1280
+ const onListening = () => {
1265
1281
  import('../cli/logger.js').then(({ banner }) => {
1266
1282
  banner({
1267
1283
  url: `http://localhost:${port}`,
@@ -1272,7 +1288,9 @@ export async function createServer(entries, options = {}) {
1272
1288
  }).catch(() => {
1273
1289
  console.log(`⚡ Pulse running at http://localhost:${port}`)
1274
1290
  })
1275
- })
1291
+ }
1292
+ if (listenHost) server.listen(port, listenHost, onListening)
1293
+ else server.listen(port, onListening)
1276
1294
 
1277
1295
  return {
1278
1296
  server,
package/types/server.d.ts CHANGED
@@ -36,6 +36,21 @@ export interface ServerOptions {
36
36
  */
37
37
  trailingSlash?: 'remove' | 'add' | 'allow'
38
38
 
39
+ /**
40
+ * Pin the site's public origin, e.g. `'https://example.com'`. Used for `<link rel="canonical">`,
41
+ * `sitemap.xml` and `robots.txt` INSTEAD of the `Host` / `X-Forwarded-Host` / `X-Forwarded-Proto`
42
+ * headers, which a client — or a CDN that ignores `Vary` — can forge to poison those URLs.
43
+ * Recommended in production. An explicit `sitemap.origin` still takes precedence for the sitemap.
44
+ * Must be an absolute http(s) URL; anything after the origin is dropped.
45
+ */
46
+ origin?: string
47
+
48
+ /**
49
+ * Network interface to listen on. Default: all interfaces (what a server behind a proxy or in
50
+ * a container needs). `pulse dev` passes `'127.0.0.1'` unless told otherwise.
51
+ */
52
+ host?: string
53
+
39
54
  /** Maximum request body size in bytes. Default 1 MB. Requests exceeding this receive a 413. */
40
55
  maxBody?: number
41
56