@jasonshimmy/vite-plugin-cer-app 0.5.0 → 0.7.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 (61) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/IMPLEMENTATION_PLAN.md +2 -2
  3. package/commits.txt +1 -1
  4. package/dist/cli/commands/preview-isr.d.ts +9 -0
  5. package/dist/cli/commands/preview-isr.d.ts.map +1 -1
  6. package/dist/cli/commands/preview-isr.js +16 -0
  7. package/dist/cli/commands/preview-isr.js.map +1 -1
  8. package/dist/cli/commands/preview.d.ts.map +1 -1
  9. package/dist/cli/commands/preview.js +44 -1
  10. package/dist/cli/commands/preview.js.map +1 -1
  11. package/dist/cli/create/templates/spa/package.json.tpl +2 -2
  12. package/dist/cli/create/templates/ssg/package.json.tpl +2 -2
  13. package/dist/cli/create/templates/ssr/package.json.tpl +2 -2
  14. package/dist/plugin/build-ssg.d.ts.map +1 -1
  15. package/dist/plugin/build-ssg.js +17 -3
  16. package/dist/plugin/build-ssg.js.map +1 -1
  17. package/dist/plugin/dev-server.d.ts.map +1 -1
  18. package/dist/plugin/dev-server.js +33 -0
  19. package/dist/plugin/dev-server.js.map +1 -1
  20. package/dist/plugin/virtual/routes.d.ts.map +1 -1
  21. package/dist/plugin/virtual/routes.js +24 -2
  22. package/dist/plugin/virtual/routes.js.map +1 -1
  23. package/dist/runtime/entry-server-template.d.ts +2 -2
  24. package/dist/runtime/entry-server-template.d.ts.map +1 -1
  25. package/dist/runtime/entry-server-template.js +57 -19
  26. package/dist/runtime/entry-server-template.js.map +1 -1
  27. package/dist/runtime/isr-handler.d.ts +40 -0
  28. package/dist/runtime/isr-handler.d.ts.map +1 -0
  29. package/dist/runtime/isr-handler.js +152 -0
  30. package/dist/runtime/isr-handler.js.map +1 -0
  31. package/dist/types/page.d.ts +14 -0
  32. package/dist/types/page.d.ts.map +1 -1
  33. package/docs/data-loading.md +69 -2
  34. package/docs/rendering-modes.md +66 -5
  35. package/docs/routing.md +33 -0
  36. package/e2e/cypress/e2e/error-boundary.cy.ts +43 -0
  37. package/e2e/cypress/e2e/isr-nested-runtime.cy.ts +6 -4
  38. package/e2e/cypress/e2e/per-route-render.cy.ts +70 -0
  39. package/e2e/kitchen-sink/app/error.ts +7 -2
  40. package/e2e/kitchen-sink/app/pages/loader-error-test.ts +13 -0
  41. package/e2e/kitchen-sink/app/pages/render-server-test.ts +12 -0
  42. package/e2e/kitchen-sink/app/pages/render-spa-test.ts +12 -0
  43. package/package.json +7 -3
  44. package/src/__tests__/cli/preview-isr.test.ts +44 -0
  45. package/src/__tests__/plugin/build-ssg-render.test.ts +46 -0
  46. package/src/__tests__/plugin/build-ssg.test.ts +126 -1
  47. package/src/__tests__/plugin/dev-server.test.ts +91 -0
  48. package/src/__tests__/plugin/entry-server-template.test.ts +76 -5
  49. package/src/__tests__/plugin/virtual/routes.test.ts +65 -0
  50. package/src/__tests__/runtime/isr-handler.test.ts +331 -0
  51. package/src/cli/commands/preview-isr.ts +19 -0
  52. package/src/cli/commands/preview.ts +46 -0
  53. package/src/cli/create/templates/spa/package.json.tpl +2 -2
  54. package/src/cli/create/templates/ssg/package.json.tpl +2 -2
  55. package/src/cli/create/templates/ssr/package.json.tpl +2 -2
  56. package/src/plugin/build-ssg.ts +15 -3
  57. package/src/plugin/dev-server.ts +33 -0
  58. package/src/plugin/virtual/routes.ts +24 -2
  59. package/src/runtime/entry-server-template.ts +57 -19
  60. package/src/runtime/isr-handler.ts +183 -0
  61. package/src/types/page.ts +14 -0
