@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
@@ -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>> {