@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
|
@@ -64,6 +64,23 @@ function extractTransition(source: string): string | boolean | null {
|
|
|
64
64
|
return null
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Extracts the per-route `render` strategy from a page file's source.
|
|
69
|
+
* Returns 'static', 'server', 'spa', or null if absent.
|
|
70
|
+
*
|
|
71
|
+
* Matches patterns like:
|
|
72
|
+
* render: 'server'
|
|
73
|
+
* render: 'spa'
|
|
74
|
+
* render: 'static'
|
|
75
|
+
*/
|
|
76
|
+
function extractRender(source: string): 'static' | 'server' | 'spa' | null {
|
|
77
|
+
const match = source.match(/render\s*:\s*['"]([^'"]+)['"]/)
|
|
78
|
+
if (!match) return null
|
|
79
|
+
const val = match[1]
|
|
80
|
+
if (val === 'static' || val === 'server' || val === 'spa') return val
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
|
|
67
84
|
/**
|
|
68
85
|
* Resolves the layout chain for a page by walking its ancestor directories
|
|
69
86
|
* inside pagesDir looking for `_layout.ts` files. Each `_layout.ts` must
|
|
@@ -162,6 +179,7 @@ export async function generateRoutesCode(pagesDir: string): Promise<string> {
|
|
|
162
179
|
layoutChain: string[] | null
|
|
163
180
|
revalidate: number | null
|
|
164
181
|
transition: string | boolean | null
|
|
182
|
+
render: 'static' | 'server' | 'spa' | null
|
|
165
183
|
}> = await Promise.all(
|
|
166
184
|
sorted.map(async (entry) => {
|
|
167
185
|
try {
|
|
@@ -174,9 +192,10 @@ export async function generateRoutesCode(pagesDir: string): Promise<string> {
|
|
|
174
192
|
layoutChain,
|
|
175
193
|
revalidate: extractRevalidate(src),
|
|
176
194
|
transition: extractTransition(src),
|
|
195
|
+
render: extractRender(src),
|
|
177
196
|
}
|
|
178
197
|
} catch {
|
|
179
|
-
return { middleware: [], layout: null, layoutChain: null, revalidate: null, transition: null }
|
|
198
|
+
return { middleware: [], layout: null, layoutChain: null, revalidate: null, transition: null, render: null }
|
|
180
199
|
}
|
|
181
200
|
}),
|
|
182
201
|
)
|
|
@@ -185,7 +204,7 @@ export async function generateRoutesCode(pagesDir: string): Promise<string> {
|
|
|
185
204
|
|
|
186
205
|
// Build routes array with lazy load() functions for code splitting.
|
|
187
206
|
const routeItems = sorted.map((entry, i) => {
|
|
188
|
-
const { middleware: mw, layout, layoutChain, revalidate, transition } = metaPerEntry[i]
|
|
207
|
+
const { middleware: mw, layout, layoutChain, revalidate, transition, render } = metaPerEntry[i]
|
|
189
208
|
const filePath = JSON.stringify(entry.filePath)
|
|
190
209
|
const tagName = JSON.stringify(entry.tagName)
|
|
191
210
|
const routePath = JSON.stringify(entry.routePath)
|
|
@@ -209,6 +228,9 @@ export async function generateRoutesCode(pagesDir: string): Promise<string> {
|
|
|
209
228
|
if (transition !== null) {
|
|
210
229
|
metaFields.push(`transition: ${JSON.stringify(transition)}`)
|
|
211
230
|
}
|
|
231
|
+
if (render !== null) {
|
|
232
|
+
metaFields.push(`render: ${JSON.stringify(render)}`)
|
|
233
|
+
}
|
|
212
234
|
const metaStr = metaFields.length > 0 ? ` meta: { ${metaFields.join(', ')} },\n` : ''
|
|
213
235
|
|
|
214
236
|
if (mw.length === 0) {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Key features:
|
|
9
9
|
* - AsyncLocalStorage for race-condition-free concurrent renders (SSG concurrency > 1)
|
|
10
|
-
* - Declarative Shadow DOM via
|
|
10
|
+
* - Declarative Shadow DOM via renderToStreamWithJITCSSDSD (always on, streamed)
|
|
11
11
|
* - useHead() support via beginHeadCollection / endHeadCollection
|
|
12
12
|
* - DSD polyfill injected at end of <body> after client-template merge
|
|
13
13
|
*/
|
|
@@ -23,10 +23,12 @@ import plugins from 'virtual:cer-plugins'
|
|
|
23
23
|
import apiRoutes from 'virtual:cer-server-api'
|
|
24
24
|
import { runtimeConfig } from 'virtual:cer-app-config'
|
|
25
25
|
import { registerBuiltinComponents } from '@jasonshimmy/custom-elements-runtime'
|
|
26
|
-
import { registerEntityMap,
|
|
26
|
+
import { registerEntityMap, renderToStreamWithJITCSSDSD, DSD_POLYFILL_SCRIPT } from '@jasonshimmy/custom-elements-runtime/ssr'
|
|
27
27
|
import entitiesJson from '@jasonshimmy/custom-elements-runtime/entities.json'
|
|
28
28
|
import { initRouter } from '@jasonshimmy/custom-elements-runtime/router'
|
|
29
29
|
import { beginHeadCollection, endHeadCollection, serializeHeadTags, initRuntimeConfig } from '@jasonshimmy/vite-plugin-cer-app/composables'
|
|
30
|
+
import { errorTag } from 'virtual:cer-error'
|
|
31
|
+
import { createIsrHandler } from '@jasonshimmy/vite-plugin-cer-app/isr'
|
|
30
32
|
|
|
31
33
|
registerBuiltinComponents()
|
|
32
34
|
initRuntimeConfig(runtimeConfig)
|
|
@@ -155,8 +157,18 @@ const _prepareRequest = async (req) => {
|
|
|
155
157
|
head = \`<script>window.__CER_DATA__ = \${JSON.stringify(data)}</script>\`
|
|
156
158
|
}
|
|
157
159
|
}
|
|
158
|
-
} catch {
|
|
159
|
-
//
|
|
160
|
+
} catch (err) {
|
|
161
|
+
// Loader threw — render the error page server-side if app/error.ts exists.
|
|
162
|
+
const status = (err && typeof err === 'object' && 'status' in err && typeof err.status === 'number')
|
|
163
|
+
? err.status : 500
|
|
164
|
+
const message = (err instanceof Error) ? err.message : String(err)
|
|
165
|
+
if (!errorTag) {
|
|
166
|
+
console.error('[cer-app] Loader error (no app/error.ts defined):', err)
|
|
167
|
+
}
|
|
168
|
+
const errVnode = errorTag
|
|
169
|
+
? { tag: errorTag, props: { attrs: { error: message, status: String(status) } }, children: [] }
|
|
170
|
+
: { tag: 'div', props: {}, children: [] }
|
|
171
|
+
return { vnode: errVnode, router, head: undefined, status }
|
|
160
172
|
}
|
|
161
173
|
}
|
|
162
174
|
|
|
@@ -172,49 +184,75 @@ const _prepareRequest = async (req) => {
|
|
|
172
184
|
if (tag) vnode = { tag, props: {}, children: [vnode] }
|
|
173
185
|
}
|
|
174
186
|
|
|
175
|
-
return { vnode, router, head }
|
|
187
|
+
return { vnode, router, head, status: null }
|
|
176
188
|
}
|
|
177
189
|
|
|
178
190
|
export const handler = async (req, res) => {
|
|
179
191
|
await _cerDataStore.run(null, async () => {
|
|
180
|
-
const { vnode, router, head } = await _prepareRequest(req)
|
|
192
|
+
const { vnode, router, head, status } = await _prepareRequest(req)
|
|
193
|
+
if (status != null) res.statusCode = status
|
|
181
194
|
|
|
182
195
|
// Begin collecting useHead() calls made during the synchronous render pass.
|
|
196
|
+
// IMPORTANT: the stream's start() function runs synchronously on construction,
|
|
197
|
+
// so ALL useHead() calls happen before the stream object is returned. We must
|
|
198
|
+
// call endHeadCollection() immediately — before any await — to avoid a race
|
|
199
|
+
// window where a concurrent request (e.g. SSG concurrency > 1) resets the
|
|
200
|
+
// shared globalThis collector while this handler is suspended at an await.
|
|
183
201
|
beginHeadCollection()
|
|
184
202
|
|
|
185
203
|
// dsdPolyfill: false — we inject the polyfill manually after merging so it
|
|
186
204
|
// lands at the end of <body>, not inside <cer-layout-view> light DOM where
|
|
187
205
|
// scripts may not execute.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
})
|
|
206
|
+
// The first chunk from the stream is the full synchronous render. Subsequent
|
|
207
|
+
// chunks are async component swap scripts streamed as they resolve.
|
|
208
|
+
const stream = renderToStreamWithJITCSSDSD(vnode, { dsdPolyfill: false, router })
|
|
192
209
|
|
|
193
|
-
// Collect
|
|
210
|
+
// Collect head tags synchronously — all useHead() calls have already fired
|
|
211
|
+
// inside the stream constructor's start() before it returned.
|
|
194
212
|
const headTags = serializeHeadTags(endHeadCollection())
|
|
195
213
|
|
|
214
|
+
const reader = stream.getReader()
|
|
215
|
+
|
|
216
|
+
// Read the first (synchronous) chunk.
|
|
217
|
+
const { value: firstChunk = '' } = await reader.read()
|
|
218
|
+
|
|
196
219
|
// Merge loader data script + useHead() tags into the document head.
|
|
197
220
|
const headContent = [head, headTags].filter(Boolean).join('\\n')
|
|
198
221
|
|
|
199
222
|
// Wrap the rendered body in a full HTML document and inject the head additions
|
|
200
223
|
// (loader data script, useHead() tags, JIT styles). No polyfill in body yet.
|
|
201
|
-
const ssrHtml = \`<!DOCTYPE html><html><head>\${headContent}</head><body>\${
|
|
224
|
+
const ssrHtml = \`<!DOCTYPE html><html><head>\${headContent}</head><body>\${firstChunk}</body></html>\`
|
|
202
225
|
|
|
203
|
-
|
|
226
|
+
const merged = _clientTemplate
|
|
204
227
|
? _mergeWithClientTemplate(ssrHtml, _clientTemplate)
|
|
205
228
|
: ssrHtml
|
|
206
229
|
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
230
|
+
// Split at </body> so async swap scripts and the DSD polyfill can be streamed
|
|
231
|
+
// in before the document is closed.
|
|
232
|
+
const bodyCloseIdx = merged.lastIndexOf('</body>')
|
|
233
|
+
const beforeBodyClose = bodyCloseIdx >= 0 ? merged.slice(0, bodyCloseIdx) : merged
|
|
234
|
+
const fromBodyClose = bodyCloseIdx >= 0 ? merged.slice(bodyCloseIdx) : ''
|
|
212
235
|
|
|
213
236
|
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
|
214
|
-
res.
|
|
237
|
+
res.setHeader('Transfer-Encoding', 'chunked')
|
|
238
|
+
res.write(beforeBodyClose)
|
|
239
|
+
|
|
240
|
+
// Stream async component swap scripts through as-is.
|
|
241
|
+
while (true) {
|
|
242
|
+
const { value, done } = await reader.read()
|
|
243
|
+
if (done) break
|
|
244
|
+
res.write(value)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Inject DSD polyfill immediately before </body>, then close the document.
|
|
248
|
+
res.end(DSD_POLYFILL_SCRIPT + fromBodyClose)
|
|
215
249
|
})
|
|
216
250
|
}
|
|
217
251
|
|
|
252
|
+
// ISR-wrapped handler for production integrations (Express, Hono, Fastify).
|
|
253
|
+
// Routes with meta.ssg.revalidate are served stale-while-revalidate.
|
|
254
|
+
export const isrHandler = createIsrHandler(routes, handler)
|
|
255
|
+
|
|
218
256
|
export { apiRoutes, plugins, layouts, routes }
|
|
219
257
|
export default handler
|
|
220
258
|
`
|
|
@@ -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>> {
|