@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.
Files changed (79) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/ROADMAP.md +278 -0
  3. package/commits.txt +1 -1
  4. package/dist/cli/commands/preview-isr.d.ts +6 -0
  5. package/dist/cli/commands/preview-isr.d.ts.map +1 -1
  6. package/dist/cli/commands/preview-isr.js +12 -0
  7. package/dist/cli/commands/preview-isr.js.map +1 -1
  8. package/dist/cli/commands/preview.d.ts.map +1 -1
  9. package/dist/cli/commands/preview.js +9 -2
  10. package/dist/cli/commands/preview.js.map +1 -1
  11. package/dist/index.d.ts +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/plugin/dev-server.d.ts +1 -0
  14. package/dist/plugin/dev-server.d.ts.map +1 -1
  15. package/dist/plugin/dts-generator.d.ts.map +1 -1
  16. package/dist/plugin/dts-generator.js +4 -2
  17. package/dist/plugin/dts-generator.js.map +1 -1
  18. package/dist/plugin/index.d.ts.map +1 -1
  19. package/dist/plugin/index.js +30 -12
  20. package/dist/plugin/index.js.map +1 -1
  21. package/dist/plugin/transforms/auto-import.d.ts.map +1 -1
  22. package/dist/plugin/transforms/auto-import.js +5 -4
  23. package/dist/plugin/transforms/auto-import.js.map +1 -1
  24. package/dist/plugin/virtual/routes.d.ts.map +1 -1
  25. package/dist/plugin/virtual/routes.js +7 -1
  26. package/dist/plugin/virtual/routes.js.map +1 -1
  27. package/dist/runtime/composables/define-middleware.d.ts +15 -0
  28. package/dist/runtime/composables/define-middleware.d.ts.map +1 -0
  29. package/dist/runtime/composables/define-middleware.js +16 -0
  30. package/dist/runtime/composables/define-middleware.js.map +1 -0
  31. package/dist/runtime/composables/index.d.ts +3 -1
  32. package/dist/runtime/composables/index.d.ts.map +1 -1
  33. package/dist/runtime/composables/index.js +2 -1
  34. package/dist/runtime/composables/index.js.map +1 -1
  35. package/dist/runtime/composables/use-runtime-config.d.ts +32 -14
  36. package/dist/runtime/composables/use-runtime-config.d.ts.map +1 -1
  37. package/dist/runtime/composables/use-runtime-config.js +34 -8
  38. package/dist/runtime/composables/use-runtime-config.js.map +1 -1
  39. package/dist/runtime/entry-server-template.d.ts +1 -1
  40. package/dist/runtime/entry-server-template.d.ts.map +1 -1
  41. package/dist/runtime/entry-server-template.js +7 -3
  42. package/dist/runtime/entry-server-template.js.map +1 -1
  43. package/dist/types/config.d.ts +14 -0
  44. package/dist/types/config.d.ts.map +1 -1
  45. package/dist/types/config.js.map +1 -1
  46. package/dist/types/index.d.ts +2 -2
  47. package/dist/types/index.d.ts.map +1 -1
  48. package/dist/types/middleware.d.ts +8 -2
  49. package/dist/types/middleware.d.ts.map +1 -1
  50. package/docs/cli.md +1 -0
  51. package/docs/composables.md +32 -7
  52. package/docs/configuration.md +53 -3
  53. package/docs/middleware.md +53 -25
  54. package/e2e/cypress/e2e/middleware.cy.ts +45 -0
  55. package/e2e/kitchen-sink/app/middleware/auth.ts +3 -7
  56. package/package.json +1 -1
  57. package/src/__tests__/cli/preview-isr.test.ts +30 -0
  58. package/src/__tests__/plugin/cer-app-plugin.test.ts +50 -0
  59. package/src/__tests__/plugin/resolve-config.test.ts +18 -0
  60. package/src/__tests__/plugin/transforms/auto-import.test.ts +16 -0
  61. package/src/__tests__/plugin/virtual/middleware.test.ts +15 -0
  62. package/src/__tests__/plugin/virtual/routes.test.ts +32 -0
  63. package/src/__tests__/runtime/define-middleware.test.ts +43 -0
  64. package/src/__tests__/runtime/use-runtime-config.test.ts +62 -1
  65. package/src/cli/commands/preview-isr.ts +14 -0
  66. package/src/cli/commands/preview.ts +12 -1
  67. package/src/index.ts +1 -1
  68. package/src/plugin/dev-server.ts +1 -1
  69. package/src/plugin/dts-generator.ts +4 -2
  70. package/src/plugin/index.ts +32 -11
  71. package/src/plugin/transforms/auto-import.ts +5 -4
  72. package/src/plugin/virtual/routes.ts +7 -1
  73. package/src/runtime/composables/define-middleware.ts +17 -0
  74. package/src/runtime/composables/index.ts +3 -1
  75. package/src/runtime/composables/use-runtime-config.ts +57 -11
  76. package/src/runtime/entry-server-template.ts +7 -3
  77. package/src/types/config.ts +15 -0
  78. package/src/types/index.ts +2 -2
  79. package/src/types/middleware.ts +8 -6
