@jasonshimmy/vite-plugin-cer-app 0.4.6 → 0.6.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.
Files changed (93) hide show
  1. package/.github/copilot-instructions.md +4 -2
  2. package/CHANGELOG.md +8 -0
  3. package/IMPLEMENTATION_PLAN.md +52 -10
  4. package/commits.txt +1 -1
  5. package/dist/cli/commands/preview-isr.d.ts +51 -0
  6. package/dist/cli/commands/preview-isr.d.ts.map +1 -0
  7. package/dist/cli/commands/preview-isr.js +104 -0
  8. package/dist/cli/commands/preview-isr.js.map +1 -0
  9. package/dist/cli/commands/preview.d.ts.map +1 -1
  10. package/dist/cli/commands/preview.js +65 -1
  11. package/dist/cli/commands/preview.js.map +1 -1
  12. package/dist/cli/create/templates/spa/package.json.tpl +2 -2
  13. package/dist/cli/create/templates/ssg/package.json.tpl +2 -2
  14. package/dist/cli/create/templates/ssr/package.json.tpl +2 -2
  15. package/dist/plugin/build-ssg.d.ts.map +1 -1
  16. package/dist/plugin/build-ssg.js +4 -2
  17. package/dist/plugin/build-ssg.js.map +1 -1
  18. package/dist/plugin/dev-server.d.ts +3 -0
  19. package/dist/plugin/dev-server.d.ts.map +1 -1
  20. package/dist/plugin/dev-server.js.map +1 -1
  21. package/dist/plugin/dts-generator.d.ts.map +1 -1
  22. package/dist/plugin/dts-generator.js +8 -1
  23. package/dist/plugin/dts-generator.js.map +1 -1
  24. package/dist/plugin/index.d.ts.map +1 -1
  25. package/dist/plugin/index.js +9 -1
  26. package/dist/plugin/index.js.map +1 -1
  27. package/dist/plugin/transforms/auto-import.js +2 -2
  28. package/dist/plugin/transforms/auto-import.js.map +1 -1
  29. package/dist/plugin/virtual/routes.d.ts.map +1 -1
  30. package/dist/plugin/virtual/routes.js +95 -8
  31. package/dist/plugin/virtual/routes.js.map +1 -1
  32. package/dist/runtime/app-template.d.ts +1 -1
  33. package/dist/runtime/app-template.d.ts.map +1 -1
  34. package/dist/runtime/app-template.js +16 -4
  35. package/dist/runtime/app-template.js.map +1 -1
  36. package/dist/runtime/composables/index.d.ts +1 -0
  37. package/dist/runtime/composables/index.d.ts.map +1 -1
  38. package/dist/runtime/composables/index.js +1 -0
  39. package/dist/runtime/composables/index.js.map +1 -1
  40. package/dist/runtime/composables/use-runtime-config.d.ts +32 -0
  41. package/dist/runtime/composables/use-runtime-config.d.ts.map +1 -0
  42. package/dist/runtime/composables/use-runtime-config.js +41 -0
  43. package/dist/runtime/composables/use-runtime-config.js.map +1 -0
  44. package/dist/runtime/entry-server-template.d.ts +2 -2
  45. package/dist/runtime/entry-server-template.d.ts.map +1 -1
  46. package/dist/runtime/entry-server-template.js +50 -21
  47. package/dist/runtime/entry-server-template.js.map +1 -1
  48. package/dist/types/config.d.ts +24 -0
  49. package/dist/types/config.d.ts.map +1 -1
  50. package/dist/types/config.js.map +1 -1
  51. package/dist/types/index.d.ts +1 -1
  52. package/dist/types/index.d.ts.map +1 -1
  53. package/dist/types/page.d.ts +17 -0
  54. package/dist/types/page.d.ts.map +1 -1
  55. package/docs/composables.md +36 -0
  56. package/docs/configuration.md +52 -0
  57. package/docs/layouts.md +82 -0
  58. package/docs/rendering-modes.md +55 -14
  59. package/docs/routing.md +66 -0
  60. package/e2e/cypress/e2e/isr-nested-runtime.cy.ts +100 -0
  61. package/e2e/kitchen-sink/app/layouts/admin.ts +13 -0
  62. package/e2e/kitchen-sink/app/pages/admin/_layout.ts +1 -0
  63. package/e2e/kitchen-sink/app/pages/admin/dashboard.ts +11 -0
  64. package/e2e/kitchen-sink/app/pages/blog/[slug].ts +1 -0
  65. package/e2e/kitchen-sink/app/pages/isr-test.ts +17 -0
  66. package/e2e/kitchen-sink/cer.config.ts +5 -0
  67. package/package.json +3 -3
  68. package/src/__tests__/cli/preview-isr.test.ts +246 -0
  69. package/src/__tests__/plugin/build-ssg-render.test.ts +46 -0
  70. package/src/__tests__/plugin/dts-generator.test.ts +20 -0
  71. package/src/__tests__/plugin/entry-server-template.test.ts +23 -5
  72. package/src/__tests__/plugin/resolve-config.test.ts +15 -0
  73. package/src/__tests__/plugin/transforms/auto-import.test.ts +16 -0
  74. package/src/__tests__/plugin/virtual/routes.test.ts +195 -0
  75. package/src/__tests__/runtime/use-runtime-config.test.ts +59 -0
  76. package/src/cli/commands/preview-isr.ts +139 -0
  77. package/src/cli/commands/preview.ts +71 -2
  78. package/src/cli/create/templates/spa/package.json.tpl +2 -2
  79. package/src/cli/create/templates/ssg/package.json.tpl +2 -2
  80. package/src/cli/create/templates/ssr/package.json.tpl +2 -2
  81. package/src/plugin/build-ssg.ts +4 -2
  82. package/src/plugin/dev-server.ts +1 -0
  83. package/src/plugin/dts-generator.ts +8 -1
  84. package/src/plugin/index.ts +11 -1
  85. package/src/plugin/transforms/auto-import.ts +2 -2
  86. package/src/plugin/virtual/routes.ts +106 -9
  87. package/src/runtime/app-template.ts +16 -4
  88. package/src/runtime/composables/index.ts +1 -0
  89. package/src/runtime/composables/use-runtime-config.ts +40 -0
  90. package/src/runtime/entry-server-template.ts +50 -21
  91. package/src/types/config.ts +26 -0
  92. package/src/types/index.ts +1 -1
  93. package/src/types/page.ts +17 -0
