@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/IMPLEMENTATION_PLAN.md +2 -2
  3. package/commits.txt +1 -1
  4. package/dist/cli/commands/preview-isr.d.ts +9 -0
  5. package/dist/cli/commands/preview-isr.d.ts.map +1 -1
  6. package/dist/cli/commands/preview-isr.js +16 -0
  7. package/dist/cli/commands/preview-isr.js.map +1 -1
  8. package/dist/cli/commands/preview.d.ts.map +1 -1
  9. package/dist/cli/commands/preview.js +44 -1
  10. package/dist/cli/commands/preview.js.map +1 -1
  11. package/dist/plugin/build-ssg.d.ts.map +1 -1
  12. package/dist/plugin/build-ssg.js +13 -1
  13. package/dist/plugin/build-ssg.js.map +1 -1
  14. package/dist/plugin/dev-server.d.ts.map +1 -1
  15. package/dist/plugin/dev-server.js +33 -0
  16. package/dist/plugin/dev-server.js.map +1 -1
  17. package/dist/plugin/virtual/routes.d.ts.map +1 -1
  18. package/dist/plugin/virtual/routes.js +24 -2
  19. package/dist/plugin/virtual/routes.js.map +1 -1
  20. package/dist/runtime/entry-server-template.d.ts +1 -1
  21. package/dist/runtime/entry-server-template.d.ts.map +1 -1
  22. package/dist/runtime/entry-server-template.js +21 -4
  23. package/dist/runtime/entry-server-template.js.map +1 -1
  24. package/dist/runtime/isr-handler.d.ts +40 -0
  25. package/dist/runtime/isr-handler.d.ts.map +1 -0
  26. package/dist/runtime/isr-handler.js +152 -0
  27. package/dist/runtime/isr-handler.js.map +1 -0
  28. package/dist/types/page.d.ts +14 -0
  29. package/dist/types/page.d.ts.map +1 -1
  30. package/docs/data-loading.md +69 -2
  31. package/docs/rendering-modes.md +63 -2
  32. package/docs/routing.md +33 -0
  33. package/e2e/cypress/e2e/error-boundary.cy.ts +43 -0
  34. package/e2e/cypress/e2e/isr-nested-runtime.cy.ts +6 -4
  35. package/e2e/cypress/e2e/per-route-render.cy.ts +70 -0
  36. package/e2e/kitchen-sink/app/error.ts +7 -2
  37. package/e2e/kitchen-sink/app/pages/loader-error-test.ts +13 -0
  38. package/e2e/kitchen-sink/app/pages/render-server-test.ts +12 -0
  39. package/e2e/kitchen-sink/app/pages/render-spa-test.ts +12 -0
  40. package/package.json +5 -1
  41. package/src/__tests__/cli/preview-isr.test.ts +44 -0
  42. package/src/__tests__/plugin/build-ssg.test.ts +126 -1
  43. package/src/__tests__/plugin/dev-server.test.ts +91 -0
  44. package/src/__tests__/plugin/entry-server-template.test.ts +53 -0
  45. package/src/__tests__/plugin/virtual/routes.test.ts +65 -0
  46. package/src/__tests__/runtime/isr-handler.test.ts +331 -0
  47. package/src/cli/commands/preview-isr.ts +19 -0
  48. package/src/cli/commands/preview.ts +46 -0
  49. package/src/plugin/build-ssg.ts +11 -1
  50. package/src/plugin/dev-server.ts +33 -0
  51. package/src/plugin/virtual/routes.ts +24 -2
  52. package/src/runtime/entry-server-template.ts +21 -4
  53. package/src/runtime/isr-handler.ts +183 -0
  54. 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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmOpC,CAAA"}
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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"}
@@ -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;
@@ -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;CAC9B;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"}
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"}
@@ -123,18 +123,85 @@ export const loader: PageLoader<{ slug: string }> = async ({ params }) => {
123
123
 
124
124
  ## Error handling in loaders
125
125
 
126
- Unhandled errors in `loader` propagate to the SSR error handler and return a 500 response. Handle expected failures explicitly:
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
- throw new Response('Not Found', { status: 404 })
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
@@ -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 currently active in the built-in **preview server** (`cer-app preview`). When integrating the server bundle into Express / Hono / Fastify in production, implement the same stale-while-revalidate pattern using `route.meta?.ssg?.revalidate` from the exported `routes` array.
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 — the TTL is always expired after the first
37
- // render, so the second request is always served stale-while-revalidate.
38
- it('first request to a revalidate:0 route returns X-Cache: HIT', () => {
36
+ // /isr-test uses revalidate: 0 — ISR is always engaged. The first request
37
+ // returns HIT (cold cache) or STALE (warm cache from a previous test run),
38
+ // but x-cache is always present. Exact HIT/STALE distinction for a cold
39
+ // cache is covered by unit tests (createIsrHandler).
40
+ it('revalidate:0 route always has X-Cache header (ISR engaged)', () => {
39
41
  cy.request('/isr-test').then((response) => {
40
- expect(response.headers['x-cache']).to.equal('HIT')
42
+ expect(['HIT', 'STALE']).to.include(response.headers['x-cache'])
41
43
  })
42
44
  })
43
45
 
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Tests for per-route render strategy (meta.render).
3
+ *
4
+ * render: 'server' — route is always SSR'd, skipped during SSG pre-rendering.
5
+ * render: 'spa' — route is served as SPA shell in SSR mode, skipped in SSG.
6
+ * render: 'static' — serve pre-rendered HTML from disk; fall back to SSR.
7
+ */
8
+
9
+ const mode = Cypress.env('mode') as 'spa' | 'ssr' | 'ssg'
10
+
11
+ // ─── render: 'server' ─────────────────────────────────────────────────────────
12
+
13
+ describe('render: server — always SSR', () => {
14
+ it('renders the page in SSR mode', () => {
15
+ if (mode !== 'ssr') return
16
+ cy.visit('/render-server-test')
17
+ cy.get('[data-cy=render-server-heading]').should('contain', 'Render Server Test')
18
+ })
19
+
20
+ it('renders the page in SPA mode (client-side navigation)', () => {
21
+ if (mode !== 'spa') return
22
+ cy.visit('/render-server-test')
23
+ cy.get('[data-cy=render-server-heading]').should('contain', 'Render Server Test')
24
+ })
25
+
26
+ if (mode === 'ssr') {
27
+ it('pre-renders the page in the initial HTML (SSR)', () => {
28
+ cy.request('/render-server-test').then((response) => {
29
+ expect(response.body).to.include('render-server-heading')
30
+ expect(response.body).to.include('Render Server Test')
31
+ })
32
+ })
33
+ }
34
+
35
+ if (mode === 'ssg') {
36
+ it('route with render:server is not pre-rendered — not found in ssg dist', () => {
37
+ // The route was skipped during SSG. The static preview falls back to
38
+ // dist/index.html (SPA shell) rather than a pre-rendered page, so the
39
+ // server-rendered heading is absent from the raw HTML response.
40
+ cy.request('/render-server-test').then((response) => {
41
+ expect(response.body).not.to.include('render-server-heading')
42
+ })
43
+ })
44
+ }
45
+ })
46
+
47
+ // ─── render: 'spa' ────────────────────────────────────────────────────────────
48
+
49
+ describe('render: spa — client-only', () => {
50
+ it('renders the page heading after JS boots', () => {
51
+ cy.visit('/render-spa-test')
52
+ cy.get('[data-cy=render-spa-heading]').should('contain', 'Render SPA Test')
53
+ })
54
+
55
+ if (mode === 'ssr') {
56
+ it('raw HTML response is the SPA shell (no SSR content)', () => {
57
+ cy.request('/render-spa-test').then((response) => {
58
+ expect(response.body).not.to.include('render-spa-heading')
59
+ })
60
+ })
61
+ }
62
+
63
+ if (mode === 'ssg') {
64
+ it('route with render:spa is not pre-rendered — not found in ssg dist', () => {
65
+ cy.request('/render-spa-test').then((response) => {
66
+ expect(response.body).not.to.include('render-spa-heading')
67
+ })
68
+ })
69
+ }
70
+ })
@@ -1,9 +1,14 @@
1
1
  component('page-error', () => {
2
- const props = useProps<{ error: string }>({ error: 'An unexpected error occurred.' })
2
+ const props = useProps<{ error: string; status: string }>({
3
+ error: 'An unexpected error occurred.',
4
+ status: '500',
5
+ })
3
6
 
4
7
  return html`
5
8
  <div data-cy="error-boundary" style="padding:2rem;font-family:sans-serif">
6
- <h2 data-cy="error-heading" style="color:#c00;margin-top:0">Something went wrong</h2>
9
+ <h2 data-cy="error-heading" style="color:#c00;margin-top:0">
10
+ Error ${props.status}
11
+ </h2>
7
12
  <pre data-cy="error-message" style="background:#fff0f0;border:1px solid #fcc;padding:1rem;border-radius:4px">${props.error}</pre>
8
13
  <button data-cy="error-retry" @click="${() => (globalThis as any).resetError?.()}">
9
14
  Try again
@@ -0,0 +1,13 @@
1
+ component('page-loader-error-test', () => {
2
+ return html`
3
+ <div>
4
+ <h1 data-cy="loader-error-heading">Loader Error Test</h1>
5
+ </div>
6
+ `
7
+ })
8
+
9
+ export async function loader() {
10
+ const err = new Error('Loader intentionally failed') as Error & { status?: number }
11
+ err.status = 503
12
+ throw err
13
+ }
@@ -0,0 +1,12 @@
1
+ component('page-render-server-test', () => {
2
+ return html`
3
+ <div>
4
+ <h1 data-cy="render-server-heading">Render Server Test</h1>
5
+ <p>This page always renders server-side.</p>
6
+ </div>
7
+ `
8
+ })
9
+
10
+ export const meta = {
11
+ render: 'server',
12
+ }
@@ -0,0 +1,12 @@
1
+ component('page-render-spa-test', () => {
2
+ return html`
3
+ <div>
4
+ <h1 data-cy="render-spa-heading">Render SPA Test</h1>
5
+ <p>This page is client-only.</p>
6
+ </div>
7
+ `
8
+ })
9
+
10
+ export const meta = {
11
+ render: 'spa',
12
+ }