@@ -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;CAC7B;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"}
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"}
@@ -1 +1 @@
1
- {"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/types/config.ts"],"names":[],"mappings":"AAwDA,MAAM,UAAU,YAAY,CAAC,MAAoB;IAC/C,OAAO,MAAM,CAAA;AACf,CAAC"}
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"}
@@ -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 { NextFunction, RouteMiddleware, ServerMiddleware } from './middleware.js';
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;AAC/H,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,eAAe,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,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
- export type NextFunction = (redirectTo?: string) => void;
4
- export type RouteMiddleware = (to: RouteState, from: RouteState | null, next: NextFunction) => void | Promise<void>;
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,YAAY,GAAG,CAAC,UAAU,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;AAExD,MAAM,MAAM,eAAe,GAAG,CAC5B,EAAE,EAAE,UAAU,EACd,IAAI,EAAE,UAAU,GAAG,IAAI,EACvB,IAAI,EAAE,YAAY,KACf,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;AAEzB,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"}
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
 
@@ -144,7 +144,9 @@ import { useInject } from '@jasonshimmy/vite-plugin-cer-app/composables'
144
144
 
145
145
  ### `useRuntimeConfig()`
146
146
 
147
- Returns the `public` runtime configuration object set in `cer.config.ts` under `runtimeConfig.public`. Available in all rendering modes (SPA, SSR, SSG) and on both server and client.
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
- featureFlags: { darkMode: true },
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 — auto-imported, no import statement needed
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
- The config is initialized at app boot (both client and server) by calling `initRuntimeConfig(runtimeConfig)` with the value from `virtual:cer-app-config`. You only need `useRuntimeConfig()` to read it.
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.** Secrets, tokens, and private keys must stay in server-only code (loaders, API handlers, server middleware).
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.
@@ -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 public configuration to both server and client code via `useRuntimeConfig()`.
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`. Those should be read directly from `process.env` inside server-only code (loaders, server middleware, API handlers).
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 and will be lost or throw during the build.
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`:
@@ -9,39 +9,35 @@ The framework has two kinds of middleware:
9
9
 
10
10
  ## Route middleware
11
11
 
12
- ### Defining global middleware
12
+ ### Defining middleware
13
13
 
14
- Create a file in `app/middleware/`. It runs before every route navigation:
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
- import type { RouteMiddleware } from '@jasonshimmy/vite-plugin-cer-app/types'
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
- next('/login') // redirect
24
- } else {
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
- ### `RouteMiddleware` signature
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 NextFunction = (redirectTo?: string) => void
31
+ type GuardResult = boolean | string | Promise<boolean | string>
36
32
 
37
- type RouteMiddleware = (
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
- Call `next()` to allow navigation, or `next('/path')` to redirect.
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 (to: any, _from: any, next: (path?: string) => void) => {
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
- if (!isLoggedIn) {
9
- next('/login')
10
- } else {
11
- next()
12
- }
13
- }
8
+ return isLoggedIn ? true : '/login'
9
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jasonshimmy/vite-plugin-cer-app",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Nuxt-style meta-framework for @jasonshimmy/custom-elements-runtime",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -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 ─────────────────────────────────────────────────────────────