@jasonshimmy/vite-plugin-cer-app 0.18.2 → 0.19.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/CHANGELOG.md +5 -0
- package/commits.txt +2 -1
- package/dist/cli/create/templates/spa/package.json.tpl +1 -1
- package/dist/cli/create/templates/ssg/package.json.tpl +1 -1
- package/dist/cli/create/templates/ssr/package.json.tpl +1 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +14 -0
- package/dist/plugin/index.js.map +1 -1
- package/dist/runtime/composables/use-auth.d.ts +6 -0
- package/dist/runtime/composables/use-auth.d.ts.map +1 -1
- package/dist/runtime/composables/use-auth.js.map +1 -1
- package/dist/runtime/composables/use-cookie.d.ts +2 -0
- package/dist/runtime/composables/use-cookie.d.ts.map +1 -1
- package/dist/runtime/composables/use-cookie.js.map +1 -1
- package/dist/runtime/composables/use-fetch.d.ts +1 -0
- package/dist/runtime/composables/use-fetch.d.ts.map +1 -1
- package/dist/runtime/composables/use-fetch.js.map +1 -1
- package/dist/runtime/composables/use-head.d.ts +4 -0
- package/dist/runtime/composables/use-head.d.ts.map +1 -1
- package/dist/runtime/composables/use-head.js.map +1 -1
- package/dist/runtime/composables/use-locale.d.ts +1 -0
- package/dist/runtime/composables/use-locale.d.ts.map +1 -1
- package/dist/runtime/composables/use-locale.js.map +1 -1
- package/dist/runtime/composables/use-runtime-config.d.ts +3 -0
- package/dist/runtime/composables/use-runtime-config.d.ts.map +1 -1
- package/dist/runtime/composables/use-runtime-config.js +17 -4
- package/dist/runtime/composables/use-runtime-config.js.map +1 -1
- package/dist/runtime/composables/use-seo-meta.d.ts +5 -0
- package/dist/runtime/composables/use-seo-meta.d.ts.map +1 -1
- package/dist/runtime/composables/use-seo-meta.js.map +1 -1
- package/dist/runtime/composables/use-session.d.ts +5 -0
- package/dist/runtime/composables/use-session.d.ts.map +1 -1
- package/dist/runtime/composables/use-session.js.map +1 -1
- package/dist/runtime/entry-server-template.d.ts +1 -1
- package/dist/runtime/entry-server-template.d.ts.map +1 -1
- package/dist/runtime/entry-server-template.js +21 -1
- package/dist/runtime/entry-server-template.js.map +1 -1
- package/dist/runtime/isr-handler.d.ts +2 -0
- package/dist/runtime/isr-handler.d.ts.map +1 -1
- package/dist/runtime/isr-handler.js.map +1 -1
- package/dist/types/api.d.ts +21 -0
- package/dist/types/api.d.ts.map +1 -1
- package/dist/types/config.d.ts +120 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js +19 -0
- 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/middleware.d.ts +16 -0
- package/dist/types/middleware.d.ts.map +1 -1
- package/dist/types/page.d.ts +56 -0
- package/dist/types/page.d.ts.map +1 -1
- package/dist/types/plugin.d.ts +21 -0
- package/dist/types/plugin.d.ts.map +1 -1
- package/docs/authentication.md +18 -0
- package/docs/configuration.md +126 -1
- package/e2e/cypress/e2e/observability.cy.ts +77 -0
- package/e2e/kitchen-sink/app/pages/observability-test.ts +25 -0
- package/e2e/kitchen-sink/cer.config.ts +14 -0
- package/package.json +1 -1
- package/src/__tests__/plugin/entry-server-template.test.ts +50 -0
- package/src/__tests__/runtime/use-runtime-config.test.ts +40 -1
- package/src/cli/create/templates/spa/package.json.tpl +1 -1
- package/src/cli/create/templates/ssg/package.json.tpl +1 -1
- package/src/cli/create/templates/ssr/package.json.tpl +1 -1
- package/src/plugin/index.ts +13 -0
- package/src/runtime/composables/use-auth.ts +6 -0
- package/src/runtime/composables/use-cookie.ts +2 -0
- package/src/runtime/composables/use-fetch.ts +1 -0
- package/src/runtime/composables/use-head.ts +4 -0
- package/src/runtime/composables/use-locale.ts +1 -0
- package/src/runtime/composables/use-runtime-config.ts +23 -3
- package/src/runtime/composables/use-seo-meta.ts +5 -0
- package/src/runtime/composables/use-session.ts +5 -0
- package/src/runtime/entry-server-template.ts +21 -1
- package/src/runtime/isr-handler.ts +2 -0
- package/src/types/api.ts +21 -0
- package/src/types/config.ts +126 -1
- package/src/types/index.ts +1 -1
- package/src/types/middleware.ts +16 -0
- package/src/types/page.ts +58 -2
- package/src/types/plugin.ts +21 -0
- package/docs/plan-production-hardening.md +0 -1010
|
@@ -1,1010 +0,0 @@
|
|
|
1
|
-
# Implementation Plan: Production Hardening
|
|
2
|
-
|
|
3
|
-
## Overview
|
|
4
|
-
|
|
5
|
-
This plan addresses every gap identified in the evidence-based production-readiness audit.
|
|
6
|
-
Items are ordered by severity: critical blockers first, minor gaps second, feature parity
|
|
7
|
-
gaps last. Each item includes the exact diagnosis (file + line), the fix design, all
|
|
8
|
-
implementation steps, and required tests.
|
|
9
|
-
|
|
10
|
-
---
|
|
11
|
-
|
|
12
|
-
## Priority legend
|
|
13
|
-
|
|
14
|
-
| Priority | Meaning |
|
|
15
|
-
|---|---|
|
|
16
|
-
| **P0 — Critical** | Can crash or corrupt an active request in production |
|
|
17
|
-
| **P1 — Minor** | Incorrect behavior or security gap; not immediately fatal |
|
|
18
|
-
| **P2 — Feature parity** | Present in Nuxt/Next.js; absence limits use cases |
|
|
19
|
-
|
|
20
|
-
---
|
|
21
|
-
|
|
22
|
-
## P0-1 — SSR render errors crash the response stream ✅ IMPLEMENTED
|
|
23
|
-
|
|
24
|
-
### Diagnosis
|
|
25
|
-
|
|
26
|
-
**File:** `src/runtime/entry-server-template.ts`, line 319
|
|
27
|
-
|
|
28
|
-
```ts
|
|
29
|
-
const stream = renderToStreamWithJITCSSDSD(vnode, { dsdPolyfill: false, router })
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
`renderToStreamWithJITCSSDSD` constructs a `ReadableStream` whose `start()` callback runs
|
|
33
|
-
**synchronously** inside the constructor. If any component render function throws, that
|
|
34
|
-
exception propagates out of the constructor and through the `handler` function entirely
|
|
35
|
-
unhandled — because lines 290–389 have no top-level `try/catch`. The HTTP response headers
|
|
36
|
-
may or may not have been written at that point. In Node.js HTTP servers, an uncaught
|
|
37
|
-
exception in a request handler kills the connection immediately. There is no fallback to a
|
|
38
|
-
client-side render and no 500 response.
|
|
39
|
-
|
|
40
|
-
The async streaming loop (lines 376–380) also lacks error handling:
|
|
41
|
-
|
|
42
|
-
```ts
|
|
43
|
-
while (true) {
|
|
44
|
-
const { value, done } = await reader.read()
|
|
45
|
-
if (done) break
|
|
46
|
-
res.write(value)
|
|
47
|
-
}
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
If `reader.read()` rejects mid-stream (headers already sent), the exception propagates
|
|
51
|
-
upward through the ALS chains and crashes the handler with a half-flushed response.
|
|
52
|
-
|
|
53
|
-
### Fix design
|
|
54
|
-
|
|
55
|
-
Wrap the synchronous stream construction and the async streaming loop in `try/catch`
|
|
56
|
-
blocks inside the entry-server template. On render error:
|
|
57
|
-
|
|
58
|
-
1. **Before headers sent** (`!res.headersSent`): send HTTP 500 with the error page vnode
|
|
59
|
-
rendered as a fallback, or a plain HTML error message if the error page itself fails.
|
|
60
|
-
2. **After headers sent** (mid-stream): close the stream cleanly with an inline error
|
|
61
|
-
comment and call `res.end()`. The browser will receive a complete but truncated
|
|
62
|
-
document; JS hydration will fail gracefully since the error boundary component is
|
|
63
|
-
already in the entry chunk.
|
|
64
|
-
|
|
65
|
-
Inline error rendering must not call `renderToStreamWithJITCSSDSD` again recursively
|
|
66
|
-
(infinite loop risk). Use a synchronous `renderToString` fallback if available, or emit
|
|
67
|
-
a minimal HTML error string directly.
|
|
68
|
-
|
|
69
|
-
### Implementation steps
|
|
70
|
-
|
|
71
|
-
**Project:** `vite-plugin-cer-app`
|
|
72
|
-
|
|
73
|
-
1. **`src/runtime/entry-server-template.ts`** — wrap the stream construction and read loop:
|
|
74
|
-
|
|
75
|
-
```ts
|
|
76
|
-
// Replace line 319 area with:
|
|
77
|
-
let stream: ReadableStream<string>
|
|
78
|
-
try {
|
|
79
|
-
stream = renderToStreamWithJITCSSDSD(vnode, { dsdPolyfill: false, router })
|
|
80
|
-
} catch (renderErr) {
|
|
81
|
-
const errMsg = renderErr instanceof Error ? renderErr.message : String(renderErr)
|
|
82
|
-
console.error('[cer-app] SSR render error (synchronous):', renderErr)
|
|
83
|
-
if (!res.headersSent) {
|
|
84
|
-
res.statusCode = 500
|
|
85
|
-
const errBody = errorTag
|
|
86
|
-
? `<${errorTag} error=${JSON.stringify(errMsg)}></${errorTag}>`
|
|
87
|
-
: `<div style="font-family:monospace;padding:2rem">SSR error: ${errMsg}</div>`
|
|
88
|
-
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
|
89
|
-
res.end(`<!DOCTYPE html><html><body>${errBody}</body></html>`)
|
|
90
|
-
} else {
|
|
91
|
-
res.end()
|
|
92
|
-
}
|
|
93
|
-
return
|
|
94
|
-
}
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
Wrap the streaming read loop:
|
|
98
|
-
```ts
|
|
99
|
-
try {
|
|
100
|
-
while (true) {
|
|
101
|
-
const { value, done } = await reader.read()
|
|
102
|
-
if (done) break
|
|
103
|
-
res.write(value)
|
|
104
|
-
}
|
|
105
|
-
res.end(DSD_POLYFILL_SCRIPT + fromBodyClose)
|
|
106
|
-
} catch (streamErr) {
|
|
107
|
-
console.error('[cer-app] SSR stream error (mid-stream):', streamErr)
|
|
108
|
-
if (!res.writableEnded) res.end()
|
|
109
|
-
}
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
2. **`src/__tests__/plugin/entry-server-template.test.ts`** — add assertions:
|
|
113
|
-
- Template contains the synchronous render error catch block
|
|
114
|
-
- Template contains the stream loop error catch block
|
|
115
|
-
|
|
116
|
-
3. **`e2e/kitchen-sink/app/pages/`** — add `render-error-test.ts`: a page whose render
|
|
117
|
-
function intentionally `throw new Error('render-error-test')` when
|
|
118
|
-
`?crash=1` query param is present.
|
|
119
|
-
|
|
120
|
-
4. **`e2e/cypress/e2e/`** — add `render-error.cy.ts`:
|
|
121
|
-
- `cy.request('/render-error-test?crash=1')` — assert status 500, body contains error
|
|
122
|
-
markup (no connection reset)
|
|
123
|
-
- `cy.visit('/render-error-test')` — assert page loads normally (no query param)
|
|
124
|
-
|
|
125
|
-
### Implementation notes
|
|
126
|
-
|
|
127
|
-
- Implemented as a single `try/catch (_renderErr)` wrapping the full render + stream block.
|
|
128
|
-
- `_headCollectionOpen` flag ensures `endHeadCollection()` is always called to prevent global state leaks.
|
|
129
|
-
- Before-headers path: sets `res.statusCode = 500`, sends a complete HTML error page.
|
|
130
|
-
- After-headers path: calls `res.end()` to close cleanly.
|
|
131
|
-
- Unit tests added to `src/__tests__/plugin/entry-server-template.test.ts`.
|
|
132
|
-
- **Important clarification:** The custom-elements runtime already catches *component-level* render errors internally (`ssr-context.ts` `runComponentSSRRender` try/catch). A component throwing during render produces a warning log and an empty DSD placeholder — the server returns 200. The entry-server-template try/catch protects against *infrastructure-level* failures that escape the runtime (e.g., a crash in the SSR helper machinery itself).
|
|
133
|
-
- Kitchen-sink page `e2e/kitchen-sink/app/pages/render-error-test.ts` throws unconditionally to verify graceful degradation.
|
|
134
|
-
- Cypress spec `e2e/cypress/e2e/ssr-render-error.cy.ts` verifies: HTTP 200 response (runtime catches the component error), valid HTML body, component rendered as empty placeholder, server survives for subsequent requests.
|
|
135
|
-
|
|
136
|
-
---
|
|
137
|
-
|
|
138
|
-
## P0-2 — ISR concurrent revalidation race condition ✅ IMPLEMENTED
|
|
139
|
-
|
|
140
|
-
### Diagnosis
|
|
141
|
-
|
|
142
|
-
**File:** `src/runtime/isr-handler.ts`, lines 156–171
|
|
143
|
-
|
|
144
|
-
```ts
|
|
145
|
-
if (!cached.revalidating) {
|
|
146
|
-
cached.revalidating = true
|
|
147
|
-
_serveFromCache(cached, res, 'STALE')
|
|
148
|
-
const timeout = setTimeout(() => { if (cached) cached.revalidating = false }, 30_000)
|
|
149
|
-
_renderForCache(urlPath, handler, revalidate).then((entry) => {
|
|
150
|
-
clearTimeout(timeout)
|
|
151
|
-
if (entry) cache.set(urlPath, entry)
|
|
152
|
-
else if (cached) cached.revalidating = false
|
|
153
|
-
}).catch(() => {
|
|
154
|
-
clearTimeout(timeout)
|
|
155
|
-
if (cached) cached.revalidating = false
|
|
156
|
-
})
|
|
157
|
-
return
|
|
158
|
-
}
|
|
159
|
-
_serveFromCache(cached, res, 'STALE') // line 170
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
The `revalidating` boolean is a soft lock. The 30-second `setTimeout` resets it to `false`
|
|
163
|
-
even if `_renderForCache` is still running (it has not been cancelled). If the render takes
|
|
164
|
-
over 30 seconds (slow page, large SSR), `revalidating` resets to `false`. The next request
|
|
165
|
-
sets it to `true` again and starts **a second concurrent render** for the same path. Under
|
|
166
|
-
high traffic this can produce many simultaneous background renders for the same URL,
|
|
167
|
-
consuming unbounded memory and CPU.
|
|
168
|
-
|
|
169
|
-
Additionally, `_renderForCache` is a promise that is not cancelled when a second render
|
|
170
|
-
starts. The first render may complete after the second and overwrite the cache with older
|
|
171
|
-
content (`cache.set` on line 162 is unconditional on the key).
|
|
172
|
-
|
|
173
|
-
### Fix design
|
|
174
|
-
|
|
175
|
-
Replace the boolean flag with a **per-path pending Promise** stored in a `Map`. A path
|
|
176
|
-
has at most one in-flight revalidation. New requests for the same stale path while
|
|
177
|
-
revalidation is pending are served the stale cached response immediately — no new Promise
|
|
178
|
-
is created. When the revalidation resolves or rejects, the pending entry is cleared.
|
|
179
|
-
|
|
180
|
-
Remove the `setTimeout` watchdog entirely. Instead, add an optional `revalidateTimeout`
|
|
181
|
-
option (default: `30_000` ms) that races with `_renderForCache` using `Promise.race`. If
|
|
182
|
-
the render times out, the pending entry is cleared and the stale response continues to be
|
|
183
|
-
served on the next request (which will attempt a fresh render).
|
|
184
|
-
|
|
185
|
-
The last-writer-wins problem is eliminated because there is only ever one in-flight render
|
|
186
|
-
per path.
|
|
187
|
-
|
|
188
|
-
### Implementation steps
|
|
189
|
-
|
|
190
|
-
**Project:** `vite-plugin-cer-app`
|
|
191
|
-
|
|
192
|
-
1. **`src/runtime/isr-handler.ts`** — replace the `revalidating` boolean with a pending map:
|
|
193
|
-
|
|
194
|
-
```ts
|
|
195
|
-
// Replace: cached.revalidating field entirely
|
|
196
|
-
// Add module-level:
|
|
197
|
-
const _pending = new Map<string, Promise<void>>()
|
|
198
|
-
|
|
199
|
-
// In createIsrHandler, stale branch:
|
|
200
|
-
if (!_pending.has(urlPath)) {
|
|
201
|
-
const revalidateMs = (options?.revalidateTimeout ?? 30_000)
|
|
202
|
-
const renderPromise = Promise.race([
|
|
203
|
-
_renderForCache(urlPath, handler, revalidate),
|
|
204
|
-
new Promise<null>((_, reject) =>
|
|
205
|
-
setTimeout(() => reject(new Error('ISR revalidation timeout')), revalidateMs)
|
|
206
|
-
),
|
|
207
|
-
])
|
|
208
|
-
.then((entry) => {
|
|
209
|
-
if (entry) cache.set(urlPath, entry)
|
|
210
|
-
})
|
|
211
|
-
.catch((err) => {
|
|
212
|
-
console.warn('[cer-app] ISR revalidation failed for', urlPath, err?.message ?? err)
|
|
213
|
-
})
|
|
214
|
-
.finally(() => {
|
|
215
|
-
_pending.delete(urlPath)
|
|
216
|
-
})
|
|
217
|
-
_pending.set(urlPath, renderPromise)
|
|
218
|
-
}
|
|
219
|
-
_serveFromCache(cached, res, 'STALE')
|
|
220
|
-
```
|
|
221
|
-
|
|
222
|
-
2. **`src/runtime/isr-handler.ts`** — remove the `revalidating` field from `CacheEntry`
|
|
223
|
-
interface and all references to it.
|
|
224
|
-
|
|
225
|
-
3. **`src/__tests__/runtime/isr-handler.test.ts`** (create if missing) — add tests:
|
|
226
|
-
- Two concurrent stale requests → only one `_renderForCache` call made
|
|
227
|
-
- Render timeout → pending cleared, next request triggers a new render
|
|
228
|
-
- Render success → cache updated, no stale entry served after
|
|
229
|
-
|
|
230
|
-
4. **`e2e/kitchen-sink/`** — existing `isr-nested-runtime.cy.ts` may already cover ISR;
|
|
231
|
-
verify it exercises the stale-while-revalidate path.
|
|
232
|
-
|
|
233
|
-
### Implementation notes
|
|
234
|
-
|
|
235
|
-
- Replaced `revalidating: boolean` in `IsrCacheEntry` with `Map<string, Promise<void>> _inFlight` inside `createIsrHandler`.
|
|
236
|
-
- At most one background render runs per URL path at any time (true lock, not a soft boolean).
|
|
237
|
-
- Lock is released via `.finally(() => _inFlight.delete(urlPath))` when the Promise settles — no 30s timer needed.
|
|
238
|
-
- The "30s timeout" test was replaced with a "lock released after Promise resolves" test.
|
|
239
|
-
- `revalidating` field removed from the `IsrCacheEntry` interface.
|
|
240
|
-
|
|
241
|
-
---
|
|
242
|
-
|
|
243
|
-
## P0-3 — Async SSR components can hang indefinitely ✅ IMPLEMENTED
|
|
244
|
-
|
|
245
|
-
### Diagnosis
|
|
246
|
-
|
|
247
|
-
**File:** `custom-elements/src/lib/runtime/` — `renderToStreamWithJITCSSDSD`
|
|
248
|
-
|
|
249
|
-
Async components are rendered as placeholder elements during the synchronous first pass;
|
|
250
|
-
their resolved content is streamed as swap scripts. If an async component's Promise never
|
|
251
|
-
settles (network failure, infinite loop in setup), the `reader.read()` loop in the
|
|
252
|
-
entry-server template (lines 376–380) awaits forever. The HTTP connection is left open and
|
|
253
|
-
the client spinner never stops. There is no configurable timeout.
|
|
254
|
-
|
|
255
|
-
### Fix design
|
|
256
|
-
|
|
257
|
-
Add a `ssrTimeout` option (milliseconds, default `10_000`) to `renderToStreamWithJITCSSDSD`.
|
|
258
|
-
Internally, the stream controller should track all pending async component Promises.
|
|
259
|
-
When `ssrTimeout` elapses, any remaining pending Promises are forcibly resolved with an
|
|
260
|
-
empty swap (the placeholder element stays in the DOM). The stream is then closed normally.
|
|
261
|
-
|
|
262
|
-
If `ssrTimeout: 0` is passed, the timeout is disabled (explicit opt-out for known-safe
|
|
263
|
-
environments).
|
|
264
|
-
|
|
265
|
-
### Implementation steps
|
|
266
|
-
|
|
267
|
-
**Project:** `@jasonshimmy/custom-elements-runtime`
|
|
268
|
-
|
|
269
|
-
1. **`src/lib/runtime/ssr/` (whichever file implements `renderToStreamWithJITCSSDSD`)**
|
|
270
|
-
— add `ssrTimeout` parameter and timeout logic:
|
|
271
|
-
- Track pending async Promises in a `Set`
|
|
272
|
-
- After the synchronous first-pass render, set a `setTimeout` for `ssrTimeout` ms
|
|
273
|
-
- When it fires, resolve all remaining pending Promises with empty content and enqueue
|
|
274
|
-
the stream close
|
|
275
|
-
- Clear the timeout if all Promises resolve naturally before it fires
|
|
276
|
-
|
|
277
|
-
2. **`src/lib/vite-plugin.ts`** — thread `ssrTimeout` through the `CerSSROptions` interface
|
|
278
|
-
so it's configurable in `cer.config.ts` under `jitCss.ssr.ssrTimeout`.
|
|
279
|
-
|
|
280
|
-
3. **`src/runtime/entry-server-template.ts`** — pass the configured timeout:
|
|
281
|
-
```ts
|
|
282
|
-
const stream = renderToStreamWithJITCSSDSD(vnode, {
|
|
283
|
-
dsdPolyfill: false,
|
|
284
|
-
router,
|
|
285
|
-
ssrTimeout: runtimeConfig.ssrTimeout ?? 10_000,
|
|
286
|
-
})
|
|
287
|
-
```
|
|
288
|
-
|
|
289
|
-
4. **Tests (runtime):**
|
|
290
|
-
- `test/ssr-async-timeout.spec.ts` — component with a never-settling Promise; assert
|
|
291
|
-
stream closes within `ssrTimeout + buffer` ms with placeholder content in output
|
|
292
|
-
- `test/ssr-async-timeout.spec.ts` — component that resolves before timeout; assert
|
|
293
|
-
resolved content appears and timeout is cleared
|
|
294
|
-
|
|
295
|
-
5. **`e2e/kitchen-sink/app/pages/`** — add `async-component-test.ts` with a slow
|
|
296
|
-
async component (resolves after 100 ms) and a never-settling one (guarded by a query
|
|
297
|
-
param). E2e: assert the page renders within a reasonable time and does not hang.
|
|
298
|
-
|
|
299
|
-
6. **`docs/configuration.md`** — document `ssrTimeout` option.
|
|
300
|
-
|
|
301
|
-
### Implementation notes
|
|
302
|
-
|
|
303
|
-
- Implemented as `asyncTimeout?: number` option (default `30_000` ms) on `renderToStream` and `renderToStreamWithJITCSSDSD` in `custom-elements/src/lib/ssr.ts`.
|
|
304
|
-
- Each `await entry.promise` is wrapped with `Promise.race([entry.promise, timeoutPromise])` — timed-out entries leave their placeholders in the DOM for client-side hydration.
|
|
305
|
-
- Unit tests in `custom-elements/test/render-to-stream.spec.ts` cover: basic streaming, timeout closes the stream, option threads through `renderToStreamWithJITCSSDSD`, sync render errors.
|
|
306
|
-
- Note: The plan proposed `ssrTimeout` as the option name; the implementation uses `asyncTimeout` to better describe what is being timed (the async component Promises, not the overall SSR render).
|
|
307
|
-
|
|
308
|
-
---
|
|
309
|
-
|
|
310
|
-
## P0-4 — Reactive subscriptions leak on component disconnect ✅ IMPLEMENTED
|
|
311
|
-
|
|
312
|
-
### Diagnosis
|
|
313
|
-
|
|
314
|
-
**File:** `custom-elements/src/lib/runtime/component/factory.ts`, lines 161–169
|
|
315
|
-
|
|
316
|
-
The `onDisconnected` lifecycle hook fires when the component is removed from the DOM.
|
|
317
|
-
However, it only calls the **user-provided** cleanup function (`lifecycleHooks.onDisconnected`).
|
|
318
|
-
The framework itself does not track or clean up `watch()`, `watchEffect()`, and `computed()`
|
|
319
|
-
subscriptions created during the component's render lifecycle. If the user forgets to call
|
|
320
|
-
cleanup — which is common, especially in layout components — subscriptions accumulate for
|
|
321
|
-
the life of the session. Layouts are particularly high-risk: a `layout-default` component
|
|
322
|
-
may be mounted once and never disconnected, accumulating subscriptions on every navigation.
|
|
323
|
-
|
|
324
|
-
### Fix design
|
|
325
|
-
|
|
326
|
-
The reactive system already tracks "which component is currently rendering" via
|
|
327
|
-
`reactiveSystem.setCurrentComponent(componentId, callback)`. Extend this to also **track
|
|
328
|
-
all subscriptions created** while a component is the current component. Store the stop
|
|
329
|
-
functions in a per-component registry keyed by `componentId`.
|
|
330
|
-
|
|
331
|
-
When `onDisconnected` fires on a component, the framework automatically calls all registered
|
|
332
|
-
stop functions for that `componentId` **before** calling the user's hook. This is transparent
|
|
333
|
-
to the user and backward compatible.
|
|
334
|
-
|
|
335
|
-
`watch`, `watchEffect`, and `computed` must return stop functions (they likely already do —
|
|
336
|
-
verify) and register themselves with the reactive system when a `componentId` is active.
|
|
337
|
-
|
|
338
|
-
### Implementation steps
|
|
339
|
-
|
|
340
|
-
**Project:** `@jasonshimmy/custom-elements-runtime`
|
|
341
|
-
|
|
342
|
-
1. **`src/lib/runtime/reactive/` (wherever `reactiveSystem` lives)**
|
|
343
|
-
— add a `componentSubscriptions: Map<string, Set<() => void>>` registry:
|
|
344
|
-
```ts
|
|
345
|
-
function trackSubscription(componentId: string, stop: () => void): void {
|
|
346
|
-
if (!componentSubscriptions.has(componentId)) {
|
|
347
|
-
componentSubscriptions.set(componentId, new Set())
|
|
348
|
-
}
|
|
349
|
-
componentSubscriptions.get(componentId)!.add(stop)
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
function cleanupComponent(componentId: string): void {
|
|
353
|
-
const stops = componentSubscriptions.get(componentId)
|
|
354
|
-
if (stops) {
|
|
355
|
-
for (const stop of stops) stop()
|
|
356
|
-
componentSubscriptions.delete(componentId)
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
```
|
|
360
|
-
|
|
361
|
-
2. **`src/lib/runtime/reactive/watch.ts`** (or equivalent) — when `watch()` or
|
|
362
|
-
`watchEffect()` is called while a `componentId` is active (i.e., during a render),
|
|
363
|
-
register the returned stop function:
|
|
364
|
-
```ts
|
|
365
|
-
const stop = _createWatch(...)
|
|
366
|
-
const activeId = reactiveSystem.getCurrentComponent()
|
|
367
|
-
if (activeId) trackSubscription(activeId, stop)
|
|
368
|
-
return stop
|
|
369
|
-
```
|
|
370
|
-
|
|
371
|
-
3. **`src/lib/runtime/component/factory.ts`, `onDisconnected` path (line 161)**
|
|
372
|
-
— call `cleanupComponent(componentId)` before the user's hook:
|
|
373
|
-
```ts
|
|
374
|
-
onDisconnected: (context) => {
|
|
375
|
-
cleanupComponent((context as InternalContext)._componentId)
|
|
376
|
-
if (lifecycleHooks.onDisconnected) { ... }
|
|
377
|
-
}
|
|
378
|
-
```
|
|
379
|
-
|
|
380
|
-
4. **`src/lib/runtime/reactive/computed.ts`** — same registration for computed
|
|
381
|
-
dependencies if computed tracks subscriptions internally.
|
|
382
|
-
|
|
383
|
-
5. **Tests:**
|
|
384
|
-
- `test/subscription-cleanup.spec.ts` — mount a component with `watch()`, disconnect
|
|
385
|
-
it, assert the watch callback is never called after disconnect
|
|
386
|
-
- `test/subscription-cleanup.spec.ts` — mount, reconnect, remount; assert subscriptions
|
|
387
|
-
are re-registered correctly
|
|
388
|
-
- `test/subscription-cleanup.spec.ts` — layout component (never disconnected); assert
|
|
389
|
-
subscriptions are still live after a simulated navigation
|
|
390
|
-
|
|
391
|
-
6. **`docs/components.md`** — add a note that manual cleanup in `onDisconnected` is no
|
|
392
|
-
longer required for `watch`, `watchEffect`, and `computed` created during render.
|
|
393
|
-
|
|
394
|
-
### Implementation notes
|
|
395
|
-
|
|
396
|
-
- Fixed in `custom-elements/src/lib/runtime/reactive.ts` `cleanup()` method.
|
|
397
|
-
- Before deleting the component's `componentData` entry, iterates `data.watchers` and calls `this.cleanup(wid)` recursively for each — identical to the pattern already used in `setCurrentComponent()` for re-renders.
|
|
398
|
-
- This means `watch()`, `watchEffect()`, and `computed()` created during render are automatically unsubscribed when the component disconnects, without requiring manual `useOnDisconnected(stop)` calls.
|
|
399
|
-
- Unit tests in `custom-elements/test/reactive-cleanup-cascade.spec.ts`.
|
|
400
|
-
- Note: The plan described a more complex "subscription registry" approach. The actual fix leverages the existing `data.watchers` map that `registerWatcher()` already populates — no new data structure needed.
|
|
401
|
-
|
|
402
|
-
---
|
|
403
|
-
|
|
404
|
-
## P1-1 — No 404 fallback when catch-all route is absent ✅ IMPLEMENTED
|
|
405
|
-
|
|
406
|
-
### Diagnosis
|
|
407
|
-
|
|
408
|
-
**File:** `src/plugin/virtual/routes.ts`, lines 197–202
|
|
409
|
-
|
|
410
|
-
The framework converts `app/pages/404.ts` to a `/:all*` catch-all route. If neither
|
|
411
|
-
`404.ts` nor a user-defined `[...all].ts` exists, no catch-all is generated. The router's
|
|
412
|
-
`matchRoute()` returns `null` for any unrecognized path. The behavior is undefined: likely
|
|
413
|
-
a blank page or a thrown exception inside `_prepareRequest`.
|
|
414
|
-
|
|
415
|
-
### Fix design
|
|
416
|
-
|
|
417
|
-
In `generateRoutesCode`, after the deduplication and sort step, check whether any route
|
|
418
|
-
with `routePath: '/:all*'` exists. If not, append a minimal generated catch-all that
|
|
419
|
-
renders nothing but returns HTTP 404:
|
|
420
|
-
|
|
421
|
-
```ts
|
|
422
|
-
// Append synthesized catch-all if none exists
|
|
423
|
-
if (!sorted.some(e => e.isCatchAll)) {
|
|
424
|
-
items.push(` {\n path: '/:all*',\n load: () => Promise.resolve({ default: null, loader: null }),\n meta: { render: 'ssr' }\n }`)
|
|
425
|
-
}
|
|
426
|
-
```
|
|
427
|
-
|
|
428
|
-
The `null` tag name must be handled in `_prepareRequest` in the entry-server template:
|
|
429
|
-
if `mod.default` is `null`, skip rendering and return `{ status: 404, vnode: errorVnode }`.
|
|
430
|
-
|
|
431
|
-
### Implementation steps
|
|
432
|
-
|
|
433
|
-
**Project:** `vite-plugin-cer-app`
|
|
434
|
-
|
|
435
|
-
1. **`src/plugin/virtual/routes.ts`** — after `sortRoutes`, add the synthetic catch-all
|
|
436
|
-
guard as described above.
|
|
437
|
-
|
|
438
|
-
2. **`src/runtime/entry-server-template.ts`**, `_prepareRequest` — add null tag handling:
|
|
439
|
-
```ts
|
|
440
|
-
if (!mod.default) {
|
|
441
|
-
// No page component — return 404
|
|
442
|
-
const notFoundVnode = errorTag
|
|
443
|
-
? { tag: errorTag, props: { attrs: { error: 'Not Found', status: '404' } }, children: [] }
|
|
444
|
-
: { tag: 'div', props: {}, children: 'Not Found' }
|
|
445
|
-
return { vnode: notFoundVnode, router, head: '', status: 404 }
|
|
446
|
-
}
|
|
447
|
-
```
|
|
448
|
-
|
|
449
|
-
3. **`src/__tests__/plugin/virtual/routes.test.ts`** — add test: when no catch-all page
|
|
450
|
-
exists in the pages directory, the generated routes code contains `'/:all*'`.
|
|
451
|
-
|
|
452
|
-
4. **`src/__tests__/plugin/entry-server-template.test.ts`** — add assertion: template
|
|
453
|
-
handles `mod.default === null` with a 404 status return.
|
|
454
|
-
|
|
455
|
-
### Implementation notes
|
|
456
|
-
|
|
457
|
-
- `src/plugin/virtual/routes.ts`: After `sortRoutes`, if no `/:all*` catch-all exists, a synthetic route is appended: `load: () => Promise.resolve({ default: null, loader: null })`.
|
|
458
|
-
- `src/runtime/entry-server-template.ts`: Two complementary fixes handle HTTP 404 for all catch-all cases:
|
|
459
|
-
1. `if (!pageTag)` — synthetic route (`mod.default === null`): renders the per-route or global `errorTag` with `status: 404`.
|
|
460
|
-
2. `const isCatchAll = route?.path === '/:all*'` → `status: isCatchAll ? 404 : null` — user-defined `404.ts` or `[...all].ts`: the real page component renders but the HTTP response code is 404.
|
|
461
|
-
- Unit tests in `src/__tests__/plugin/entry-server-template.test.ts` and `src/__tests__/plugin/virtual/routes.test.ts`.
|
|
462
|
-
- Cypress spec `e2e/cypress/e2e/synthetic-404.cy.ts` verifies all three behaviors: client-side navigation, HTTP 404 status, and no HTTP 500.
|
|
463
|
-
|
|
464
|
-
---
|
|
465
|
-
|
|
466
|
-
## P1-2 — Server middleware cannot return status codes other than 500 ✅ IMPLEMENTED
|
|
467
|
-
|
|
468
|
-
### Diagnosis
|
|
469
|
-
|
|
470
|
-
**File:** `src/runtime/entry-server-template.ts` (the `runServerMiddleware` template body)
|
|
471
|
-
|
|
472
|
-
The middleware error branch always writes `res.statusCode = 500`. Middleware that wants to
|
|
473
|
-
reject with 401 (unauthorized) or 403 (forbidden) must manually write the full response
|
|
474
|
-
and call `res.end()` before returning — there is no structured way to signal a specific
|
|
475
|
-
HTTP status from a middleware throw.
|
|
476
|
-
|
|
477
|
-
### Fix design
|
|
478
|
-
|
|
479
|
-
Inspect the thrown error for a `status` or `statusCode` numeric property (same pattern
|
|
480
|
-
used by `_prepareRequest` for loader errors). If found, use that value; otherwise fall back
|
|
481
|
-
to 500.
|
|
482
|
-
|
|
483
|
-
```ts
|
|
484
|
-
} catch (err: unknown) {
|
|
485
|
-
if (!res.writableEnded) {
|
|
486
|
-
const status = typeof err === 'object' && err !== null && 'status' in err
|
|
487
|
-
? Number((err as { status: unknown }).status)
|
|
488
|
-
: 500
|
|
489
|
-
res.statusCode = isNaN(status) ? 500 : status
|
|
490
|
-
res.end('Internal Server Error')
|
|
491
|
-
}
|
|
492
|
-
return false
|
|
493
|
-
}
|
|
494
|
-
```
|
|
495
|
-
|
|
496
|
-
Document that middleware can do `throw { status: 401, message: 'Unauthorized' }` to
|
|
497
|
-
produce a non-500 response.
|
|
498
|
-
|
|
499
|
-
### Implementation steps
|
|
500
|
-
|
|
501
|
-
**Project:** `vite-plugin-cer-app`
|
|
502
|
-
|
|
503
|
-
1. **`src/runtime/entry-server-template.ts`** — update the server middleware error catch
|
|
504
|
-
block as described.
|
|
505
|
-
|
|
506
|
-
2. **`src/__tests__/plugin/entry-server-template.test.ts`** — add assertions:
|
|
507
|
-
- Template inspects `err.status` for non-500 values in server middleware catch block
|
|
508
|
-
|
|
509
|
-
3. **`docs/middleware.md`** — document `throw { status: 401 }` pattern.
|
|
510
|
-
|
|
511
|
-
---
|
|
512
|
-
|
|
513
|
-
## P1-3 — Session secret is not rotatable ✅ IMPLEMENTED
|
|
514
|
-
|
|
515
|
-
### Diagnosis
|
|
516
|
-
|
|
517
|
-
**File:** `src/runtime/composables/use-session.ts`, lines 124–138
|
|
518
|
-
|
|
519
|
-
`_getSecret()` reads a single string from `runtimeConfig.private.sessionSecret`. The
|
|
520
|
-
HMAC key is derived from this string on every sign/verify call. If the secret is changed
|
|
521
|
-
(periodic rotation, leak remediation), all existing session cookies fail signature
|
|
522
|
-
verification and every signed-in user is immediately logged out.
|
|
523
|
-
|
|
524
|
-
### Fix design
|
|
525
|
-
|
|
526
|
-
Support `sessionSecret` as either a `string` (existing behavior) or an `array of strings`.
|
|
527
|
-
When validating an incoming cookie, try each key in order. When signing a new cookie,
|
|
528
|
-
always use the **first** key (the active key). Old sessions signed with any of the other
|
|
529
|
-
keys are still accepted until they expire, giving users a graceful rotation window.
|
|
530
|
-
|
|
531
|
-
```ts
|
|
532
|
-
// cer.config.ts / runtimeConfig.private
|
|
533
|
-
sessionSecret: process.env.SESSION_SECRET // existing: single string
|
|
534
|
-
sessionSecret: [
|
|
535
|
-
process.env.SESSION_SECRET_NEW, // active key (signs new sessions)
|
|
536
|
-
process.env.SESSION_SECRET_OLD, // accepted for validation only
|
|
537
|
-
]
|
|
538
|
-
```
|
|
539
|
-
|
|
540
|
-
### Implementation steps
|
|
541
|
-
|
|
542
|
-
**Project:** `vite-plugin-cer-app`
|
|
543
|
-
|
|
544
|
-
1. **`src/types/config.ts`** — update `RuntimePrivateConfig.sessionSecret` type:
|
|
545
|
-
```ts
|
|
546
|
-
sessionSecret?: string | string[]
|
|
547
|
-
```
|
|
548
|
-
|
|
549
|
-
2. **`src/runtime/composables/use-session.ts`**:
|
|
550
|
-
- `_getSecret()` → `_getSecrets(): string[]` — always returns an array (single string
|
|
551
|
-
is wrapped in `[secret]`)
|
|
552
|
-
- `set()`: signs with `secrets[0]`
|
|
553
|
-
- `get()`: tries each key in order; returns data from the first that verifies
|
|
554
|
-
|
|
555
|
-
3. **`src/__tests__/runtime/use-session.test.ts`** — add tests:
|
|
556
|
-
- Array of two secrets: cookie signed with second key is still accepted
|
|
557
|
-
- Array of two secrets: new cookies are signed with the first key
|
|
558
|
-
- Secret rotation: old cookie accepted with old secret in array; rejected after
|
|
559
|
-
old secret removed from array
|
|
560
|
-
|
|
561
|
-
4. **`docs/configuration.md`** — document array form under `runtimeConfig.private.sessionSecret`.
|
|
562
|
-
|
|
563
|
-
---
|
|
564
|
-
|
|
565
|
-
## P1-4 — Cloudflare adapter has no size check for inlined HTML ✅ IMPLEMENTED
|
|
566
|
-
|
|
567
|
-
### Diagnosis
|
|
568
|
-
|
|
569
|
-
**File:** `src/cli/adapters/cloudflare.ts`, line 58–68
|
|
570
|
-
|
|
571
|
-
The entire contents of `dist/client/index.html` are inlined as a template literal string
|
|
572
|
-
constant inside `_worker.js`. Cloudflare Workers have a **1 MB compressed script size
|
|
573
|
-
limit** (Free plan) or **10 MB** (Paid plan). A large HTML template — one with many
|
|
574
|
-
inlined scripts, large DSD payloads, or bulky meta tags — can push the worker file over
|
|
575
|
-
this limit, causing deployment to fail with a cryptic Wrangler error.
|
|
576
|
-
|
|
577
|
-
### Fix design
|
|
578
|
-
|
|
579
|
-
After generating `_worker.js`, measure its byte length. If it exceeds 900 KB (conservative
|
|
580
|
-
Free plan limit), print a clear warning with the measured size and the Cloudflare limit.
|
|
581
|
-
If it exceeds 9 MB (conservative Paid plan limit), print an error and exit with a non-zero
|
|
582
|
-
code.
|
|
583
|
-
|
|
584
|
-
Thresholds should be configurable via `adapter: { name: 'cloudflare', warnSize: ..., errorSize: ... }`.
|
|
585
|
-
|
|
586
|
-
### Implementation steps
|
|
587
|
-
|
|
588
|
-
**Project:** `vite-plugin-cer-app`
|
|
589
|
-
|
|
590
|
-
1. **`src/cli/adapters/cloudflare.ts`** — after writing `_worker.js`, check file size:
|
|
591
|
-
|
|
592
|
-
```ts
|
|
593
|
-
const workerPath = join(outputDir, '_worker.js')
|
|
594
|
-
const sizeBytes = statSync(workerPath).size
|
|
595
|
-
const warnLimit = options?.warnSize ?? 900_000
|
|
596
|
-
const errorLimit = options?.errorSize ?? 9_000_000
|
|
597
|
-
if (sizeBytes > errorLimit) {
|
|
598
|
-
console.error(`[cer-app] Cloudflare _worker.js is ${(sizeBytes / 1e6).toFixed(1)} MB — exceeds the ${(errorLimit / 1e6).toFixed(0)} MB limit. Build will likely fail to deploy.`)
|
|
599
|
-
process.exit(1)
|
|
600
|
-
} else if (sizeBytes > warnLimit) {
|
|
601
|
-
console.warn(`[cer-app] Cloudflare _worker.js is ${(sizeBytes / 1e3).toFixed(0)} KB — approaching the Cloudflare Free plan 1 MB limit.`)
|
|
602
|
-
}
|
|
603
|
-
```
|
|
604
|
-
|
|
605
|
-
2. **`src/types/config.ts`** — add optional `CloudflareAdapterOptions` type and thread it
|
|
606
|
-
through `CerAppConfig`.
|
|
607
|
-
|
|
608
|
-
3. **`src/__tests__/cli/adapters/cloudflare.test.ts`** — add test: mock `statSync` to
|
|
609
|
-
return a large size; assert the warning/error message is emitted.
|
|
610
|
-
|
|
611
|
-
4. **`docs/configuration.md`** — document `adapter` options for Cloudflare including
|
|
612
|
-
`warnSize` and `errorSize`.
|
|
613
|
-
|
|
614
|
-
---
|
|
615
|
-
|
|
616
|
-
## P1-5 — Auto-import injects full import groups; unused exports cannot be tree-shaken ✅ IMPLEMENTED
|
|
617
|
-
|
|
618
|
-
### Diagnosis
|
|
619
|
-
|
|
620
|
-
**File:** `src/plugin/transforms/auto-import.ts`, lines 90–113
|
|
621
|
-
|
|
622
|
-
`isFrameworkImportNeeded` returns `true` if any single identifier from `FRAMEWORK_IDENTIFIERS`
|
|
623
|
-
is found in the file. When it does, the entire `FRAMEWORK_IMPORTS` string is prepended —
|
|
624
|
-
all 15 composables in one import statement. A page that uses only `useHead` gets:
|
|
625
|
-
|
|
626
|
-
```ts
|
|
627
|
-
import { useHead, usePageData, useInject, useRuntimeConfig, defineMiddleware,
|
|
628
|
-
defineServerMiddleware, useSeoMeta, useCookie, useSession, useAuth,
|
|
629
|
-
useFetch, useRoute, navigateTo, useState, useLocale }
|
|
630
|
-
from '@jasonshimmy/vite-plugin-cer-app/composables'
|
|
631
|
-
```
|
|
632
|
-
|
|
633
|
-
Rollup cannot tree-shake named imports from packages whose `sideEffects` is not `false`.
|
|
634
|
-
Both packages declare `sideEffects: ["**/*.css"]`, which means Rollup must conservatively
|
|
635
|
-
treat all non-CSS files as having side effects. The unused 14 composables ship in every
|
|
636
|
-
page chunk that uses even one framework composable.
|
|
637
|
-
|
|
638
|
-
The same problem applies to `RUNTIME_IMPORTS` (29 runtime exports for any page using
|
|
639
|
-
even `html`) and `DIRECTIVE_IMPORTS` (4 directive exports for any page using `when`).
|
|
640
|
-
|
|
641
|
-
### Fix design
|
|
642
|
-
|
|
643
|
-
Replace the three monolithic import strings with per-identifier injection. `FRAMEWORK_IMPORTS`,
|
|
644
|
-
`RUNTIME_IMPORTS`, and `DIRECTIVE_IMPORTS` become maps of identifier → source module path.
|
|
645
|
-
The `autoImportTransform` function builds the minimum import statement containing only the
|
|
646
|
-
identifiers actually referenced in the file.
|
|
647
|
-
|
|
648
|
-
```ts
|
|
649
|
-
const RUNTIME_MAP: Record<string, string> = {
|
|
650
|
-
component: '@jasonshimmy/custom-elements-runtime',
|
|
651
|
-
html: '@jasonshimmy/custom-elements-runtime',
|
|
652
|
-
ref: '@jasonshimmy/custom-elements-runtime',
|
|
653
|
-
// ...
|
|
654
|
-
}
|
|
655
|
-
const DIRECTIVE_MAP: Record<string, string> = {
|
|
656
|
-
when: '@jasonshimmy/custom-elements-runtime/directives',
|
|
657
|
-
each: '@jasonshimmy/custom-elements-runtime/directives',
|
|
658
|
-
// ...
|
|
659
|
-
}
|
|
660
|
-
const FRAMEWORK_MAP: Record<string, string> = {
|
|
661
|
-
useHead: '@jasonshimmy/vite-plugin-cer-app/composables',
|
|
662
|
-
useState: '@jasonshimmy/vite-plugin-cer-app/composables',
|
|
663
|
-
// ...
|
|
664
|
-
}
|
|
665
|
-
```
|
|
666
|
-
|
|
667
|
-
Group by source path before emitting so that `import { useHead, useState } from '...'` is
|
|
668
|
-
a single statement rather than two. This preserves the existing injection shape for pages
|
|
669
|
-
that use many identifiers.
|
|
670
|
-
|
|
671
|
-
The change is backward compatible: the injected code produces identical named imports from
|
|
672
|
-
the same module paths. Only the subset changes.
|
|
673
|
-
|
|
674
|
-
### Implementation steps
|
|
675
|
-
|
|
676
|
-
**Project:** `vite-plugin-cer-app`
|
|
677
|
-
|
|
678
|
-
1. **`src/plugin/transforms/auto-import.ts`** — replace the three string constants and
|
|
679
|
-
their `isXImportNeeded` + injection pattern with map-based per-identifier injection.
|
|
680
|
-
Keep the existing duplicate-import guard (check if already importing from the source
|
|
681
|
-
path before injecting).
|
|
682
|
-
|
|
683
|
-
2. **`src/__tests__/plugin/transforms/auto-import.test.ts`** — update all tests that
|
|
684
|
-
check the injected import string to match the new per-identifier form:
|
|
685
|
-
- Page using only `html` → injects only `{ html }` from runtime, not all 29
|
|
686
|
-
- Page using `useHead` + `useState` → injects `{ useHead, useState }` from composables,
|
|
687
|
-
not all 15
|
|
688
|
-
- Page already importing `{ html }` → no duplicate injection
|
|
689
|
-
- Page using `when` → injects only `{ when }` from directives
|
|
690
|
-
- Page using no recognized identifier → returns null
|
|
691
|
-
|
|
692
|
-
3. **`docs/configuration.md`** — update the auto-imports section to note that only used
|
|
693
|
-
identifiers are injected.
|
|
694
|
-
|
|
695
|
-
---
|
|
696
|
-
|
|
697
|
-
## P2-1 — Nested routes ✅ IMPLEMENTED
|
|
698
|
-
|
|
699
|
-
### Diagnosis
|
|
700
|
-
|
|
701
|
-
The router ([`virtual/routes.ts`](../src/plugin/virtual/routes.ts)) generates a flat route
|
|
702
|
-
array. The `layoutChain` meta property is a workaround for layout nesting but it is not
|
|
703
|
-
true nested routing — child routes cannot define their own data loaders that compose with
|
|
704
|
-
parent loaders, and there is no shared parent URL segment prefix enforcement.
|
|
705
|
-
|
|
706
|
-
### Fix design
|
|
707
|
-
|
|
708
|
-
Adopt the `_layout.ts` convention already partially present in the codebase
|
|
709
|
-
(`app/pages/admin/_layout.ts` is excluded from page scanning at
|
|
710
|
-
[`virtual/routes.ts:189`](../src/plugin/virtual/routes.ts)). Extend this to make
|
|
711
|
-
`_layout.ts` files act as **route group wrappers** that set shared `meta` (layout,
|
|
712
|
-
middleware, prefix) for all routes in the same directory.
|
|
713
|
-
|
|
714
|
-
A route group directory (`app/pages/admin/`) with a `_layout.ts` that exports:
|
|
715
|
-
```ts
|
|
716
|
-
export const meta = { layout: 'admin', middleware: ['requireAuth'] }
|
|
717
|
-
```
|
|
718
|
-
would automatically apply those meta fields to all routes in `app/pages/admin/**/*.ts`
|
|
719
|
-
without repeating them on each page.
|
|
720
|
-
|
|
721
|
-
This is additive only — no behavior changes for existing pages. True deeply nested router
|
|
722
|
-
rendering (React Router / Nuxt nested views) is deferred as a larger scope change; the
|
|
723
|
-
immediate goal is shared meta inheritance.
|
|
724
|
-
|
|
725
|
-
### Implementation steps
|
|
726
|
-
|
|
727
|
-
**Project:** `vite-plugin-cer-app`
|
|
728
|
-
|
|
729
|
-
1. **`src/plugin/virtual/routes.ts`**:
|
|
730
|
-
- After scanning pages, scan for `_layout.ts` files in any subdirectory
|
|
731
|
-
- Import each `_layout.ts` at build time (via `readFileSync` + regex extraction or
|
|
732
|
-
a Vite `load` call) to extract the exported `meta` object
|
|
733
|
-
- Merge that meta into every route whose `filePath` is under that directory
|
|
734
|
-
- Directory meta has lower precedence than page-level meta (page can override)
|
|
735
|
-
|
|
736
|
-
2. **`src/__tests__/plugin/virtual/routes.test.ts`** — add tests:
|
|
737
|
-
- `admin/_layout.ts` with `middleware: ['requireAuth']` → all `admin/**` routes
|
|
738
|
-
inherit `middleware`
|
|
739
|
-
- Page-level `meta.middleware` overrides inherited value
|
|
740
|
-
- No `_layout.ts` → no change to existing behavior
|
|
741
|
-
|
|
742
|
-
3. **`e2e/kitchen-sink/app/pages/admin/`** — already has `_layout.ts` and `dashboard.ts`;
|
|
743
|
-
update to verify meta inheritance in `e2e/cypress/e2e/routes.cy.ts`.
|
|
744
|
-
|
|
745
|
-
4. **`docs/routing.md`** — document `_layout.ts` meta inheritance.
|
|
746
|
-
|
|
747
|
-
---
|
|
748
|
-
|
|
749
|
-
## P2-2 — Per-route error components ✅ IMPLEMENTED
|
|
750
|
-
|
|
751
|
-
### Diagnosis
|
|
752
|
-
|
|
753
|
-
`app/error.ts` is the single global error boundary. A 404 page uses the same error
|
|
754
|
-
component as a database crash on the admin dashboard. Nuxt and Next.js support
|
|
755
|
-
per-segment `error.vue` / `error.tsx` files.
|
|
756
|
-
|
|
757
|
-
### Fix design
|
|
758
|
-
|
|
759
|
-
Support an `app/pages/[route].error.ts` convention (co-located error component):
|
|
760
|
-
|
|
761
|
-
- `app/pages/admin/dashboard.error.ts` → used as the error boundary for the
|
|
762
|
-
`/admin/dashboard` route only
|
|
763
|
-
- `app/pages/admin/_error.ts` → used as the error boundary for all routes in
|
|
764
|
-
`app/pages/admin/**`
|
|
765
|
-
- `app/error.ts` → global fallback (existing behavior)
|
|
766
|
-
|
|
767
|
-
The error component resolution priority: co-located `.error.ts` > directory `_error.ts` >
|
|
768
|
-
global `app/error.ts`.
|
|
769
|
-
|
|
770
|
-
The route `meta` object gains an `errorTag` field that the entry-server template and the
|
|
771
|
-
client `cer-layout-view` component consult when displaying an error boundary.
|
|
772
|
-
|
|
773
|
-
### Implementation steps
|
|
774
|
-
|
|
775
|
-
**Project:** `vite-plugin-cer-app`
|
|
776
|
-
|
|
777
|
-
1. **`src/plugin/virtual/routes.ts`** — during route building, check for a co-located
|
|
778
|
-
`*.error.ts` or a directory-level `_error.ts`. If found, import the file, extract the
|
|
779
|
-
component tag, and add `errorTag: 'page-admin-dashboard-error'` to the route meta.
|
|
780
|
-
|
|
781
|
-
2. **`src/runtime/entry-server-template.ts`** (`_prepareRequest`) — prefer route-level
|
|
782
|
-
`routeMeta?.errorTag` over the global `errorTag` when rendering loader errors.
|
|
783
|
-
|
|
784
|
-
3. **`src/runtime/app-template.ts`** (`cer-layout-view` render function) — prefer
|
|
785
|
-
`routeMeta?.errorTag` over the global `errorTag` for client-side error rendering.
|
|
786
|
-
|
|
787
|
-
4. **`src/plugin/virtual/error.ts`** — update to also expose a per-route lookup API
|
|
788
|
-
alongside the current boolean/string exports.
|
|
789
|
-
|
|
790
|
-
5. **`src/__tests__/plugin/virtual/routes.test.ts`** — test that co-located `.error.ts`
|
|
791
|
-
produces correct `errorTag` in route meta.
|
|
792
|
-
|
|
793
|
-
6. **`docs/routing.md`** — document co-located error components.
|
|
794
|
-
|
|
795
|
-
### Implementation notes
|
|
796
|
-
|
|
797
|
-
- `src/plugin/virtual/routes.ts`: `resolveRouteErrorComponent()` checks for a co-located `*.error.ts` first, then a directory-level `_error.ts`. The resolved tag name is stored in `route.meta.errorTag` and the file is bundled alongside the page via the `load()` function.
|
|
798
|
-
- `src/runtime/entry-server-template.ts` (`_prepareRequest`): Uses `route?.meta?.errorTag ?? errorTag` as `effectiveErrorTag` when a loader throws, giving per-route components priority over the global error boundary.
|
|
799
|
-
- `src/runtime/app-template.ts` (`cer-layout-view`): Computes `matched`/`routeMeta` before the `currentError` check so that `routeMeta?.errorTag ?? (hasError ? errorTag : null)` can be used as `effectiveErrorTag` for client-side errors — mirrors the server-side priority.
|
|
800
|
-
- Kitchen-sink fixtures: `e2e/kitchen-sink/app/pages/per-route-error-test.ts` (throws in loader) and `e2e/kitchen-sink/app/pages/per-route-error-test.error.ts` (co-located error component).
|
|
801
|
-
- Cypress spec `e2e/cypress/e2e/per-route-error.cy.ts` verifies the per-route error component renders (not the global one) in both SPA and SSR modes.
|
|
802
|
-
|
|
803
|
-
---
|
|
804
|
-
|
|
805
|
-
## P2-3 — Client-side `useFetch()` does not deduplicate concurrent calls ✅ IMPLEMENTED
|
|
806
|
-
|
|
807
|
-
### Diagnosis
|
|
808
|
-
|
|
809
|
-
**File:** `src/runtime/composables/use-fetch.ts`
|
|
810
|
-
|
|
811
|
-
Server-side: `useFetch` deduplicates via the per-request `_cerFetchStore` map. If
|
|
812
|
-
the same key is fetched twice inside one loader, only one network request is made.
|
|
813
|
-
|
|
814
|
-
Client-side: when a component mounts and calls `useFetch('/api/posts')`, and another
|
|
815
|
-
component on the same page also calls `useFetch('/api/posts')`, two identical network
|
|
816
|
-
requests are issued concurrently. There is no in-flight deduplication.
|
|
817
|
-
|
|
818
|
-
### Fix design
|
|
819
|
-
|
|
820
|
-
Add a module-level `_inflight: Map<string, Promise<unknown>>` on the client. When
|
|
821
|
-
`refresh()` is called:
|
|
822
|
-
|
|
823
|
-
1. If a key is already in `_inflight`, await the existing Promise rather than issuing a
|
|
824
|
-
new fetch.
|
|
825
|
-
2. Remove the key from `_inflight` when the Promise settles (both resolve and reject).
|
|
826
|
-
|
|
827
|
-
This is a lightweight version of React Query's deduplication — no persistence, no
|
|
828
|
-
background refetch coordination, just in-flight deduplication within a single render cycle.
|
|
829
|
-
|
|
830
|
-
The map is module-level (not per-component), so two components fetching the same URL
|
|
831
|
-
concurrently share one request.
|
|
832
|
-
|
|
833
|
-
### Implementation steps
|
|
834
|
-
|
|
835
|
-
**Project:** `vite-plugin-cer-app`
|
|
836
|
-
|
|
837
|
-
1. **`src/runtime/composables/use-fetch.ts`** — add client deduplication:
|
|
838
|
-
```ts
|
|
839
|
-
const _inflight = new Map<string, Promise<unknown>>()
|
|
840
|
-
|
|
841
|
-
// Inside the client refresh() function:
|
|
842
|
-
if (_inflight.has(key)) {
|
|
843
|
-
return _inflight.get(key)!.then(/* update reactive state */)
|
|
844
|
-
}
|
|
845
|
-
const promise = _fetchData(url, fetchOptions).finally(() => _inflight.delete(key))
|
|
846
|
-
_inflight.set(key, promise)
|
|
847
|
-
```
|
|
848
|
-
|
|
849
|
-
2. **`src/__tests__/runtime/use-fetch-component.test.ts`** — add tests:
|
|
850
|
-
- Two concurrent `refresh()` calls with the same key → only one fetch issued
|
|
851
|
-
- Deduplication resolves both callers with the same data
|
|
852
|
-
- After resolution, `_inflight` is cleared (next call issues a new fetch)
|
|
853
|
-
|
|
854
|
-
3. **`docs/data-loading.md`** (or equivalent) — document client deduplication behavior.
|
|
855
|
-
|
|
856
|
-
---
|
|
857
|
-
|
|
858
|
-
## P2-4 — No lazy-loaded component support ✅ IMPLEMENTED
|
|
859
|
-
|
|
860
|
-
### Diagnosis
|
|
861
|
-
|
|
862
|
-
All component registration calls (`component('ks-badge', renderFn)`) are eager and
|
|
863
|
-
synchronous. There is no equivalent of Vue's `defineAsyncComponent` or React's
|
|
864
|
-
`React.lazy()` — no way to split a component's implementation into a separate chunk that
|
|
865
|
-
loads on first render rather than at page load.
|
|
866
|
-
|
|
867
|
-
This is distinct from per-page code splitting (which is implemented): the gap is
|
|
868
|
-
**within-page** lazy loading of heavy components (e.g., a rich text editor, a chart
|
|
869
|
-
library, a code editor) that may not always render on first paint.
|
|
870
|
-
|
|
871
|
-
### Fix design
|
|
872
|
-
|
|
873
|
-
Add `defineAsyncComponent(loader: () => Promise<RenderFunction>)` to the runtime:
|
|
874
|
-
|
|
875
|
-
```ts
|
|
876
|
-
// app/components/heavy-editor.ts
|
|
877
|
-
export default defineAsyncComponent(() =>
|
|
878
|
-
import('./heavy-editor-impl.ts').then(m => m.default)
|
|
879
|
-
)
|
|
880
|
-
|
|
881
|
-
// Usage in a page:
|
|
882
|
-
component('my-page', () => {
|
|
883
|
-
return html`<heavy-editor></heavy-editor>`
|
|
884
|
-
})
|
|
885
|
-
```
|
|
886
|
-
|
|
887
|
-
Internally, `defineAsyncComponent` returns a render function that:
|
|
888
|
-
1. On first render: returns a placeholder (empty or user-specified `loading` slot)
|
|
889
|
-
2. Triggers the `loader()` Promise
|
|
890
|
-
3. When the Promise resolves: registers the real component, requests a re-render
|
|
891
|
-
|
|
892
|
-
The framework already handles async component streaming in SSR (swap scripts). The client
|
|
893
|
-
path needs to wire into the existing on-demand registration mechanism.
|
|
894
|
-
|
|
895
|
-
### Implementation steps
|
|
896
|
-
|
|
897
|
-
**Project:** `@jasonshimmy/custom-elements-runtime`
|
|
898
|
-
|
|
899
|
-
1. **`src/lib/runtime/component/async-component.ts`** (new file):
|
|
900
|
-
- `defineAsyncComponent(loader, options?)` function
|
|
901
|
-
- Options: `loading?: RenderFunction`, `error?: RenderFunction`, `timeout?: number`
|
|
902
|
-
- Internal state machine: `idle → loading → resolved | error | timeout`
|
|
903
|
-
|
|
904
|
-
2. **`src/lib/runtime/component/factory.ts`** — detect when the render function is
|
|
905
|
-
an async-component wrapper and handle the loading/resolved states in the render pipeline.
|
|
906
|
-
|
|
907
|
-
3. **`src/lib/index.ts`** — export `defineAsyncComponent`.
|
|
908
|
-
|
|
909
|
-
4. **`src/lib/vite-plugin.ts`** — `extractComponentRegistrations` must recognize
|
|
910
|
-
`defineAsyncComponent` calls in addition to `component()` calls so the component
|
|
911
|
-
manifest is built correctly.
|
|
912
|
-
|
|
913
|
-
5. **Tests:**
|
|
914
|
-
- `test/async-component.spec.ts`:
|
|
915
|
-
- Renders placeholder on first mount
|
|
916
|
-
- Renders resolved content after loader settles
|
|
917
|
-
- Renders error component on loader rejection (when error option provided)
|
|
918
|
-
- Timeout option: renders error state if loader takes too long
|
|
919
|
-
|
|
920
|
-
6. **`docs/components.md`** — document `defineAsyncComponent`.
|
|
921
|
-
|
|
922
|
-
---
|
|
923
|
-
|
|
924
|
-
## P2-5 — `adoptedStyleSheets` not used; component styles are embedded `<style>` tags ✅ IMPLEMENTED
|
|
925
|
-
|
|
926
|
-
### Diagnosis
|
|
927
|
-
|
|
928
|
-
The SSR DSD output embeds `<style>` inside each `<template shadowrootmode="open">` block.
|
|
929
|
-
On the client, when a component upgrades its shadow root from DSD, the styles are already
|
|
930
|
-
inline. The framework does not use `adoptedStyleSheets` (the `CSSStyleSheet` API) for
|
|
931
|
-
any component styles.
|
|
932
|
-
|
|
933
|
-
The gap vs. Nuxt/Next.js is that `adoptedStyleSheets` allows **style deduplication**:
|
|
934
|
-
if 50 instances of `<ks-badge>` are on the page, there is currently one `<style>` block
|
|
935
|
-
per shadow root (50 total). With `adoptedStyleSheets`, a single `CSSStyleSheet` object
|
|
936
|
-
is constructed once and shared across all 50 instances via `shadowRoot.adoptedStyleSheets`.
|
|
937
|
-
|
|
938
|
-
This is a performance optimization for pages with many repeated component instances.
|
|
939
|
-
It does not affect correctness.
|
|
940
|
-
|
|
941
|
-
### Fix design
|
|
942
|
-
|
|
943
|
-
After client-side hydration, the `component()` factory can detect whether a component
|
|
944
|
-
uses a CSS block from the `css\`` helper and, if `CSSStyleSheet` is supported (Chrome 73+,
|
|
945
|
-
Firefox 101+, Safari 16.4+), adopt a shared stylesheet instead of per-instance `<style>`.
|
|
946
|
-
|
|
947
|
-
SSR output remains unchanged (DSD requires inline `<style>`). The optimization is purely
|
|
948
|
-
client-side post-hydration.
|
|
949
|
-
|
|
950
|
-
Since `adoptedStyleSheets` is broadly but not universally supported, the implementation
|
|
951
|
-
must gate on `typeof CSSStyleSheet !== 'undefined' && 'replace' in CSSStyleSheet.prototype`.
|
|
952
|
-
|
|
953
|
-
### Implementation steps
|
|
954
|
-
|
|
955
|
-
**Project:** `@jasonshimmy/custom-elements-runtime`
|
|
956
|
-
|
|
957
|
-
1. **`src/lib/runtime/component/factory.ts`** — in the shadow root setup path (post-DSD
|
|
958
|
-
upgrade), detect CSS content and, when `CSSStyleSheet` is supported:
|
|
959
|
-
```ts
|
|
960
|
-
const sheet = new CSSStyleSheet()
|
|
961
|
-
sheet.replaceSync(cssContent)
|
|
962
|
-
shadowRoot.adoptedStyleSheets = [sheet]
|
|
963
|
-
// Remove the inline <style> block from the shadow root DOM
|
|
964
|
-
shadowRoot.querySelector('style')?.remove()
|
|
965
|
-
```
|
|
966
|
-
Store sheets in a `Map<tagName, CSSStyleSheet>` at module level for reuse.
|
|
967
|
-
|
|
968
|
-
2. **`src/lib/runtime/ssr/` (DSD serializer)** — no change; SSR still emits inline
|
|
969
|
-
`<style>` as required by the DSD specification.
|
|
970
|
-
|
|
971
|
-
3. **Tests:**
|
|
972
|
-
- `test/adopted-stylesheets.spec.ts` (JSDOM does not support `adoptedStyleSheets`
|
|
973
|
-
fully; use a mock or skip when unsupported):
|
|
974
|
-
- Two instances of the same component share the same `CSSStyleSheet` object
|
|
975
|
-
- Graceful fallback: when `CSSStyleSheet.prototype.replace` is absent, inline
|
|
976
|
-
`<style>` is retained
|
|
977
|
-
|
|
978
|
-
4. **`docs/components.md`** — add a note about `adoptedStyleSheets` optimization.
|
|
979
|
-
|
|
980
|
-
---
|
|
981
|
-
|
|
982
|
-
## Summary
|
|
983
|
-
|
|
984
|
-
| # | Priority | Issue | Project |
|
|
985
|
-
|---|---|---|---|
|
|
986
|
-
| P0-1 | Critical | SSR render errors crash the response stream | vite-plugin-cer-app |
|
|
987
|
-
| P0-2 | Critical | ISR concurrent revalidation race condition | vite-plugin-cer-app |
|
|
988
|
-
| P0-3 | Critical | Async SSR components can hang indefinitely | custom-elements-runtime |
|
|
989
|
-
| P0-4 | Critical | Reactive subscriptions leak on component disconnect | custom-elements-runtime |
|
|
990
|
-
| P1-1 | Minor | No 404 fallback when catch-all route is absent | vite-plugin-cer-app |
|
|
991
|
-
| P1-2 | Minor | Server middleware limited to status 500 | vite-plugin-cer-app |
|
|
992
|
-
| P1-3 | Minor | Session secret is not rotatable | vite-plugin-cer-app |
|
|
993
|
-
| P1-4 | Minor | Cloudflare adapter has no size check for inlined HTML | vite-plugin-cer-app |
|
|
994
|
-
| P1-5 | Minor | Auto-import injects full import groups | vite-plugin-cer-app |
|
|
995
|
-
| P2-1 | Feature | Nested routes / meta inheritance via `_layout.ts` | vite-plugin-cer-app |
|
|
996
|
-
| P2-2 | Feature | Per-route error components | vite-plugin-cer-app |
|
|
997
|
-
| P2-3 | Feature | Client-side `useFetch()` deduplication | vite-plugin-cer-app |
|
|
998
|
-
| P2-4 | Feature | Lazy-loaded components (`defineAsyncComponent`) | custom-elements-runtime |
|
|
999
|
-
| P2-5 | Feature | `adoptedStyleSheets` for style deduplication | custom-elements-runtime |
|
|
1000
|
-
|
|
1001
|
-
---
|
|
1002
|
-
|
|
1003
|
-
## Non-goals
|
|
1004
|
-
|
|
1005
|
-
The following ❌ items from the audit comparison table are explicitly deferred:
|
|
1006
|
-
|
|
1007
|
-
- **Partial Prerendering (PPR)**: Requires Server Components, a different rendering model.
|
|
1008
|
-
- **Parallel routes**: Niche use case; `layoutChain` covers most real needs.
|
|
1009
|
-
- **Server Components (RSC)**: Fundamentally incompatible with the Web Components model.
|
|
1010
|
-
- **Module ecosystem / Devtools**: Community and tooling; not implementable in this codebase.
|