@@ -140,4 +140,19 @@ describe('resolveConfig', () => {
140
140
  const cfg = resolveConfig({}, ROOT)
141
141
  expect(cfg.root).toBe(ROOT)
142
142
  })
143
+
144
+ it('defaults runtimeConfig.public to an empty object', () => {
145
+ const cfg = resolveConfig({}, ROOT)
146
+ expect(cfg.runtimeConfig.public).toEqual({})
147
+ })
148
+
149
+ it('passes runtimeConfig.public values through', () => {
150
+ const cfg = resolveConfig({ runtimeConfig: { public: { apiBase: '/api', version: '1' } } }, ROOT)
151
+ expect(cfg.runtimeConfig.public).toEqual({ apiBase: '/api', version: '1' })
152
+ })
153
+
154
+ it('preserves runtimeConfig.public when other runtimeConfig fields are omitted', () => {
155
+ const cfg = resolveConfig({ runtimeConfig: { public: { foo: 42 } } }, ROOT)
156
+ expect(cfg.runtimeConfig.public.foo).toBe(42)
157
+ })
143
158
  })
@@ -233,6 +233,22 @@ describe('autoImportTransform — framework composable injection', () => {
233
233
  const result = autoImportTransform(code, '/project/app/loading.ts', opts)!
234
234
  expect(result).toContain('usePageData')
235
235
  })
236
+
237
+ it('injects useRuntimeConfig import when useRuntimeConfig is used', () => {
238
+ const code = "component('page-dashboard', () => { const cfg = useRuntimeConfig(); return html`<div></div>` })"
239
+ const result = autoImportTransform(code, '/project/app/pages/dashboard.ts', opts)!
240
+ expect(result).toContain(`from ${FRAMEWORK_PKG}`)
241
+ expect(result).toContain('useRuntimeConfig')
242
+ })
243
+
244
+ it('injects useRuntimeConfig alongside other framework composables', () => {
245
+ const code = "component('page-x', () => { useHead({ title: 'x' }); const cfg = useRuntimeConfig(); return html`<div></div>` })"
246
+ const result = autoImportTransform(code, '/project/app/pages/x.ts', opts)!
247
+ expect(result).toContain('useHead')
248
+ expect(result).toContain('useRuntimeConfig')
249
+ const count = result.split(`from ${FRAMEWORK_PKG}`).length - 1
250
+ expect(count).toBe(1)
251
+ })
236
252
  })
237
253
 
238
254
  // ─── Composable import injection ─────────────────────────────────────────────
@@ -200,3 +200,198 @@ describe('generateRoutesCode — 404.ts convention', () => {
200
200
  expect(idIdx).toBeLessThan(allIdx)
201
201
  })
202
202
  })
