@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
@@ -64,6 +64,23 @@ function extractTransition(source: string): string | boolean | null {
64
64
  return null
65
65
  }
66
66
 
67
+ /**
68
+ * Extracts the per-route `render` strategy from a page file's source.
69
+ * Returns 'static', 'server', 'spa', or null if absent.
70
+ *
71
+ * Matches patterns like:
72
+ * render: 'server'
73
+ * render: 'spa'
74
+ * render: 'static'
75
+ */
76
+ function extractRender(source: string): 'static' | 'server' | 'spa' | null {
77
+ const match = source.match(/render\s*:\s*['"]([^'"]+)['"]/)
78
+ if (!match) return null
79
+ const val = match[1]
80
+ if (val === 'static' || val === 'server' || val === 'spa') return val
81
+ return null
82
+ }
83
+
67
84
  /**
68
85
  * Resolves the layout chain for a page by walking its ancestor directories
69
86
  * inside pagesDir looking for `_layout.ts` files. Each `_layout.ts` must
@@ -162,6 +179,7 @@ export async function generateRoutesCode(pagesDir: string): Promise<string> {
162
179
  layoutChain: string[] | null
163
180
  revalidate: number | null
164
181
  transition: string | boolean | null
182
+ render: 'static' | 'server' | 'spa' | null
165
183
  }> = await Promise.all(
166
184
  sorted.map(async (entry) => {
167
185
  try {
@@ -174,9 +192,10 @@ export async function generateRoutesCode(pagesDir: string): Promise<string> {
174
192
  layoutChain,
175
193
  revalidate: extractRevalidate(src),
176
194
  transition: extractTransition(src),
195
+ render: extractRender(src),
177
196
  }
178
197
  } catch {
179
- return { middleware: [], layout: null, layoutChain: null, revalidate: null, transition: null }
198
+ return { middleware: [], layout: null, layoutChain: null, revalidate: null, transition: null, render: null }
180
199
  }
181
200
  }),
182
201
  )
@@ -185,7 +204,7 @@ export async function generateRoutesCode(pagesDir: string): Promise<string> {
185
204
 
186
205
  // Build routes array with lazy load() functions for code splitting.
187
206
  const routeItems = sorted.map((entry, i) => {
188
- const { middleware: mw, layout, layoutChain, revalidate, transition } = metaPerEntry[i]
207
+ const { middleware: mw, layout, layoutChain, revalidate, transition, render } = metaPerEntry[i]
189
208
  const filePath = JSON.stringify(entry.filePath)
190
209
  const tagName = JSON.stringify(entry.tagName)
191
210
  const routePath = JSON.stringify(entry.routePath)
@@ -209,6 +228,9 @@ export async function generateRoutesCode(pagesDir: string): Promise<string> {
209
228
  if (transition !== null) {
210
229
  metaFields.push(`transition: ${JSON.stringify(transition)}`)
211
230
  }
231
+ if (render !== null) {
232
+ metaFields.push(`render: ${JSON.stringify(render)}`)
233
+ }
212
234
  const metaStr = metaFields.length > 0 ? ` meta: { ${metaFields.join(', ')} },\n` : ''
213
235
 
214
236
  if (mw.length === 0) {
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Key features:
9
9
  * - AsyncLocalStorage for race-condition-free concurrent renders (SSG concurrency > 1)
10
- * - Declarative Shadow DOM via 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
  `
@@ -0,0 +1,183 @@
1
+ /**
2
+ * createIsrHandler — portable stale-while-revalidate ISR factory.
3
+ *
4
+ * Wraps any Express-compatible SSR handler with an in-memory ISR cache.
5
+ * Routes that export `meta.ssg.revalidate` get cached for the declared TTL.
6
+ *
7
+ * Usage (Express):
8
+ * import { createIsrHandler } from '@jasonshimmy/vite-plugin-cer-app/isr'
9
+ * import { handler, routes } from './dist/server/server.js'
10
+ * app.use(createIsrHandler(routes, handler))
11
+ *
12
+ * Usage (Hono):
13
+ * import { createIsrHandler } from '@jasonshimmy/vite-plugin-cer-app/isr'
14
+ * import { handler, routes } from './dist/server/server.js'
15
+ * app.use('*', createIsrHandler(routes, handler))
16
+ */
17
+
18
+ import type { IncomingMessage, ServerResponse } from 'node:http'
19
+
20
+ export interface IsrCacheEntry {
21
+ html: string
22
+ headers: Record<string, string>
23
+ statusCode: number
24
+ builtAt: number
25
+ revalidate: number
26
+ revalidating: boolean
27
+ }
28
+
29
+ export type SsrHandlerFn = (req: IncomingMessage, res: ServerResponse) => unknown
30
+
31
+ // ─── Internal helpers ─────────────────────────────────────────────────────────
32
+
33
+ function _matchPattern(pattern: string, urlPath: string): boolean {
34
+ const norm = (s: string) => s.replace(/\/+$/, '') || '/'
35
+ if (norm(pattern) === norm(urlPath)) return true
36
+ const regexStr =
37
+ '^' +
38
+ norm(pattern)
39
+ .replace(/[.+?^${}()|[\]\\]/g, '\\$&')
40
+ .replace(/:[^/]+\*/g, '.*')
41
+ .replace(/:[^/]+/g, '[^/]+') +
42
+ '$'
43
+ return new RegExp(regexStr).test(norm(urlPath))
44
+ }
45
+
46
+ function _findRevalidate(
47
+ routes: Array<{ path: string; meta?: Record<string, unknown> }>,
48
+ urlPath: string,
49
+ ): number | null {
50
+ for (const route of routes) {
51
+ if (_matchPattern(route.path, urlPath)) {
52
+ const ssg = route.meta?.ssg as Record<string, unknown> | undefined
53
+ if (typeof ssg?.revalidate === 'number') return ssg.revalidate
54
+ return null
55
+ }
56
+ }
57
+ return null
58
+ }
59
+
60
+ async function _renderForCache(
61
+ urlPath: string,
62
+ handler: SsrHandlerFn,
63
+ revalidate: number,
64
+ ): Promise<IsrCacheEntry | null> {
65
+ return new Promise((resolve) => {
66
+ const chunks: Buffer[] = []
67
+ const capturedHeaders: Record<string, string | string[]> = {}
68
+ let capturedStatus = 200
69
+
70
+ const fakeRes = {
71
+ get statusCode() { return capturedStatus },
72
+ set statusCode(v: number) { capturedStatus = v },
73
+ setHeader(name: string, value: string | string[]) {
74
+ capturedHeaders[name.toLowerCase()] = value
75
+ },
76
+ write(chunk: string | Buffer) {
77
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, 'utf-8'))
78
+ },
79
+ end(body?: string | Buffer) {
80
+ if (body !== undefined) {
81
+ chunks.push(Buffer.isBuffer(body) ? body : Buffer.from(String(body), 'utf-8'))
82
+ }
83
+ resolve({
84
+ html: Buffer.concat(chunks).toString('utf-8'),
85
+ headers: Object.fromEntries(
86
+ Object.entries(capturedHeaders).map(([k, v]) => [k, Array.isArray(v) ? v.join(', ') : v]),
87
+ ),
88
+ statusCode: capturedStatus,
89
+ builtAt: Date.now(),
90
+ revalidate,
91
+ revalidating: false,
92
+ })
93
+ },
94
+ } as unknown as ServerResponse
95
+
96
+ const fakeReq = {
97
+ url: urlPath,
98
+ method: 'GET',
99
+ headers: { accept: 'text/html' },
100
+ } as IncomingMessage
101
+
102
+ try {
103
+ const result = handler(fakeReq, fakeRes)
104
+ if (result && typeof (result as Promise<void>).catch === 'function') {
105
+ ;(result as Promise<void>).catch(() => resolve(null))
106
+ }
107
+ } catch {
108
+ resolve(null)
109
+ }
110
+ })
111
+ }
112
+
113
+ function _serveFromCache(entry: IsrCacheEntry, res: ServerResponse, status: 'HIT' | 'STALE'): void {
114
+ res.statusCode = entry.statusCode
115
+ for (const [name, value] of Object.entries(entry.headers)) {
116
+ res.setHeader(name, value)
117
+ }
118
+ res.setHeader('X-Cache', status)
119
+ res.end(entry.html)
120
+ }
121
+
122
+ // ─── Public API ───────────────────────────────────────────────────────────────
123
+
124
+ /**
125
+ * Wraps an SSR handler with stale-while-revalidate ISR caching.
126
+ *
127
+ * Routes that declare `meta.ssg.revalidate` in the `routes` array are cached
128
+ * in memory. After the TTL expires the stale response is served immediately
129
+ * while a fresh render runs in the background (stale-while-revalidate).
130
+ *
131
+ * Routes without a `revalidate` value are passed through to the handler directly.
132
+ */
133
+ export function createIsrHandler(
134
+ routes: Array<{ path: string; meta?: Record<string, unknown> }>,
135
+ handler: SsrHandlerFn,
136
+ ): SsrHandlerFn {
137
+ const cache = new Map<string, IsrCacheEntry>()
138
+
139
+ return async (req: IncomingMessage, res: ServerResponse): Promise<unknown> => {
140
+ const urlPath = (req.url ?? '/').split('?')[0]
141
+ const revalidate = _findRevalidate(routes, urlPath)
142
+
143
+ if (revalidate === null) {
144
+ return handler(req, res)
145
+ }
146
+
147
+ const cached = cache.get(urlPath)
148
+ const now = Date.now()
149
+
150
+ if (cached) {
151
+ const ageSeconds = (now - cached.builtAt) / 1000
152
+ if (ageSeconds < cached.revalidate) {
153
+ _serveFromCache(cached, res, 'HIT')
154
+ return
155
+ }
156
+ if (!cached.revalidating) {
157
+ cached.revalidating = true
158
+ _serveFromCache(cached, res, 'STALE')
159
+ const timeout = setTimeout(() => { if (cached) cached.revalidating = false }, 30_000)
160
+ _renderForCache(urlPath, handler, revalidate).then((entry) => {
161
+ clearTimeout(timeout)
162
+ if (entry) cache.set(urlPath, entry)
163
+ else if (cached) cached.revalidating = false
164
+ }).catch(() => {
165
+ clearTimeout(timeout)
166
+ if (cached) cached.revalidating = false
167
+ })
168
+ return
169
+ }
170
+ _serveFromCache(cached, res, 'STALE')
171
+ return
172
+ }
173
+
174
+ // Cache miss — render, cache, then serve.
175
+ const entry = await _renderForCache(urlPath, handler, revalidate)
176
+ if (entry) {
177
+ cache.set(urlPath, entry)
178
+ _serveFromCache(entry, res, 'HIT')
179
+ } else {
180
+ await handler(req, res)
181
+ }
182
+ }
183
+ }
package/src/types/page.ts CHANGED
@@ -32,6 +32,20 @@ export interface PageMeta {
32
32
  * @example export const meta = { transition: 'fade' }
33
33
  */
34
34
  transition?: string | boolean
35
+ /**
36
+ * Per-route rendering strategy. Overrides the global `mode` for this route.
37
+ *
38
+ * - `'server'` — always render server-side, never pre-render. In SSG mode
39
+ * the route is skipped during the static build.
40
+ * - `'static'` — always serve pre-rendered static HTML. In the SSR preview
41
+ * server the pre-rendered file is served from disk; falls back to SSR if
42
+ * not found.
43
+ * - `'spa'` — client-only. In SSR mode the server returns the SPA shell
44
+ * (index.html) without rendering. In SSG mode the route is skipped.
45
+ *
46
+ * @example export const meta = { render: 'server' }
47
+ */
48
+ render?: 'static' | 'server' | 'spa'
35
49
  }
36
50
 
37
51
  export interface PageLoaderContext<P extends Record<string, string> = Record<string, string>> {