@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.
- package/CHANGELOG.md +8 -0
- package/IMPLEMENTATION_PLAN.md +2 -2
- package/commits.txt +1 -1
- package/dist/cli/commands/preview-isr.d.ts +9 -0
- package/dist/cli/commands/preview-isr.d.ts.map +1 -1
- package/dist/cli/commands/preview-isr.js +16 -0
- package/dist/cli/commands/preview-isr.js.map +1 -1
- package/dist/cli/commands/preview.d.ts.map +1 -1
- package/dist/cli/commands/preview.js +44 -1
- package/dist/cli/commands/preview.js.map +1 -1
- package/dist/cli/create/templates/spa/package.json.tpl +2 -2
- package/dist/cli/create/templates/ssg/package.json.tpl +2 -2
- package/dist/cli/create/templates/ssr/package.json.tpl +2 -2
- package/dist/plugin/build-ssg.d.ts.map +1 -1
- package/dist/plugin/build-ssg.js +17 -3
- package/dist/plugin/build-ssg.js.map +1 -1
- package/dist/plugin/dev-server.d.ts.map +1 -1
- package/dist/plugin/dev-server.js +33 -0
- package/dist/plugin/dev-server.js.map +1 -1
- package/dist/plugin/virtual/routes.d.ts.map +1 -1
- package/dist/plugin/virtual/routes.js +24 -2
- package/dist/plugin/virtual/routes.js.map +1 -1
- package/dist/runtime/entry-server-template.d.ts +2 -2
- package/dist/runtime/entry-server-template.d.ts.map +1 -1
- package/dist/runtime/entry-server-template.js +57 -19
- package/dist/runtime/entry-server-template.js.map +1 -1
- package/dist/runtime/isr-handler.d.ts +40 -0
- package/dist/runtime/isr-handler.d.ts.map +1 -0
- package/dist/runtime/isr-handler.js +152 -0
- package/dist/runtime/isr-handler.js.map +1 -0
- package/dist/types/page.d.ts +14 -0
- package/dist/types/page.d.ts.map +1 -1
- package/docs/data-loading.md +69 -2
- package/docs/rendering-modes.md +66 -5
- package/docs/routing.md +33 -0
- package/e2e/cypress/e2e/error-boundary.cy.ts +43 -0
- package/e2e/cypress/e2e/isr-nested-runtime.cy.ts +6 -4
- package/e2e/cypress/e2e/per-route-render.cy.ts +70 -0
- package/e2e/kitchen-sink/app/error.ts +7 -2
- package/e2e/kitchen-sink/app/pages/loader-error-test.ts +13 -0
- package/e2e/kitchen-sink/app/pages/render-server-test.ts +12 -0
- package/e2e/kitchen-sink/app/pages/render-spa-test.ts +12 -0
- package/package.json +7 -3
- package/src/__tests__/cli/preview-isr.test.ts +44 -0
- package/src/__tests__/plugin/build-ssg-render.test.ts +46 -0
- package/src/__tests__/plugin/build-ssg.test.ts +126 -1
- package/src/__tests__/plugin/dev-server.test.ts +91 -0
- package/src/__tests__/plugin/entry-server-template.test.ts +76 -5
- package/src/__tests__/plugin/virtual/routes.test.ts +65 -0
- package/src/__tests__/runtime/isr-handler.test.ts +331 -0
- package/src/cli/commands/preview-isr.ts +19 -0
- package/src/cli/commands/preview.ts +46 -0
- package/src/cli/create/templates/spa/package.json.tpl +2 -2
- package/src/cli/create/templates/ssg/package.json.tpl +2 -2
- package/src/cli/create/templates/ssr/package.json.tpl +2 -2
- package/src/plugin/build-ssg.ts +15 -3
- package/src/plugin/dev-server.ts +33 -0
- package/src/plugin/virtual/routes.ts +24 -2
- package/src/runtime/entry-server-template.ts +57 -19
- package/src/runtime/isr-handler.ts +183 -0
- 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 —
|
|
37
|
-
//
|
|
38
|
-
|
|
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(
|
|
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 }>({
|
|
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">
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jasonshimmy/vite-plugin-cer-app",
|
|
3
|
-
"version": "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.
|
|
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.
|
|
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
|
|
37
|
-
expect(src).toContain('
|
|
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('
|
|
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("
|
|
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
|
})
|