@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
package/docs/routing.md CHANGED
@@ -172,6 +172,72 @@ export const meta = {
172
172
  }
173
173
  ```
174
174
 
175
+ ### `meta.ssg.revalidate`
176
+
177
+ **Type:** `number` (seconds)
178
+
179
+ Enables Incremental Static Regeneration (ISR) for the route. When set, the preview server caches the rendered HTML and serves it until the TTL expires. After expiry, stale HTML is served immediately while a fresh render runs in the background (stale-while-revalidate).
180
+
181
+ ```ts
182
+ // app/pages/blog/[slug].ts
183
+ export const meta = {
184
+ ssg: {
185
+ revalidate: 60, // re-render at most once per minute
186
+ paths: async () => { /* ... */ },
187
+ },
188
+ }
189
+ ```
190
+
191
+ See [Rendering Modes — ISR](rendering-modes.md#isr--incremental-static-regeneration) for full details.
192
+
193
+ ### `meta.transition`
194
+
195
+ **Type:** `string | boolean`
196
+
197
+ Attaches transition metadata to the route. The value is extracted at build time and emitted as `meta.transition` on the route object — the framework does **not** apply any CSS or DOM changes automatically.
198
+
199
+ ```ts
200
+ // app/pages/about.ts
201
+ export const meta = {
202
+ transition: 'fade', // stored as route.meta.transition at runtime
203
+ }
204
+ ```
205
+
206
+ Read the value in your navigation handler or layout to implement transitions yourself:
207
+
208
+ ```ts
209
+ import routes from 'virtual:cer-routes'
210
+ const about = routes.find(r => r.path === '/about')
211
+ // about.meta.transition → 'fade'
212
+ ```
213
+
214
+ Example — apply a CSS class during navigation using a plugin:
215
+
216
+ ```ts
217
+ // app/plugins/transitions.ts
218
+ export default {
219
+ setup({ router }) {
220
+ router.beforeEach((to) => {
221
+ const name = to.meta?.transition
222
+ if (name) document.documentElement.setAttribute('data-transition', String(name))
223
+ else document.documentElement.removeAttribute('data-transition')
224
+ })
225
+ },
226
+ }
227
+ ```
228
+
229
+ ```css
230
+ [data-transition="fade"] cer-layout-view {
231
+ animation: fadeIn 0.2s ease;
232
+ }
233
+ @keyframes fadeIn {
234
+ from { opacity: 0 }
235
+ to { opacity: 1 }
236
+ }
237
+ ```
238
+
239
+ Set to `false` to explicitly mark a page as having no transition (useful when a catch-all or default would otherwise apply one).
240
+
175
241
  ---
176
242
 
177
243
  ## Route sorting
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Tests for ISR (Incremental Static Regeneration), nested layouts, and
3
+ * runtimeConfig — features added alongside Nuxt/Next parity improvements.
4
+ *
5
+ * ISR and runtimeConfig tests only run in SSR mode (preview server).
6
+ * Nested-layout tests run in all modes.
7
+ */
8
+
9
+ const mode = Cypress.env('mode') as 'spa' | 'ssr' | 'ssg'
10
+
11
+ // ─── ISR (preview server only) ────────────────────────────────────────────────
12
+
13
+ if (mode === 'ssr') {
14
+ describe('ISR — stale-while-revalidate cache', () => {
15
+ it('request to a route with revalidate returns X-Cache: HIT', () => {
16
+ // On first render (cache miss) the server renders, caches, then serves
17
+ // from cache — so all requests within the TTL return HIT.
18
+ cy.request('/blog/first-post').then((response) => {
19
+ expect(response.headers['x-cache']).to.equal('HIT')
20
+ })
21
+ })
22
+
23
+ it('subsequent requests within TTL also return X-Cache: HIT', () => {
24
+ cy.request('/blog/first-post')
25
+ cy.request('/blog/first-post').then((response) => {
26
+ expect(response.headers['x-cache']).to.equal('HIT')
27
+ })
28
+ })
29
+
30
+ it('X-Cache header is absent on non-revalidate routes', () => {
31
+ cy.request('/about').then((response) => {
32
+ expect(response.headers).not.to.have.property('x-cache')
33
+ })
34
+ })
35
+
36
+ // /isr-test uses revalidate: 0 — the TTL is always expired after the first
37
+ // render, so the second request is always served stale-while-revalidate.
38
+ it('first request to a revalidate:0 route returns X-Cache: HIT', () => {
39
+ cy.request('/isr-test').then((response) => {
40
+ expect(response.headers['x-cache']).to.equal('HIT')
41
+ })
42
+ })
43
+
44
+ it('second request to a revalidate:0 route returns X-Cache: STALE', () => {
45
+ cy.request('/isr-test') // prime the cache
46
+ cy.request('/isr-test').then((response) => {
47
+ expect(response.headers['x-cache']).to.equal('STALE')
48
+ })
49
+ })
50
+ })
51
+ }
52
+
53
+ // ─── Nested layouts ────────────────────────────────────────────────────────────
54
+
55
+ describe('Nested layouts — admin section', () => {
56
+ it('renders the admin dashboard page', () => {
57
+ cy.visit('/admin/dashboard')
58
+ cy.get('[data-cy=admin-dashboard-heading]').should('contain', 'Admin Dashboard')
59
+ })
60
+
61
+ it('renders the outer default layout (site-header, site-nav)', () => {
62
+ cy.visit('/admin/dashboard')
63
+ cy.get('[data-cy=site-header]').should('exist')
64
+ cy.get('[data-cy=site-nav]').should('exist')
65
+ })
66
+
67
+ it('renders the inner admin layout (admin-sidebar, admin-content)', () => {
68
+ cy.visit('/admin/dashboard')
69
+ cy.get('[data-cy=admin-layout]').should('exist')
70
+ cy.get('[data-cy=admin-sidebar]').should('exist')
71
+ cy.get('[data-cy=admin-content]').should('exist')
72
+ })
73
+
74
+ it('layout-admin is a DOM child of layout-default (layout chain order)', () => {
75
+ // Verifies the chain ['default', 'admin'] is rendered outermost-first.
76
+ // layout-admin must be in the light DOM of layout-default, not the other
77
+ // way around. A CSS descendant selector proves this without needing to
78
+ // pierce slot boundaries.
79
+ cy.visit('/admin/dashboard')
80
+ cy.get('layout-default layout-admin').should('exist')
81
+ })
82
+
83
+ if (mode !== 'spa') {
84
+ it('admin layout is pre-rendered in initial HTML (SSR/SSG)', () => {
85
+ cy.request('/admin/dashboard').then((response) => {
86
+ expect(response.body).to.include('admin-layout')
87
+ expect(response.body).to.include('Admin Dashboard')
88
+ })
89
+ })
90
+ }
91
+ })
92
+
93
+ // ─── runtimeConfig ─────────────────────────────────────────────────────────────
94
+
95
+ describe('runtimeConfig.public', () => {
96
+ it('renders the appName from runtimeConfig on the admin dashboard', () => {
97
+ cy.visit('/admin/dashboard')
98
+ cy.get('[data-cy=admin-app-name]').should('contain', 'Kitchen Sink')
99
+ })
100
+ })
@@ -0,0 +1,13 @@
1
+ component('layout-admin', () => {
2
+ return html`
3
+ <div data-cy="admin-layout">
4
+ <aside data-cy="admin-sidebar">
5
+ <p>Admin Panel</p>
6
+ <a href="/admin/dashboard" data-cy="admin-nav-dashboard">Dashboard</a>
7
+ </aside>
8
+ <section data-cy="admin-content">
9
+ <slot></slot>
10
+ </section>
11
+ </div>
12
+ `
13
+ })
@@ -0,0 +1 @@
1
+ export default 'admin'
@@ -0,0 +1,11 @@
1
+ component('page-admin-dashboard', () => {
2
+ const config = useRuntimeConfig()
3
+ const appName = config.public?.appName ?? 'Kitchen Sink'
4
+
5
+ return html`
6
+ <div>
7
+ <h1 data-cy="admin-dashboard-heading">Admin Dashboard</h1>
8
+ <p data-cy="admin-app-name">App: ${appName}</p>
9
+ </div>
10
+ `
11
+ })
@@ -46,6 +46,7 @@ export const loader = async ({ params }: { params: { slug: string } }) => {
46
46
 
47
47
  export const meta = {
48
48
  ssg: {
49
+ revalidate: 60,
49
50
  paths: async () => [
50
51
  { params: { slug: 'first-post' } },
51
52
  { params: { slug: 'second-post' } },
@@ -0,0 +1,17 @@
1
+ component('page-isr-test', () => {
2
+ return html`
3
+ <div>
4
+ <h1 data-cy="isr-test-heading">ISR Test</h1>
5
+ <p data-cy="isr-test-description">
6
+ This page uses <code>revalidate: 0</code> so every post-first request
7
+ is immediately stale.
8
+ </p>
9
+ </div>
10
+ `
11
+ })
12
+
13
+ export const meta = {
14
+ ssg: {
15
+ revalidate: 0,
16
+ },
17
+ }
@@ -2,4 +2,9 @@
2
2
  export default {
3
3
  ssg: { routes: 'auto', concurrency: 2 },
4
4
  autoImports: { runtime: true, components: true, composables: true },
5
+ runtimeConfig: {
6
+ public: {
7
+ appName: 'Kitchen Sink',
8
+ },
9
+ },
5
10
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jasonshimmy/vite-plugin-cer-app",
3
- "version": "0.4.6",
3
+ "version": "0.6.0",
4
4
  "description": "Nuxt-style meta-framework for @jasonshimmy/custom-elements-runtime",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -68,7 +68,7 @@
68
68
  "cypress:open": "cypress open"
69
69
  },
70
70
  "peerDependencies": {
71
- "@jasonshimmy/custom-elements-runtime": ">=3.0.0",
71
+ "@jasonshimmy/custom-elements-runtime": ">=3.4.0",
72
72
  "vite": ">=5.0.0"
73
73
  },
74
74
  "dependencies": {
@@ -78,7 +78,7 @@
78
78
  "pathe": "^2.0.3"
79
79
  },
80
80
  "devDependencies": {
81
- "@jasonshimmy/custom-elements-runtime": "^3.2.0",
81
+ "@jasonshimmy/custom-elements-runtime": "^3.4.0",
82
82
  "@types/node": "^25.5.0",
83
83
  "@vitest/coverage-v8": "^4.1.0",
84
84
  "cypress": "^15.12.0",
@@ -0,0 +1,246 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import type { IncomingMessage, ServerResponse } from 'node:http'
3
+ import {
4
+ matchRoutePattern,
5
+ findRevalidate,
6
+ renderForIsr,
7
+ serveFromIsrCache,
8
+ type IsrCacheEntry,
9
+ } from '../../cli/commands/preview-isr.js'
10
+
11
+ // ─── matchRoutePattern ────────────────────────────────────────────────────────
12
+
13
+ describe('matchRoutePattern', () => {
14
+ it('matches identical paths', () => {
15
+ expect(matchRoutePattern('/about', '/about')).toBe(true)
16
+ })
17
+
18
+ it('matches root path', () => {
19
+ expect(matchRoutePattern('/', '/')).toBe(true)
20
+ })
21
+
22
+ it('does not match different static paths', () => {
23
+ expect(matchRoutePattern('/about', '/contact')).toBe(false)
24
+ })
25
+
26
+ it('matches :param segments', () => {
27
+ expect(matchRoutePattern('/blog/:slug', '/blog/hello-world')).toBe(true)
28
+ })
29
+
30
+ it('does not match when param segment is missing', () => {
31
+ expect(matchRoutePattern('/blog/:slug', '/blog')).toBe(false)
32
+ })
33
+
34
+ it('matches :param* catch-all against single segment', () => {
35
+ expect(matchRoutePattern('/:all*', '/about')).toBe(true)
36
+ })
37
+
38
+ it('matches :param* catch-all against multi-segment path', () => {
39
+ expect(matchRoutePattern('/:all*', '/deeply/nested/path')).toBe(true)
40
+ })
41
+
42
+ it('matches :param* catch-all against root', () => {
43
+ expect(matchRoutePattern('/:all*', '/')).toBe(true)
44
+ })
45
+
46
+ it('does not match longer path against shorter static pattern', () => {
47
+ expect(matchRoutePattern('/blog', '/blog/extra')).toBe(false)
48
+ })
49
+
50
+ it('handles trailing slashes gracefully', () => {
51
+ expect(matchRoutePattern('/about/', '/about')).toBe(true)
52
+ expect(matchRoutePattern('/about', '/about/')).toBe(true)
53
+ })
54
+
55
+ it('matches multiple :param segments', () => {
56
+ expect(matchRoutePattern('/users/:id/posts/:postId', '/users/42/posts/99')).toBe(true)
57
+ })
58
+
59
+ it('does not match paths that differ only by a regex wildcard character (dot-safety)', () => {
60
+ // Without escaping, '/api.v1' regex would be '^/apixv1$' which matches '/apixv1'.
61
+ // With escaping, the dot is literal and '/apixv1' must not match '/api.v1'.
62
+ expect(matchRoutePattern('/api.v1/users', '/apixv1/users')).toBe(false)
63
+ })
64
+
65
+ it('matches route with literal dot in static segment', () => {
66
+ expect(matchRoutePattern('/api.v1/users', '/api.v1/users')).toBe(true)
67
+ })
68
+ })
69
+
70
+ // ─── findRevalidate ───────────────────────────────────────────────────────────
71
+
72
+ describe('findRevalidate', () => {
73
+ it('returns null for an empty routes array', () => {
74
+ expect(findRevalidate([], '/about')).toBeNull()
75
+ })
76
+
77
+ it('returns null when no route matches', () => {
78
+ const routes = [{ path: '/contact', meta: { ssg: { revalidate: 60 } } }]
79
+ expect(findRevalidate(routes, '/about')).toBeNull()
80
+ })
81
+
82
+ it('returns null when matched route has no meta', () => {
83
+ const routes = [{ path: '/about' }]
84
+ expect(findRevalidate(routes, '/about')).toBeNull()
85
+ })
86
+
87
+ it('returns null when matched route has no ssg.revalidate', () => {
88
+ const routes = [{ path: '/about', meta: { layout: 'default' } }]
89
+ expect(findRevalidate(routes, '/about')).toBeNull()
90
+ })
91
+
92
+ it('returns revalidate value for a matching static route', () => {
93
+ const routes = [{ path: '/about', meta: { ssg: { revalidate: 60 } } }]
94
+ expect(findRevalidate(routes, '/about')).toBe(60)
95
+ })
96
+
97
+ it('returns revalidate value for a matching dynamic route', () => {
98
+ const routes = [{ path: '/blog/:slug', meta: { ssg: { revalidate: 300 } } }]
99
+ expect(findRevalidate(routes, '/blog/hello')).toBe(300)
100
+ })
101
+
102
+ it('returns revalidate value for a catch-all route', () => {
103
+ const routes = [{ path: '/:all*', meta: { ssg: { revalidate: 120 } } }]
104
+ expect(findRevalidate(routes, '/some/unmatched/path')).toBe(120)
105
+ })
106
+
107
+ it('picks the first matching route when multiple match', () => {
108
+ const routes = [
109
+ { path: '/blog/:slug', meta: { ssg: { revalidate: 300 } } },
110
+ { path: '/:all*', meta: { ssg: { revalidate: 60 } } },
111
+ ]
112
+ // /blog/:slug matches first
113
+ expect(findRevalidate(routes, '/blog/post')).toBe(300)
114
+ })
115
+
116
+ it('ignores non-numeric revalidate values', () => {
117
+ const routes = [{ path: '/about', meta: { ssg: { revalidate: 'invalid' } } }]
118
+ expect(findRevalidate(routes, '/about')).toBeNull()
119
+ })
120
+ })
121
+
122
+ // ─── renderForIsr ─────────────────────────────────────────────────────────────
123
+
124
+ describe('renderForIsr', () => {
125
+ it('captures HTML from the handler', async () => {
126
+ const handler = (_req: IncomingMessage, res: ServerResponse) => {
127
+ res.setHeader('Content-Type', 'text/html')
128
+ res.end('<html>hello</html>')
129
+ }
130
+ const entry = await renderForIsr('/about', handler, 60)
131
+ expect(entry).not.toBeNull()
132
+ expect(entry!.html).toBe('<html>hello</html>')
133
+ })
134
+
135
+ it('captures status code from the handler', async () => {
136
+ const handler = (_req: IncomingMessage, res: ServerResponse) => {
137
+ res.statusCode = 404
138
+ res.end('Not Found')
139
+ }
140
+ const entry = await renderForIsr('/missing', handler, 30)
141
+ expect(entry!.statusCode).toBe(404)
142
+ })
143
+
144
+ it('captures response headers from the handler', async () => {
145
+ const handler = (_req: IncomingMessage, res: ServerResponse) => {
146
+ res.setHeader('Content-Type', 'text/html; charset=utf-8')
147
+ res.end('<html/>')
148
+ }
149
+ const entry = await renderForIsr('/about', handler, 60)
150
+ expect(entry!.headers['content-type']).toBe('text/html; charset=utf-8')
151
+ })
152
+
153
+ it('sets revalidate to the provided TTL', async () => {
154
+ const handler = (_req: IncomingMessage, res: ServerResponse) => { res.end('ok') }
155
+ const entry = await renderForIsr('/about', handler, 300)
156
+ expect(entry!.revalidate).toBe(300)
157
+ })
158
+
159
+ it('sets revalidating to false on the returned entry', async () => {
160
+ const handler = (_req: IncomingMessage, res: ServerResponse) => { res.end('ok') }
161
+ const entry = await renderForIsr('/about', handler, 60)
162
+ expect(entry!.revalidating).toBe(false)
163
+ })
164
+
165
+ it('returns null when the handler throws', async () => {
166
+ const handler = () => { throw new Error('boom') }
167
+ const entry = await renderForIsr('/about', handler as SsrHandlerFn, 60)
168
+ expect(entry).toBeNull()
169
+ })
170
+
171
+ it('passes the correct URL to the synthetic request', async () => {
172
+ let capturedUrl = ''
173
+ const handler = (req: IncomingMessage, res: ServerResponse) => {
174
+ capturedUrl = req.url ?? ''
175
+ res.end('ok')
176
+ }
177
+ await renderForIsr('/blog/hello', handler, 60)
178
+ expect(capturedUrl).toBe('/blog/hello')
179
+ })
180
+ })
181
+
182
+ type SsrHandlerFn = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
183
+
184
+ // ─── serveFromIsrCache ────────────────────────────────────────────────────────
185
+
186
+ describe('serveFromIsrCache', () => {
187
+ function makeFakeRes() {
188
+ const headers: Record<string, string> = {}
189
+ let statusCode = 200
190
+ let body = ''
191
+ const res = {
192
+ get statusCode() { return statusCode },
193
+ set statusCode(v: number) { statusCode = v },
194
+ setHeader: vi.fn((name: string, value: string) => { headers[name] = value }),
195
+ end: vi.fn((chunk: string) => { body = chunk }),
196
+ _headers: headers,
197
+ _body: () => body,
198
+ _status: () => statusCode,
199
+ }
200
+ return res
201
+ }
202
+
203
+ function makeEntry(overrides: Partial<IsrCacheEntry> = {}): IsrCacheEntry {
204
+ return {
205
+ html: '<html>cached</html>',
206
+ headers: { 'content-type': 'text/html' },
207
+ statusCode: 200,
208
+ builtAt: Date.now(),
209
+ revalidate: 60,
210
+ revalidating: false,
211
+ ...overrides,
212
+ }
213
+ }
214
+
215
+ it('writes the cached HTML to the response', () => {
216
+ const res = makeFakeRes()
217
+ serveFromIsrCache(makeEntry(), res as unknown as ServerResponse, 'HIT')
218
+ expect(res.end).toHaveBeenCalledWith('<html>cached</html>')
219
+ })
220
+
221
+ it('sets X-Cache: HIT header', () => {
222
+ const res = makeFakeRes()
223
+ serveFromIsrCache(makeEntry(), res as unknown as ServerResponse, 'HIT')
224
+ expect(res.setHeader).toHaveBeenCalledWith('X-Cache', 'HIT')
225
+ })
226
+
227
+ it('sets X-Cache: STALE header', () => {
228
+ const res = makeFakeRes()
229
+ serveFromIsrCache(makeEntry(), res as unknown as ServerResponse, 'STALE')
230
+ expect(res.setHeader).toHaveBeenCalledWith('X-Cache', 'STALE')
231
+ })
232
+
233
+ it('forwards cached headers to the response', () => {
234
+ const entry = makeEntry({ headers: { 'content-type': 'text/html; charset=utf-8' } })
235
+ const res = makeFakeRes()
236
+ serveFromIsrCache(entry, res as unknown as ServerResponse, 'HIT')
237
+ expect(res.setHeader).toHaveBeenCalledWith('content-type', 'text/html; charset=utf-8')
238
+ })
239
+
240
+ it('sets the status code from the cache entry', () => {
241
+ const entry = makeEntry({ statusCode: 404 })
242
+ const res = makeFakeRes()
243
+ serveFromIsrCache(entry, res as unknown as ServerResponse, 'HIT')
244
+ expect(res._status()).toBe(404)
245
+ })
246
+ })
@@ -97,6 +97,52 @@ describe('buildSSG — renderPath success (real server bundle)', () => {
97
97
  expect(manifest.paths).toHaveLength(2)
98
98
  })
99
99
 
100
+ it('captures HTML from a streaming handler that calls write() then end()', async () => {
101
+ // Simulate the renderToStreamWithJITCSSDSD-based handler which calls
102
+ // res.write(firstChunk) for sync content and res.end(polyfill + tail).
103
+ // Use a completely separate tmpdir with a unique path so Node's native ESM
104
+ // import cache cannot return a previously-loaded module for the same URL.
105
+ const streamRoot = join(tmpdir(), `cer-ssg-stream-${Date.now()}`)
106
+ const streamServerDir = join(streamRoot, 'dist', 'server')
107
+ mkdirSync(streamServerDir, { recursive: true })
108
+ writeFileSync(
109
+ join(streamServerDir, 'server.js'),
110
+ `export const handler = async (req, res) => {
111
+ res.setHeader('Content-Type', 'text/html');
112
+ res.setHeader('Transfer-Encoding', 'chunked');
113
+ res.write('<html><head></head>');
114
+ res.write('<body>streamed</body>');
115
+ res.end('</html>');
116
+ };
117
+ export const apiRoutes = [];
118
+ export const plugins = [];
119
+ export const layouts = {};
120
+ `,
121
+ 'utf-8',
122
+ )
123
+
124
+ await vi.resetModules()
125
+ const { buildSSG } = await import('../../plugin/build-ssg.js')
126
+ const config = {
127
+ root: streamRoot,
128
+ srcDir: join(streamRoot, 'app'),
129
+ pagesDir: join(streamRoot, 'app', 'pages'),
130
+ mode: 'ssg',
131
+ ssg: { routes: ['/'], concurrency: 1 },
132
+ } as unknown as ResolvedCerConfig
133
+ await buildSSG(config)
134
+
135
+ const { readFileSync, existsSync } = await import('node:fs')
136
+ const outPath = join(streamRoot, 'dist', 'index.html')
137
+ expect(existsSync(outPath)).toBe(true)
138
+ const html = readFileSync(outPath, 'utf-8')
139
+ expect(html).toContain('<html><head></head>')
140
+ expect(html).toContain('<body>streamed</body>')
141
+ expect(html).toContain('</html>')
142
+
143
+ rmSync(streamRoot, { recursive: true, force: true })
144
+ })
145
+
100
146
  it('uses cached _serverMod on second renderPath call (no double import)', async () => {
101
147
  // In a fresh module instance, render two paths sequentially.
102
148
  // The second renderPath call hits the !_serverMod === false branch (cache).
@@ -168,6 +168,11 @@ describe('generateAutoImportDts', () => {
168
168
  expect(dts).toContain("const useInject: typeof import('@jasonshimmy/vite-plugin-cer-app/composables')['useInject']")
169
169
  })
170
170
 
171
+ it('declares useRuntimeConfig as a framework global', async () => {
172
+ const dts = await generateAutoImportDts(ROOT, COMPOSABLES_DIR)
173
+ expect(dts).toContain("const useRuntimeConfig: typeof import('@jasonshimmy/vite-plugin-cer-app/composables')['useRuntimeConfig']")
174
+ })
175
+
171
176
  it('declares when directive as a global', async () => {
172
177
  const dts = await generateAutoImportDts(ROOT, COMPOSABLES_DIR)
173
178
  expect(dts).toContain("const when: typeof import('@jasonshimmy/custom-elements-runtime/directives')['when']")
@@ -233,6 +238,21 @@ describe('generateVirtualModuleDts', () => {
233
238
  expect(dts).toContain('errorTag')
234
239
  })
235
240
 
241
+ it('declares virtual:cer-app-config module', async () => {
242
+ const dts = await generateVirtualModuleDts(ROOT, COMPOSABLES_DIR)
243
+ expect(dts).toContain("declare module 'virtual:cer-app-config'")
244
+ })
245
+
246
+ it('declares runtimeConfig export in virtual:cer-app-config', async () => {
247
+ const dts = await generateVirtualModuleDts(ROOT, COMPOSABLES_DIR)
248
+ expect(dts).toContain('runtimeConfig')
249
+ })
250
+
251
+ it('declares RuntimePublicConfig in virtual:cer-app-config', async () => {
252
+ const dts = await generateVirtualModuleDts(ROOT, COMPOSABLES_DIR)
253
+ expect(dts).toContain('RuntimePublicConfig')
254
+ })
255
+
236
256
  it('includes user composable re-exports in virtual:cer-composables', async () => {
237
257
  const exports = new Map([['useMyThing', `${ROOT}/app/composables/my-thing.ts`]])
238
258
  const dts = await generateVirtualModuleDts(ROOT, COMPOSABLES_DIR, exports)
@@ -33,8 +33,8 @@ describe('entry-server-template (ENTRY_SERVER_TEMPLATE content)', () => {
33
33
  expect(src).toContain('@jasonshimmy/custom-elements-runtime')
34
34
  })
35
35
 
36
- it('imports renderToStringWithJITCSSDSD and DSD_POLYFILL_SCRIPT from ssr subpath', () => {
37
- expect(src).toContain('renderToStringWithJITCSSDSD')
36
+ it('imports renderToStreamWithJITCSSDSD and DSD_POLYFILL_SCRIPT from ssr subpath', () => {
37
+ expect(src).toContain('renderToStreamWithJITCSSDSD')
38
38
  expect(src).toContain('DSD_POLYFILL_SCRIPT')
39
39
  expect(src).toContain('custom-elements-runtime/ssr')
40
40
  })
@@ -94,9 +94,17 @@ describe('entry-server-template (ENTRY_SERVER_TEMPLATE content)', () => {
94
94
  expect(src).toContain('_prepareRequest')
95
95
  })
96
96
 
97
- it('uses beginHeadCollection / endHeadCollection around the render', () => {
97
+ it('calls endHeadCollection() synchronously before any await to avoid race conditions', () => {
98
98
  expect(src).toContain('beginHeadCollection()')
99
99
  expect(src).toContain('endHeadCollection()')
100
+ // endHeadCollection must come before reader.read() so concurrent requests
101
+ // (SSG concurrency > 1) cannot reset the shared globalThis collector between
102
+ // beginHeadCollection and endHeadCollection.
103
+ const endIdx = src.indexOf('endHeadCollection()')
104
+ const readIdx = src.indexOf('reader.read()')
105
+ expect(endIdx).toBeGreaterThan(-1)
106
+ expect(readIdx).toBeGreaterThan(-1)
107
+ expect(endIdx).toBeLessThan(readIdx)
100
108
  })
101
109
 
102
110
  it('passes dsdPolyfill: false to suppress inline polyfill', () => {
@@ -104,8 +112,8 @@ describe('entry-server-template (ENTRY_SERVER_TEMPLATE content)', () => {
104
112
  })
105
113
 
106
114
  it('injects DSD_POLYFILL_SCRIPT before </body>', () => {
107
- expect(src).toContain("finalHtml.replace('</body>'")
108
- expect(src).toContain('DSD_POLYFILL_SCRIPT')
115
+ expect(src).toContain("lastIndexOf('</body>')")
116
+ expect(src).toContain('DSD_POLYFILL_SCRIPT + fromBodyClose')
109
117
  })
110
118
 
111
119
  it('merges SSR html with client template when available', () => {
@@ -124,4 +132,14 @@ describe('entry-server-template (ENTRY_SERVER_TEMPLATE content)', () => {
124
132
  it('sets Content-Type header on response', () => {
125
133
  expect(src).toContain('text/html; charset=utf-8')
126
134
  })
135
+
136
+ it('sets Transfer-Encoding: chunked header for streaming', () => {
137
+ expect(src).toContain('Transfer-Encoding')
138
+ expect(src).toContain('chunked')
139
+ })
140
+
141
+ it('reads the stream using a reader loop', () => {
142
+ expect(src).toContain('stream.getReader()')
143
+ expect(src).toContain('reader.read()')
144
+ })
127
145
  })