@jasonshimmy/vite-plugin-cer-app 0.7.0 → 0.8.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 +4 -0
- package/ROADMAP.md +278 -0
- package/commits.txt +1 -1
- package/dist/cli/commands/preview-isr.d.ts +6 -0
- package/dist/cli/commands/preview-isr.d.ts.map +1 -1
- package/dist/cli/commands/preview-isr.js +12 -0
- package/dist/cli/commands/preview-isr.js.map +1 -1
- package/dist/cli/commands/preview.d.ts.map +1 -1
- package/dist/cli/commands/preview.js +9 -2
- package/dist/cli/commands/preview.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/plugin/dev-server.d.ts +1 -0
- package/dist/plugin/dev-server.d.ts.map +1 -1
- package/dist/plugin/dts-generator.d.ts.map +1 -1
- package/dist/plugin/dts-generator.js +4 -2
- package/dist/plugin/dts-generator.js.map +1 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +30 -12
- package/dist/plugin/index.js.map +1 -1
- package/dist/plugin/transforms/auto-import.d.ts.map +1 -1
- package/dist/plugin/transforms/auto-import.js +5 -4
- 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 +7 -1
- package/dist/plugin/virtual/routes.js.map +1 -1
- package/dist/runtime/composables/define-middleware.d.ts +15 -0
- package/dist/runtime/composables/define-middleware.d.ts.map +1 -0
- package/dist/runtime/composables/define-middleware.js +16 -0
- package/dist/runtime/composables/define-middleware.js.map +1 -0
- package/dist/runtime/composables/index.d.ts +3 -1
- package/dist/runtime/composables/index.d.ts.map +1 -1
- package/dist/runtime/composables/index.js +2 -1
- package/dist/runtime/composables/index.js.map +1 -1
- package/dist/runtime/composables/use-runtime-config.d.ts +32 -14
- package/dist/runtime/composables/use-runtime-config.d.ts.map +1 -1
- package/dist/runtime/composables/use-runtime-config.js +34 -8
- package/dist/runtime/composables/use-runtime-config.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 +7 -3
- package/dist/runtime/entry-server-template.js.map +1 -1
- package/dist/types/config.d.ts +14 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js.map +1 -1
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/middleware.d.ts +8 -2
- package/dist/types/middleware.d.ts.map +1 -1
- package/docs/cli.md +1 -0
- package/docs/composables.md +32 -7
- package/docs/configuration.md +53 -3
- package/docs/middleware.md +53 -25
- package/e2e/cypress/e2e/middleware.cy.ts +45 -0
- package/e2e/kitchen-sink/app/middleware/auth.ts +3 -7
- package/package.json +1 -1
- package/src/__tests__/cli/preview-isr.test.ts +30 -0
- package/src/__tests__/plugin/cer-app-plugin.test.ts +50 -0
- package/src/__tests__/plugin/resolve-config.test.ts +18 -0
- package/src/__tests__/plugin/transforms/auto-import.test.ts +16 -0
- package/src/__tests__/plugin/virtual/middleware.test.ts +15 -0
- package/src/__tests__/plugin/virtual/routes.test.ts +32 -0
- package/src/__tests__/runtime/define-middleware.test.ts +43 -0
- package/src/__tests__/runtime/use-runtime-config.test.ts +62 -1
- package/src/cli/commands/preview-isr.ts +14 -0
- package/src/cli/commands/preview.ts +12 -1
- package/src/index.ts +1 -1
- package/src/plugin/dev-server.ts +1 -1
- package/src/plugin/dts-generator.ts +4 -2
- package/src/plugin/index.ts +32 -11
- package/src/plugin/transforms/auto-import.ts +5 -4
- package/src/plugin/virtual/routes.ts +7 -1
- package/src/runtime/composables/define-middleware.ts +17 -0
- package/src/runtime/composables/index.ts +3 -1
- package/src/runtime/composables/use-runtime-config.ts +57 -11
- package/src/runtime/entry-server-template.ts +7 -3
- package/src/types/config.ts +15 -0
- package/src/types/index.ts +2 -2
- package/src/types/middleware.ts +8 -6
package/dist/types/config.d.ts
CHANGED
|
@@ -17,6 +17,9 @@ export interface AutoImportsConfig {
|
|
|
17
17
|
export interface RuntimePublicConfig {
|
|
18
18
|
[key: string]: unknown;
|
|
19
19
|
}
|
|
20
|
+
export interface RuntimePrivateConfig {
|
|
21
|
+
[key: string]: string;
|
|
22
|
+
}
|
|
20
23
|
export interface RuntimeConfig {
|
|
21
24
|
/**
|
|
22
25
|
* Public runtime config — available on both server and client via
|
|
@@ -31,6 +34,17 @@ export interface RuntimeConfig {
|
|
|
31
34
|
* }
|
|
32
35
|
*/
|
|
33
36
|
public?: RuntimePublicConfig;
|
|
37
|
+
/**
|
|
38
|
+
* Server-only secrets — never serialized into the client bundle.
|
|
39
|
+
* Declare keys with empty-string defaults here; at server startup each key
|
|
40
|
+
* is resolved from `process.env[KEY]` (case-insensitive, ALL_CAPS preferred).
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* runtimeConfig: {
|
|
44
|
+
* private: { dbUrl: '', secretKey: '' },
|
|
45
|
+
* }
|
|
46
|
+
*/
|
|
47
|
+
private?: RuntimePrivateConfig;
|
|
34
48
|
}
|
|
35
49
|
export interface CerAppConfig {
|
|
36
50
|
mode?: 'spa' | 'ssr' | 'ssg';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/types/config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,6CAA6C,CAAA;AAE/E,MAAM,WAAW,SAAS;IACxB,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;IAC1B,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB,cAAc,CAAC,EAAE,OAAO,CAAA;CACzB;AAED,MAAM,WAAW,iBAAiB;IAChC,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,MAAM,WAAW,mBAAmB;IAClC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB;AAED,MAAM,WAAW,aAAa;IAC5B;;;;;;;;;;;OAWG;IACH,MAAM,CAAC,EAAE,mBAAmB,CAAA;
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/types/config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,6CAA6C,CAAA;AAE/E,MAAM,WAAW,SAAS;IACxB,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;IAC1B,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB,cAAc,CAAC,EAAE,OAAO,CAAA;CACzB;AAED,MAAM,WAAW,iBAAiB;IAChC,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,MAAM,WAAW,mBAAmB;IAClC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB;AAED,MAAM,WAAW,oBAAoB;IACnC,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAA;CACtB;AAED,MAAM,WAAW,aAAa;IAC5B;;;;;;;;;;;OAWG;IACH,MAAM,CAAC,EAAE,mBAAmB,CAAA;IAC5B;;;;;;;;;OASG;IACH,OAAO,CAAC,EAAE,oBAAoB,CAAA;CAC/B;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,EAAE,KAAK,GAAG,KAAK,GAAG,KAAK,CAAA;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,GAAG,CAAC,EAAE,SAAS,CAAA;IACf,MAAM,CAAC,EAAE,IAAI,CAAC,YAAY,EAAE,MAAM,GAAG,kBAAkB,CAAC,CAAA;IACxD,MAAM,CAAC,EAAE,YAAY,CAAA;IACrB,WAAW,CAAC,EAAE,iBAAiB,CAAA;IAC/B,IAAI,CAAC,EAAE,MAAM,CAAA;IACb;;;;OAIG;IACH,aAAa,CAAC,EAAE,aAAa,CAAA;CAC9B;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,YAAY,CAE/D"}
|
package/dist/types/config.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/types/config.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/types/config.ts"],"names":[],"mappings":"AAuEA,MAAM,UAAU,YAAY,CAAC,MAAoB;IAC/C,OAAO,MAAM,CAAA;AACf,CAAC"}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
export type { CerAppConfig, SsgConfig, JitCssConfig, AutoImportsConfig, RuntimeConfig, RuntimePublicConfig } from './config.js';
|
|
1
|
+
export type { CerAppConfig, SsgConfig, JitCssConfig, AutoImportsConfig, RuntimeConfig, RuntimePublicConfig, RuntimePrivateConfig } from './config.js';
|
|
2
2
|
export { defineConfig } from './config.js';
|
|
3
3
|
export type { HydrateStrategy, SsgPathsContext, PageSsgConfig, PageMeta, PageLoaderContext, PageLoader } from './page.js';
|
|
4
4
|
export type { ApiRequest, ApiResponse, ApiHandler, ApiContext } from './api.js';
|
|
5
5
|
export type { AppContext, AppPlugin } from './plugin.js';
|
|
6
|
-
export type {
|
|
6
|
+
export type { MiddlewareFn, GuardResult, ServerMiddleware } from './middleware.js';
|
|
7
7
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,iBAAiB,EAAE,aAAa,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAA;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,iBAAiB,EAAE,aAAa,EAAE,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAA;AACrJ,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAC1C,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,aAAa,EAAE,QAAQ,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,WAAW,CAAA;AACzH,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AAC/E,YAAY,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AACxD,YAAY,EAAE,YAAY,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAA"}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { RouteState } from '@jasonshimmy/custom-elements-runtime/router';
|
|
2
2
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Return value from a route middleware function:
|
|
5
|
+
* - `true` — allow navigation
|
|
6
|
+
* - `false` — block navigation
|
|
7
|
+
* - `string` — redirect to that path
|
|
8
|
+
*/
|
|
9
|
+
export type GuardResult = boolean | string | Promise<boolean | string>;
|
|
10
|
+
export type MiddlewareFn = (to: RouteState, from: RouteState | null) => GuardResult;
|
|
5
11
|
export type ServerMiddleware = (req: IncomingMessage, res: ServerResponse, next: () => void) => void | Promise<void>;
|
|
6
12
|
//# sourceMappingURL=middleware.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../../src/types/middleware.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6CAA6C,CAAA;AAC7E,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAEhE,MAAM,MAAM,
|
|
1
|
+
{"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../../src/types/middleware.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6CAA6C,CAAA;AAC7E,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAEhE;;;;;GAKG;AACH,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,MAAM,CAAC,CAAA;AAEtE,MAAM,MAAM,YAAY,GAAG,CAAC,EAAE,EAAE,UAAU,EAAE,IAAI,EAAE,UAAU,GAAG,IAAI,KAAK,WAAW,CAAA;AAEnF,MAAM,MAAM,gBAAgB,GAAG,CAC7B,GAAG,EAAE,eAAe,EACpB,GAAG,EAAE,cAAc,EACnB,IAAI,EAAE,MAAM,IAAI,KACb,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA"}
|
package/docs/cli.md
CHANGED
|
@@ -110,6 +110,7 @@ cer-app preview --port 8080
|
|
|
110
110
|
- Static assets from `dist/client/` are served first; HTML requests fall through to the SSR handler
|
|
111
111
|
- Otherwise, starts a static file server with SPA fallback (serves `index.html` for unknown paths)
|
|
112
112
|
- Returns 404 for paths not found in `dist/` (static mode only)
|
|
113
|
+
- **Path traversal protection:** all file requests are validated against the `dist/` root — requests attempting to escape it (e.g. `GET /../../../../etc/passwd`) receive a `400` response
|
|
113
114
|
|
|
114
115
|
---
|
|
115
116
|
|
package/docs/composables.md
CHANGED
|
@@ -144,7 +144,9 @@ import { useInject } from '@jasonshimmy/vite-plugin-cer-app/composables'
|
|
|
144
144
|
|
|
145
145
|
### `useRuntimeConfig()`
|
|
146
146
|
|
|
147
|
-
Returns the
|
|
147
|
+
Returns the runtime configuration set in `cer.config.ts`. Returns `{ public, private? }`:
|
|
148
|
+
- `public` — available everywhere (server and client)
|
|
149
|
+
- `private` — server-only secrets resolved from `process.env` at startup; `undefined` on the client
|
|
148
150
|
|
|
149
151
|
```ts
|
|
150
152
|
// cer.config.ts
|
|
@@ -152,28 +154,51 @@ export default defineConfig({
|
|
|
152
154
|
runtimeConfig: {
|
|
153
155
|
public: {
|
|
154
156
|
apiBase: process.env.VITE_API_BASE ?? '/api',
|
|
155
|
-
|
|
157
|
+
},
|
|
158
|
+
private: {
|
|
159
|
+
dbUrl: '', // resolved from process.env.DB_URL at server startup
|
|
156
160
|
},
|
|
157
161
|
},
|
|
158
162
|
})
|
|
159
163
|
```
|
|
160
164
|
|
|
161
165
|
```ts
|
|
162
|
-
// app/pages/index.ts —
|
|
166
|
+
// app/pages/index.ts — public config, works on client and server
|
|
163
167
|
component('page-index', () => {
|
|
164
168
|
const { public: cfg } = useRuntimeConfig()
|
|
165
|
-
// cfg.apiBase → '/api'
|
|
166
|
-
|
|
167
169
|
return html`<p>API base: ${cfg.apiBase}</p>`
|
|
168
170
|
})
|
|
169
171
|
```
|
|
170
172
|
|
|
171
|
-
|
|
173
|
+
```ts
|
|
174
|
+
// app/pages/data.ts — private config, server-only (loader)
|
|
175
|
+
export const loader = async () => {
|
|
176
|
+
const { private: priv } = useRuntimeConfig()
|
|
177
|
+
const rows = await db.query(priv!.dbUrl)
|
|
178
|
+
return { rows }
|
|
179
|
+
}
|
|
180
|
+
```
|
|
172
181
|
|
|
173
|
-
**Only use `runtimeConfig.public` for values safe to expose to the browser.**
|
|
182
|
+
**Only use `runtimeConfig.public` for values safe to expose to the browser.** Use `runtimeConfig.private` for secrets — they are never sent to the client.
|
|
174
183
|
|
|
175
184
|
If you need it outside auto-imported directories:
|
|
176
185
|
|
|
177
186
|
```ts
|
|
178
187
|
import { useRuntimeConfig } from '@jasonshimmy/vite-plugin-cer-app/composables'
|
|
179
188
|
```
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
### `defineMiddleware(fn)`
|
|
193
|
+
|
|
194
|
+
Identity helper that gives TypeScript the correct `MiddlewareFn` type. Auto-imported — no import needed in `app/middleware/` files.
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
// app/middleware/auth.ts
|
|
198
|
+
export default defineMiddleware(async (to, from) => {
|
|
199
|
+
const isLoggedIn = !!localStorage.getItem('token')
|
|
200
|
+
return isLoggedIn ? true : '/login'
|
|
201
|
+
})
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
See [Middleware](./middleware.md) for full documentation.
|
package/docs/configuration.md
CHANGED
|
@@ -210,7 +210,7 @@ Set any flag to `false` to opt out and manage imports manually.
|
|
|
210
210
|
|
|
211
211
|
## `runtimeConfig` options
|
|
212
212
|
|
|
213
|
-
Expose typed, centralized
|
|
213
|
+
Expose typed, centralized configuration to your app. Public values are available everywhere; private values are server-only secrets resolved from environment variables at startup.
|
|
214
214
|
|
|
215
215
|
```ts
|
|
216
216
|
export default defineConfig({
|
|
@@ -219,6 +219,10 @@ export default defineConfig({
|
|
|
219
219
|
apiBase: process.env.VITE_API_BASE ?? 'https://api.example.com',
|
|
220
220
|
appVersion: '1.0.0',
|
|
221
221
|
},
|
|
222
|
+
private: {
|
|
223
|
+
dbUrl: '', // resolved from process.env.DB_URL at server startup
|
|
224
|
+
secretKey: '', // resolved from process.env.SECRET_KEY at server startup
|
|
225
|
+
},
|
|
222
226
|
},
|
|
223
227
|
})
|
|
224
228
|
```
|
|
@@ -230,9 +234,9 @@ export default defineConfig({
|
|
|
230
234
|
|
|
231
235
|
Values placed here are serialized into `virtual:cer-app-config` at build time and accessible on both server and client via `useRuntimeConfig().public`.
|
|
232
236
|
|
|
233
|
-
> **Security:** Only put values here that are safe to expose to the browser. Do not put secrets, tokens, or private keys in `public`.
|
|
237
|
+
> **Security:** Only put values here that are safe to expose to the browser. Do not put secrets, tokens, or private keys in `public`.
|
|
234
238
|
|
|
235
|
-
> **Serialization:** Values must be JSON-serializable (strings, numbers, booleans, plain objects, arrays). Functions, class instances, `undefined`, and circular references are not supported
|
|
239
|
+
> **Serialization:** Values must be JSON-serializable (strings, numbers, booleans, plain objects, arrays). Functions, class instances, `undefined`, and circular references are not supported.
|
|
236
240
|
|
|
237
241
|
```ts
|
|
238
242
|
// Any page, layout, component, or composable
|
|
@@ -252,6 +256,52 @@ import type { RuntimePublicConfig } from '@jasonshimmy/vite-plugin-cer-app/types
|
|
|
252
256
|
|
|
253
257
|
---
|
|
254
258
|
|
|
259
|
+
### `runtimeConfig.private`
|
|
260
|
+
|
|
261
|
+
**Type:** `Record<string, string>`
|
|
262
|
+
**Default:** `{}`
|
|
263
|
+
|
|
264
|
+
Server-only secrets. Declare keys with empty-string defaults in `cer.config.ts` for typing purposes. **Private values are never included in the client bundle.**
|
|
265
|
+
|
|
266
|
+
**Environment variable resolution order** (at server startup, for each declared key):
|
|
267
|
+
|
|
268
|
+
1. `process.env[key]` — exact case (e.g. `process.env.dbUrl`)
|
|
269
|
+
2. `process.env[UPPER_SNAKE_CASE(key)]` — conventional env var form (e.g. `process.env.DB_URL`)
|
|
270
|
+
3. The declared default value — used as a last-resort fallback
|
|
271
|
+
|
|
272
|
+
camelCase keys are automatically converted: `dbUrl` → `DB_URL`, `secretKey` → `SECRET_KEY`.
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
// cer.config.ts
|
|
276
|
+
export default defineConfig({
|
|
277
|
+
runtimeConfig: {
|
|
278
|
+
private: {
|
|
279
|
+
dbUrl: '', // resolved from process.env.dbUrl or process.env.DB_URL
|
|
280
|
+
secretKey: '', // resolved from process.env.secretKey or process.env.SECRET_KEY
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
})
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
// app/pages/data.ts — loader (server-only)
|
|
288
|
+
export const loader = async () => {
|
|
289
|
+
const { private: priv } = useRuntimeConfig()
|
|
290
|
+
const rows = await db.query(priv!.dbUrl)
|
|
291
|
+
return { rows }
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
> `useRuntimeConfig().private` is `undefined` on the client. Only access it in server-only contexts (loaders, server middleware, API handlers).
|
|
296
|
+
|
|
297
|
+
**TypeScript:** Import `RuntimePrivateConfig` to type your private config:
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
import type { RuntimePrivateConfig } from '@jasonshimmy/vite-plugin-cer-app/types'
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
255
305
|
## Passing config to the Vite plugin directly
|
|
256
306
|
|
|
257
307
|
When using `vite.config.ts` instead of (or alongside) `cer.config.ts`:
|
package/docs/middleware.md
CHANGED
|
@@ -9,39 +9,35 @@ The framework has two kinds of middleware:
|
|
|
9
9
|
|
|
10
10
|
## Route middleware
|
|
11
11
|
|
|
12
|
-
### Defining
|
|
12
|
+
### Defining middleware
|
|
13
13
|
|
|
14
|
-
Create a file in `app/middleware/`.
|
|
14
|
+
Create a file in `app/middleware/`. Export a default `MiddlewareFn` using `defineMiddleware`:
|
|
15
15
|
|
|
16
16
|
```ts
|
|
17
17
|
// app/middleware/auth.ts
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const auth: RouteMiddleware = async (to, from, next) => {
|
|
18
|
+
export default defineMiddleware(async (to, from) => {
|
|
21
19
|
const session = await getSession()
|
|
22
|
-
if (!session)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
next() // allow navigation
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export default auth
|
|
20
|
+
if (!session) return '/login' // redirect
|
|
21
|
+
return true // allow navigation
|
|
22
|
+
})
|
|
30
23
|
```
|
|
31
24
|
|
|
32
|
-
|
|
25
|
+
`defineMiddleware` is a no-op identity helper — it just provides TypeScript types without
|
|
26
|
+
any runtime overhead. It is auto-imported, so you don't need to import it manually.
|
|
27
|
+
|
|
28
|
+
### `MiddlewareFn` signature
|
|
33
29
|
|
|
34
30
|
```ts
|
|
35
|
-
type
|
|
31
|
+
type GuardResult = boolean | string | Promise<boolean | string>
|
|
36
32
|
|
|
37
|
-
type
|
|
38
|
-
to: Route,
|
|
39
|
-
from: Route | null,
|
|
40
|
-
next: NextFunction,
|
|
41
|
-
) => void | Promise<void>
|
|
33
|
+
type MiddlewareFn = (to: RouteState, from: RouteState | null) => GuardResult
|
|
42
34
|
```
|
|
43
35
|
|
|
44
|
-
|
|
36
|
+
| Return value | Effect |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `true` | Allow navigation |
|
|
39
|
+
| `false` | Block navigation (stay on current route) |
|
|
40
|
+
| `string` | Redirect to that path |
|
|
45
41
|
|
|
46
42
|
---
|
|
47
43
|
|
|
@@ -56,28 +52,60 @@ export const meta = {
|
|
|
56
52
|
}
|
|
57
53
|
```
|
|
58
54
|
|
|
59
|
-
Named middleware runs in addition to any global middleware.
|
|
60
|
-
|
|
61
55
|
---
|
|
62
56
|
|
|
63
57
|
### Multiple middleware
|
|
64
58
|
|
|
59
|
+
Middleware runs in the order listed. The first non-`true` result wins:
|
|
60
|
+
|
|
65
61
|
```ts
|
|
66
62
|
// app/pages/admin.ts
|
|
67
63
|
export const meta = {
|
|
68
64
|
middleware: ['auth', 'admin-role'],
|
|
69
|
-
// Runs: auth → admin-role → page
|
|
65
|
+
// Runs: auth → admin-role → page render
|
|
70
66
|
}
|
|
71
67
|
```
|
|
72
68
|
|
|
73
69
|
---
|
|
74
70
|
|
|
71
|
+
### Execution order within a navigation
|
|
72
|
+
|
|
73
|
+
1. `beforeEnter` fires on the matched route — runs all declared middleware in order
|
|
74
|
+
2. Route state updates (component renders)
|
|
75
|
+
3. `afterEnter` fires (analytics, logging)
|
|
76
|
+
|
|
77
|
+
Redirect loop protection: the router stops after 10 consecutive redirects.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
### Error handling
|
|
82
|
+
|
|
83
|
+
If a middleware function throws (synchronously or asynchronously), navigation is **blocked** — the framework catches the error, logs it, and returns `false` to keep the user on the current route:
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
[cer-app] Middleware "auth" threw an error: Error: session store unavailable
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
This means a crashing middleware is always safe: the user stays put rather than landing on a broken page or being incorrectly redirected. Subsequent middleware in the same chain does not run.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
### TypeScript types
|
|
94
|
+
|
|
95
|
+
`MiddlewareFn` and `GuardResult` are exported from the package if you need them outside of auto-imported files:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import type { MiddlewareFn, GuardResult } from '@jasonshimmy/vite-plugin-cer-app/types'
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
75
103
|
### All middleware files
|
|
76
104
|
|
|
77
105
|
All files in `app/middleware/` are registered and available by name. They are exported from `virtual:cer-middleware`:
|
|
78
106
|
|
|
79
107
|
```ts
|
|
80
|
-
import middleware from 'virtual:cer-middleware'
|
|
108
|
+
import { middleware } from 'virtual:cer-middleware'
|
|
81
109
|
// { auth: [Function], 'admin-role': [Function], ... }
|
|
82
110
|
```
|
|
83
111
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E tests for client-side route middleware (navigation guards).
|
|
3
|
+
*
|
|
4
|
+
* The kitchen-sink app ships an `auth` middleware that redirects unauthenticated
|
|
5
|
+
* users to /login. The /protected page declares `meta: { middleware: ['auth'] }`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
describe('Route middleware', () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
// Ensure no stale auth token from a previous test
|
|
11
|
+
cy.clearLocalStorage()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
context('unauthenticated navigation', () => {
|
|
15
|
+
it('redirects to /login when visiting /protected without a token', () => {
|
|
16
|
+
cy.visit('/protected')
|
|
17
|
+
cy.url().should('include', '/login')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('shows the login page after redirect', () => {
|
|
21
|
+
cy.visit('/protected')
|
|
22
|
+
cy.get('[data-cy=login-heading]').should('contain', 'Login')
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
context('authenticated navigation', () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
cy.visit('/')
|
|
29
|
+
cy.window().then((win) => {
|
|
30
|
+
win.localStorage.setItem('ks-token', '1')
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('allows navigation to /protected when a token is present', () => {
|
|
35
|
+
cy.visit('/protected')
|
|
36
|
+
cy.url().should('include', '/protected')
|
|
37
|
+
cy.get('[data-cy=protected-heading]').should('contain', 'Protected')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('renders the protected page content', () => {
|
|
41
|
+
cy.visit('/protected')
|
|
42
|
+
cy.get('[data-cy=protected-note]').should('exist')
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
// Route middleware — redirects to /login if not authenticated.
|
|
2
2
|
// Set localStorage.setItem('ks-token', '1') to simulate login.
|
|
3
|
-
export default (
|
|
3
|
+
export default defineMiddleware((_to, _from) => {
|
|
4
4
|
const isLoggedIn = typeof localStorage !== 'undefined'
|
|
5
5
|
? !!localStorage.getItem('ks-token')
|
|
6
6
|
: false
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
} else {
|
|
11
|
-
next()
|
|
12
|
-
}
|
|
13
|
-
}
|
|
8
|
+
return isLoggedIn ? true : '/login'
|
|
9
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest'
|
|
2
2
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
3
3
|
import {
|
|
4
|
+
isPathBounded,
|
|
4
5
|
matchRoutePattern,
|
|
5
6
|
findRevalidate,
|
|
6
7
|
findRenderMode,
|
|
@@ -9,6 +10,35 @@ import {
|
|
|
9
10
|
type IsrCacheEntry,
|
|
10
11
|
} from '../../cli/commands/preview-isr.js'
|
|
11
12
|
|
|
13
|
+
// ─── isPathBounded ────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
describe('isPathBounded', () => {
|
|
16
|
+
it('allows normal file paths inside the root', () => {
|
|
17
|
+
expect(isPathBounded('/dist', '/index.html')).toBe(true)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('allows nested paths inside the root', () => {
|
|
21
|
+
expect(isPathBounded('/dist', '/assets/app.js')).toBe(true)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('allows the root path itself', () => {
|
|
25
|
+
expect(isPathBounded('/dist', '/')).toBe(true)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('blocks simple path traversal (../ escape)', () => {
|
|
29
|
+
expect(isPathBounded('/dist', '/../../../../etc/passwd')).toBe(false)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('blocks a path that escapes by one level', () => {
|
|
33
|
+
expect(isPathBounded('/dist', '/../secret')).toBe(false)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('does not confuse a sibling directory for the root', () => {
|
|
37
|
+
// /dist-other starts with /dist but is NOT inside /dist
|
|
38
|
+
expect(isPathBounded('/dist', '/../dist-other/file')).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
12
42
|
// ─── matchRoutePattern ────────────────────────────────────────────────────────
|
|
13
43
|
|
|
14
44
|
describe('matchRoutePattern', () => {
|
|
@@ -245,6 +245,36 @@ describe('cerApp plugin — load hook', () => {
|
|
|
245
245
|
expect(result).toContain('appConfig')
|
|
246
246
|
expect(result).toContain('ssg')
|
|
247
247
|
})
|
|
248
|
+
|
|
249
|
+
it('excludes _runtimePrivateDefaults from the client bundle', async () => {
|
|
250
|
+
const plugin = getCerPlugin({ runtimeConfig: { private: { dbUrl: '', secretKey: '' } } })
|
|
251
|
+
plugin.config({ root: '/project' }, { command: 'serve', mode: 'development' })
|
|
252
|
+
plugin.configResolved(FAKE_RESOLVED)
|
|
253
|
+
// Client load (ssr: false / omitted)
|
|
254
|
+
const clientResult = await plugin.load('\0virtual:cer-app-config') as string
|
|
255
|
+
expect(clientResult).not.toContain('_runtimePrivateDefaults')
|
|
256
|
+
expect(clientResult).not.toContain('dbUrl')
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('includes _runtimePrivateDefaults in the SSR bundle', async () => {
|
|
260
|
+
const plugin = getCerPlugin({ runtimeConfig: { private: { dbUrl: '', secretKey: '' } } })
|
|
261
|
+
plugin.config({ root: '/project' }, { command: 'serve', mode: 'development' })
|
|
262
|
+
plugin.configResolved(FAKE_RESOLVED)
|
|
263
|
+
// SSR load (ssr: true)
|
|
264
|
+
const ssrResult = await plugin.load('\0virtual:cer-app-config', { ssr: true }) as string
|
|
265
|
+
expect(ssrResult).toContain('_runtimePrivateDefaults')
|
|
266
|
+
expect(ssrResult).toContain('dbUrl')
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('caches SSR and client virtual:cer-app-config separately', async () => {
|
|
270
|
+
const plugin = getCerPlugin({ runtimeConfig: { private: { token: '' } } })
|
|
271
|
+
plugin.config({ root: '/project' }, { command: 'serve', mode: 'development' })
|
|
272
|
+
plugin.configResolved(FAKE_RESOLVED)
|
|
273
|
+
const client = await plugin.load('\0virtual:cer-app-config') as string
|
|
274
|
+
const ssr = await plugin.load('\0virtual:cer-app-config', { ssr: true }) as string
|
|
275
|
+
expect(client).not.toContain('_runtimePrivateDefaults')
|
|
276
|
+
expect(ssr).toContain('_runtimePrivateDefaults')
|
|
277
|
+
})
|
|
248
278
|
})
|
|
249
279
|
|
|
250
280
|
describe('cerApp plugin — transform hook', () => {
|
|
@@ -363,6 +393,26 @@ describe('cerApp plugin — buildStart hook', () => {
|
|
|
363
393
|
await plugin.buildStart()
|
|
364
394
|
expect(writeTsconfigPaths).toHaveBeenCalledTimes(1)
|
|
365
395
|
})
|
|
396
|
+
|
|
397
|
+
it('warms virtual:cer-app-config cache under :client key so load() hits it', async () => {
|
|
398
|
+
// After buildStart(), a subsequent client load (ssr: false) should be served
|
|
399
|
+
// from cache (generateAppConfigModule is not a public mock, so we verify by
|
|
400
|
+
// ensuring the load hook returns a non-null result without triggering the
|
|
401
|
+
// real generator — which is mocked at module level to a fixed string '// mock'
|
|
402
|
+
// for other modules; appConfig is not mocked, so it generates real code).
|
|
403
|
+
const plugin = getCerPlugin({ runtimeConfig: { private: { token: '' } } })
|
|
404
|
+
plugin.config({ root: '/project' }, { command: 'build', mode: 'production' })
|
|
405
|
+
plugin.configResolved(FAKE_RESOLVED)
|
|
406
|
+
await plugin.buildStart()
|
|
407
|
+
|
|
408
|
+
// Client load should return a result that does NOT include _runtimePrivateDefaults
|
|
409
|
+
const result = await plugin.load('\0virtual:cer-app-config') as string
|
|
410
|
+
expect(result).not.toContain('_runtimePrivateDefaults')
|
|
411
|
+
|
|
412
|
+
// SSR load (different cache key) should include _runtimePrivateDefaults
|
|
413
|
+
const ssrResult = await plugin.load('\0virtual:cer-app-config', { ssr: true }) as string
|
|
414
|
+
expect(ssrResult).toContain('_runtimePrivateDefaults')
|
|
415
|
+
})
|
|
366
416
|
})
|
|
367
417
|
|
|
368
418
|
describe('cerApp plugin — configureServer hook', () => {
|
|
@@ -155,4 +155,22 @@ describe('resolveConfig', () => {
|
|
|
155
155
|
const cfg = resolveConfig({ runtimeConfig: { public: { foo: 42 } } }, ROOT)
|
|
156
156
|
expect(cfg.runtimeConfig.public.foo).toBe(42)
|
|
157
157
|
})
|
|
158
|
+
|
|
159
|
+
it('defaults runtimeConfig.private to an empty object', () => {
|
|
160
|
+
const cfg = resolveConfig({}, ROOT)
|
|
161
|
+
expect(cfg.runtimeConfig.private).toEqual({})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('passes runtimeConfig.private values through', () => {
|
|
165
|
+
const cfg = resolveConfig({ runtimeConfig: { private: { dbUrl: '', secretKey: '' } } }, ROOT)
|
|
166
|
+
expect(cfg.runtimeConfig.private).toEqual({ dbUrl: '', secretKey: '' })
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('preserves both public and private when both are supplied', () => {
|
|
170
|
+
const cfg = resolveConfig({
|
|
171
|
+
runtimeConfig: { public: { apiBase: '/api' }, private: { token: '' } },
|
|
172
|
+
}, ROOT)
|
|
173
|
+
expect(cfg.runtimeConfig.public).toEqual({ apiBase: '/api' })
|
|
174
|
+
expect(cfg.runtimeConfig.private).toEqual({ token: '' })
|
|
175
|
+
})
|
|
158
176
|
})
|
|
@@ -55,6 +55,22 @@ describe('autoImportTransform — target directory gating', () => {
|
|
|
55
55
|
)
|
|
56
56
|
expect(result).not.toBeNull()
|
|
57
57
|
})
|
|
58
|
+
|
|
59
|
+
it('transforms files in middleware/ (so defineMiddleware is auto-imported)', () => {
|
|
60
|
+
const result = autoImportTransform(
|
|
61
|
+
"export default defineMiddleware(() => true)",
|
|
62
|
+
'/project/app/middleware/auth.ts',
|
|
63
|
+
opts,
|
|
64
|
+
)
|
|
65
|
+
expect(result).not.toBeNull()
|
|
66
|
+
expect(result).toContain('defineMiddleware')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('still returns null for composables/ (not in scope)', () => {
|
|
70
|
+
expect(
|
|
71
|
+
autoImportTransform("export default defineMiddleware(() => true)", '/project/app/composables/useTheme.ts', opts),
|
|
72
|
+
).toBeNull()
|
|
73
|
+
})
|
|
58
74
|
})
|
|
59
75
|
|
|
60
76
|
// ─── No injection needed ─────────────────────────────────────────────────────
|
|
@@ -65,4 +65,19 @@ describe('generateMiddlewareCode', () => {
|
|
|
65
65
|
expect(code).toContain('export const middleware')
|
|
66
66
|
expect(code).toContain('export default middleware')
|
|
67
67
|
})
|
|
68
|
+
|
|
69
|
+
it('prefixes identifier with underscore when filename starts with a digit', async () => {
|
|
70
|
+
vi.mocked(scanDirectory).mockResolvedValue([`${MIDDLEWARE}/2fa.ts`])
|
|
71
|
+
const code = await generateMiddlewareCode(MIDDLEWARE)
|
|
72
|
+
// Leading digit is not valid in a JS identifier — must be prefixed
|
|
73
|
+
expect(code).toContain('_m__2fa')
|
|
74
|
+
expect(code).toContain('"2fa"')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('strips .js extension as well as .ts', async () => {
|
|
78
|
+
vi.mocked(scanDirectory).mockResolvedValue([`${MIDDLEWARE}/auth.js`])
|
|
79
|
+
const code = await generateMiddlewareCode(MIDDLEWARE)
|
|
80
|
+
expect(code).toContain('_m_auth')
|
|
81
|
+
expect(code).toContain('"auth": _m_auth')
|
|
82
|
+
})
|
|
68
83
|
})
|
|
@@ -129,6 +129,38 @@ describe('generateRoutesCode', () => {
|
|
|
129
129
|
const code = await generateRoutesCode(PAGES)
|
|
130
130
|
expect(code).not.toContain('beforeEnter')
|
|
131
131
|
})
|
|
132
|
+
|
|
133
|
+
it('uses return-value API (await handler) not callback-style (new Promise)', async () => {
|
|
134
|
+
vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/dashboard.ts`])
|
|
135
|
+
vi.mocked(readFile).mockResolvedValue(
|
|
136
|
+
`component('page-dashboard', () => html\`<div/>\`)\nexport const meta = { middleware: ['auth'] }` as never,
|
|
137
|
+
)
|
|
138
|
+
const code = await generateRoutesCode(PAGES)
|
|
139
|
+
expect(code).toContain('await handler(to, from)')
|
|
140
|
+
expect(code).not.toContain('new Promise')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('wraps handler call in try-catch (blocks navigation on error)', async () => {
|
|
144
|
+
vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/dashboard.ts`])
|
|
145
|
+
vi.mocked(readFile).mockResolvedValue(
|
|
146
|
+
`component('page-dashboard', () => html\`<div/>\`)\nexport const meta = { middleware: ['auth'] }` as never,
|
|
147
|
+
)
|
|
148
|
+
const code = await generateRoutesCode(PAGES)
|
|
149
|
+
expect(code).toContain('try {')
|
|
150
|
+
expect(code).toContain('} catch (err) {')
|
|
151
|
+
expect(code).toContain('return false')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('logs the middleware name in the error message', async () => {
|
|
155
|
+
vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/dashboard.ts`])
|
|
156
|
+
vi.mocked(readFile).mockResolvedValue(
|
|
157
|
+
`component('page-dashboard', () => html\`<div/>\`)\nexport const meta = { middleware: ['auth'] }` as never,
|
|
158
|
+
)
|
|
159
|
+
const code = await generateRoutesCode(PAGES)
|
|
160
|
+
expect(code).toContain('console.error')
|
|
161
|
+
expect(code).toContain('Middleware')
|
|
162
|
+
expect(code).toContain('err)')
|
|
163
|
+
})
|
|
132
164
|
})
|
|
133
165
|
|
|
134
166
|
// ─── meta.layout ─────────────────────────────────────────────────────────────
|