@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +8 -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/cli/create/templates/spa/package.json.tpl +2 -2
  12. package/dist/cli/create/templates/ssg/package.json.tpl +2 -2
  13. package/dist/cli/create/templates/ssr/package.json.tpl +2 -2
  14. package/dist/plugin/build-ssg.d.ts.map +1 -1
  15. package/dist/plugin/build-ssg.js +17 -3
  16. package/dist/plugin/build-ssg.js.map +1 -1
  17. package/dist/plugin/dev-server.d.ts.map +1 -1
  18. package/dist/plugin/dev-server.js +33 -0
  19. package/dist/plugin/dev-server.js.map +1 -1
  20. package/dist/plugin/virtual/routes.d.ts.map +1 -1
  21. package/dist/plugin/virtual/routes.js +24 -2
  22. package/dist/plugin/virtual/routes.js.map +1 -1
  23. package/dist/runtime/entry-server-template.d.ts +2 -2
  24. package/dist/runtime/entry-server-template.d.ts.map +1 -1
  25. package/dist/runtime/entry-server-template.js +57 -19
  26. package/dist/runtime/entry-server-template.js.map +1 -1
  27. package/dist/runtime/isr-handler.d.ts +40 -0
  28. package/dist/runtime/isr-handler.d.ts.map +1 -0
  29. package/dist/runtime/isr-handler.js +152 -0
  30. package/dist/runtime/isr-handler.js.map +1 -0
  31. package/dist/types/page.d.ts +14 -0
  32. package/dist/types/page.d.ts.map +1 -1
  33. package/docs/data-loading.md +69 -2
  34. package/docs/rendering-modes.md +66 -5
  35. package/docs/routing.md +33 -0
  36. package/e2e/cypress/e2e/error-boundary.cy.ts +43 -0
  37. package/e2e/cypress/e2e/isr-nested-runtime.cy.ts +6 -4
  38. package/e2e/cypress/e2e/per-route-render.cy.ts +70 -0
  39. package/e2e/kitchen-sink/app/error.ts +7 -2
  40. package/e2e/kitchen-sink/app/pages/loader-error-test.ts +13 -0
  41. package/e2e/kitchen-sink/app/pages/render-server-test.ts +12 -0
  42. package/e2e/kitchen-sink/app/pages/render-spa-test.ts +12 -0
  43. package/package.json +7 -3
  44. package/src/__tests__/cli/preview-isr.test.ts +44 -0
  45. package/src/__tests__/plugin/build-ssg-render.test.ts +46 -0
  46. package/src/__tests__/plugin/build-ssg.test.ts +126 -1
  47. package/src/__tests__/plugin/dev-server.test.ts +91 -0
  48. package/src/__tests__/plugin/entry-server-template.test.ts +76 -5
  49. package/src/__tests__/plugin/virtual/routes.test.ts +65 -0
  50. package/src/__tests__/runtime/isr-handler.test.ts +331 -0
  51. package/src/cli/commands/preview-isr.ts +19 -0
  52. package/src/cli/commands/preview.ts +46 -0
  53. package/src/cli/create/templates/spa/package.json.tpl +2 -2
  54. package/src/cli/create/templates/ssg/package.json.tpl +2 -2
  55. package/src/cli/create/templates/ssr/package.json.tpl +2 -2
  56. package/src/plugin/build-ssg.ts +15 -3
  57. package/src/plugin/dev-server.ts +33 -0
  58. package/src/plugin/virtual/routes.ts +24 -2
  59. package/src/runtime/entry-server-template.ts +57 -19
  60. package/src/runtime/isr-handler.ts +183 -0
  61. package/src/types/page.ts +14 -0
@@ -1 +1 @@
1
- {"version":3,"file":"entry-server-template.d.ts","sourceRoot":"","sources":["../../src/runtime/entry-server-template.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,qBAAqB,63SA8MjC,CAAA"}
1
+ {"version":3,"file":"entry-server-template.d.ts","sourceRoot":"","sources":["../../src/runtime/entry-server-template.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,qBAAqB,6/WAoPjC,CAAA"}
@@ -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 renderToStringWithJITCSSDSD (always on)
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, renderToStringWithJITCSSDSD, DSD_POLYFILL_SCRIPT } from '@jasonshimmy/custom-elements-runtime/ssr'
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
- // Non-fatal: loader errors fall back to an empty page; client will refetch.
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
- const { htmlWithStyles } = renderToStringWithJITCSSDSD(vnode, {
189
- dsdPolyfill: false,
190
- router,
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 and serialize any useHead() calls from the rendered components.
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>\${htmlWithStyles}</body></html>\`
224
+ const ssrHtml = \`<!DOCTYPE html><html><head>\${headContent}</head><body>\${firstChunk}</body></html>\`
202
225
 
203
- let finalHtml = _clientTemplate
226
+ const merged = _clientTemplate
204
227
  ? _mergeWithClientTemplate(ssrHtml, _clientTemplate)
205
228
  : ssrHtml
206
229
 
207
- // Inject DSD polyfill at end of <body>, outside <cer-layout-view>, so the
208
- // browser runs it after parsing the declarative shadow roots.
209
- finalHtml = finalHtml.includes('</body>')
210
- ? finalHtml.replace('</body>', DSD_POLYFILL_SCRIPT + '</body>')
211
- : finalHtml + DSD_POLYFILL_SCRIPT
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.end(finalHtml)
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
  `;
@@ -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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8MpC,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
@@ -63,9 +63,9 @@ The server renders HTML for each request. Uses Declarative Shadow DOM (DSD) to e
63
63
  3. API route handlers run if the URL matches `/api/`
64
64
  4. For HTML requests, the router matches the URL to a page
65
65
  5. The page's `loader` is called (if present)
66
- 6. The component tree is rendered to HTML with Declarative Shadow DOM via `renderToStringWithJITCSSDSD`
67
- 7. `useHead()` calls are collected and injected before `</head>`
68
- 8. The rendered HTML is merged with the Vite client bundle shell and sent as a full response
66
+ 6. The component tree is rendered to HTML with Declarative Shadow DOM via `renderToStreamWithJITCSSDSD`; the synchronous first chunk is flushed immediately, then async component swap scripts follow as they resolve
67
+ 7. `useHead()` calls are collected from the synchronous render and injected before `</head>`
68
+ 8. The rendered HTML is merged with the Vite client bundle shell and streamed as a chunked response
69
69
 
70
70
  ### Build output
71
71
 
@@ -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
+ }