203
+
204
+ // ─── _layout.ts filtering ─────────────────────────────────────────────────────
205
+
206
+ describe('generateRoutesCode — _layout.ts filtering', () => {
207
+ beforeEach(() => {
208
+ vi.mocked(existsSync).mockReturnValue(true)
209
+ vi.mocked(scanDirectory).mockResolvedValue([])
210
+ vi.mocked(readFile).mockResolvedValue('' as never)
211
+ })
212
+
213
+ it('excludes _layout.ts files from the generated routes', async () => {
214
+ vi.mocked(scanDirectory).mockResolvedValue([
215
+ `${PAGES}/admin/index.ts`,
216
+ `${PAGES}/admin/_layout.ts`,
217
+ ])
218
+ const code = await generateRoutesCode(PAGES)
219
+ expect(code).not.toContain('_layout')
220
+ expect(code).toContain('/admin')
221
+ })
222
+
223
+ it('generates a route for non-underscore files in the same directory', async () => {
224
+ vi.mocked(scanDirectory).mockResolvedValue([
225
+ `${PAGES}/admin/users.ts`,
226
+ `${PAGES}/admin/_layout.ts`,
227
+ ])
228
+ const code = await generateRoutesCode(PAGES)
229
+ expect(code).toContain('/admin/users')
230
+ expect(code).not.toContain('_layout')
231
+ })
232
+ })
233
+
234
+ // ─── meta.revalidate ─────────────────────────────────────────────────────────
235
+
236
+ describe('generateRoutesCode — meta.ssg.revalidate', () => {
237
+ beforeEach(() => {
238
+ vi.mocked(existsSync).mockReturnValue(true)
239
+ vi.mocked(scanDirectory).mockResolvedValue([])
240
+ vi.mocked(readFile).mockResolvedValue('' as never)
241
+ })
242
+
243
+ it('emits meta.ssg.revalidate when declared in page source', async () => {
244
+ vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/blog.ts`])
245
+ vi.mocked(readFile).mockResolvedValue(
246
+ `export const meta = { ssg: { revalidate: 60 } }` as never,
247
+ )
248
+ const code = await generateRoutesCode(PAGES)
249
+ expect(code).toContain('ssg: { revalidate: 60 }')
250
+ })
251
+
252
+ it('emits larger revalidate values correctly', async () => {
253
+ vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/products.ts`])
254
+ vi.mocked(readFile).mockResolvedValue(
255
+ `export const meta = { ssg: { revalidate: 3600 } }` as never,
256
+ )
257
+ const code = await generateRoutesCode(PAGES)
258
+ expect(code).toContain('ssg: { revalidate: 3600 }')
259
+ })
260
+
261
+ it('omits ssg meta when no revalidate is declared', async () => {
262
+ vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/about.ts`])
263
+ vi.mocked(readFile).mockResolvedValue(
264
+ `component('page-about', () => html\`<h1>About</h1>\`)` as never,
265
+ )
266
+ const code = await generateRoutesCode(PAGES)
267
+ expect(code).not.toContain('revalidate')
268
+ })
269
+
270
+ it('can combine revalidate with layout in meta', async () => {
271
+ vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/blog.ts`])
272
+ vi.mocked(readFile).mockResolvedValue(
273
+ `export const meta = { layout: 'minimal', ssg: { revalidate: 120 } }` as never,
274
+ )
275
+ const code = await generateRoutesCode(PAGES)
276
+ expect(code).toContain('layout: "minimal"')
277
+ expect(code).toContain('ssg: { revalidate: 120 }')
278
+ })
279
+ })
280
+
281
+ // ─── meta.transition ─────────────────────────────────────────────────────────
282
+
283
+ describe('generateRoutesCode — meta.transition', () => {
284
+ beforeEach(() => {
285
+ vi.mocked(existsSync).mockReturnValue(true)
286
+ vi.mocked(scanDirectory).mockResolvedValue([])
287
+ vi.mocked(readFile).mockResolvedValue('' as never)
288
+ })
289
+
290
+ it('emits meta.transition string when declared', async () => {
291
+ vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/about.ts`])
292
+ vi.mocked(readFile).mockResolvedValue(
293
+ `export const meta = { transition: 'fade' }` as never,
294
+ )
295
+ const code = await generateRoutesCode(PAGES)
296
+ expect(code).toContain('transition: "fade"')
297
+ })
298
+
299
+ it('emits meta.transition true (boolean) when declared', async () => {
300
+ vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/about.ts`])
301
+ vi.mocked(readFile).mockResolvedValue(
302
+ `export const meta = { transition: true }` as never,
303
+ )
304
+ const code = await generateRoutesCode(PAGES)
305
+ expect(code).toContain('transition: true')
306
+ })
307
+
308
+ it('omits transition meta when not declared', async () => {
309
+ vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/about.ts`])
310
+ vi.mocked(readFile).mockResolvedValue(
311
+ `component('page-about', () => html\`<h1>About</h1>\`)` as never,
312
+ )
313
+ const code = await generateRoutesCode(PAGES)
314
+ expect(code).not.toContain('transition')
315
+ })
316
+
317
+ it('emits meta.transition false (boolean) when declared', async () => {
318
+ vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/about.ts`])
319
+ vi.mocked(readFile).mockResolvedValue(
320
+ `export const meta = { transition: false }` as never,
321
+ )
322
+ const code = await generateRoutesCode(PAGES)
323
+ expect(code).toContain('transition: false')
324
+ })
325
+
326
+ it('can combine transition with middleware', async () => {
327
+ vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/dashboard.ts`])
328
+ vi.mocked(readFile).mockResolvedValue(
329
+ `export const meta = { transition: 'slide', middleware: ['auth'] }` as never,
330
+ )
331
+ const code = await generateRoutesCode(PAGES)
332
+ expect(code).toContain('transition: "slide"')
333
+ expect(code).toContain('beforeEnter')
334
+ expect(code).toContain('"auth"')
335
+ })
336
+ })
337
+
338
+ // ─── nested layouts (layoutChain) ────────────────────────────────────────────
339
+
340
+ describe('generateRoutesCode — layoutChain (nested layouts)', () => {
341
+ beforeEach(() => {
342
+ vi.mocked(existsSync).mockReturnValue(true)
343
+ vi.mocked(scanDirectory).mockResolvedValue([])
344
+ vi.mocked(readFile).mockResolvedValue('' as never)
345
+ })
346
+
347
+ it('emits meta.layoutChain when a _layout.ts exists in ancestor directory', async () => {
348
+ vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/admin/users.ts`])
349
+ vi.mocked(readFile).mockImplementation(async (path: unknown) => {
350
+ if (String(path).endsWith('_layout.ts')) return 'export default \'minimal\''
351
+ return ''
352
+ })
353
+ const code = await generateRoutesCode(PAGES)
354
+ expect(code).toContain('layoutChain:')
355
+ expect(code).toContain('"default"')
356
+ expect(code).toContain('"minimal"')
357
+ })
358
+
359
+ it('uses outerLayout from page meta when layoutChain is emitted', async () => {
360
+ vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/admin/users.ts`])
361
+ vi.mocked(readFile).mockImplementation(async (path: unknown) => {
362
+ const p = String(path)
363
+ if (p.endsWith('_layout.ts')) return 'export default \'sidebar\''
364
+ // page source has its own layout
365
+ return `export const meta = { layout: 'clean' }`
366
+ })
367
+ const code = await generateRoutesCode(PAGES)
368
+ expect(code).toContain('layoutChain:')
369
+ expect(code).toContain('"clean"')
370
+ expect(code).toContain('"sidebar"')
371
+ // Should not emit a plain meta.layout — the chain replaces it
372
+ expect(code).not.toContain('layout: "clean"')
373
+ })
374
+
375
+ it('omits layoutChain for top-level pages with no _layout.ts', async () => {
376
+ vi.mocked(existsSync).mockImplementation((p: unknown) => {
377
+ // Return false for any _layout.ts path so no chain is resolved
378
+ if (String(p).endsWith('_layout.ts')) return false
379
+ return true
380
+ })
381
+ vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/about.ts`])
382
+ vi.mocked(readFile).mockResolvedValue(
383
+ `export const meta = { layout: 'default' }` as never,
384
+ )
385
+ const code = await generateRoutesCode(PAGES)
386
+ expect(code).not.toContain('layoutChain')
387
+ expect(code).toContain('layout: "default"')
388
+ })
389
+
390
+ it('omits layoutChain for top-level pages even when existsSync returns true', async () => {
391
+ // top-level page has no subdirectory segments, so resolveLayoutChain returns null immediately
392
+ vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/index.ts`])
393
+ vi.mocked(readFile).mockResolvedValue('' as never)
394
+ const code = await generateRoutesCode(PAGES)
395
+ expect(code).not.toContain('layoutChain')
396
+ })
397
+ })
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { useRuntimeConfig, initRuntimeConfig } from '../../runtime/composables/use-runtime-config.js'
3
+
4
+ beforeEach(() => {
5
+ // Reset global state between tests
6
+ delete (globalThis as Record<string, unknown>).__cerRuntimeConfig
7
+ })
8
+
9
+ describe('initRuntimeConfig', () => {
10
+ it('stores the config on globalThis', () => {
11
+ initRuntimeConfig({ public: { apiBase: '/api' } })
12
+ expect((globalThis as Record<string, unknown>).__cerRuntimeConfig).toEqual({ public: { apiBase: '/api' } })
13
+ })
14
+
15
+ it('overwrites a previous config', () => {
16
+ initRuntimeConfig({ public: { apiBase: '/v1' } })
17
+ initRuntimeConfig({ public: { apiBase: '/v2' } })
18
+ const stored = (globalThis as Record<string, unknown>).__cerRuntimeConfig as { public: Record<string, unknown> }
19
+ expect(stored.public.apiBase).toBe('/v2')
20
+ })
21
+ })
22
+
23
+ describe('useRuntimeConfig', () => {
24
+ it('returns empty public config when not initialized', () => {
25
+ const config = useRuntimeConfig()
26
+ expect(config.public).toEqual({})
27
+ })
28
+
29
+ it('returns the config set by initRuntimeConfig', () => {
30
+ initRuntimeConfig({ public: { apiBase: '/api' } })
31
+ const config = useRuntimeConfig()
32
+ expect(config.public.apiBase).toBe('/api')
33
+ })
34
+
35
+ it('returns the full public config object', () => {
36
+ initRuntimeConfig({ public: { apiBase: '/api', version: '1.0', debug: false } })
37
+ const config = useRuntimeConfig()
38
+ expect(config.public).toEqual({ apiBase: '/api', version: '1.0', debug: false })
39
+ })
40
+
41
+ it('returns a reference to the stored config (not a copy)', () => {
42
+ const stored = { public: { apiBase: '/api' } }
43
+ initRuntimeConfig(stored)
44
+ const config = useRuntimeConfig()
45
+ expect(config).toBe(stored)
46
+ })
47
+
48
+ it('reflects updates when initRuntimeConfig is called again', () => {
49
+ initRuntimeConfig({ public: { key: 'first' } })
50
+ initRuntimeConfig({ public: { key: 'second' } })
51
+ expect(useRuntimeConfig().public.key).toBe('second')
52
+ })
53
+
54
+ it('handles empty public config', () => {
55
+ initRuntimeConfig({ public: {} })
56
+ const config = useRuntimeConfig()
57
+ expect(config.public).toEqual({})
58
+ })
59
+ })
@@ -0,0 +1,139 @@
1
+ /**
2
+ * ISR (Incremental Static Regeneration) helpers for the preview server.
3
+ *
4
+ * Extracted into their own module so they can be unit-tested independently
5
+ * from the HTTP server wiring in preview.ts.
6
+ */
7
+
8
+ import { Readable } from 'node:stream'
9
+ import type { IncomingMessage, ServerResponse } from 'node:http'
10
+
11
+ // ─── Types ────────────────────────────────────────────────────────────────────
12
+
13
+ export interface IsrCacheEntry {
14
+ html: string
15
+ headers: Record<string, string>
16
+ statusCode: number
17
+ builtAt: number
18
+ revalidate: number
19
+ /** True while a background re-render is in flight (stale-while-revalidate). */
20
+ revalidating: boolean
21
+ }
22
+
23
+ export type IsrCacheStatus = 'HIT' | 'STALE' | 'MISS'
24
+
25
+ export type SsrHandlerFn = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
26
+
27
+ // ─── Route pattern matching ───────────────────────────────────────────────────
28
+
29
+ /**
30
+ * Tests whether a route path pattern matches a URL path string.
31
+ * Normalises trailing slashes and supports `:param` and `:param*` (catch-all)
32
+ * segments using a simple regex conversion — no external dependencies needed.
33
+ *
34
+ * @example
35
+ * matchRoutePattern('/blog/:slug', '/blog/hello') // true
36
+ * matchRoutePattern('/:all*', '/any/deep/path') // true
37
+ * matchRoutePattern('/about', '/contact') // false
38
+ */
39
+ export function matchRoutePattern(pattern: string, urlPath: string): boolean {
40
+ const norm = (s: string): string => s.replace(/\/+$/, '') || '/'
41
+ if (norm(pattern) === norm(urlPath)) return true
42
+ const regexStr =
43
+ '^' +
44
+ norm(pattern)
45
+ .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // escape regex special chars in static segments
46
+ .replace(/:[^/]+\*/g, '.*')
47
+ .replace(/:[^/]+/g, '[^/]+') +
48
+ '$'
49
+ return new RegExp(regexStr).test(norm(urlPath))
50
+ }
51
+
52
+ /**
53
+ * Looks up the `meta.ssg.revalidate` TTL (in seconds) for the route that best
54
+ * matches `urlPath`. Returns `null` when no route matches or none defines
55
+ * `revalidate`.
56
+ */
57
+ export function findRevalidate(
58
+ routes: Array<{ path: string; meta?: Record<string, unknown> }>,
59
+ urlPath: string,
60
+ ): number | null {
61
+ for (const route of routes) {
62
+ if (matchRoutePattern(route.path, urlPath)) {
63
+ const ssg = route.meta?.ssg as Record<string, unknown> | undefined
64
+ if (typeof ssg?.revalidate === 'number') return ssg.revalidate
65
+ }
66
+ }
67
+ return null
68
+ }
69
+
70
+ // ─── Response capture ─────────────────────────────────────────────────────────
71
+
72
+ /**
73
+ * Renders a URL path through `handler` using a synthetic IncomingMessage and a
74
+ * fake ServerResponse that captures the output in memory.
75
+ *
76
+ * Returns an `IsrCacheEntry` on success, or `null` if the handler throws.
77
+ */
78
+ export async function renderForIsr(
79
+ urlPath: string,
80
+ handler: SsrHandlerFn,
81
+ revalidate: number,
82
+ ): Promise<IsrCacheEntry | null> {
83
+ const req = Object.assign(new Readable({ read() {} }), {
84
+ url: urlPath,
85
+ method: 'GET',
86
+ headers: {},
87
+ socket: null,
88
+ }) as unknown as IncomingMessage
89
+
90
+ return new Promise<IsrCacheEntry | null>((resolve) => {
91
+ const chunks: Buffer[] = []
92
+ const headers: Record<string, string> = {}
93
+ let capturedStatus = 200
94
+
95
+ const fakeRes = {
96
+ get statusCode() { return capturedStatus },
97
+ set statusCode(v: number) { capturedStatus = v },
98
+ headersSent: false,
99
+ setHeader(name: string, value: string | string[]) {
100
+ headers[name.toLowerCase()] = Array.isArray(value) ? value.join(', ') : String(value)
101
+ return this
102
+ },
103
+ getHeader(name: string) { return headers[name.toLowerCase()] },
104
+ write(chunk: string | Buffer) {
105
+ if (chunk != null) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)))
106
+ return true
107
+ },
108
+ end(chunk?: string | Buffer) {
109
+ if (chunk != null) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)))
110
+ const html = Buffer.concat(chunks).toString('utf-8')
111
+ resolve({ html, headers, statusCode: capturedStatus, builtAt: Date.now(), revalidate, revalidating: false })
112
+ return this
113
+ },
114
+ } as unknown as ServerResponse
115
+
116
+ // Use Promise.resolve().then() so synchronous throws in the handler are
117
+ // also caught by the .catch() handler.
118
+ Promise.resolve().then(() => handler(req, fakeRes)).catch(() => resolve(null))
119
+ })
120
+ }
121
+
122
+ // ─── Cache serving ────────────────────────────────────────────────────────────
123
+
124
+ /**
125
+ * Writes a cached ISR entry to the real HTTP response, forwarding all captured
126
+ * headers and setting the `X-Cache` diagnostic header.
127
+ */
128
+ export function serveFromIsrCache(
129
+ entry: IsrCacheEntry,
130
+ res: ServerResponse,
131
+ cacheStatus: IsrCacheStatus,
132
+ ): void {
133
+ for (const [name, value] of Object.entries(entry.headers)) {
134
+ res.setHeader(name, value)
135
+ }
136
+ res.setHeader('X-Cache', cacheStatus)
137
+ res.statusCode = entry.statusCode
138
+ res.end(entry.html)
139
+ }
@@ -3,6 +3,15 @@ import { createServer as createHttpServer, type IncomingMessage, type ServerResp
3
3
  import { createReadStream, existsSync, statSync } from 'node:fs'
4
4
  import { resolve, join, extname } from 'pathe'
5
5
  import { pathToFileURL } from 'node:url'
6
+ import {
7
+ type IsrCacheEntry,
8
+ type SsrHandlerFn,
9
+ findRevalidate,
10
+ renderForIsr,
11
+ serveFromIsrCache,
12
+ } from './preview-isr.js'
13
+
14
+ // ─── API route matching ───────────────────────────────────────────────────────
6
15
 
7
16
  /**
8
17
  * Matches an API route pattern (e.g. '/api/items/:id') against a URL path.
@@ -107,7 +116,6 @@ export function previewCommand(): Command {
107
116
  console.log('[cer-app] Starting SSR preview server...')
108
117
 
109
118
  // Load the server bundle
110
- type SsrHandlerFn = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
111
119
  let serverMod: {
112
120
  handler?: SsrHandlerFn
113
121
  default?: SsrHandlerFn
@@ -130,6 +138,15 @@ export function previewCommand(): Command {
130
138
  const apiRoutes: Array<{ path: string; handlers: Record<string, unknown> }> =
131
139
  Array.isArray(serverMod.apiRoutes) ? serverMod.apiRoutes : []
132
140
 
141
+ // Page routes exported by the server bundle (used for ISR revalidate lookup).
142
+ const pageRoutes: Array<{ path: string; meta?: Record<string, unknown> }> =
143
+ Array.isArray((serverMod as { routes?: unknown }).routes)
144
+ ? (serverMod as { routes: Array<{ path: string; meta?: Record<string, unknown> }> }).routes
145
+ : []
146
+
147
+ // ISR cache: path → cached render entry.
148
+ const isrCache = new Map<string, IsrCacheEntry>()
149
+
133
150
  const server = createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
134
151
  const url = req.url ?? '/'
135
152
  const urlPath = url.split('?')[0]
@@ -183,7 +200,59 @@ export function previewCommand(): Command {
183
200
  if (served) return
184
201
  }
185
202
 
186
- // Fall through to SSR handler
203
+ // ISR: check whether this route has a revalidate TTL.
204
+ const revalidate = findRevalidate(pageRoutes, urlPath)
205
+ if (revalidate !== null) {
206
+ const cached = isrCache.get(urlPath)
207
+ const now = Date.now()
208
+
209
+ if (cached) {
210
+ const ageSeconds = (now - cached.builtAt) / 1000
211
+ if (ageSeconds < cached.revalidate) {
212
+ // Fresh — serve from cache.
213
+ serveFromIsrCache(cached, res, 'HIT')
214
+ return
215
+ }
216
+ // Stale — serve stale immediately, revalidate in background.
217
+ if (!cached.revalidating) {
218
+ cached.revalidating = true
219
+ serveFromIsrCache(cached, res, 'STALE')
220
+ const revalidateTimeout = setTimeout(() => {
221
+ if (cached) cached.revalidating = false
222
+ }, 30_000)
223
+ renderForIsr(urlPath, handler, revalidate).then((entry) => {
224
+ clearTimeout(revalidateTimeout)
225
+ if (entry) isrCache.set(urlPath, entry)
226
+ else if (cached) cached.revalidating = false
227
+ }).catch(() => {
228
+ clearTimeout(revalidateTimeout)
229
+ if (cached) cached.revalidating = false
230
+ })
231
+ return
232
+ }
233
+ // Already revalidating — serve stale without spawning another render.
234
+ serveFromIsrCache(cached, res, 'STALE')
235
+ return
236
+ }
237
+
238
+ // Cache miss — render, cache, then serve.
239
+ try {
240
+ const entry = await renderForIsr(urlPath, handler, revalidate)
241
+ if (entry) {
242
+ isrCache.set(urlPath, entry)
243
+ serveFromIsrCache(entry, res, 'HIT')
244
+ } else {
245
+ await handler(req, res)
246
+ }
247
+ } catch (err) {
248
+ console.error('[cer-app] ISR render error:', err)
249
+ res.statusCode = 500
250
+ res.end('Internal Server Error')
251
+ }
252
+ return
253
+ }
254
+
255
+ // Non-ISR: fall through to SSR handler directly.
187
256
  try {
188
257
  await handler(req, res)
189
258
  } catch (err) {
@@ -8,11 +8,11 @@
8
8
  "preview": "cer-app preview"
9
9
  },
10
10
  "dependencies": {
11
- "@jasonshimmy/custom-elements-runtime": "^3.2.1"
11
+ "@jasonshimmy/custom-elements-runtime": "^3.4.0"
12
12
  },
13
13
  "devDependencies": {
14
14
  "vite": "^8.0.1",
15
- "@jasonshimmy/vite-plugin-cer-app": "^0.4.2",
15
+ "@jasonshimmy/vite-plugin-cer-app": "^0.6.0",
16
16
  "typescript": "^5.9.3"
17
17
  }
18
18
  }
@@ -9,11 +9,11 @@
9
9
  "preview": "cer-app preview"
10
10
  },
11
11
  "dependencies": {
12
- "@jasonshimmy/custom-elements-runtime": "^3.2.1"
12
+ "@jasonshimmy/custom-elements-runtime": "^3.4.0"
13
13
  },
14
14
  "devDependencies": {
15
15
  "vite": "^8.0.1",
16
- "@jasonshimmy/vite-plugin-cer-app": "^0.4.2",
16
+ "@jasonshimmy/vite-plugin-cer-app": "^0.6.0",
17
17
  "typescript": "^5.9.3"
18
18
  }
19
19
  }
@@ -8,11 +8,11 @@
8
8
  "preview": "cer-app preview --ssr"
9
9
  },
10
10
  "dependencies": {
11
- "@jasonshimmy/custom-elements-runtime": "^3.2.1"
11
+ "@jasonshimmy/custom-elements-runtime": "^3.4.0"
12
12
  },
13
13
  "devDependencies": {
14
14
  "vite": "^8.0.1",
15
- "@jasonshimmy/vite-plugin-cer-app": "^0.4.2",
15
+ "@jasonshimmy/vite-plugin-cer-app": "^0.6.0",
16
16
  "typescript": "^5.9.3"
17
17
  }
18
18
  }
@@ -131,12 +131,14 @@ async function renderPath(
131
131
 
132
132
  // Mock req/res for the Express-style handler.
133
133
  // The handler internally merges with dist/client/index.html, so we just
134
- // capture whatever it ends with.
134
+ // capture whatever it writes/ends with.
135
135
  const mockReq = { url: path, headers: {} }
136
136
  return new Promise<string>((resolve, reject) => {
137
+ const chunks: string[] = []
137
138
  const mockRes = {
138
139
  setHeader: () => {},
139
- end: (body: string) => resolve(body),
140
+ write: (chunk: string) => { chunks.push(chunk) },
141
+ end: (body?: string) => resolve(chunks.join('') + (body ?? '')),
140
142
  }
141
143
  ;(handlerFn as (req: unknown, res: unknown) => Promise<void>)(mockReq, mockRes).catch(reject)
142
144
  })
@@ -19,6 +19,7 @@ export interface ResolvedCerConfig {
19
19
  router: { base?: string; scrollToFragment?: boolean | object }
20
20
  jitCss: { content: string[]; extendedColors: boolean }
21
21
  autoImports: { components: boolean; composables: boolean; directives: boolean; runtime: boolean }
22
+ runtimeConfig: { public: Record<string, unknown> }
22
23
  }
23
24
 
24
25
  /**
@@ -76,7 +76,7 @@ const RUNTIME_GLOBALS = [
76
76
 
77
77
  const DIRECTIVE_GLOBALS = ['when', 'each', 'match', 'anchorBlock']
78
78
 
79
- const FRAMEWORK_GLOBALS = ['useHead', 'usePageData', 'useInject']
79
+ const FRAMEWORK_GLOBALS = ['useHead', 'usePageData', 'useInject', 'useRuntimeConfig']
80
80
 
81
81
  /**
82
82
  * Scans a composables directory and returns a map of export name → file path.
@@ -213,6 +213,13 @@ export async function generateVirtualModuleDts(
213
213
  lines.push(` export const errorTag: string | null`)
214
214
  lines.push(`}`)
215
215
  lines.push('')
216
+ lines.push(`declare module 'virtual:cer-app-config' {`)
217
+ lines.push(` import type { RuntimePublicConfig } from '@jasonshimmy/vite-plugin-cer-app/types'`)
218
+ lines.push(` export const appConfig: { mode: string; router: Record<string, unknown>; ssg: Record<string, unknown> }`)
219
+ lines.push(` export const runtimeConfig: { public: RuntimePublicConfig }`)
220
+ lines.push(` export default appConfig`)
221
+ lines.push(`}`)
222
+ lines.push('')
216
223
 
217
224
  return lines.join('\n')
218
225
  }
@@ -92,6 +92,9 @@ export function resolveConfig(userConfig: CerAppConfig, root: string = process.c
92
92
  directives: userConfig.autoImports?.directives ?? true,
93
93
  runtime: userConfig.autoImports?.runtime ?? true,
94
94
  },
95
+ runtimeConfig: {
96
+ public: userConfig.runtimeConfig?.public ?? {},
97
+ },
95
98
  }
96
99
  }
97
100
 
@@ -139,7 +142,14 @@ function generateAppConfigModule(config: ResolvedCerConfig): string {
139
142
  router: config.router,
140
143
  ssg: config.ssg,
141
144
  }
142
- return `// AUTO-GENERATED by @jasonshimmy/vite-plugin-cer-app\nexport const appConfig = ${JSON.stringify(exportedConfig, null, 2)}\nexport default appConfig\n`
145
+ const publicConfig = config.runtimeConfig.public
146
+ return (
147
+ `// AUTO-GENERATED by @jasonshimmy/vite-plugin-cer-app\n` +
148
+ `export const appConfig = ${JSON.stringify(exportedConfig, null, 2)}\n` +
149
+ `export default appConfig\n` +
150
+ `\n` +
151
+ `export const runtimeConfig = { public: ${JSON.stringify(publicConfig, null, 2)} }\n`
152
+ )
143
153
  }
144
154
 
145
155
  /**