@jasonshimmy/vite-plugin-cer-app 0.6.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 +4 -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/plugin/build-ssg.d.ts.map +1 -1
- package/dist/plugin/build-ssg.js +13 -1
- 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 +1 -1
- package/dist/runtime/entry-server-template.d.ts.map +1 -1
- package/dist/runtime/entry-server-template.js +21 -4
- 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 +63 -2
- 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 +5 -1
- package/src/__tests__/cli/preview-isr.test.ts +44 -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 +53 -0
- 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/plugin/build-ssg.ts +11 -1
- package/src/plugin/dev-server.ts +33 -0
- package/src/plugin/virtual/routes.ts +24 -2
- package/src/runtime/entry-server-template.ts +21 -4
- package/src/runtime/isr-handler.ts +183 -0
- package/src/types/page.ts +14 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createIsrHandler — portable stale-while-revalidate ISR factory.
|
|
3
|
+
*
|
|
4
|
+
* Wraps any Express-compatible SSR handler with an in-memory ISR cache.
|
|
5
|
+
* Routes that export `meta.ssg.revalidate` get cached for the declared TTL.
|
|
6
|
+
*
|
|
7
|
+
* Usage (Express):
|
|
8
|
+
* import { createIsrHandler } from '@jasonshimmy/vite-plugin-cer-app/isr'
|
|
9
|
+
* import { handler, routes } from './dist/server/server.js'
|
|
10
|
+
* app.use(createIsrHandler(routes, handler))
|
|
11
|
+
*
|
|
12
|
+
* Usage (Hono):
|
|
13
|
+
* import { createIsrHandler } from '@jasonshimmy/vite-plugin-cer-app/isr'
|
|
14
|
+
* import { handler, routes } from './dist/server/server.js'
|
|
15
|
+
* app.use('*', createIsrHandler(routes, handler))
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
19
|
+
|
|
20
|
+
export interface IsrCacheEntry {
|
|
21
|
+
html: string
|
|
22
|
+
headers: Record<string, string>
|
|
23
|
+
statusCode: number
|
|
24
|
+
builtAt: number
|
|
25
|
+
revalidate: number
|
|
26
|
+
revalidating: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type SsrHandlerFn = (req: IncomingMessage, res: ServerResponse) => unknown
|
|
30
|
+
|
|
31
|
+
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
function _matchPattern(pattern: string, urlPath: string): boolean {
|
|
34
|
+
const norm = (s: string) => s.replace(/\/+$/, '') || '/'
|
|
35
|
+
if (norm(pattern) === norm(urlPath)) return true
|
|
36
|
+
const regexStr =
|
|
37
|
+
'^' +
|
|
38
|
+
norm(pattern)
|
|
39
|
+
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
|
|
40
|
+
.replace(/:[^/]+\*/g, '.*')
|
|
41
|
+
.replace(/:[^/]+/g, '[^/]+') +
|
|
42
|
+
'$'
|
|
43
|
+
return new RegExp(regexStr).test(norm(urlPath))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function _findRevalidate(
|
|
47
|
+
routes: Array<{ path: string; meta?: Record<string, unknown> }>,
|
|
48
|
+
urlPath: string,
|
|
49
|
+
): number | null {
|
|
50
|
+
for (const route of routes) {
|
|
51
|
+
if (_matchPattern(route.path, urlPath)) {
|
|
52
|
+
const ssg = route.meta?.ssg as Record<string, unknown> | undefined
|
|
53
|
+
if (typeof ssg?.revalidate === 'number') return ssg.revalidate
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function _renderForCache(
|
|
61
|
+
urlPath: string,
|
|
62
|
+
handler: SsrHandlerFn,
|
|
63
|
+
revalidate: number,
|
|
64
|
+
): Promise<IsrCacheEntry | null> {
|
|
65
|
+
return new Promise((resolve) => {
|
|
66
|
+
const chunks: Buffer[] = []
|
|
67
|
+
const capturedHeaders: Record<string, string | string[]> = {}
|
|
68
|
+
let capturedStatus = 200
|
|
69
|
+
|
|
70
|
+
const fakeRes = {
|
|
71
|
+
get statusCode() { return capturedStatus },
|
|
72
|
+
set statusCode(v: number) { capturedStatus = v },
|
|
73
|
+
setHeader(name: string, value: string | string[]) {
|
|
74
|
+
capturedHeaders[name.toLowerCase()] = value
|
|
75
|
+
},
|
|
76
|
+
write(chunk: string | Buffer) {
|
|
77
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, 'utf-8'))
|
|
78
|
+
},
|
|
79
|
+
end(body?: string | Buffer) {
|
|
80
|
+
if (body !== undefined) {
|
|
81
|
+
chunks.push(Buffer.isBuffer(body) ? body : Buffer.from(String(body), 'utf-8'))
|
|
82
|
+
}
|
|
83
|
+
resolve({
|
|
84
|
+
html: Buffer.concat(chunks).toString('utf-8'),
|
|
85
|
+
headers: Object.fromEntries(
|
|
86
|
+
Object.entries(capturedHeaders).map(([k, v]) => [k, Array.isArray(v) ? v.join(', ') : v]),
|
|
87
|
+
),
|
|
88
|
+
statusCode: capturedStatus,
|
|
89
|
+
builtAt: Date.now(),
|
|
90
|
+
revalidate,
|
|
91
|
+
revalidating: false,
|
|
92
|
+
})
|
|
93
|
+
},
|
|
94
|
+
} as unknown as ServerResponse
|
|
95
|
+
|
|
96
|
+
const fakeReq = {
|
|
97
|
+
url: urlPath,
|
|
98
|
+
method: 'GET',
|
|
99
|
+
headers: { accept: 'text/html' },
|
|
100
|
+
} as IncomingMessage
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const result = handler(fakeReq, fakeRes)
|
|
104
|
+
if (result && typeof (result as Promise<void>).catch === 'function') {
|
|
105
|
+
;(result as Promise<void>).catch(() => resolve(null))
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
resolve(null)
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function _serveFromCache(entry: IsrCacheEntry, res: ServerResponse, status: 'HIT' | 'STALE'): void {
|
|
114
|
+
res.statusCode = entry.statusCode
|
|
115
|
+
for (const [name, value] of Object.entries(entry.headers)) {
|
|
116
|
+
res.setHeader(name, value)
|
|
117
|
+
}
|
|
118
|
+
res.setHeader('X-Cache', status)
|
|
119
|
+
res.end(entry.html)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Wraps an SSR handler with stale-while-revalidate ISR caching.
|
|
126
|
+
*
|
|
127
|
+
* Routes that declare `meta.ssg.revalidate` in the `routes` array are cached
|
|
128
|
+
* in memory. After the TTL expires the stale response is served immediately
|
|
129
|
+
* while a fresh render runs in the background (stale-while-revalidate).
|
|
130
|
+
*
|
|
131
|
+
* Routes without a `revalidate` value are passed through to the handler directly.
|
|
132
|
+
*/
|
|
133
|
+
export function createIsrHandler(
|
|
134
|
+
routes: Array<{ path: string; meta?: Record<string, unknown> }>,
|
|
135
|
+
handler: SsrHandlerFn,
|
|
136
|
+
): SsrHandlerFn {
|
|
137
|
+
const cache = new Map<string, IsrCacheEntry>()
|
|
138
|
+
|
|
139
|
+
return async (req: IncomingMessage, res: ServerResponse): Promise<unknown> => {
|
|
140
|
+
const urlPath = (req.url ?? '/').split('?')[0]
|
|
141
|
+
const revalidate = _findRevalidate(routes, urlPath)
|
|
142
|
+
|
|
143
|
+
if (revalidate === null) {
|
|
144
|
+
return handler(req, res)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const cached = cache.get(urlPath)
|
|
148
|
+
const now = Date.now()
|
|
149
|
+
|
|
150
|
+
if (cached) {
|
|
151
|
+
const ageSeconds = (now - cached.builtAt) / 1000
|
|
152
|
+
if (ageSeconds < cached.revalidate) {
|
|
153
|
+
_serveFromCache(cached, res, 'HIT')
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
if (!cached.revalidating) {
|
|
157
|
+
cached.revalidating = true
|
|
158
|
+
_serveFromCache(cached, res, 'STALE')
|
|
159
|
+
const timeout = setTimeout(() => { if (cached) cached.revalidating = false }, 30_000)
|
|
160
|
+
_renderForCache(urlPath, handler, revalidate).then((entry) => {
|
|
161
|
+
clearTimeout(timeout)
|
|
162
|
+
if (entry) cache.set(urlPath, entry)
|
|
163
|
+
else if (cached) cached.revalidating = false
|
|
164
|
+
}).catch(() => {
|
|
165
|
+
clearTimeout(timeout)
|
|
166
|
+
if (cached) cached.revalidating = false
|
|
167
|
+
})
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
_serveFromCache(cached, res, 'STALE')
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Cache miss — render, cache, then serve.
|
|
175
|
+
const entry = await _renderForCache(urlPath, handler, revalidate)
|
|
176
|
+
if (entry) {
|
|
177
|
+
cache.set(urlPath, entry)
|
|
178
|
+
_serveFromCache(entry, res, 'HIT')
|
|
179
|
+
} else {
|
|
180
|
+
await handler(req, res)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
package/src/types/page.ts
CHANGED
|
@@ -32,6 +32,20 @@ export interface PageMeta {
|
|
|
32
32
|
* @example export const meta = { transition: 'fade' }
|
|
33
33
|
*/
|
|
34
34
|
transition?: string | boolean
|
|
35
|
+
/**
|
|
36
|
+
* Per-route rendering strategy. Overrides the global `mode` for this route.
|
|
37
|
+
*
|
|
38
|
+
* - `'server'` — always render server-side, never pre-render. In SSG mode
|
|
39
|
+
* the route is skipped during the static build.
|
|
40
|
+
* - `'static'` — always serve pre-rendered static HTML. In the SSR preview
|
|
41
|
+
* server the pre-rendered file is served from disk; falls back to SSR if
|
|
42
|
+
* not found.
|
|
43
|
+
* - `'spa'` — client-only. In SSR mode the server returns the SPA shell
|
|
44
|
+
* (index.html) without rendering. In SSG mode the route is skipped.
|
|
45
|
+
*
|
|
46
|
+
* @example export const meta = { render: 'server' }
|
|
47
|
+
*/
|
|
48
|
+
render?: 'static' | 'server' | 'spa'
|
|
35
49
|
}
|
|
36
50
|
|
|
37
51
|
export interface PageLoaderContext<P extends Record<string, string> = Record<string, string>> {
|