@@ -33,11 +33,13 @@ if (mode === 'ssr') {
33
33
  })
34
34
  })
35
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', () => {
36
+ // /isr-test uses revalidate: 0 — ISR is always engaged. The first request
37
+ // returns HIT (cold cache) or STALE (warm cache from a previous test run),
38
+ // but x-cache is always present. Exact HIT/STALE distinction for a cold
39
+ // cache is covered by unit tests (createIsrHandler).
40
+ it('revalidate:0 route always has X-Cache header (ISR engaged)', () => {
39
41
  cy.request('/isr-test').then((response) => {
40
- expect(response.headers['x-cache']).to.equal('HIT')
42
+ expect(['HIT', 'STALE']).to.include(response.headers['x-cache'])
41
43
  })
42
44
  })
43
45
 
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Tests for per-route render strategy (meta.render).
3
+ *
4
+ * render: 'server' — route is always SSR'd, skipped during SSG pre-rendering.
5
+ * render: 'spa' — route is served as SPA shell in SSR mode, skipped in SSG.
6
+ * render: 'static' — serve pre-rendered HTML from disk; fall back to SSR.
7
+ */
8
+
9
+ const mode = Cypress.env('mode') as 'spa' | 'ssr' | 'ssg'
10
+
11
+ // ─── render: 'server' ─────────────────────────────────────────────────────────
12
+
13
+ describe('render: server — always SSR', () => {
14
+ it('renders the page in SSR mode', () => {
15
+ if (mode !== 'ssr') return
16
+ cy.visit('/render-server-test')
17
+ cy.get('[data-cy=render-server-heading]').should('contain', 'Render Server Test')
18
+ })
19
+
20
+ it('renders the page in SPA mode (client-side navigation)', () => {
21
+ if (mode !== 'spa') return
22
+ cy.visit('/render-server-test')
23
+ cy.get('[data-cy=render-server-heading]').should('contain', 'Render Server Test')
24
+ })
25
+
26
+ if (mode === 'ssr') {
27
+ it('pre-renders the page in the initial HTML (SSR)', () => {
28
+ cy.request('/render-server-test').then((response) => {
29
+ expect(response.body).to.include('render-server-heading')
30
+ expect(response.body).to.include('Render Server Test')
31
+ })
32
+ })
33
+ }
34
+
35
+ if (mode === 'ssg') {
36
+ it('route with render:server is not pre-rendered — not found in ssg dist', () => {
37
+ // The route was skipped during SSG. The static preview falls back to
38
+ // dist/index.html (SPA shell) rather than a pre-rendered page, so the
39
+ // server-rendered heading is absent from the raw HTML response.
40
+ cy.request('/render-server-test').then((response) => {
41
+ expect(response.body).not.to.include('render-server-heading')
42
+ })
43
+ })
44
+ }
45
+ })
46
+
47
+ // ─── render: 'spa' ────────────────────────────────────────────────────────────
48
+
49
+ describe('render: spa — client-only', () => {
50
+ it('renders the page heading after JS boots', () => {
51
+ cy.visit('/render-spa-test')
52
+ cy.get('[data-cy=render-spa-heading]').should('contain', 'Render SPA Test')
53
+ })
54
+
55
+ if (mode === 'ssr') {
56
+ it('raw HTML response is the SPA shell (no SSR content)', () => {
57
+ cy.request('/render-spa-test').then((response) => {
58
+ expect(response.body).not.to.include('render-spa-heading')
59
+ })
60
+ })
61
+ }
62
+
63
+ if (mode === 'ssg') {
64
+ it('route with render:spa is not pre-rendered — not found in ssg dist', () => {
65
+ cy.request('/render-spa-test').then((response) => {
66
+ expect(response.body).not.to.include('render-spa-heading')
67
+ })
68
+ })
69
+ }
70
+ })
@@ -1,9 +1,14 @@
1
1
  component('page-error', () => {
2
- const props = useProps<{ error: string }>({ error: 'An unexpected error occurred.' })
2
+ const props = useProps<{ error: string; status: string }>({
3
+ error: 'An unexpected error occurred.',
4
+ status: '500',
5
+ })
3
6
 
4
7
  return html`
5
8
  <div data-cy="error-boundary" style="padding:2rem;font-family:sans-serif">
6
- <h2 data-cy="error-heading" style="color:#c00;margin-top:0">Something went wrong</h2>
9
+ <h2 data-cy="error-heading" style="color:#c00;margin-top:0">
10
+ Error ${props.status}
11
+ </h2>
7
12
  <pre data-cy="error-message" style="background:#fff0f0;border:1px solid #fcc;padding:1rem;border-radius:4px">${props.error}</pre>
8
13
  <button data-cy="error-retry" @click="${() => (globalThis as any).resetError?.()}">
9
14
  Try again
@@ -0,0 +1,13 @@
1
+ component('page-loader-error-test', () => {
2
+ return html`
3
+ <div>
4
+ <h1 data-cy="loader-error-heading">Loader Error Test</h1>
5
+ </div>
6
+ `
7
+ })
8
+
9
+ export async function loader() {
10
+ const err = new Error('Loader intentionally failed') as Error & { status?: number }
11
+ err.status = 503
12
+ throw err
13
+ }
@@ -0,0 +1,12 @@
1
+ component('page-render-server-test', () => {
2
+ return html`
3
+ <div>
4
+ <h1 data-cy="render-server-heading">Render Server Test</h1>
5
+ <p>This page always renders server-side.</p>
6
+ </div>
7
+ `
8
+ })
9
+
10
+ export const meta = {
11
+ render: 'server',
12
+ }
@@ -0,0 +1,12 @@
1
+ component('page-render-spa-test', () => {
2
+ return html`
3
+ <div>
4
+ <h1 data-cy="render-spa-heading">Render SPA Test</h1>
5
+ <p>This page is client-only.</p>
6
+ </div>
7
+ `
8
+ })
9
+
10
+ export const meta = {
11
+ render: 'spa',
12
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jasonshimmy/vite-plugin-cer-app",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Nuxt-style meta-framework for @jasonshimmy/custom-elements-runtime",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -37,6 +37,10 @@
37
37
  "import": "./dist/types/index.js",
38
38
  "require": "./dist/types/index.cjs",
39
39
  "types": "./dist/types/index.d.ts"
40
+ },
41
+ "./isr": {
42
+ "import": "./dist/runtime/isr-handler.js",
43
+ "types": "./dist/runtime/isr-handler.d.ts"
40
44
  }
41
45
  },
42
46
  "bin": {
@@ -68,7 +72,7 @@
68
72
  "cypress:open": "cypress open"
69
73
  },
70
74
  "peerDependencies": {
71
- "@jasonshimmy/custom-elements-runtime": ">=3.0.0",
75
+ "@jasonshimmy/custom-elements-runtime": ">=3.4.0",
72
76
  "vite": ">=5.0.0"
73
77
  },
74
78
  "dependencies": {
@@ -78,7 +82,7 @@
78
82
  "pathe": "^2.0.3"
79
83
  },
80
84
  "devDependencies": {
81
- "@jasonshimmy/custom-elements-runtime": "^3.2.0",
85
+ "@jasonshimmy/custom-elements-runtime": "^3.4.0",
82
86
  "@types/node": "^25.5.0",
83
87
  "@vitest/coverage-v8": "^4.1.0",
84
88
  "cypress": "^15.12.0",
@@ -3,6 +3,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
3
3
  import {
4
4
  matchRoutePattern,
5
5
  findRevalidate,
6
+ findRenderMode,
6
7
  renderForIsr,
7
8
  serveFromIsrCache,
8
9
  type IsrCacheEntry,
@@ -119,6 +120,49 @@ describe('findRevalidate', () => {
119
120
  })
120
121
  })
121
122
 
123
+ // ─── findRenderMode ───────────────────────────────────────────────────────────
124
+
125
+ describe('findRenderMode', () => {
126
+ it('returns null for an empty routes array', () => {
127
+ expect(findRenderMode([], '/about')).toBeNull()
128
+ })
129
+
130
+ it('returns null when no route matches', () => {
131
+ const routes = [{ path: '/contact', meta: { render: 'server' } }]
132
+ expect(findRenderMode(routes, '/about')).toBeNull()
133
+ })
134
+
135
+ it('returns null when matched route has no render meta', () => {
136
+ const routes = [{ path: '/about' }]
137
+ expect(findRenderMode(routes, '/about')).toBeNull()
138
+ })
139
+
140
+ it('returns "server" for a matching route with render: server', () => {
141
+ const routes = [{ path: '/dashboard', meta: { render: 'server' } }]
142
+ expect(findRenderMode(routes, '/dashboard')).toBe('server')
143
+ })
144
+
145
+ it('returns "spa" for a matching route with render: spa', () => {
146
+ const routes = [{ path: '/profile', meta: { render: 'spa' } }]
147
+ expect(findRenderMode(routes, '/profile')).toBe('spa')
148
+ })
149
+
150
+ it('returns "static" for a matching route with render: static', () => {
151
+ const routes = [{ path: '/about', meta: { render: 'static' } }]
152
+ expect(findRenderMode(routes, '/about')).toBe('static')
153
+ })
154
+
155
+ it('returns null for unrecognised render values', () => {
156
+ const routes = [{ path: '/about', meta: { render: 'unknown' } }]
157
+ expect(findRenderMode(routes, '/about')).toBeNull()
158
+ })
159
+
160
+ it('matches dynamic routes', () => {
161
+ const routes = [{ path: '/blog/:slug', meta: { render: 'server' } }]
162
+ expect(findRenderMode(routes, '/blog/my-post')).toBe('server')
163
+ })
164
+ })
165
+
122
166
  // ─── renderForIsr ─────────────────────────────────────────────────────────────
123
167
 
124
168
  describe('renderForIsr', () => {
@@ -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).
@@ -4,6 +4,7 @@ vi.mock('node:fs', () => ({ existsSync: vi.fn().mockReturnValue(false) }))
4
4
  vi.mock('node:fs/promises', () => ({
5
5
  writeFile: vi.fn().mockResolvedValue(undefined),
6
6
  mkdir: vi.fn().mockResolvedValue(undefined),
7
+ readFile: vi.fn().mockResolvedValue(''),
7
8
  }))
8
9
  vi.mock('fast-glob', () => ({ default: vi.fn().mockResolvedValue([]) }))
9
10
  vi.mock('vite', () => ({
@@ -14,7 +15,7 @@ vi.mock('../../plugin/build-ssr.js', () => ({ buildSSR: vi.fn().mockResolvedValu
14
15
  vi.mock('../../plugin/path-utils.js', () => ({ buildRouteEntry: vi.fn() }))
15
16
 
16
17
  import { existsSync } from 'node:fs'
17
- import { writeFile, mkdir } from 'node:fs/promises'
18
+ import { writeFile, mkdir, readFile } from 'node:fs/promises'
18
19
  import fg from 'fast-glob'
19
20
  import { createServer } from 'vite'
20
21
  import { buildSSR } from '../../plugin/build-ssr.js'
@@ -38,8 +39,10 @@ beforeEach(() => {
38
39
  vi.mocked(writeFile).mockClear()
39
40
  vi.mocked(mkdir).mockClear()
40
41
  vi.mocked(fg).mockClear()
42
+ vi.mocked(readFile).mockClear()
41
43
  vi.mocked(existsSync).mockReturnValue(false)
42
44
  vi.mocked(fg).mockResolvedValue([])
45
+ vi.mocked(readFile).mockResolvedValue('')
43
46
  vi.mocked(buildRouteEntry).mockReset()
44
47
  })
45
48
 
@@ -263,6 +266,128 @@ describe('buildSSG — path collection', () => {
263
266
  })
264
267
  })
265
268
 
269
+ // ─── render: 'server' / 'spa' skip ───────────────────────────────────────────
270
+
271
+ describe('buildSSG — render strategy skip (static pages)', () => {
272
+ it('skips static page with render: server', async () => {
273
+ vi.mocked(existsSync).mockReturnValue(true)
274
+ vi.mocked(fg).mockResolvedValue(['/project/app/pages/dashboard.ts'])
275
+ vi.mocked(readFile).mockResolvedValue("export const meta = { render: 'server' }")
276
+ vi.mocked(buildRouteEntry).mockReturnValueOnce({
277
+ routePath: '/dashboard',
278
+ isDynamic: false,
279
+ isCatchAll: false,
280
+ } as ReturnType<typeof buildRouteEntry>)
281
+
282
+ const config = makeConfig({ ssg: { concurrency: 1 } } as Partial<ResolvedCerConfig>)
283
+ await buildSSG(config)
284
+
285
+ // buildRouteEntry should never be called — page is skipped before it
286
+ expect(buildRouteEntry).not.toHaveBeenCalled()
287
+ })
288
+
289
+ it('skips static page with render: spa', async () => {
290
+ vi.mocked(existsSync).mockReturnValue(true)
291
+ vi.mocked(fg).mockResolvedValue(['/project/app/pages/profile.ts'])
292
+ vi.mocked(readFile).mockResolvedValue("export const meta = { render: 'spa' }")
293
+ vi.mocked(buildRouteEntry).mockReturnValueOnce({
294
+ routePath: '/profile',
295
+ isDynamic: false,
296
+ isCatchAll: false,
297
+ } as ReturnType<typeof buildRouteEntry>)
298
+
299
+ const config = makeConfig({ ssg: { concurrency: 1 } } as Partial<ResolvedCerConfig>)
300
+ await buildSSG(config)
301
+
302
+ expect(buildRouteEntry).not.toHaveBeenCalled()
303
+ })
304
+
305
+ it('does not skip static page with render: static', async () => {
306
+ vi.mocked(existsSync).mockReturnValue(true)
307
+ vi.mocked(fg).mockResolvedValue(['/project/app/pages/legal.ts'])
308
+ vi.mocked(readFile).mockResolvedValue("export const meta = { render: 'static' }")
309
+ vi.mocked(buildRouteEntry).mockReturnValueOnce({
310
+ routePath: '/legal',
311
+ isDynamic: false,
312
+ isCatchAll: false,
313
+ } as ReturnType<typeof buildRouteEntry>)
314
+
315
+ await buildSSG(makeConfig())
316
+
317
+ expect(buildRouteEntry).toHaveBeenCalledTimes(1)
318
+ })
319
+
320
+ it('does not skip static page with no render meta', async () => {
321
+ vi.mocked(existsSync).mockReturnValue(true)
322
+ vi.mocked(fg).mockResolvedValue(['/project/app/pages/about.ts'])
323
+ vi.mocked(readFile).mockResolvedValue("component('page-about', () => html`<h1>About</h1>`)")
324
+ vi.mocked(buildRouteEntry).mockReturnValueOnce({
325
+ routePath: '/about',
326
+ isDynamic: false,
327
+ isCatchAll: false,
328
+ } as ReturnType<typeof buildRouteEntry>)
329
+
330
+ await buildSSG(makeConfig())
331
+
332
+ expect(buildRouteEntry).toHaveBeenCalledTimes(1)
333
+ })
334
+ })
335
+
336
+ describe('buildSSG — render strategy skip (dynamic pages)', () => {
337
+ it('skips dynamic page with render: server from ssrLoadModule', async () => {
338
+ vi.mocked(existsSync).mockReturnValue(true)
339
+ vi.mocked(fg).mockResolvedValue(['/project/app/pages/[slug].ts'])
340
+ vi.mocked(readFile).mockResolvedValue('')
341
+ vi.mocked(buildRouteEntry).mockReturnValueOnce({
342
+ routePath: '/:slug',
343
+ isDynamic: true,
344
+ isCatchAll: false,
345
+ } as ReturnType<typeof buildRouteEntry>)
346
+
347
+ const closeFn = vi.fn().mockResolvedValue(undefined)
348
+ vi.mocked(createServer).mockResolvedValue({
349
+ ssrLoadModule: vi.fn().mockResolvedValue({ meta: { render: 'server' } }),
350
+ close: closeFn,
351
+ } as unknown as Awaited<ReturnType<typeof createServer>>)
352
+
353
+ const config = makeConfig({ ssg: { concurrency: 1 } } as Partial<ResolvedCerConfig>)
354
+ await buildSSG(config)
355
+
356
+ // Only '/' (always added) — the dynamic route was skipped
357
+ const manifestCall = vi.mocked(writeFile).mock.calls.find(([p]) =>
358
+ String(p).includes('ssg-manifest.json'),
359
+ )
360
+ const manifest = JSON.parse(String(manifestCall![1]))
361
+ expect(manifest.paths.length + manifest.errors.length).toBe(1)
362
+ })
363
+
364
+ it('skips dynamic page with render: spa from ssrLoadModule', async () => {
365
+ vi.mocked(existsSync).mockReturnValue(true)
366
+ vi.mocked(fg).mockResolvedValue(['/project/app/pages/[id].ts'])
367
+ vi.mocked(readFile).mockResolvedValue('')
368
+ vi.mocked(buildRouteEntry).mockReturnValueOnce({
369
+ routePath: '/:id',
370
+ isDynamic: true,
371
+ isCatchAll: false,
372
+ } as ReturnType<typeof buildRouteEntry>)
373
+
374
+ const closeFn = vi.fn().mockResolvedValue(undefined)
375
+ vi.mocked(createServer).mockResolvedValue({
376
+ ssrLoadModule: vi.fn().mockResolvedValue({ meta: { render: 'spa' } }),
377
+ close: closeFn,
378
+ } as unknown as Awaited<ReturnType<typeof createServer>>)
379
+
380
+ const config = makeConfig({ ssg: { concurrency: 1 } } as Partial<ResolvedCerConfig>)
381
+ await buildSSG(config)
382
+
383
+ const manifestCall = vi.mocked(writeFile).mock.calls.find(([p]) =>
384
+ String(p).includes('ssg-manifest.json'),
385
+ )
386
+ const manifest = JSON.parse(String(manifestCall![1]))
387
+ expect(manifest.paths.length + manifest.errors.length).toBe(1)
388
+ })
389
+ })
390
+
266
391
  // ─── writeRenderedPath ────────────────────────────────────────────────────────
267
392
 
268
393
  describe('writeRenderedPath', () => {
@@ -464,6 +464,97 @@ describe('configureCerDevServer — SSR mode', () => {
464
464
  })
465
465
  })
466
466
 
467
+ // ─── Per-route render mode (SSR mode) ────────────────────────────────────────
468
+
469
+ describe("configureCerDevServer — per-route render mode in SSR", () => {
470
+ /** Build a server where virtual:cer-routes returns the given routes array. */
471
+ function makeServerWithRoutes(
472
+ pageRoutes: Array<{ path: string; meta?: Record<string, unknown> }>,
473
+ ssrHandler = vi.fn(async (_req: any, res: any) => res.end('<html>SSR</html>')),
474
+ ) {
475
+ const { server, getMiddleware } = makeServer()
476
+ server.ssrLoadModule.mockImplementation(async (path: string) => {
477
+ if (path.includes('server-middleware')) return { serverMiddleware: [] }
478
+ if (path.includes('server-api') || path.includes('cer-server-api')) return { apiRoutes: [] }
479
+ if (path.includes('cer-routes')) return { default: pageRoutes }
480
+ return { handler: ssrHandler }
481
+ })
482
+ configureCerDevServer(server as any, makeConfig({ mode: 'ssr' }))
483
+ return { middleware: getMiddleware(), ssrHandler }
484
+ }
485
+
486
+ it("calls next() instead of SSR for a route with render: 'spa'", async () => {
487
+ const { middleware, ssrHandler } = makeServerWithRoutes([
488
+ { path: '/spa-page', meta: { render: 'spa' } },
489
+ ])
490
+ const next = vi.fn()
491
+ await middleware(
492
+ createReq({ url: '/spa-page', headers: { accept: 'text/html' } }),
493
+ createRes(),
494
+ next,
495
+ )
496
+ expect(next).toHaveBeenCalled()
497
+ expect(ssrHandler).not.toHaveBeenCalled()
498
+ })
499
+
500
+ it("proceeds to SSR for a route with render: 'server'", async () => {
501
+ const { middleware, ssrHandler } = makeServerWithRoutes([
502
+ { path: '/server-page', meta: { render: 'server' } },
503
+ ])
504
+ await middleware(
505
+ createReq({ url: '/server-page', headers: { accept: 'text/html' } }),
506
+ createRes(),
507
+ vi.fn(),
508
+ )
509
+ expect(ssrHandler).toHaveBeenCalled()
510
+ })
511
+
512
+ it("proceeds to SSR for a route with no render meta", async () => {
513
+ const { middleware, ssrHandler } = makeServerWithRoutes([
514
+ { path: '/normal-page' },
515
+ ])
516
+ await middleware(
517
+ createReq({ url: '/normal-page', headers: { accept: 'text/html' } }),
518
+ createRes(),
519
+ vi.fn(),
520
+ )
521
+ expect(ssrHandler).toHaveBeenCalled()
522
+ })
523
+
524
+ it("falls back to SSR when routes module throws during render mode check", async () => {
525
+ const ssrHandler = vi.fn(async (_req: any, res: any) => res.end('<html>SSR</html>'))
526
+ const { server, getMiddleware } = makeServer()
527
+ server.ssrLoadModule.mockImplementation(async (path: string) => {
528
+ if (path.includes('server-middleware')) return { serverMiddleware: [] }
529
+ if (path.includes('server-api') || path.includes('cer-server-api')) return { apiRoutes: [] }
530
+ if (path.includes('cer-routes')) throw new Error('module not ready')
531
+ return { handler: ssrHandler }
532
+ })
533
+ configureCerDevServer(server as any, makeConfig({ mode: 'ssr' }))
534
+ await getMiddleware()(
535
+ createReq({ url: '/some-page', headers: { accept: 'text/html' } }),
536
+ createRes(),
537
+ vi.fn(),
538
+ )
539
+ // Despite the routes module throwing, SSR should still run
540
+ expect(ssrHandler).toHaveBeenCalled()
541
+ })
542
+
543
+ it("does not load routes module in SPA mode (no render mode check needed)", async () => {
544
+ const { server, getMiddleware } = makeServer()
545
+ configureCerDevServer(server as any, makeConfig({ mode: 'spa' }))
546
+ await getMiddleware()(
547
+ createReq({ url: '/about', headers: { accept: 'text/html' } }),
548
+ createRes(),
549
+ vi.fn(),
550
+ )
551
+ const routesCalls = (server.ssrLoadModule as any).mock.calls.filter(
552
+ ([p]: [string]) => p.includes('cer-routes'),
553
+ )
554
+ expect(routesCalls).toHaveLength(0)
555
+ })
556
+ })
557
+
467
558
  // ─── parseBody edge cases ─────────────────────────────────────────────────────
468
559
 
469
560
  describe('configureCerDevServer — malformed JSON body', () => {
@@ -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,67 @@ 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
+ })
145
+
146
+ // ─── Error boundary ──────────────────────────────────────────────────────────
147
+
148
+ it('imports errorTag from virtual:cer-error', () => {
149
+ expect(src).toContain('errorTag')
150
+ expect(src).toContain('virtual:cer-error')
151
+ })
152
+
153
+ it('catches loader errors in _prepareRequest', () => {
154
+ expect(src).toContain('catch (err)')
155
+ // The catch block is inside _prepareRequest, before the layout chain
156
+ const catchIdx = src.indexOf('catch (err)')
157
+ const prepareIdx = src.indexOf('_prepareRequest')
158
+ expect(catchIdx).toBeGreaterThan(prepareIdx)
159
+ })
160
+
161
+ it('extracts .status from the thrown error for HTTP status code', () => {
162
+ expect(src).toContain("'status' in err")
163
+ expect(src).toContain('err.status')
164
+ })
165
+
166
+ it('defaults to status 500 when thrown error has no .status', () => {
167
+ expect(src).toContain(': 500')
168
+ })
169
+
170
+ it('renders errorTag component when loader throws and errorTag is set', () => {
171
+ expect(src).toContain('tag: errorTag')
172
+ })
173
+
174
+ it('logs to console.error when loader throws and no errorTag is defined', () => {
175
+ expect(src).toContain('console.error')
176
+ expect(src).toContain('!errorTag')
177
+ })
178
+
179
+ it('propagates status to res.statusCode', () => {
180
+ expect(src).toContain('res.statusCode = status')
181
+ })
182
+
183
+ it('returns status: null on the happy path', () => {
184
+ expect(src).toContain('status: null')
185
+ })
186
+
187
+ // ─── ISR production export ───────────────────────────────────────────────────
188
+
189
+ it('imports createIsrHandler from the isr subpath', () => {
190
+ expect(src).toContain('createIsrHandler')
191
+ expect(src).toContain('vite-plugin-cer-app/isr')
192
+ })
193
+
194
+ it('exports isrHandler wrapping handler with ISR caching', () => {
195
+ expect(src).toContain('export const isrHandler')
196
+ expect(src).toContain('createIsrHandler(routes, handler)')
197
+ })
127
198
  })