@invisibleloop/pulse 0.18.2 → 0.19.0
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/CLAUDE.md +4 -0
- package/docs/src/pages/routing.js +13 -0
- package/docs/src/pages/server-api.js +1 -0
- package/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/src/agent/guide-routing.md +19 -0
- package/src/server/index.js +75 -0
- package/src/server/server.test.js +86 -0
- package/types/server.d.ts +14 -0
package/CLAUDE.md
CHANGED
|
@@ -294,6 +294,10 @@ await createServer(
|
|
|
294
294
|
// dynamic :param routes contribute via spec.sitemap enumerators;
|
|
295
295
|
// a physical sitemap.xml/robots.txt in staticDir always wins
|
|
296
296
|
robots: null, // robots.txt when sitemap on: null = auto, false = off, string = verbatim
|
|
297
|
+
redirects: { // declarative redirects for legacy URLs — 301 by default,
|
|
298
|
+
'/old-blog/:slug': '/blog/:slug', // :params carry over, query string preserved
|
|
299
|
+
'/promo': { to: '/pricing', status: 302 }, // custom status: 301 | 302 | 307 | 308
|
|
300
|
+
}, // checked before routing (GET/HEAD only); validated at startup
|
|
297
301
|
csp: { // extra sources merged into the framework's default CSP
|
|
298
302
|
'style-src': ['https://fonts.googleapis.com'],
|
|
299
303
|
'font-src': ['https://fonts.gstatic.com'],
|
|
@@ -79,6 +79,19 @@ server: {
|
|
|
79
79
|
},
|
|
80
80
|
}`, 'js'))}
|
|
81
81
|
|
|
82
|
+
${section('redirects', 'Redirects')}
|
|
83
|
+
<p>Migrating an existing site? Preserve the old URLs with the <code>redirects</code> map — legacy links and search rankings survive the move:</p>
|
|
84
|
+
${codeBlock(highlight(`await createServer(pages, {
|
|
85
|
+
redirects: {
|
|
86
|
+
'/old-blog/:slug': '/blog/:slug', // 301 — :params carry over
|
|
87
|
+
'/pricing-2024': '/pricing', // 301 (default)
|
|
88
|
+
'/promo': { to: '/sale', status: 302 }, // temporary redirect
|
|
89
|
+
'/moved/:slug': 'https://other.com/:slug', // absolute targets for domain moves
|
|
90
|
+
},
|
|
91
|
+
})`, 'js'))}
|
|
92
|
+
<p>Redirects respond <strong>301</strong> by default (permanent — search engines transfer ranking to the target). Use <code>{ to, status }</code> for <code>302</code>, <code>307</code>, or <code>308</code>. The query string is preserved, redirects apply to GET/HEAD only, and the map is validated at startup — a bad entry fails the boot rather than silently misrouting traffic.</p>
|
|
93
|
+
${callout('note', 'Redirects are checked before route matching, so a redirect source that equals a registered route shadows the page — the server logs a startup warning when that happens. Occasionally intentional (retiring a page), usually a mistake.')}
|
|
94
|
+
|
|
82
95
|
${section('not-found', '404 handling')}
|
|
83
96
|
<p>If no spec matches the incoming request path, Pulse returns a 404. To customise it, create a spec with <code>route: '*'</code> — it renders through the normal pipeline (layout, styles, components) with status 404:</p>
|
|
84
97
|
${codeBlock(highlight(`export default {
|
|
@@ -51,6 +51,7 @@ await createServer(entries, options)`, 'js'))}
|
|
|
51
51
|
['<code>secret</code>', '<code>string</code>', 'random per boot', 'HMAC secret for CSRF tokens (<a href="/forms">Forms</a>). Set a stable value when running multiple instances so tokens issued by one validate on another.'],
|
|
52
52
|
['<code>sitemap</code>', '<code>boolean | { origin }</code>', '<code>false</code>', 'Auto-serve <code>/sitemap.xml</code> + <code>robots.txt</code> from the registered routes. Dynamic <code>:param</code> routes contribute via <code>spec.sitemap</code> enumerators. See <a href="/meta#sitemap">Metadata & SEO</a>.'],
|
|
53
53
|
['<code>robots</code>', '<code>string | false</code>', 'auto', 'robots.txt behaviour when sitemap is enabled — <code>false</code> disables, a string is served verbatim.'],
|
|
54
|
+
['<code>redirects</code>', '<code>object</code>', '<code>null</code>', 'Declarative redirect map for legacy URLs — <code>{ \'/old/:slug\': \'/new/:slug\' }</code>, 301 by default. See <a href="/routing#redirects">Routing</a>.'],
|
|
54
55
|
['<code>onRequest</code>', '<code>function</code>', '<code>undefined</code>', 'Called on every request before routing. Return <code>false</code> to short-circuit Pulse handling.'],
|
|
55
56
|
['<code>onError</code>', '<code>function</code>', '<code>undefined</code>', 'Called on unhandled errors. Receives <code>(err, req, res)</code>. See <a href="/error-pages">Error Pages</a>.'],
|
|
56
57
|
]
|
package/package.json
CHANGED
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.19.0
|
|
@@ -147,6 +147,25 @@ export default {
|
|
|
147
147
|
- **Every site should have one** — without it, visitors to a bad URL get the framework's unbranded default 404.
|
|
148
148
|
- 500 errors are customised via `createServer`'s `onError` option, not a spec.
|
|
149
149
|
|
|
150
|
+
## Declarative redirects (legacy URLs)
|
|
151
|
+
|
|
152
|
+
When migrating an existing site to Pulse, preserve the old URLs with the `redirects` map in `pulse.config.js` / `createServer` — never let legacy URLs 404:
|
|
153
|
+
|
|
154
|
+
```js
|
|
155
|
+
export default {
|
|
156
|
+
redirects: {
|
|
157
|
+
'/old-blog/:slug': '/blog/:slug', // 301 — :params carry over
|
|
158
|
+
'/pricing-2024': '/pricing', // 301
|
|
159
|
+
'/promo': { to: '/sale', status: 302 }, // temporary
|
|
160
|
+
'/moved/:slug': 'https://other.com/:slug', // absolute targets for domain moves
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
- **301 by default** (permanent — search engines transfer ranking). Use `{ to, status }` for 302/307/308.
|
|
166
|
+
- Query strings are preserved; checked before route matching (GET/HEAD only); validated at startup — bad entries fail the boot.
|
|
167
|
+
- A redirect source that equals a registered route shadows the page (startup warning) — that is sometimes intentional (retiring a page), usually a mistake.
|
|
168
|
+
|
|
150
169
|
## Sitemap & robots.txt
|
|
151
170
|
|
|
152
171
|
Enable in `pulse.config.js` / `createServer` — the framework serves `/sitemap.xml` and `/robots.txt` generated from the registered routes:
|
package/src/server/index.js
CHANGED
|
@@ -551,6 +551,9 @@ export async function createServer(entries, options = {}) {
|
|
|
551
551
|
// spec.sitemap = async () => ['/blog/a', ...]
|
|
552
552
|
robots = null, // robots.txt content when sitemap is enabled:
|
|
553
553
|
// null = auto-generate, false = disable, string = serve verbatim
|
|
554
|
+
redirects = null, // declarative redirect map for legacy URLs (SEO-safe migrations):
|
|
555
|
+
// { '/old-blog/:slug': '/blog/:slug', — 301 (default)
|
|
556
|
+
// '/promo': { to: '/pricing', status: 302 } } — custom status
|
|
554
557
|
onError = (err, req, res) => defaultErrorHandler(err, req, res, dev),
|
|
555
558
|
onRequest,
|
|
556
559
|
agentMode = false, // show agent-active indicator in banner
|
|
@@ -619,6 +622,16 @@ export async function createServer(entries, options = {}) {
|
|
|
619
622
|
// tied to a cookie, so a restart only means in-flight forms re-render once.
|
|
620
623
|
const csrfSecret = secret || crypto.randomBytes(32).toString('hex')
|
|
621
624
|
|
|
625
|
+
// Redirects — compile the declarative map once at startup (throws on bad
|
|
626
|
+
// entries) and warn when a redirect source shadows a registered page route.
|
|
627
|
+
const redirectTable = compileRedirects(redirects)
|
|
628
|
+
if (redirectTable.length > 0 && !quiet) {
|
|
629
|
+
const shadowed = redirectTable.filter(r => specs.some(s => s.route === r.source))
|
|
630
|
+
if (shadowed.length > 0) {
|
|
631
|
+
console.log(` ⚠ redirects: ${shadowed.map(r => r.source).join(', ')} also match registered page routes — the redirect wins and the page is unreachable`)
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
622
635
|
// Sitemap — track the live spec list (updated on hot reload) and surface a
|
|
623
636
|
// startup hint for dynamic routes that can't be enumerated automatically.
|
|
624
637
|
const sitemapEnabled = !!sitemap
|
|
@@ -727,6 +740,27 @@ export async function createServer(entries, options = {}) {
|
|
|
727
740
|
// 'allow' — no redirect
|
|
728
741
|
}
|
|
729
742
|
|
|
743
|
+
// Declarative redirects — checked before route matching so legacy URLs
|
|
744
|
+
// redirect even when a spec also matches (a startup warning flags that).
|
|
745
|
+
// GET/HEAD only: redirecting a POST silently drops the body.
|
|
746
|
+
if (redirectTable.length > 0 && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
747
|
+
for (const r of redirectTable) {
|
|
748
|
+
const m = pathname.match(r.pattern)
|
|
749
|
+
if (!m) continue
|
|
750
|
+
// Substitute captured :params into the target, keeping their original
|
|
751
|
+
// percent-encoding intact (no decode/re-encode round trip). Longest
|
|
752
|
+
// names first so :s never clobbers part of :slug.
|
|
753
|
+
let target = r.target
|
|
754
|
+
;[...r.params.entries()]
|
|
755
|
+
.sort((a, b) => b[1].length - a[1].length)
|
|
756
|
+
.forEach(([i, name]) => { target = target.split(`:${name}`).join(m[i + 1]) })
|
|
757
|
+
if (target === pathname) break // self-redirect — fall through to normal routing
|
|
758
|
+
res.writeHead(r.status, { Location: target + (url.search || ''), ...SECURITY_HEADERS, ...httpsHeaders(req) })
|
|
759
|
+
res.end()
|
|
760
|
+
return
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
730
764
|
// Match route
|
|
731
765
|
let match = matchRoute(router, pathname)
|
|
732
766
|
let responseStatus = 200
|
|
@@ -1724,6 +1758,47 @@ function escHtml(str) {
|
|
|
1724
1758
|
.replace(/'/g, ''')
|
|
1725
1759
|
}
|
|
1726
1760
|
|
|
1761
|
+
/**
|
|
1762
|
+
* Compile the declarative redirects map into matchable entries.
|
|
1763
|
+
* Validates at startup — a bad entry throws before the server accepts connections.
|
|
1764
|
+
*
|
|
1765
|
+
* Sources are route patterns ('/old/:slug'); targets are paths or absolute URLs
|
|
1766
|
+
* and may reference the same :params. Values are a target string (301) or
|
|
1767
|
+
* { to, status } for 302/307/308.
|
|
1768
|
+
*
|
|
1769
|
+
* @param {Record<string, string | { to: string, status?: number }> | null} redirects
|
|
1770
|
+
* @returns {Array<{ source: string, pattern: RegExp, params: string[], target: string, status: number }>}
|
|
1771
|
+
*/
|
|
1772
|
+
function compileRedirects(redirects) {
|
|
1773
|
+
if (!redirects) return []
|
|
1774
|
+
const VALID_STATUS = new Set([301, 302, 307, 308])
|
|
1775
|
+
const table = []
|
|
1776
|
+
|
|
1777
|
+
for (const [source, value] of Object.entries(redirects)) {
|
|
1778
|
+
if (!source.startsWith('/')) {
|
|
1779
|
+
throw new Error(`Invalid redirect source "${source}" — sources must start with "/" (e.g. '/old-blog/:slug')`)
|
|
1780
|
+
}
|
|
1781
|
+
const target = typeof value === 'string' ? value : value?.to
|
|
1782
|
+
const status = typeof value === 'string' ? 301 : (value?.status ?? 301)
|
|
1783
|
+
if (typeof target !== 'string' || !(target.startsWith('/') || /^https?:\/\//.test(target))) {
|
|
1784
|
+
throw new Error(`Invalid redirect target for "${source}" — targets must start with "/" or be an absolute http(s) URL`)
|
|
1785
|
+
}
|
|
1786
|
+
if (!VALID_STATUS.has(status)) {
|
|
1787
|
+
throw new Error(`Invalid redirect status ${status} for "${source}" — use 301, 302, 307, or 308`)
|
|
1788
|
+
}
|
|
1789
|
+
// Targets may only reference params that the source captures
|
|
1790
|
+
const { pattern, params } = routeToRegex(source)
|
|
1791
|
+
const targetParams = [...target.matchAll(/:([a-zA-Z][\w-]*)/g)].map(m => m[1])
|
|
1792
|
+
const unknown = targetParams.filter(p => !params.includes(p))
|
|
1793
|
+
if (unknown.length > 0) {
|
|
1794
|
+
throw new Error(`Redirect target for "${source}" references :${unknown[0]} which the source does not capture`)
|
|
1795
|
+
}
|
|
1796
|
+
table.push({ source, pattern, params, target, status })
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
return table
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1727
1802
|
/**
|
|
1728
1803
|
* Collect sitemap entries from the registered specs.
|
|
1729
1804
|
*
|
|
@@ -1446,6 +1446,92 @@ await test('submit pages are excluded from the in-process page cache', async ()
|
|
|
1446
1446
|
|
|
1447
1447
|
// ---------------------------------------------------------------------------
|
|
1448
1448
|
|
|
1449
|
+
console.log('\nDeclarative redirects\n')
|
|
1450
|
+
|
|
1451
|
+
await test('plain redirect responds 301 with Location', async () => {
|
|
1452
|
+
await withServer([helloSpec], { stream: false, redirects: { '/old-hello': '/hello' } }, async (port) => {
|
|
1453
|
+
const { status, headers } = await get(port, '/old-hello')
|
|
1454
|
+
assert(status === 301, `Expected 301, got ${status}`)
|
|
1455
|
+
assert(headers.location === '/hello', `Expected /hello, got ${headers.location}`)
|
|
1456
|
+
})
|
|
1457
|
+
})
|
|
1458
|
+
|
|
1459
|
+
await test(':params are substituted into the target', async () => {
|
|
1460
|
+
await withServer([helloSpec], { stream: false, redirects: { '/old-blog/:slug': '/blog/:slug' } }, async (port) => {
|
|
1461
|
+
const { status, headers } = await get(port, '/old-blog/my-post')
|
|
1462
|
+
assert(status === 301)
|
|
1463
|
+
assert(headers.location === '/blog/my-post', `Expected /blog/my-post, got ${headers.location}`)
|
|
1464
|
+
})
|
|
1465
|
+
})
|
|
1466
|
+
|
|
1467
|
+
await test('percent-encoded params keep their encoding through substitution', async () => {
|
|
1468
|
+
await withServer([helloSpec], { stream: false, redirects: { '/old/:slug': '/new/:slug' } }, async (port) => {
|
|
1469
|
+
const { headers } = await get(port, '/old/caf%C3%A9')
|
|
1470
|
+
assert(headers.location === '/new/caf%C3%A9', `Encoding mangled: ${headers.location}`)
|
|
1471
|
+
})
|
|
1472
|
+
})
|
|
1473
|
+
|
|
1474
|
+
await test('query string is preserved on redirect', async () => {
|
|
1475
|
+
await withServer([helloSpec], { stream: false, redirects: { '/old-hello': '/hello' } }, async (port) => {
|
|
1476
|
+
const { headers } = await get(port, '/old-hello?utm=x&page=2')
|
|
1477
|
+
assert(headers.location === '/hello?utm=x&page=2', `Query lost: ${headers.location}`)
|
|
1478
|
+
})
|
|
1479
|
+
})
|
|
1480
|
+
|
|
1481
|
+
await test('custom status via { to, status }', async () => {
|
|
1482
|
+
await withServer([helloSpec], { stream: false, redirects: { '/promo': { to: '/hello', status: 302 } } }, async (port) => {
|
|
1483
|
+
const { status, headers } = await get(port, '/promo')
|
|
1484
|
+
assert(status === 302, `Expected 302, got ${status}`)
|
|
1485
|
+
assert(headers.location === '/hello')
|
|
1486
|
+
})
|
|
1487
|
+
})
|
|
1488
|
+
|
|
1489
|
+
await test('absolute URL targets work (domain moves)', async () => {
|
|
1490
|
+
await withServer([helloSpec], { stream: false, redirects: { '/moved/:slug': 'https://newsite.com/blog/:slug' } }, async (port) => {
|
|
1491
|
+
const { headers } = await get(port, '/moved/post-1')
|
|
1492
|
+
assert(headers.location === 'https://newsite.com/blog/post-1', `Got ${headers.location}`)
|
|
1493
|
+
})
|
|
1494
|
+
})
|
|
1495
|
+
|
|
1496
|
+
await test('redirect wins over a registered route at the same path', async () => {
|
|
1497
|
+
await withServer([helloSpec], { stream: false, quiet: true, redirects: { '/hello': '/elsewhere' } }, async (port) => {
|
|
1498
|
+
const { status, headers } = await get(port, '/hello')
|
|
1499
|
+
assert(status === 301 && headers.location === '/elsewhere', `Expected redirect to win, got ${status}`)
|
|
1500
|
+
})
|
|
1501
|
+
})
|
|
1502
|
+
|
|
1503
|
+
await test('POST is not redirected', async () => {
|
|
1504
|
+
await withServer([helloSpec], { stream: false, redirects: { '/old-api': '/hello' } }, async (port) => {
|
|
1505
|
+
const { status } = await requestFull(port, 'POST', '/old-api', {})
|
|
1506
|
+
assert(status === 404, `Expected POST to skip redirects (404), got ${status}`)
|
|
1507
|
+
})
|
|
1508
|
+
})
|
|
1509
|
+
|
|
1510
|
+
await test('self-redirects fall through to normal routing instead of looping', async () => {
|
|
1511
|
+
await withServer([helloSpec], { stream: false, redirects: { '/hello/:x': '/hello/:x' } }, async (port) => {
|
|
1512
|
+
const { status } = await get(port, '/hello/abc')
|
|
1513
|
+
assert(status === 404, `Expected fall-through to routing (404 here), got ${status}`)
|
|
1514
|
+
const ok = await get(port, '/hello')
|
|
1515
|
+
assert(ok.status === 200, 'Unrelated routes unaffected')
|
|
1516
|
+
})
|
|
1517
|
+
})
|
|
1518
|
+
|
|
1519
|
+
await test('invalid redirect config throws at startup', async () => {
|
|
1520
|
+
const cases = [
|
|
1521
|
+
{ redirects: { 'no-slash': '/x' } },
|
|
1522
|
+
{ redirects: { '/x': 'no-slash-target' } },
|
|
1523
|
+
{ redirects: { '/x': { to: '/y', status: 418 } } },
|
|
1524
|
+
{ redirects: { '/x': '/y/:missing' } },
|
|
1525
|
+
]
|
|
1526
|
+
for (const opts of cases) {
|
|
1527
|
+
let threw = false
|
|
1528
|
+
try { await createServer([helloSpec], { ...opts, port: nextPort++, quiet: true }) } catch { threw = true }
|
|
1529
|
+
assert(threw, `Expected startup throw for ${JSON.stringify(opts.redirects)}`)
|
|
1530
|
+
}
|
|
1531
|
+
})
|
|
1532
|
+
|
|
1533
|
+
// ---------------------------------------------------------------------------
|
|
1534
|
+
|
|
1449
1535
|
console.log('\nSitemap + robots.txt\n')
|
|
1450
1536
|
|
|
1451
1537
|
const aboutSpec = { route: '/about', view: () => '<main id="main-content">About</main>' }
|
package/types/server.d.ts
CHANGED
|
@@ -94,6 +94,20 @@ export interface ServerOptions {
|
|
|
94
94
|
*/
|
|
95
95
|
robots?: string | false | null
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Declarative redirect map for legacy URLs (SEO-safe migrations).
|
|
99
|
+
* Sources are route patterns; targets are paths or absolute URLs and may
|
|
100
|
+
* reference the same :params. A string target redirects with 301; use
|
|
101
|
+
* { to, status } for 302/307/308. Checked before route matching (GET/HEAD
|
|
102
|
+
* only); the query string is preserved. Validated at startup.
|
|
103
|
+
* @example
|
|
104
|
+
* redirects: {
|
|
105
|
+
* '/old-blog/:slug': '/blog/:slug',
|
|
106
|
+
* '/promo': { to: '/pricing', status: 302 },
|
|
107
|
+
* }
|
|
108
|
+
*/
|
|
109
|
+
redirects?: Record<string, string | { to: string; status?: 301 | 302 | 307 | 308 }> | null
|
|
110
|
+
|
|
97
111
|
/**
|
|
98
112
|
* Extra CSP sources to merge into the framework's default Content-Security-Policy.
|
|
99
113
|
* Use this to allow external stylesheets, fonts, or API origins.
|