@jasonshimmy/vite-plugin-cer-app 0.4.6 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/copilot-instructions.md +4 -2
- package/CHANGELOG.md +8 -0
- package/IMPLEMENTATION_PLAN.md +52 -10
- package/commits.txt +1 -1
- package/dist/cli/commands/preview-isr.d.ts +51 -0
- package/dist/cli/commands/preview-isr.d.ts.map +1 -0
- package/dist/cli/commands/preview-isr.js +104 -0
- package/dist/cli/commands/preview-isr.js.map +1 -0
- package/dist/cli/commands/preview.d.ts.map +1 -1
- package/dist/cli/commands/preview.js +65 -1
- package/dist/cli/commands/preview.js.map +1 -1
- package/dist/cli/create/templates/spa/package.json.tpl +2 -2
- package/dist/cli/create/templates/ssg/package.json.tpl +2 -2
- package/dist/cli/create/templates/ssr/package.json.tpl +2 -2
- package/dist/plugin/build-ssg.d.ts.map +1 -1
- package/dist/plugin/build-ssg.js +4 -2
- package/dist/plugin/build-ssg.js.map +1 -1
- package/dist/plugin/dev-server.d.ts +3 -0
- package/dist/plugin/dev-server.d.ts.map +1 -1
- package/dist/plugin/dev-server.js.map +1 -1
- package/dist/plugin/dts-generator.d.ts.map +1 -1
- package/dist/plugin/dts-generator.js +8 -1
- package/dist/plugin/dts-generator.js.map +1 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +9 -1
- package/dist/plugin/index.js.map +1 -1
- package/dist/plugin/transforms/auto-import.js +2 -2
- package/dist/plugin/transforms/auto-import.js.map +1 -1
- package/dist/plugin/virtual/routes.d.ts.map +1 -1
- package/dist/plugin/virtual/routes.js +95 -8
- package/dist/plugin/virtual/routes.js.map +1 -1
- package/dist/runtime/app-template.d.ts +1 -1
- package/dist/runtime/app-template.d.ts.map +1 -1
- package/dist/runtime/app-template.js +16 -4
- package/dist/runtime/app-template.js.map +1 -1
- package/dist/runtime/composables/index.d.ts +1 -0
- package/dist/runtime/composables/index.d.ts.map +1 -1
- package/dist/runtime/composables/index.js +1 -0
- package/dist/runtime/composables/index.js.map +1 -1
- package/dist/runtime/composables/use-runtime-config.d.ts +32 -0
- package/dist/runtime/composables/use-runtime-config.d.ts.map +1 -0
- package/dist/runtime/composables/use-runtime-config.js +41 -0
- package/dist/runtime/composables/use-runtime-config.js.map +1 -0
- package/dist/runtime/entry-server-template.d.ts +2 -2
- package/dist/runtime/entry-server-template.d.ts.map +1 -1
- package/dist/runtime/entry-server-template.js +50 -21
- package/dist/runtime/entry-server-template.js.map +1 -1
- package/dist/types/config.d.ts +24 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js.map +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/page.d.ts +17 -0
- package/dist/types/page.d.ts.map +1 -1
- package/docs/composables.md +36 -0
- package/docs/configuration.md +52 -0
- package/docs/layouts.md +82 -0
- package/docs/rendering-modes.md +55 -14
- package/docs/routing.md +66 -0
- package/e2e/cypress/e2e/isr-nested-runtime.cy.ts +100 -0
- package/e2e/kitchen-sink/app/layouts/admin.ts +13 -0
- package/e2e/kitchen-sink/app/pages/admin/_layout.ts +1 -0
- package/e2e/kitchen-sink/app/pages/admin/dashboard.ts +11 -0
- package/e2e/kitchen-sink/app/pages/blog/[slug].ts +1 -0
- package/e2e/kitchen-sink/app/pages/isr-test.ts +17 -0
- package/e2e/kitchen-sink/cer.config.ts +5 -0
- package/package.json +3 -3
- package/src/__tests__/cli/preview-isr.test.ts +246 -0
- package/src/__tests__/plugin/build-ssg-render.test.ts +46 -0
- package/src/__tests__/plugin/dts-generator.test.ts +20 -0
- package/src/__tests__/plugin/entry-server-template.test.ts +23 -5
- package/src/__tests__/plugin/resolve-config.test.ts +15 -0
- package/src/__tests__/plugin/transforms/auto-import.test.ts +16 -0
- package/src/__tests__/plugin/virtual/routes.test.ts +195 -0
- package/src/__tests__/runtime/use-runtime-config.test.ts +59 -0
- package/src/cli/commands/preview-isr.ts +139 -0
- package/src/cli/commands/preview.ts +71 -2
- package/src/cli/create/templates/spa/package.json.tpl +2 -2
- package/src/cli/create/templates/ssg/package.json.tpl +2 -2
- package/src/cli/create/templates/ssr/package.json.tpl +2 -2
- package/src/plugin/build-ssg.ts +4 -2
- package/src/plugin/dev-server.ts +1 -0
- package/src/plugin/dts-generator.ts +8 -1
- package/src/plugin/index.ts +11 -1
- package/src/plugin/transforms/auto-import.ts +2 -2
- package/src/plugin/virtual/routes.ts +106 -9
- package/src/runtime/app-template.ts +16 -4
- package/src/runtime/composables/index.ts +1 -0
- package/src/runtime/composables/use-runtime-config.ts +40 -0
- package/src/runtime/entry-server-template.ts +50 -21
- package/src/types/config.ts +26 -0
- package/src/types/index.ts +1 -1
- package/src/types/page.ts +17 -0
|
@@ -140,4 +140,19 @@ describe('resolveConfig', () => {
|
|
|
140
140
|
const cfg = resolveConfig({}, ROOT)
|
|
141
141
|
expect(cfg.root).toBe(ROOT)
|
|
142
142
|
})
|
|
143
|
+
|
|
144
|
+
it('defaults runtimeConfig.public to an empty object', () => {
|
|
145
|
+
const cfg = resolveConfig({}, ROOT)
|
|
146
|
+
expect(cfg.runtimeConfig.public).toEqual({})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('passes runtimeConfig.public values through', () => {
|
|
150
|
+
const cfg = resolveConfig({ runtimeConfig: { public: { apiBase: '/api', version: '1' } } }, ROOT)
|
|
151
|
+
expect(cfg.runtimeConfig.public).toEqual({ apiBase: '/api', version: '1' })
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('preserves runtimeConfig.public when other runtimeConfig fields are omitted', () => {
|
|
155
|
+
const cfg = resolveConfig({ runtimeConfig: { public: { foo: 42 } } }, ROOT)
|
|
156
|
+
expect(cfg.runtimeConfig.public.foo).toBe(42)
|
|
157
|
+
})
|
|
143
158
|
})
|
|
@@ -233,6 +233,22 @@ describe('autoImportTransform — framework composable injection', () => {
|
|
|
233
233
|
const result = autoImportTransform(code, '/project/app/loading.ts', opts)!
|
|
234
234
|
expect(result).toContain('usePageData')
|
|
235
235
|
})
|
|
236
|
+
|
|
237
|
+
it('injects useRuntimeConfig import when useRuntimeConfig is used', () => {
|
|
238
|
+
const code = "component('page-dashboard', () => { const cfg = useRuntimeConfig(); return html`<div></div>` })"
|
|
239
|
+
const result = autoImportTransform(code, '/project/app/pages/dashboard.ts', opts)!
|
|
240
|
+
expect(result).toContain(`from ${FRAMEWORK_PKG}`)
|
|
241
|
+
expect(result).toContain('useRuntimeConfig')
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('injects useRuntimeConfig alongside other framework composables', () => {
|
|
245
|
+
const code = "component('page-x', () => { useHead({ title: 'x' }); const cfg = useRuntimeConfig(); return html`<div></div>` })"
|
|
246
|
+
const result = autoImportTransform(code, '/project/app/pages/x.ts', opts)!
|
|
247
|
+
expect(result).toContain('useHead')
|
|
248
|
+
expect(result).toContain('useRuntimeConfig')
|
|
249
|
+
const count = result.split(`from ${FRAMEWORK_PKG}`).length - 1
|
|
250
|
+
expect(count).toBe(1)
|
|
251
|
+
})
|
|
236
252
|
})
|
|
237
253
|
|
|
238
254
|
// ─── Composable import injection ─────────────────────────────────────────────
|
|
@@ -200,3 +200,198 @@ describe('generateRoutesCode — 404.ts convention', () => {
|
|
|
200
200
|
expect(idIdx).toBeLessThan(allIdx)
|
|
201
201
|
})
|
|
202
202
|
})
|
|
203
|
+
|
|
204
|
+
// ─── _layout.ts filtering ─────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
describe('generateRoutesCode — _layout.ts filtering', () => {
|
|
207
|
+
beforeEach(() => {
|
|
208
|
+
vi.mocked(existsSync).mockReturnValue(true)
|
|
209
|
+
vi.mocked(scanDirectory).mockResolvedValue([])
|
|
210
|
+
vi.mocked(readFile).mockResolvedValue('' as never)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('excludes _layout.ts files from the generated routes', async () => {
|
|
214
|
+
vi.mocked(scanDirectory).mockResolvedValue([
|
|
215
|
+
`${PAGES}/admin/index.ts`,
|
|
216
|
+
`${PAGES}/admin/_layout.ts`,
|
|
217
|
+
])
|
|
218
|
+
const code = await generateRoutesCode(PAGES)
|
|
219
|
+
expect(code).not.toContain('_layout')
|
|
220
|
+
expect(code).toContain('/admin')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('generates a route for non-underscore files in the same directory', async () => {
|
|
224
|
+
vi.mocked(scanDirectory).mockResolvedValue([
|
|
225
|
+
`${PAGES}/admin/users.ts`,
|
|
226
|
+
`${PAGES}/admin/_layout.ts`,
|
|
227
|
+
])
|
|
228
|
+
const code = await generateRoutesCode(PAGES)
|
|
229
|
+
expect(code).toContain('/admin/users')
|
|
230
|
+
expect(code).not.toContain('_layout')
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
// ─── meta.revalidate ─────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
describe('generateRoutesCode — meta.ssg.revalidate', () => {
|
|
237
|
+
beforeEach(() => {
|
|
238
|
+
vi.mocked(existsSync).mockReturnValue(true)
|
|
239
|
+
vi.mocked(scanDirectory).mockResolvedValue([])
|
|
240
|
+
vi.mocked(readFile).mockResolvedValue('' as never)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('emits meta.ssg.revalidate when declared in page source', async () => {
|
|
244
|
+
vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/blog.ts`])
|
|
245
|
+
vi.mocked(readFile).mockResolvedValue(
|
|
246
|
+
`export const meta = { ssg: { revalidate: 60 } }` as never,
|
|
247
|
+
)
|
|
248
|
+
const code = await generateRoutesCode(PAGES)
|
|
249
|
+
expect(code).toContain('ssg: { revalidate: 60 }')
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('emits larger revalidate values correctly', async () => {
|
|
253
|
+
vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/products.ts`])
|
|
254
|
+
vi.mocked(readFile).mockResolvedValue(
|
|
255
|
+
`export const meta = { ssg: { revalidate: 3600 } }` as never,
|
|
256
|
+
)
|
|
257
|
+
const code = await generateRoutesCode(PAGES)
|
|
258
|
+
expect(code).toContain('ssg: { revalidate: 3600 }')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('omits ssg meta when no revalidate is declared', async () => {
|
|
262
|
+
vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/about.ts`])
|
|
263
|
+
vi.mocked(readFile).mockResolvedValue(
|
|
264
|
+
`component('page-about', () => html\`<h1>About</h1>\`)` as never,
|
|
265
|
+
)
|
|
266
|
+
const code = await generateRoutesCode(PAGES)
|
|
267
|
+
expect(code).not.toContain('revalidate')
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('can combine revalidate with layout in meta', async () => {
|
|
271
|
+
vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/blog.ts`])
|
|
272
|
+
vi.mocked(readFile).mockResolvedValue(
|
|
273
|
+
`export const meta = { layout: 'minimal', ssg: { revalidate: 120 } }` as never,
|
|
274
|
+
)
|
|
275
|
+
const code = await generateRoutesCode(PAGES)
|
|
276
|
+
expect(code).toContain('layout: "minimal"')
|
|
277
|
+
expect(code).toContain('ssg: { revalidate: 120 }')
|
|
278
|
+
})
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
// ─── meta.transition ─────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
describe('generateRoutesCode — meta.transition', () => {
|
|
284
|
+
beforeEach(() => {
|
|
285
|
+
vi.mocked(existsSync).mockReturnValue(true)
|
|
286
|
+
vi.mocked(scanDirectory).mockResolvedValue([])
|
|
287
|
+
vi.mocked(readFile).mockResolvedValue('' as never)
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('emits meta.transition string when declared', async () => {
|
|
291
|
+
vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/about.ts`])
|
|
292
|
+
vi.mocked(readFile).mockResolvedValue(
|
|
293
|
+
`export const meta = { transition: 'fade' }` as never,
|
|
294
|
+
)
|
|
295
|
+
const code = await generateRoutesCode(PAGES)
|
|
296
|
+
expect(code).toContain('transition: "fade"')
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('emits meta.transition true (boolean) when declared', async () => {
|
|
300
|
+
vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/about.ts`])
|
|
301
|
+
vi.mocked(readFile).mockResolvedValue(
|
|
302
|
+
`export const meta = { transition: true }` as never,
|
|
303
|
+
)
|
|
304
|
+
const code = await generateRoutesCode(PAGES)
|
|
305
|
+
expect(code).toContain('transition: true')
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('omits transition meta when not declared', async () => {
|
|
309
|
+
vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/about.ts`])
|
|
310
|
+
vi.mocked(readFile).mockResolvedValue(
|
|
311
|
+
`component('page-about', () => html\`<h1>About</h1>\`)` as never,
|
|
312
|
+
)
|
|
313
|
+
const code = await generateRoutesCode(PAGES)
|
|
314
|
+
expect(code).not.toContain('transition')
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('emits meta.transition false (boolean) when declared', async () => {
|
|
318
|
+
vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/about.ts`])
|
|
319
|
+
vi.mocked(readFile).mockResolvedValue(
|
|
320
|
+
`export const meta = { transition: false }` as never,
|
|
321
|
+
)
|
|
322
|
+
const code = await generateRoutesCode(PAGES)
|
|
323
|
+
expect(code).toContain('transition: false')
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it('can combine transition with middleware', async () => {
|
|
327
|
+
vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/dashboard.ts`])
|
|
328
|
+
vi.mocked(readFile).mockResolvedValue(
|
|
329
|
+
`export const meta = { transition: 'slide', middleware: ['auth'] }` as never,
|
|
330
|
+
)
|
|
331
|
+
const code = await generateRoutesCode(PAGES)
|
|
332
|
+
expect(code).toContain('transition: "slide"')
|
|
333
|
+
expect(code).toContain('beforeEnter')
|
|
334
|
+
expect(code).toContain('"auth"')
|
|
335
|
+
})
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
// ─── nested layouts (layoutChain) ────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
describe('generateRoutesCode — layoutChain (nested layouts)', () => {
|
|
341
|
+
beforeEach(() => {
|
|
342
|
+
vi.mocked(existsSync).mockReturnValue(true)
|
|
343
|
+
vi.mocked(scanDirectory).mockResolvedValue([])
|
|
344
|
+
vi.mocked(readFile).mockResolvedValue('' as never)
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('emits meta.layoutChain when a _layout.ts exists in ancestor directory', async () => {
|
|
348
|
+
vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/admin/users.ts`])
|
|
349
|
+
vi.mocked(readFile).mockImplementation(async (path: unknown) => {
|
|
350
|
+
if (String(path).endsWith('_layout.ts')) return 'export default \'minimal\''
|
|
351
|
+
return ''
|
|
352
|
+
})
|
|
353
|
+
const code = await generateRoutesCode(PAGES)
|
|
354
|
+
expect(code).toContain('layoutChain:')
|
|
355
|
+
expect(code).toContain('"default"')
|
|
356
|
+
expect(code).toContain('"minimal"')
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('uses outerLayout from page meta when layoutChain is emitted', async () => {
|
|
360
|
+
vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/admin/users.ts`])
|
|
361
|
+
vi.mocked(readFile).mockImplementation(async (path: unknown) => {
|
|
362
|
+
const p = String(path)
|
|
363
|
+
if (p.endsWith('_layout.ts')) return 'export default \'sidebar\''
|
|
364
|
+
// page source has its own layout
|
|
365
|
+
return `export const meta = { layout: 'clean' }`
|
|
366
|
+
})
|
|
367
|
+
const code = await generateRoutesCode(PAGES)
|
|
368
|
+
expect(code).toContain('layoutChain:')
|
|
369
|
+
expect(code).toContain('"clean"')
|
|
370
|
+
expect(code).toContain('"sidebar"')
|
|
371
|
+
// Should not emit a plain meta.layout — the chain replaces it
|
|
372
|
+
expect(code).not.toContain('layout: "clean"')
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('omits layoutChain for top-level pages with no _layout.ts', async () => {
|
|
376
|
+
vi.mocked(existsSync).mockImplementation((p: unknown) => {
|
|
377
|
+
// Return false for any _layout.ts path so no chain is resolved
|
|
378
|
+
if (String(p).endsWith('_layout.ts')) return false
|
|
379
|
+
return true
|
|
380
|
+
})
|
|
381
|
+
vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/about.ts`])
|
|
382
|
+
vi.mocked(readFile).mockResolvedValue(
|
|
383
|
+
`export const meta = { layout: 'default' }` as never,
|
|
384
|
+
)
|
|
385
|
+
const code = await generateRoutesCode(PAGES)
|
|
386
|
+
expect(code).not.toContain('layoutChain')
|
|
387
|
+
expect(code).toContain('layout: "default"')
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
it('omits layoutChain for top-level pages even when existsSync returns true', async () => {
|
|
391
|
+
// top-level page has no subdirectory segments, so resolveLayoutChain returns null immediately
|
|
392
|
+
vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/index.ts`])
|
|
393
|
+
vi.mocked(readFile).mockResolvedValue('' as never)
|
|
394
|
+
const code = await generateRoutesCode(PAGES)
|
|
395
|
+
expect(code).not.toContain('layoutChain')
|
|
396
|
+
})
|
|
397
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import { useRuntimeConfig, initRuntimeConfig } from '../../runtime/composables/use-runtime-config.js'
|
|
3
|
+
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
// Reset global state between tests
|
|
6
|
+
delete (globalThis as Record<string, unknown>).__cerRuntimeConfig
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
describe('initRuntimeConfig', () => {
|
|
10
|
+
it('stores the config on globalThis', () => {
|
|
11
|
+
initRuntimeConfig({ public: { apiBase: '/api' } })
|
|
12
|
+
expect((globalThis as Record<string, unknown>).__cerRuntimeConfig).toEqual({ public: { apiBase: '/api' } })
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('overwrites a previous config', () => {
|
|
16
|
+
initRuntimeConfig({ public: { apiBase: '/v1' } })
|
|
17
|
+
initRuntimeConfig({ public: { apiBase: '/v2' } })
|
|
18
|
+
const stored = (globalThis as Record<string, unknown>).__cerRuntimeConfig as { public: Record<string, unknown> }
|
|
19
|
+
expect(stored.public.apiBase).toBe('/v2')
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('useRuntimeConfig', () => {
|
|
24
|
+
it('returns empty public config when not initialized', () => {
|
|
25
|
+
const config = useRuntimeConfig()
|
|
26
|
+
expect(config.public).toEqual({})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('returns the config set by initRuntimeConfig', () => {
|
|
30
|
+
initRuntimeConfig({ public: { apiBase: '/api' } })
|
|
31
|
+
const config = useRuntimeConfig()
|
|
32
|
+
expect(config.public.apiBase).toBe('/api')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('returns the full public config object', () => {
|
|
36
|
+
initRuntimeConfig({ public: { apiBase: '/api', version: '1.0', debug: false } })
|
|
37
|
+
const config = useRuntimeConfig()
|
|
38
|
+
expect(config.public).toEqual({ apiBase: '/api', version: '1.0', debug: false })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('returns a reference to the stored config (not a copy)', () => {
|
|
42
|
+
const stored = { public: { apiBase: '/api' } }
|
|
43
|
+
initRuntimeConfig(stored)
|
|
44
|
+
const config = useRuntimeConfig()
|
|
45
|
+
expect(config).toBe(stored)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('reflects updates when initRuntimeConfig is called again', () => {
|
|
49
|
+
initRuntimeConfig({ public: { key: 'first' } })
|
|
50
|
+
initRuntimeConfig({ public: { key: 'second' } })
|
|
51
|
+
expect(useRuntimeConfig().public.key).toBe('second')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('handles empty public config', () => {
|
|
55
|
+
initRuntimeConfig({ public: {} })
|
|
56
|
+
const config = useRuntimeConfig()
|
|
57
|
+
expect(config.public).toEqual({})
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ISR (Incremental Static Regeneration) helpers for the preview server.
|
|
3
|
+
*
|
|
4
|
+
* Extracted into their own module so they can be unit-tested independently
|
|
5
|
+
* from the HTTP server wiring in preview.ts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Readable } from 'node:stream'
|
|
9
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
10
|
+
|
|
11
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export interface IsrCacheEntry {
|
|
14
|
+
html: string
|
|
15
|
+
headers: Record<string, string>
|
|
16
|
+
statusCode: number
|
|
17
|
+
builtAt: number
|
|
18
|
+
revalidate: number
|
|
19
|
+
/** True while a background re-render is in flight (stale-while-revalidate). */
|
|
20
|
+
revalidating: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type IsrCacheStatus = 'HIT' | 'STALE' | 'MISS'
|
|
24
|
+
|
|
25
|
+
export type SsrHandlerFn = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
|
|
26
|
+
|
|
27
|
+
// ─── Route pattern matching ───────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Tests whether a route path pattern matches a URL path string.
|
|
31
|
+
* Normalises trailing slashes and supports `:param` and `:param*` (catch-all)
|
|
32
|
+
* segments using a simple regex conversion — no external dependencies needed.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* matchRoutePattern('/blog/:slug', '/blog/hello') // true
|
|
36
|
+
* matchRoutePattern('/:all*', '/any/deep/path') // true
|
|
37
|
+
* matchRoutePattern('/about', '/contact') // false
|
|
38
|
+
*/
|
|
39
|
+
export function matchRoutePattern(pattern: string, urlPath: string): boolean {
|
|
40
|
+
const norm = (s: string): string => s.replace(/\/+$/, '') || '/'
|
|
41
|
+
if (norm(pattern) === norm(urlPath)) return true
|
|
42
|
+
const regexStr =
|
|
43
|
+
'^' +
|
|
44
|
+
norm(pattern)
|
|
45
|
+
.replace(/[.+?^${}()|[\]\\]/g, '\\$&') // escape regex special chars in static segments
|
|
46
|
+
.replace(/:[^/]+\*/g, '.*')
|
|
47
|
+
.replace(/:[^/]+/g, '[^/]+') +
|
|
48
|
+
'$'
|
|
49
|
+
return new RegExp(regexStr).test(norm(urlPath))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Looks up the `meta.ssg.revalidate` TTL (in seconds) for the route that best
|
|
54
|
+
* matches `urlPath`. Returns `null` when no route matches or none defines
|
|
55
|
+
* `revalidate`.
|
|
56
|
+
*/
|
|
57
|
+
export function findRevalidate(
|
|
58
|
+
routes: Array<{ path: string; meta?: Record<string, unknown> }>,
|
|
59
|
+
urlPath: string,
|
|
60
|
+
): number | null {
|
|
61
|
+
for (const route of routes) {
|
|
62
|
+
if (matchRoutePattern(route.path, urlPath)) {
|
|
63
|
+
const ssg = route.meta?.ssg as Record<string, unknown> | undefined
|
|
64
|
+
if (typeof ssg?.revalidate === 'number') return ssg.revalidate
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ─── Response capture ─────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Renders a URL path through `handler` using a synthetic IncomingMessage and a
|
|
74
|
+
* fake ServerResponse that captures the output in memory.
|
|
75
|
+
*
|
|
76
|
+
* Returns an `IsrCacheEntry` on success, or `null` if the handler throws.
|
|
77
|
+
*/
|
|
78
|
+
export async function renderForIsr(
|
|
79
|
+
urlPath: string,
|
|
80
|
+
handler: SsrHandlerFn,
|
|
81
|
+
revalidate: number,
|
|
82
|
+
): Promise<IsrCacheEntry | null> {
|
|
83
|
+
const req = Object.assign(new Readable({ read() {} }), {
|
|
84
|
+
url: urlPath,
|
|
85
|
+
method: 'GET',
|
|
86
|
+
headers: {},
|
|
87
|
+
socket: null,
|
|
88
|
+
}) as unknown as IncomingMessage
|
|
89
|
+
|
|
90
|
+
return new Promise<IsrCacheEntry | null>((resolve) => {
|
|
91
|
+
const chunks: Buffer[] = []
|
|
92
|
+
const headers: Record<string, string> = {}
|
|
93
|
+
let capturedStatus = 200
|
|
94
|
+
|
|
95
|
+
const fakeRes = {
|
|
96
|
+
get statusCode() { return capturedStatus },
|
|
97
|
+
set statusCode(v: number) { capturedStatus = v },
|
|
98
|
+
headersSent: false,
|
|
99
|
+
setHeader(name: string, value: string | string[]) {
|
|
100
|
+
headers[name.toLowerCase()] = Array.isArray(value) ? value.join(', ') : String(value)
|
|
101
|
+
return this
|
|
102
|
+
},
|
|
103
|
+
getHeader(name: string) { return headers[name.toLowerCase()] },
|
|
104
|
+
write(chunk: string | Buffer) {
|
|
105
|
+
if (chunk != null) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)))
|
|
106
|
+
return true
|
|
107
|
+
},
|
|
108
|
+
end(chunk?: string | Buffer) {
|
|
109
|
+
if (chunk != null) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)))
|
|
110
|
+
const html = Buffer.concat(chunks).toString('utf-8')
|
|
111
|
+
resolve({ html, headers, statusCode: capturedStatus, builtAt: Date.now(), revalidate, revalidating: false })
|
|
112
|
+
return this
|
|
113
|
+
},
|
|
114
|
+
} as unknown as ServerResponse
|
|
115
|
+
|
|
116
|
+
// Use Promise.resolve().then() so synchronous throws in the handler are
|
|
117
|
+
// also caught by the .catch() handler.
|
|
118
|
+
Promise.resolve().then(() => handler(req, fakeRes)).catch(() => resolve(null))
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Cache serving ────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Writes a cached ISR entry to the real HTTP response, forwarding all captured
|
|
126
|
+
* headers and setting the `X-Cache` diagnostic header.
|
|
127
|
+
*/
|
|
128
|
+
export function serveFromIsrCache(
|
|
129
|
+
entry: IsrCacheEntry,
|
|
130
|
+
res: ServerResponse,
|
|
131
|
+
cacheStatus: IsrCacheStatus,
|
|
132
|
+
): void {
|
|
133
|
+
for (const [name, value] of Object.entries(entry.headers)) {
|
|
134
|
+
res.setHeader(name, value)
|
|
135
|
+
}
|
|
136
|
+
res.setHeader('X-Cache', cacheStatus)
|
|
137
|
+
res.statusCode = entry.statusCode
|
|
138
|
+
res.end(entry.html)
|
|
139
|
+
}
|
|
@@ -3,6 +3,15 @@ import { createServer as createHttpServer, type IncomingMessage, type ServerResp
|
|
|
3
3
|
import { createReadStream, existsSync, statSync } from 'node:fs'
|
|
4
4
|
import { resolve, join, extname } from 'pathe'
|
|
5
5
|
import { pathToFileURL } from 'node:url'
|
|
6
|
+
import {
|
|
7
|
+
type IsrCacheEntry,
|
|
8
|
+
type SsrHandlerFn,
|
|
9
|
+
findRevalidate,
|
|
10
|
+
renderForIsr,
|
|
11
|
+
serveFromIsrCache,
|
|
12
|
+
} from './preview-isr.js'
|
|
13
|
+
|
|
14
|
+
// ─── API route matching ───────────────────────────────────────────────────────
|
|
6
15
|
|
|
7
16
|
/**
|
|
8
17
|
* Matches an API route pattern (e.g. '/api/items/:id') against a URL path.
|
|
@@ -107,7 +116,6 @@ export function previewCommand(): Command {
|
|
|
107
116
|
console.log('[cer-app] Starting SSR preview server...')
|
|
108
117
|
|
|
109
118
|
// Load the server bundle
|
|
110
|
-
type SsrHandlerFn = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
|
|
111
119
|
let serverMod: {
|
|
112
120
|
handler?: SsrHandlerFn
|
|
113
121
|
default?: SsrHandlerFn
|
|
@@ -130,6 +138,15 @@ export function previewCommand(): Command {
|
|
|
130
138
|
const apiRoutes: Array<{ path: string; handlers: Record<string, unknown> }> =
|
|
131
139
|
Array.isArray(serverMod.apiRoutes) ? serverMod.apiRoutes : []
|
|
132
140
|
|
|
141
|
+
// Page routes exported by the server bundle (used for ISR revalidate lookup).
|
|
142
|
+
const pageRoutes: Array<{ path: string; meta?: Record<string, unknown> }> =
|
|
143
|
+
Array.isArray((serverMod as { routes?: unknown }).routes)
|
|
144
|
+
? (serverMod as { routes: Array<{ path: string; meta?: Record<string, unknown> }> }).routes
|
|
145
|
+
: []
|
|
146
|
+
|
|
147
|
+
// ISR cache: path → cached render entry.
|
|
148
|
+
const isrCache = new Map<string, IsrCacheEntry>()
|
|
149
|
+
|
|
133
150
|
const server = createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
134
151
|
const url = req.url ?? '/'
|
|
135
152
|
const urlPath = url.split('?')[0]
|
|
@@ -183,7 +200,59 @@ export function previewCommand(): Command {
|
|
|
183
200
|
if (served) return
|
|
184
201
|
}
|
|
185
202
|
|
|
186
|
-
//
|
|
203
|
+
// ISR: check whether this route has a revalidate TTL.
|
|
204
|
+
const revalidate = findRevalidate(pageRoutes, urlPath)
|
|
205
|
+
if (revalidate !== null) {
|
|
206
|
+
const cached = isrCache.get(urlPath)
|
|
207
|
+
const now = Date.now()
|
|
208
|
+
|
|
209
|
+
if (cached) {
|
|
210
|
+
const ageSeconds = (now - cached.builtAt) / 1000
|
|
211
|
+
if (ageSeconds < cached.revalidate) {
|
|
212
|
+
// Fresh — serve from cache.
|
|
213
|
+
serveFromIsrCache(cached, res, 'HIT')
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
// Stale — serve stale immediately, revalidate in background.
|
|
217
|
+
if (!cached.revalidating) {
|
|
218
|
+
cached.revalidating = true
|
|
219
|
+
serveFromIsrCache(cached, res, 'STALE')
|
|
220
|
+
const revalidateTimeout = setTimeout(() => {
|
|
221
|
+
if (cached) cached.revalidating = false
|
|
222
|
+
}, 30_000)
|
|
223
|
+
renderForIsr(urlPath, handler, revalidate).then((entry) => {
|
|
224
|
+
clearTimeout(revalidateTimeout)
|
|
225
|
+
if (entry) isrCache.set(urlPath, entry)
|
|
226
|
+
else if (cached) cached.revalidating = false
|
|
227
|
+
}).catch(() => {
|
|
228
|
+
clearTimeout(revalidateTimeout)
|
|
229
|
+
if (cached) cached.revalidating = false
|
|
230
|
+
})
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
// Already revalidating — serve stale without spawning another render.
|
|
234
|
+
serveFromIsrCache(cached, res, 'STALE')
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Cache miss — render, cache, then serve.
|
|
239
|
+
try {
|
|
240
|
+
const entry = await renderForIsr(urlPath, handler, revalidate)
|
|
241
|
+
if (entry) {
|
|
242
|
+
isrCache.set(urlPath, entry)
|
|
243
|
+
serveFromIsrCache(entry, res, 'HIT')
|
|
244
|
+
} else {
|
|
245
|
+
await handler(req, res)
|
|
246
|
+
}
|
|
247
|
+
} catch (err) {
|
|
248
|
+
console.error('[cer-app] ISR render error:', err)
|
|
249
|
+
res.statusCode = 500
|
|
250
|
+
res.end('Internal Server Error')
|
|
251
|
+
}
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Non-ISR: fall through to SSR handler directly.
|
|
187
256
|
try {
|
|
188
257
|
await handler(req, res)
|
|
189
258
|
} catch (err) {
|
|
@@ -8,11 +8,11 @@
|
|
|
8
8
|
"preview": "cer-app preview"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@jasonshimmy/custom-elements-runtime": "^3.
|
|
11
|
+
"@jasonshimmy/custom-elements-runtime": "^3.4.0"
|
|
12
12
|
},
|
|
13
13
|
"devDependencies": {
|
|
14
14
|
"vite": "^8.0.1",
|
|
15
|
-
"@jasonshimmy/vite-plugin-cer-app": "^0.
|
|
15
|
+
"@jasonshimmy/vite-plugin-cer-app": "^0.6.0",
|
|
16
16
|
"typescript": "^5.9.3"
|
|
17
17
|
}
|
|
18
18
|
}
|
|
@@ -9,11 +9,11 @@
|
|
|
9
9
|
"preview": "cer-app preview"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@jasonshimmy/custom-elements-runtime": "^3.
|
|
12
|
+
"@jasonshimmy/custom-elements-runtime": "^3.4.0"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
15
|
"vite": "^8.0.1",
|
|
16
|
-
"@jasonshimmy/vite-plugin-cer-app": "^0.
|
|
16
|
+
"@jasonshimmy/vite-plugin-cer-app": "^0.6.0",
|
|
17
17
|
"typescript": "^5.9.3"
|
|
18
18
|
}
|
|
19
19
|
}
|
|
@@ -8,11 +8,11 @@
|
|
|
8
8
|
"preview": "cer-app preview --ssr"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@jasonshimmy/custom-elements-runtime": "^3.
|
|
11
|
+
"@jasonshimmy/custom-elements-runtime": "^3.4.0"
|
|
12
12
|
},
|
|
13
13
|
"devDependencies": {
|
|
14
14
|
"vite": "^8.0.1",
|
|
15
|
-
"@jasonshimmy/vite-plugin-cer-app": "^0.
|
|
15
|
+
"@jasonshimmy/vite-plugin-cer-app": "^0.6.0",
|
|
16
16
|
"typescript": "^5.9.3"
|
|
17
17
|
}
|
|
18
18
|
}
|
package/src/plugin/build-ssg.ts
CHANGED
|
@@ -131,12 +131,14 @@ async function renderPath(
|
|
|
131
131
|
|
|
132
132
|
// Mock req/res for the Express-style handler.
|
|
133
133
|
// The handler internally merges with dist/client/index.html, so we just
|
|
134
|
-
// capture whatever it ends with.
|
|
134
|
+
// capture whatever it writes/ends with.
|
|
135
135
|
const mockReq = { url: path, headers: {} }
|
|
136
136
|
return new Promise<string>((resolve, reject) => {
|
|
137
|
+
const chunks: string[] = []
|
|
137
138
|
const mockRes = {
|
|
138
139
|
setHeader: () => {},
|
|
139
|
-
|
|
140
|
+
write: (chunk: string) => { chunks.push(chunk) },
|
|
141
|
+
end: (body?: string) => resolve(chunks.join('') + (body ?? '')),
|
|
140
142
|
}
|
|
141
143
|
;(handlerFn as (req: unknown, res: unknown) => Promise<void>)(mockReq, mockRes).catch(reject)
|
|
142
144
|
})
|
package/src/plugin/dev-server.ts
CHANGED
|
@@ -19,6 +19,7 @@ export interface ResolvedCerConfig {
|
|
|
19
19
|
router: { base?: string; scrollToFragment?: boolean | object }
|
|
20
20
|
jitCss: { content: string[]; extendedColors: boolean }
|
|
21
21
|
autoImports: { components: boolean; composables: boolean; directives: boolean; runtime: boolean }
|
|
22
|
+
runtimeConfig: { public: Record<string, unknown> }
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
/**
|
|
@@ -76,7 +76,7 @@ const RUNTIME_GLOBALS = [
|
|
|
76
76
|
|
|
77
77
|
const DIRECTIVE_GLOBALS = ['when', 'each', 'match', 'anchorBlock']
|
|
78
78
|
|
|
79
|
-
const FRAMEWORK_GLOBALS = ['useHead', 'usePageData', 'useInject']
|
|
79
|
+
const FRAMEWORK_GLOBALS = ['useHead', 'usePageData', 'useInject', 'useRuntimeConfig']
|
|
80
80
|
|
|
81
81
|
/**
|
|
82
82
|
* Scans a composables directory and returns a map of export name → file path.
|
|
@@ -213,6 +213,13 @@ export async function generateVirtualModuleDts(
|
|
|
213
213
|
lines.push(` export const errorTag: string | null`)
|
|
214
214
|
lines.push(`}`)
|
|
215
215
|
lines.push('')
|
|
216
|
+
lines.push(`declare module 'virtual:cer-app-config' {`)
|
|
217
|
+
lines.push(` import type { RuntimePublicConfig } from '@jasonshimmy/vite-plugin-cer-app/types'`)
|
|
218
|
+
lines.push(` export const appConfig: { mode: string; router: Record<string, unknown>; ssg: Record<string, unknown> }`)
|
|
219
|
+
lines.push(` export const runtimeConfig: { public: RuntimePublicConfig }`)
|
|
220
|
+
lines.push(` export default appConfig`)
|
|
221
|
+
lines.push(`}`)
|
|
222
|
+
lines.push('')
|
|
216
223
|
|
|
217
224
|
return lines.join('\n')
|
|
218
225
|
}
|
package/src/plugin/index.ts
CHANGED
|
@@ -92,6 +92,9 @@ export function resolveConfig(userConfig: CerAppConfig, root: string = process.c
|
|
|
92
92
|
directives: userConfig.autoImports?.directives ?? true,
|
|
93
93
|
runtime: userConfig.autoImports?.runtime ?? true,
|
|
94
94
|
},
|
|
95
|
+
runtimeConfig: {
|
|
96
|
+
public: userConfig.runtimeConfig?.public ?? {},
|
|
97
|
+
},
|
|
95
98
|
}
|
|
96
99
|
}
|
|
97
100
|
|
|
@@ -139,7 +142,14 @@ function generateAppConfigModule(config: ResolvedCerConfig): string {
|
|
|
139
142
|
router: config.router,
|
|
140
143
|
ssg: config.ssg,
|
|
141
144
|
}
|
|
142
|
-
|
|
145
|
+
const publicConfig = config.runtimeConfig.public
|
|
146
|
+
return (
|
|
147
|
+
`// AUTO-GENERATED by @jasonshimmy/vite-plugin-cer-app\n` +
|
|
148
|
+
`export const appConfig = ${JSON.stringify(exportedConfig, null, 2)}\n` +
|
|
149
|
+
`export default appConfig\n` +
|
|
150
|
+
`\n` +
|
|
151
|
+
`export const runtimeConfig = { public: ${JSON.stringify(publicConfig, null, 2)} }\n`
|
|
152
|
+
)
|
|
143
153
|
}
|
|
144
154
|
|
|
145
155
|
/**
|