@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
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"entry-server-template.js","sourceRoot":"","sources":["../../src/runtime/entry-server-template.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG
|
|
1
|
+
{"version":3,"file":"entry-server-template.js","sourceRoot":"","sources":["../../src/runtime/entry-server-template.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoPpC,CAAA"}
|
|
@@ -0,0 +1,40 @@
|
|
|
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
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
18
|
+
export interface IsrCacheEntry {
|
|
19
|
+
html: string;
|
|
20
|
+
headers: Record<string, string>;
|
|
21
|
+
statusCode: number;
|
|
22
|
+
builtAt: number;
|
|
23
|
+
revalidate: number;
|
|
24
|
+
revalidating: boolean;
|
|
25
|
+
}
|
|
26
|
+
export type SsrHandlerFn = (req: IncomingMessage, res: ServerResponse) => unknown;
|
|
27
|
+
/**
|
|
28
|
+
* Wraps an SSR handler with stale-while-revalidate ISR caching.
|
|
29
|
+
*
|
|
30
|
+
* Routes that declare `meta.ssg.revalidate` in the `routes` array are cached
|
|
31
|
+
* in memory. After the TTL expires the stale response is served immediately
|
|
32
|
+
* while a fresh render runs in the background (stale-while-revalidate).
|
|
33
|
+
*
|
|
34
|
+
* Routes without a `revalidate` value are passed through to the handler directly.
|
|
35
|
+
*/
|
|
36
|
+
export declare function createIsrHandler(routes: Array<{
|
|
37
|
+
path: string;
|
|
38
|
+
meta?: Record<string, unknown>;
|
|
39
|
+
}>, handler: SsrHandlerFn): SsrHandlerFn;
|
|
40
|
+
//# sourceMappingURL=isr-handler.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"isr-handler.d.ts","sourceRoot":"","sources":["../../src/runtime/isr-handler.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAEhE,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC/B,UAAU,EAAE,MAAM,CAAA;IAClB,OAAO,EAAE,MAAM,CAAA;IACf,UAAU,EAAE,MAAM,CAAA;IAClB,YAAY,EAAE,OAAO,CAAA;CACtB;AAED,MAAM,MAAM,YAAY,GAAG,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,KAAK,OAAO,CAAA;AA+FjF;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAAC,EAC/D,OAAO,EAAE,YAAY,GACpB,YAAY,CA+Cd"}
|
|
@@ -0,0 +1,152 @@
|
|
|
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
|
+
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
|
18
|
+
function _matchPattern(pattern, urlPath) {
|
|
19
|
+
const norm = (s) => s.replace(/\/+$/, '') || '/';
|
|
20
|
+
if (norm(pattern) === norm(urlPath))
|
|
21
|
+
return true;
|
|
22
|
+
const regexStr = '^' +
|
|
23
|
+
norm(pattern)
|
|
24
|
+
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
|
|
25
|
+
.replace(/:[^/]+\*/g, '.*')
|
|
26
|
+
.replace(/:[^/]+/g, '[^/]+') +
|
|
27
|
+
'$';
|
|
28
|
+
return new RegExp(regexStr).test(norm(urlPath));
|
|
29
|
+
}
|
|
30
|
+
function _findRevalidate(routes, urlPath) {
|
|
31
|
+
for (const route of routes) {
|
|
32
|
+
if (_matchPattern(route.path, urlPath)) {
|
|
33
|
+
const ssg = route.meta?.ssg;
|
|
34
|
+
if (typeof ssg?.revalidate === 'number')
|
|
35
|
+
return ssg.revalidate;
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
async function _renderForCache(urlPath, handler, revalidate) {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
const chunks = [];
|
|
44
|
+
const capturedHeaders = {};
|
|
45
|
+
let capturedStatus = 200;
|
|
46
|
+
const fakeRes = {
|
|
47
|
+
get statusCode() { return capturedStatus; },
|
|
48
|
+
set statusCode(v) { capturedStatus = v; },
|
|
49
|
+
setHeader(name, value) {
|
|
50
|
+
capturedHeaders[name.toLowerCase()] = value;
|
|
51
|
+
},
|
|
52
|
+
write(chunk) {
|
|
53
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, 'utf-8'));
|
|
54
|
+
},
|
|
55
|
+
end(body) {
|
|
56
|
+
if (body !== undefined) {
|
|
57
|
+
chunks.push(Buffer.isBuffer(body) ? body : Buffer.from(String(body), 'utf-8'));
|
|
58
|
+
}
|
|
59
|
+
resolve({
|
|
60
|
+
html: Buffer.concat(chunks).toString('utf-8'),
|
|
61
|
+
headers: Object.fromEntries(Object.entries(capturedHeaders).map(([k, v]) => [k, Array.isArray(v) ? v.join(', ') : v])),
|
|
62
|
+
statusCode: capturedStatus,
|
|
63
|
+
builtAt: Date.now(),
|
|
64
|
+
revalidate,
|
|
65
|
+
revalidating: false,
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
const fakeReq = {
|
|
70
|
+
url: urlPath,
|
|
71
|
+
method: 'GET',
|
|
72
|
+
headers: { accept: 'text/html' },
|
|
73
|
+
};
|
|
74
|
+
try {
|
|
75
|
+
const result = handler(fakeReq, fakeRes);
|
|
76
|
+
if (result && typeof result.catch === 'function') {
|
|
77
|
+
;
|
|
78
|
+
result.catch(() => resolve(null));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
resolve(null);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
function _serveFromCache(entry, res, status) {
|
|
87
|
+
res.statusCode = entry.statusCode;
|
|
88
|
+
for (const [name, value] of Object.entries(entry.headers)) {
|
|
89
|
+
res.setHeader(name, value);
|
|
90
|
+
}
|
|
91
|
+
res.setHeader('X-Cache', status);
|
|
92
|
+
res.end(entry.html);
|
|
93
|
+
}
|
|
94
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
95
|
+
/**
|
|
96
|
+
* Wraps an SSR handler with stale-while-revalidate ISR caching.
|
|
97
|
+
*
|
|
98
|
+
* Routes that declare `meta.ssg.revalidate` in the `routes` array are cached
|
|
99
|
+
* in memory. After the TTL expires the stale response is served immediately
|
|
100
|
+
* while a fresh render runs in the background (stale-while-revalidate).
|
|
101
|
+
*
|
|
102
|
+
* Routes without a `revalidate` value are passed through to the handler directly.
|
|
103
|
+
*/
|
|
104
|
+
export function createIsrHandler(routes, handler) {
|
|
105
|
+
const cache = new Map();
|
|
106
|
+
return async (req, res) => {
|
|
107
|
+
const urlPath = (req.url ?? '/').split('?')[0];
|
|
108
|
+
const revalidate = _findRevalidate(routes, urlPath);
|
|
109
|
+
if (revalidate === null) {
|
|
110
|
+
return handler(req, res);
|
|
111
|
+
}
|
|
112
|
+
const cached = cache.get(urlPath);
|
|
113
|
+
const now = Date.now();
|
|
114
|
+
if (cached) {
|
|
115
|
+
const ageSeconds = (now - cached.builtAt) / 1000;
|
|
116
|
+
if (ageSeconds < cached.revalidate) {
|
|
117
|
+
_serveFromCache(cached, res, 'HIT');
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (!cached.revalidating) {
|
|
121
|
+
cached.revalidating = true;
|
|
122
|
+
_serveFromCache(cached, res, 'STALE');
|
|
123
|
+
const timeout = setTimeout(() => { if (cached)
|
|
124
|
+
cached.revalidating = false; }, 30_000);
|
|
125
|
+
_renderForCache(urlPath, handler, revalidate).then((entry) => {
|
|
126
|
+
clearTimeout(timeout);
|
|
127
|
+
if (entry)
|
|
128
|
+
cache.set(urlPath, entry);
|
|
129
|
+
else if (cached)
|
|
130
|
+
cached.revalidating = false;
|
|
131
|
+
}).catch(() => {
|
|
132
|
+
clearTimeout(timeout);
|
|
133
|
+
if (cached)
|
|
134
|
+
cached.revalidating = false;
|
|
135
|
+
});
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
_serveFromCache(cached, res, 'STALE');
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
// Cache miss — render, cache, then serve.
|
|
142
|
+
const entry = await _renderForCache(urlPath, handler, revalidate);
|
|
143
|
+
if (entry) {
|
|
144
|
+
cache.set(urlPath, entry);
|
|
145
|
+
_serveFromCache(entry, res, 'HIT');
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
await handler(req, res);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
//# sourceMappingURL=isr-handler.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"isr-handler.js","sourceRoot":"","sources":["../../src/runtime/isr-handler.ts"],"names":[],"mappings":"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"}
|
package/dist/types/page.d.ts
CHANGED
|
@@ -28,6 +28,20 @@ export interface PageMeta {
|
|
|
28
28
|
* @example export const meta = { transition: 'fade' }
|
|
29
29
|
*/
|
|
30
30
|
transition?: string | boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Per-route rendering strategy. Overrides the global `mode` for this route.
|
|
33
|
+
*
|
|
34
|
+
* - `'server'` — always render server-side, never pre-render. In SSG mode
|
|
35
|
+
* the route is skipped during the static build.
|
|
36
|
+
* - `'static'` — always serve pre-rendered static HTML. In the SSR preview
|
|
37
|
+
* server the pre-rendered file is served from disk; falls back to SSR if
|
|
38
|
+
* not found.
|
|
39
|
+
* - `'spa'` — client-only. In SSR mode the server returns the SPA shell
|
|
40
|
+
* (index.html) without rendering. In SSG mode the route is skipped.
|
|
41
|
+
*
|
|
42
|
+
* @example export const meta = { render: 'server' }
|
|
43
|
+
*/
|
|
44
|
+
render?: 'static' | 'server' | 'spa';
|
|
31
45
|
}
|
|
32
46
|
export interface PageLoaderContext<P extends Record<string, string> = Record<string, string>> {
|
|
33
47
|
params: P;
|
package/dist/types/page.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"page.d.ts","sourceRoot":"","sources":["../../src/types/page.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA;AAEhD,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,MAAM,GAAG,SAAS,GAAG,MAAM,CAAA;AAElE,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAC/B;AAED,MAAM,WAAW,aAAa;IAC5B,KAAK,CAAC,EAAE,MAAM,OAAO,CAAC,eAAe,EAAE,CAAC,GAAG,eAAe,EAAE,CAAA;IAC5D;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,QAAQ;IACvB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,UAAU,CAAC,EAAE,MAAM,EAAE,CAAA;IACrB,OAAO,CAAC,EAAE,eAAe,CAAA;IACzB,GAAG,CAAC,EAAE,aAAa,CAAA;IACnB;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"page.d.ts","sourceRoot":"","sources":["../../src/types/page.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA;AAEhD,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,MAAM,GAAG,SAAS,GAAG,MAAM,CAAA;AAElE,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAC/B;AAED,MAAM,WAAW,aAAa;IAC5B,KAAK,CAAC,EAAE,MAAM,OAAO,CAAC,eAAe,EAAE,CAAC,GAAG,eAAe,EAAE,CAAA;IAC5D;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,QAAQ;IACvB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,UAAU,CAAC,EAAE,MAAM,EAAE,CAAA;IACrB,OAAO,CAAC,EAAE,eAAe,CAAA;IACzB,GAAG,CAAC,EAAE,aAAa,CAAA;IACnB;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;IAC7B;;;;;;;;;;;;OAYG;IACH,MAAM,CAAC,EAAE,QAAQ,GAAG,QAAQ,GAAG,KAAK,CAAA;CACrC;AAED,MAAM,WAAW,iBAAiB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;IAC1F,MAAM,EAAE,CAAC,CAAA;IACT,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC7B,GAAG,EAAE,eAAe,CAAA;CACrB;AAED,MAAM,MAAM,UAAU,CACpB,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EACzD,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,IACzB,CAAC,GAAG,EAAE,iBAAiB,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAA"}
|
package/docs/data-loading.md
CHANGED
|
@@ -123,18 +123,85 @@ export const loader: PageLoader<{ slug: string }> = async ({ params }) => {
|
|
|
123
123
|
|
|
124
124
|
## Error handling in loaders
|
|
125
125
|
|
|
126
|
-
|
|
126
|
+
When a `loader` throws, the SSR error boundary intercepts it:
|
|
127
|
+
|
|
128
|
+
- If `app/error.ts` exists, the server renders the `page-error` component instead of the normal page and returns the appropriate HTTP status.
|
|
129
|
+
- If `app/error.ts` does not exist, the error is logged to the server console and a blank 500 response is returned.
|
|
130
|
+
|
|
131
|
+
To send a specific HTTP status code from a loader, attach a `status` property to the thrown error:
|
|
127
132
|
|
|
128
133
|
```ts
|
|
129
134
|
export const loader: PageLoader<{ id: string }> = async ({ params }) => {
|
|
130
135
|
const item = await db.item.findById(params.id)
|
|
131
136
|
if (!item) {
|
|
132
|
-
|
|
137
|
+
const err = Object.assign(new Error('Not Found'), { status: 404 })
|
|
138
|
+
throw err
|
|
133
139
|
}
|
|
134
140
|
return { item }
|
|
135
141
|
}
|
|
136
142
|
```
|
|
137
143
|
|
|
144
|
+
You can also throw a standard `Response` — the framework reads its `.status` property:
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
throw new Response('Not Found', { status: 404 })
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Unhandled errors without a `status` property default to HTTP 500.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Error boundary — `app/error.ts`
|
|
155
|
+
|
|
156
|
+
Create `app/error.ts` to define a custom error page shown when navigation fails. The file must export a custom element named `page-error`:
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
// app/error.ts
|
|
160
|
+
component('page-error', () => {
|
|
161
|
+
const props = useProps<{ error: string; status: string }>({
|
|
162
|
+
error: 'An unexpected error occurred.',
|
|
163
|
+
status: '500',
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
return html`
|
|
167
|
+
<div style="padding:2rem">
|
|
168
|
+
<h2 style="color:#c00">Error ${props.status}</h2>
|
|
169
|
+
<p>${props.error}</p>
|
|
170
|
+
<button @click="${() => (globalThis as any).resetError?.()}">
|
|
171
|
+
Try again
|
|
172
|
+
</button>
|
|
173
|
+
</div>
|
|
174
|
+
`
|
|
175
|
+
})
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Props received by `page-error`
|
|
179
|
+
|
|
180
|
+
| Prop | Type | Source |
|
|
181
|
+
|------|------|--------|
|
|
182
|
+
| `error` | `string` | Error message from the thrown value |
|
|
183
|
+
| `status` | `string` | HTTP status code as a string (`"404"`, `"500"`, etc.) — **SSR only** |
|
|
184
|
+
|
|
185
|
+
### `resetError()`
|
|
186
|
+
|
|
187
|
+
The framework exposes `globalThis.resetError()` as a global function. Calling it clears the error state and re-navigates to the current path, giving the user a way to recover without a full page reload.
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
// Call from a button click handler inside page-error
|
|
191
|
+
(globalThis as any).resetError?.()
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### SSR vs. client-side behavior
|
|
195
|
+
|
|
196
|
+
| Scenario | Behavior |
|
|
197
|
+
|----------|----------|
|
|
198
|
+
| **Loader throws during SSR** | Server renders `page-error` with `error` and `status` props; response uses the thrown status code |
|
|
199
|
+
| **Navigation throws on the client** | `cer-layout-view` renders `page-error` with `error` prop only (no HTTP status in the browser) |
|
|
200
|
+
| **No `app/error.ts` defined (SSR)** | Error is logged to the server console; blank 500 response |
|
|
201
|
+
| **No `app/error.ts` defined (client)** | Raw error message rendered in a `<div>` |
|
|
202
|
+
|
|
203
|
+
> **SPA mode:** There is no server-side error boundary in SPA mode. The `loader` function is never called server-side, so `page-error` is only rendered for client-side navigation errors. To handle loading failures in SPA, catch errors inside `useOnConnected` and render an error state manually.
|
|
204
|
+
|
|
138
205
|
---
|
|
139
206
|
|
|
140
207
|
## Loader vs. client-side fetching
|
package/docs/rendering-modes.md
CHANGED
|
@@ -106,6 +106,8 @@ Any request that:
|
|
|
106
106
|
|
|
107
107
|
…is treated as an HTML request and rendered server-side.
|
|
108
108
|
|
|
109
|
+
Per-route `meta.render` overrides are respected in the dev server: a route with `render: 'spa'` skips SSR and falls through to Vite's own asset handler (returning the SPA shell), exactly as it would in production.
|
|
110
|
+
|
|
109
111
|
### Integrating with Express / Fastify / Hono
|
|
110
112
|
|
|
111
113
|
In production, wire the server bundle's handler into your web framework:
|
|
@@ -232,7 +234,7 @@ ISR is a per-route cache layer in the SSR preview server. Pages with `meta.ssg.r
|
|
|
232
234
|
1. **First request (HIT after fresh render):** Cache miss — render via SSR, store in memory cache with TTL, then serve from the newly-populated cache. `X-Cache: HIT` is set.
|
|
233
235
|
2. **Within TTL (HIT):** Serve directly from cache. `X-Cache: HIT` header is set.
|
|
234
236
|
3. **After TTL expires (STALE):** Serve the stale cached HTML immediately with `X-Cache: STALE`. Kick off a background re-render. When the re-render completes, update the cache.
|
|
235
|
-
4. **While revalidating:** Continue serving stale HTML to new requests.
|
|
237
|
+
4. **While revalidating:** Continue serving stale HTML to new requests. If the background render takes longer than **30 seconds**, the revalidating lock is automatically released so the next request can trigger a fresh attempt.
|
|
236
238
|
|
|
237
239
|
### Configuration
|
|
238
240
|
|
|
@@ -260,9 +262,68 @@ export const meta = {
|
|
|
260
262
|
| `revalidate: 3600` | Product pages, documentation |
|
|
261
263
|
| `revalidate: 86400` | Marketing pages, rarely-changing content |
|
|
262
264
|
|
|
265
|
+
### Query string handling
|
|
266
|
+
|
|
267
|
+
ISR caches by **path only** — query strings are stripped from the cache key. Requests to `/blog/post?preview=true` and `/blog/post` share the same cache entry. Use `render: 'server'` (no `revalidate`) for routes where query parameters affect the rendered output.
|
|
268
|
+
|
|
269
|
+
### Decision order in the preview server
|
|
270
|
+
|
|
271
|
+
The built-in preview server resolves each request using this precedence:
|
|
272
|
+
|
|
273
|
+
1. Static asset (`dist/client/**.*`) — served directly
|
|
274
|
+
2. `render: 'spa'` — returns `dist/client/index.html` (SPA shell)
|
|
275
|
+
3. `render: 'static'` — returns `dist/<path>/index.html`; falls back to SSR if file not found
|
|
276
|
+
4. `render: 'server'` — always SSR, bypasses ISR cache
|
|
277
|
+
5. ISR — if `meta.ssg.revalidate` is set, apply stale-while-revalidate caching
|
|
278
|
+
6. Regular SSR
|
|
279
|
+
|
|
280
|
+
### Compatibility with per-route render modes
|
|
281
|
+
|
|
282
|
+
ISR is controlled solely by `meta.ssg.revalidate`. The `meta.render` override is independent:
|
|
283
|
+
|
|
284
|
+
| `meta.render` | `meta.ssg.revalidate` | ISR behavior |
|
|
285
|
+
|---|---|---|
|
|
286
|
+
| _(not set)_ | set | ISR active |
|
|
287
|
+
| `'server'` | set | ISR active — SSR output is cached |
|
|
288
|
+
| `'server'` | not set | Pass-through; no caching |
|
|
289
|
+
| `'static'` | — | Not applicable — `render: 'static'` serves pre-rendered files, not a live SSR render |
|
|
290
|
+
| `'spa'` | — | Not applicable — `render: 'spa'` returns the SPA shell, not an SSR render |
|
|
291
|
+
|
|
292
|
+
> **Production note:** The `isrHandler` export in the server bundle is a pure ISR-caching wrapper around the SSR handler. It does **not** implement `render: 'spa'` or `render: 'static'` behavior — those are handled by the preview server and the SSG build pipeline. In a custom production Express setup, `render: 'spa'` routes will be SSR-rendered unless you add your own middleware to intercept them before `isrHandler`. For most SSR apps, `render: 'spa'` routes are rare; if you need them in production, serve your SPA shell explicitly for those paths.
|
|
293
|
+
|
|
263
294
|
### Availability
|
|
264
295
|
|
|
265
|
-
ISR is
|
|
296
|
+
ISR is active in the built-in **preview server** (`cer-app preview`) and in production via the `isrHandler` export from the server bundle.
|
|
297
|
+
|
|
298
|
+
**Production (Express):**
|
|
299
|
+
```ts
|
|
300
|
+
import express from 'express'
|
|
301
|
+
import sirv from 'sirv'
|
|
302
|
+
import { isrHandler } from './dist/server/server.js'
|
|
303
|
+
|
|
304
|
+
const app = express()
|
|
305
|
+
app.use(sirv('dist/client', { dev: false }))
|
|
306
|
+
app.use(isrHandler) // ISR-aware; routes without revalidate pass straight through
|
|
307
|
+
app.listen(3000)
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
**Production (Hono):**
|
|
311
|
+
```ts
|
|
312
|
+
import { Hono } from 'hono'
|
|
313
|
+
import { isrHandler } from './dist/server/server.js'
|
|
314
|
+
|
|
315
|
+
const app = new Hono()
|
|
316
|
+
app.use('*', isrHandler)
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
If you need to build the cache yourself, use the `createIsrHandler` utility:
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
import { createIsrHandler } from '@jasonshimmy/vite-plugin-cer-app/isr'
|
|
323
|
+
import { handler, routes } from './dist/server/server.js'
|
|
324
|
+
|
|
325
|
+
const isrHandler = createIsrHandler(routes, handler)
|
|
326
|
+
```
|
|
266
327
|
|
|
267
328
|
---
|
|
268
329
|
|
package/docs/routing.md
CHANGED
|
@@ -238,6 +238,39 @@ export default {
|
|
|
238
238
|
|
|
239
239
|
Set to `false` to explicitly mark a page as having no transition (useful when a catch-all or default would otherwise apply one).
|
|
240
240
|
|
|
241
|
+
### `meta.render`
|
|
242
|
+
|
|
243
|
+
**Type:** `'static' | 'server' | 'spa'`
|
|
244
|
+
|
|
245
|
+
Overrides the global rendering mode for a single route. Useful in mixed apps where most pages share one strategy but a few need different treatment.
|
|
246
|
+
|
|
247
|
+
| Value | Behavior |
|
|
248
|
+
|---|---|
|
|
249
|
+
| `'server'` | Always renders server-side. In SSG mode the route is **skipped** during the static build — it is never pre-rendered. |
|
|
250
|
+
| `'static'` | Always serves pre-rendered HTML from disk. In the SSR **preview server**, the framework looks for `dist/<path>/index.html`; falls back to SSR if the file is not found. In SSG mode the route is still pre-rendered at build time as normal. |
|
|
251
|
+
| `'spa'` | Client-only. In SSR mode the server returns the SPA shell (`index.html`) without rendering. In SSG mode the route is skipped. |
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
// app/pages/dashboard.ts — always SSR even in an otherwise SSG app
|
|
255
|
+
export const meta = {
|
|
256
|
+
render: 'server',
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
// app/pages/profile.ts — client-only (auth wall, no crawlable content)
|
|
262
|
+
export const meta = {
|
|
263
|
+
render: 'spa',
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
// app/pages/legal/privacy.ts — force static even in SSR mode
|
|
269
|
+
export const meta = {
|
|
270
|
+
render: 'static',
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
241
274
|
---
|
|
242
275
|
|
|
243
276
|
## Route sorting
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the SSR error boundary.
|
|
3
|
+
*
|
|
4
|
+
* When a page loader throws, the server renders `page-error` (from app/error.ts)
|
|
5
|
+
* instead of crashing, and returns the correct HTTP status code.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const mode = Cypress.env('mode') as 'spa' | 'ssr' | 'ssg'
|
|
9
|
+
|
|
10
|
+
// SSR loader error boundary — only meaningful when a server render happens
|
|
11
|
+
if (mode === 'ssr') {
|
|
12
|
+
describe('SSR error boundary — loader throws', () => {
|
|
13
|
+
it('returns the status code from the thrown error (503)', () => {
|
|
14
|
+
cy.request({ url: '/loader-error-test', failOnStatusCode: false }).then((response) => {
|
|
15
|
+
expect(response.status).to.eq(503)
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('renders page-error element in the server response', () => {
|
|
20
|
+
cy.request({ url: '/loader-error-test', failOnStatusCode: false }).then((response) => {
|
|
21
|
+
expect(response.body).to.include('page-error')
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('does not render the normal page heading when loader throws', () => {
|
|
26
|
+
cy.request({ url: '/loader-error-test', failOnStatusCode: false }).then((response) => {
|
|
27
|
+
expect(response.body).not.to.include('loader-error-heading')
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('shows the error boundary UI in the browser', () => {
|
|
32
|
+
cy.visit('/loader-error-test', { failOnStatusCode: false })
|
|
33
|
+
cy.get('[data-cy=error-boundary]').should('exist')
|
|
34
|
+
cy.get('[data-cy=error-heading]').should('contain', '503')
|
|
35
|
+
cy.get('[data-cy=error-message]').should('contain', 'Loader intentionally failed')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('exposes a retry button that calls resetError()', () => {
|
|
39
|
+
cy.visit('/loader-error-test', { failOnStatusCode: false })
|
|
40
|
+
cy.get('[data-cy=error-retry]').should('exist')
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
}
|
|
@@ -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
|
+
}
|