hono-preact 0.1.0 → 0.2.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 (88) hide show
  1. package/README.md +2 -1
  2. package/dist/adapter-cloudflare.d.ts +1 -0
  3. package/dist/adapter-cloudflare.d.ts.map +1 -0
  4. package/dist/adapter-cloudflare.js +2 -0
  5. package/dist/adapter-node.d.ts +1 -0
  6. package/dist/adapter-node.d.ts.map +1 -0
  7. package/dist/adapter-node.js +2 -0
  8. package/dist/internal.d.ts +1 -1
  9. package/dist/internal.js +1 -1
  10. package/dist/iso/action.d.ts +10 -14
  11. package/dist/iso/action.js +57 -21
  12. package/dist/iso/define-app.d.ts +7 -0
  13. package/dist/iso/define-app.js +3 -0
  14. package/dist/iso/define-loader.d.ts +19 -0
  15. package/dist/iso/define-loader.js +4 -0
  16. package/dist/iso/define-middleware.d.ts +43 -0
  17. package/dist/iso/define-middleware.js +6 -0
  18. package/dist/iso/define-page.d.ts +7 -2
  19. package/dist/iso/define-page.js +1 -1
  20. package/dist/iso/define-routes.d.ts +24 -1
  21. package/dist/iso/define-routes.js +34 -0
  22. package/dist/iso/define-stream-observer.d.ts +20 -0
  23. package/dist/iso/define-stream-observer.js +3 -0
  24. package/dist/iso/index.d.ts +10 -5
  25. package/dist/iso/index.js +5 -3
  26. package/dist/iso/internal/contexts.d.ts +0 -2
  27. package/dist/iso/internal/contexts.js +0 -1
  28. package/dist/iso/internal/loader-fetch.js +37 -7
  29. package/dist/iso/internal/loader-runner.js +105 -8
  30. package/dist/iso/internal/middleware-runner.d.ts +22 -0
  31. package/dist/iso/internal/middleware-runner.js +79 -0
  32. package/dist/iso/internal/page-middleware-host.d.ts +13 -0
  33. package/dist/iso/internal/page-middleware-host.js +119 -0
  34. package/dist/iso/internal/route-boundary.d.ts +1 -0
  35. package/dist/iso/internal/route-boundary.js +16 -0
  36. package/dist/iso/internal/stream-observer-runner.d.ts +13 -0
  37. package/dist/iso/internal/stream-observer-runner.js +48 -0
  38. package/dist/iso/internal/use-partitioner.d.ts +9 -0
  39. package/dist/iso/internal/use-partitioner.js +11 -0
  40. package/dist/iso/internal/use-types.d.ts +7 -0
  41. package/dist/iso/internal/use-types.js +1 -0
  42. package/dist/iso/internal.d.ts +5 -4
  43. package/dist/iso/internal.js +8 -6
  44. package/dist/iso/outcomes.d.ts +38 -0
  45. package/dist/iso/outcomes.js +56 -0
  46. package/dist/iso/page-only.d.ts +5 -0
  47. package/dist/iso/page-only.js +20 -0
  48. package/dist/iso/page.d.ts +3 -3
  49. package/dist/iso/page.js +3 -3
  50. package/dist/page.d.ts +1 -0
  51. package/dist/page.d.ts.map +1 -0
  52. package/dist/page.js +8 -0
  53. package/dist/server/actions-handler.d.ts +20 -6
  54. package/dist/server/actions-handler.js +83 -47
  55. package/dist/server/context.js +1 -1
  56. package/dist/server/index.d.ts +1 -1
  57. package/dist/server/index.js +1 -1
  58. package/dist/server/loaders-handler.d.ts +16 -0
  59. package/dist/server/loaders-handler.js +94 -17
  60. package/dist/server/render.d.ts +2 -0
  61. package/dist/server/render.js +104 -33
  62. package/dist/server/route-server-modules.d.ts +42 -1
  63. package/dist/server/route-server-modules.js +184 -0
  64. package/dist/server/sse.d.ts +24 -1
  65. package/dist/server/sse.js +56 -4
  66. package/dist/vite/adapter-cloudflare.d.ts +2 -0
  67. package/dist/vite/adapter-cloudflare.js +25 -0
  68. package/dist/vite/adapter-node.d.ts +2 -0
  69. package/dist/vite/adapter-node.js +49 -0
  70. package/dist/vite/adapter.d.ts +29 -0
  71. package/dist/vite/adapter.js +1 -0
  72. package/dist/vite/client-shim.js +5 -4
  73. package/dist/vite/guard-strip.js +52 -27
  74. package/dist/vite/hono-preact.d.ts +6 -6
  75. package/dist/vite/hono-preact.js +48 -77
  76. package/dist/vite/index.d.ts +2 -1
  77. package/dist/vite/index.js +1 -1
  78. package/dist/vite/node-dev-server.d.ts +4 -0
  79. package/dist/vite/node-dev-server.js +121 -0
  80. package/dist/vite/server-entry.d.ts +30 -7
  81. package/dist/vite/server-entry.js +161 -78
  82. package/dist/vite/server-exports-contract.d.ts +6 -0
  83. package/dist/vite/server-exports-contract.js +43 -0
  84. package/dist/vite/server-loader-validation.js +36 -9
  85. package/dist/vite/server-loaders-parser.d.ts +17 -1
  86. package/dist/vite/server-loaders-parser.js +41 -0
  87. package/dist/vite/server-only.js +20 -2
  88. package/package.json +32 -4
@@ -2,10 +2,18 @@ import * as path from 'node:path';
2
2
  import * as fs from 'node:fs';
3
3
  import { parse } from '@babel/parser';
4
4
  import { BABEL_PARSER_PLUGINS } from './parser-options.js';
5
- export function generateServerEntrySource(opts) {
6
- const { layoutAbsPath, routesAbsPath, apiAbsPath } = opts;
5
+ export function generateCoreAppModule(opts) {
6
+ const { layoutAbsPath, routesAbsPath, apiAbsPath, appConfigAbsPath } = opts;
7
7
  const apiImport = apiAbsPath ? `import userApp from '${apiAbsPath}';\n` : '';
8
8
  const apiMount = apiAbsPath ? ` .route('/', userApp)\n` : '';
9
+ // appConfig is optional: when no app-config.ts file exists, fall back to
10
+ // an empty config so the handler chain composition (root -> page -> unit)
11
+ // still works without the user authoring anything. The default-export
12
+ // shape mirrors the `import appConfig from './app-config'` convention so
13
+ // consumers can adopt the file later without other entry changes.
14
+ const appConfigImport = appConfigAbsPath
15
+ ? `import appConfig from '${appConfigAbsPath}';\n`
16
+ : `const appConfig = { use: [] };\n`;
9
17
  // The generated source is loaded as a virtual module, which Vite/esbuild
10
18
  // treats as plain JS by default. Use h() to construct vnodes rather than
11
19
  // JSX so the source compiles without a TSX loader hint.
@@ -16,25 +24,32 @@ export function generateServerEntrySource(opts) {
16
24
  `import {\n` +
17
25
  ` actionsHandler,\n` +
18
26
  ` loadersHandler,\n` +
27
+ ` makePageUseResolvers,\n` +
19
28
  ` renderPage,\n` +
20
29
  ` routeServerModules,\n` +
21
30
  `} from 'hono-preact/server';\n` +
22
31
  `import Layout from '${layoutAbsPath}';\n` +
23
32
  `import routes from '${routesAbsPath}';\n` +
24
33
  apiImport +
34
+ appConfigImport +
25
35
  `\n` +
26
36
  `env.current = 'server';\n` +
37
+ `const dev = import.meta.env.DEV;\n` +
27
38
  `const serverModules = routeServerModules(routes);\n` +
28
- `const handlerOpts = { dev: import.meta.env.DEV };\n` +
39
+ `const pageUseResolvers = makePageUseResolvers(routes.serverRoutes, { dev });\n` +
29
40
  `\n` +
30
41
  `export const app = new Hono()\n` +
31
- ` .post('/__loaders', loadersHandler(serverModules, handlerOpts))\n` +
32
- ` .post('/__actions', actionsHandler(serverModules, handlerOpts))\n` +
33
42
  apiMount +
34
- ` .get('*', (c) => renderPage(c, h(Layout, null, h(LocationProvider, null, h(Routes, { routes })))));\n` +
43
+ ` .post('/__loaders', loadersHandler(serverModules, { dev, appConfig, resolvePageUse: pageUseResolvers.byPath }))\n` +
44
+ ` .post('/__actions', actionsHandler(serverModules, { dev, appConfig, resolvePageUse: pageUseResolvers.byModuleKey }))\n` +
45
+ ` .get('*', (c) => renderPage(c, h(Layout, null, h(LocationProvider, null, h(Routes, { routes }))), { appConfig }));\n` +
35
46
  `\n` +
36
47
  `export default app;\n`);
37
48
  }
49
+ // Framework-reserved request paths. A literal registration of either in
50
+ // api.ts shadows the framework's RPC handler now that the user app mounts
51
+ // ahead of them.
52
+ const RESERVED_PATHS = new Set(['/__loaders', '/__actions']);
38
53
  const HONO_METHODS = new Set([
39
54
  'get',
40
55
  'post',
@@ -57,8 +72,8 @@ const FUNCTION_BODY_PARENTS = new Set([
57
72
  'ObjectMethod',
58
73
  'ClassMethod',
59
74
  ]);
60
- export function findApiCatchAllRoutes(source) {
61
- const warnings = [];
75
+ export function findApiShadowingRoutes(source) {
76
+ const found = [];
62
77
  let ast;
63
78
  try {
64
79
  ast = parse(source, {
@@ -70,23 +85,23 @@ export function findApiCatchAllRoutes(source) {
70
85
  catch (err) {
71
86
  // If api.ts won't parse, the build will fail elsewhere with a clearer
72
87
  // error. Surface a note so the framework user can correlate a missing
73
- // catch-all warning with a parse-time syntax issue rather than wondering
88
+ // shadowing warning with a parse-time syntax issue rather than wondering
74
89
  // why nothing was reported.
75
90
  const msg = err instanceof Error ? err.message : String(err);
76
- console.warn(`[hono-preact] Failed to parse api.ts for catch-all route detection: ${msg}. ` +
91
+ console.warn(`[hono-preact] Failed to parse api.ts for shadowing-route detection: ${msg}. ` +
77
92
  `The build will surface the real syntax error; this warning explains why ` +
78
- `route-overlap warnings may be missing.`);
79
- return warnings;
93
+ `route-overlap diagnostics may be missing.`);
94
+ return found;
80
95
  }
81
- walk(ast.program, warnings);
82
- return warnings;
96
+ walk(ast.program, found);
97
+ return found;
83
98
  }
84
- function walk(node, warnings) {
99
+ function walk(node, found) {
85
100
  if (!node || typeof node !== 'object')
86
101
  return;
87
102
  if (Array.isArray(node)) {
88
103
  for (const child of node)
89
- walk(child, warnings);
104
+ walk(child, found);
90
105
  return;
91
106
  }
92
107
  const n = node;
@@ -97,19 +112,32 @@ function walk(node, warnings) {
97
112
  const method = n.callee.property.name;
98
113
  const line = n.loc?.start?.line;
99
114
  if (method === 'notFound') {
100
- warnings.push({ kind: 'notFound', line });
115
+ found.push({ kind: 'notFound', line, severity: 'warning' });
101
116
  }
102
117
  else if (HONO_METHODS.has(method)) {
103
- const firstArg = n.arguments?.[0];
104
- if (firstArg?.type === 'StringLiteral' &&
105
- typeof firstArg.value === 'string' &&
106
- WILDCARD_PATTERNS.has(firstArg.value)) {
107
- warnings.push({
108
- kind: 'wildcard',
109
- method,
110
- pattern: firstArg.value,
111
- line,
112
- });
118
+ // `app.on(method, path, ...)` puts the path at argument index 1;
119
+ // every other Hono routing method takes the path as argument 0.
120
+ const pathArg = n.arguments?.[method === 'on' ? 1 : 0];
121
+ if (pathArg?.type === 'StringLiteral' &&
122
+ typeof pathArg.value === 'string') {
123
+ if (WILDCARD_PATTERNS.has(pathArg.value)) {
124
+ found.push({
125
+ kind: 'wildcard',
126
+ method,
127
+ pattern: pathArg.value,
128
+ line,
129
+ severity: 'error',
130
+ });
131
+ }
132
+ else if (RESERVED_PATHS.has(pathArg.value)) {
133
+ found.push({
134
+ kind: 'reserved',
135
+ method,
136
+ pattern: pathArg.value,
137
+ line,
138
+ severity: 'error',
139
+ });
140
+ }
113
141
  }
114
142
  }
115
143
  }
@@ -121,81 +149,136 @@ function walk(node, warnings) {
121
149
  continue;
122
150
  if (isFunctionParent && key === 'body')
123
151
  continue;
124
- walk(node[key], warnings);
152
+ walk(node[key], found);
125
153
  }
126
154
  }
127
- // Project-relative path of the on-disk file the plugin writes during
128
- // configResolved. We use a relative path because @hono/vite-build prepends
129
- // `/` to the entry when constructing its `import.meta.glob([...])`, and
130
- // absolute paths produce a `//Users/...` double-slash that's brittle in the
131
- // Cloudflare Workers runtime. Project-relative keeps the resulting glob
132
- // pattern (`/node_modules/.vite/hono-preact/server-entry.tsx`) clean.
133
- //
134
- // We write to disk rather than register a virtual module because
135
- // @hono/vite-build resolves its entry via `import.meta.glob([entry])`, which
136
- // cannot match a `virtual:*` id. The Cloudflare Workers runtime then fails
137
- // with "Can't import modules from ['/virtual:...']" when it tries to load the
138
- // generated bundle.
139
- export const GENERATED_SERVER_ENTRY_RELATIVE = 'node_modules/.vite/hono-preact/server-entry.tsx';
140
- export function generatedServerEntryAbsPath(cwd = process.cwd()) {
141
- return path.resolve(cwd, GENERATED_SERVER_ENTRY_RELATIVE);
155
+ // Both generated files live in the Vite cache dir. The wrapper keeps the
156
+ // `server-entry.tsx` name because that is the file the adapter's build/dev
157
+ // plugins (and wrangler.jsonc `main`) point at; the core app module is a
158
+ // separate file the wrapper imports.
159
+ export const GENERATED_CORE_APP_RELATIVE = 'node_modules/.vite/hono-preact/core-app.tsx';
160
+ export const GENERATED_ENTRY_WRAPPER_RELATIVE = 'node_modules/.vite/hono-preact/server-entry.tsx';
161
+ export function generatedCoreAppAbsPath(cwd = process.cwd()) {
162
+ return path.resolve(cwd, GENERATED_CORE_APP_RELATIVE);
163
+ }
164
+ export function generatedEntryWrapperAbsPath(cwd = process.cwd()) {
165
+ return path.resolve(cwd, GENERATED_ENTRY_WRAPPER_RELATIVE);
166
+ }
167
+ // Returns true if the parsed program contains a top-level
168
+ // `export default ...`. The app-config diagnostic uses this to detect a
169
+ // common mistake: writing `export const appConfig = defineApp(...)`
170
+ // instead of `export default defineApp(...)`. Without a default the
171
+ // generated `import appConfig from '...'` binds to undefined and the
172
+ // app-level middleware chain silently never runs.
173
+ function hasDefaultExport(source) {
174
+ let ast;
175
+ try {
176
+ ast = parse(source, {
177
+ sourceType: 'module',
178
+ plugins: BABEL_PARSER_PLUGINS,
179
+ errorRecovery: true,
180
+ });
181
+ }
182
+ catch {
183
+ // Fall back to "true" on parse failure so we don't pile a misleading
184
+ // app-config error on top of an obvious syntax error elsewhere in the
185
+ // file. The real parse error surfaces from the Vite build itself.
186
+ return true;
187
+ }
188
+ for (const node of ast.program.body) {
189
+ if (node.type === 'ExportDefaultDeclaration')
190
+ return true;
191
+ }
192
+ return false;
142
193
  }
143
194
  export function serverEntryPlugin(opts) {
144
- // Paths resolved during configResolved (cheap) — actual disk write
145
- // happens in buildStart so config-only Vite invocations (IDE probes,
146
- // typecheck-only runs, dependency-optimizer cold runs) don't side-effect
147
- // the cache directory. Writing on every config resolution was a tax that
148
- // showed up when integration tests loaded vite.config.ts to inspect the
149
- // plugin chain.
150
- let layoutAbsPath;
151
- let routesAbsPath;
152
195
  let apiAbsPath;
196
+ let appConfigAbsPath;
153
197
  return {
154
198
  name: 'hono-preact:server-entry',
155
199
  enforce: 'pre',
156
- configResolved(config) {
157
- layoutAbsPath = path.isAbsolute(opts.layout)
200
+ // Write generated files in `config` -- the earliest hook -- so the entry
201
+ // wrapper exists before @cloudflare/vite-plugin's own `config` hook does
202
+ // fs.existsSync on wrangler.jsonc `main`.
203
+ config(userConfig) {
204
+ const root = userConfig.root
205
+ ? path.resolve(userConfig.root)
206
+ : process.cwd();
207
+ const layoutAbsPath = path.isAbsolute(opts.layout)
158
208
  ? opts.layout
159
- : path.resolve(config.root, opts.layout);
160
- routesAbsPath = path.isAbsolute(opts.routes)
209
+ : path.resolve(root, opts.layout);
210
+ const routesAbsPath = path.isAbsolute(opts.routes)
161
211
  ? opts.routes
162
- : path.resolve(config.root, opts.routes);
212
+ : path.resolve(root, opts.routes);
163
213
  const candidateApi = path.isAbsolute(opts.api)
164
214
  ? opts.api
165
- : path.resolve(config.root, opts.api);
215
+ : path.resolve(root, opts.api);
166
216
  apiAbsPath = fs.existsSync(candidateApi) ? candidateApi : undefined;
167
- },
168
- buildStart() {
169
- // configResolved must have run first. Bail loudly if not — silent
170
- // emission of a stub would be much harder to debug.
171
- if (!layoutAbsPath || !routesAbsPath) {
172
- throw new Error('[hono-preact] server-entry buildStart fired before configResolved; ' +
173
- 'layout/routes paths were never resolved.');
174
- }
175
- const source = generateServerEntrySource({
217
+ const candidateAppConfig = path.isAbsolute(opts.appConfig)
218
+ ? opts.appConfig
219
+ : path.resolve(root, opts.appConfig);
220
+ appConfigAbsPath = fs.existsSync(candidateAppConfig)
221
+ ? candidateAppConfig
222
+ : undefined;
223
+ const source = generateCoreAppModule({
176
224
  layoutAbsPath,
177
225
  routesAbsPath,
178
226
  apiAbsPath,
227
+ appConfigAbsPath,
228
+ });
229
+ fs.mkdirSync(path.dirname(opts.coreAppPath), { recursive: true });
230
+ fs.writeFileSync(opts.coreAppPath, source, 'utf8');
231
+ const wrapper = opts.adapter.wrapEntry({
232
+ root,
233
+ coreAppModuleId: opts.coreAppPath,
234
+ entryWrapperId: opts.entryWrapperPath,
235
+ apiModuleId: apiAbsPath,
179
236
  });
180
- fs.mkdirSync(path.dirname(opts.outputPath), { recursive: true });
181
- fs.writeFileSync(opts.outputPath, source, 'utf8');
237
+ fs.writeFileSync(opts.entryWrapperPath, wrapper, 'utf8');
238
+ },
239
+ buildStart() {
240
+ // The api.ts shadowing diagnostic stays in buildStart: it needs
241
+ // this.warn / this.error, which the `config` hook context lacks.
242
+ // The app-config default-export diagnostic lives here for the same
243
+ // reason.
244
+ if (appConfigAbsPath) {
245
+ const appConfigSource = fs.readFileSync(appConfigAbsPath, 'utf8');
246
+ if (!hasDefaultExport(appConfigSource)) {
247
+ this.error(`[hono-preact] ${appConfigAbsPath}: app-config.ts must default-export ` +
248
+ `the result of defineApp(...) (e.g. ` +
249
+ `\`export default defineApp({ use: [...] })\`). The generated entry ` +
250
+ `does \`import appConfig from '...'\`; without a default export the ` +
251
+ `import resolves to undefined and the app-level middleware chain ` +
252
+ `silently never runs.`);
253
+ }
254
+ }
182
255
  if (!apiAbsPath)
183
256
  return;
184
257
  const apiSource = fs.readFileSync(apiAbsPath, 'utf8');
185
- const warnings = findApiCatchAllRoutes(apiSource);
186
- for (const w of warnings) {
187
- const where = `${apiAbsPath}${w.line != null ? `:${w.line}` : ''}`;
188
- if (w.kind === 'notFound') {
189
- this.warn(`[hono-preact] ${where}: app.notFound(...) acts as a catch-all and ` +
190
- `will be shadowed by the framework's renderPage handler. ` +
191
- `Move the behavior to a more specific path, or accept that it won't fire.`);
258
+ const shadowing = findApiShadowingRoutes(apiSource);
259
+ const errors = [];
260
+ for (const r of shadowing) {
261
+ const where = `${apiAbsPath}${r.line != null ? `:${r.line}` : ''}`;
262
+ if (r.kind === 'notFound') {
263
+ this.warn(`[hono-preact] ${where}: app.notFound(...) will not fire the ` +
264
+ `framework's renderPage handler matches every unmatched request. ` +
265
+ `Move the behavior to a specific path, or accept that it won't fire.`);
266
+ }
267
+ else if (r.kind === 'wildcard') {
268
+ errors.push(`${where}: app.${r.method}('${r.pattern}', ...) is a catch-all route`);
192
269
  }
193
270
  else {
194
- this.warn(`[hono-preact] ${where}: app.${w.method}('${w.pattern}', ...) is a ` +
195
- `catch-all route and will be shadowed by the framework's renderPage ` +
196
- `handler. Move it to a more specific path, or accept that it won't fire.`);
271
+ errors.push(`${where}: app.${r.method}('${r.pattern}', ...) registers the ` +
272
+ `framework-reserved path '${r.pattern}'`);
197
273
  }
198
274
  }
275
+ if (errors.length > 0) {
276
+ this.error(`[hono-preact] api.ts registers routes that shadow framework handlers:\n` +
277
+ errors.map((e) => ` - ${e}`).join('\n') +
278
+ `\nThe framework mounts your app ahead of its reserved paths ` +
279
+ `(/__loaders, /__actions) and the SSR handler, so these routes break ` +
280
+ `loaders/actions and/or page rendering. Use specific, non-wildcard paths.`);
281
+ }
199
282
  },
200
283
  };
201
284
  }
@@ -0,0 +1,6 @@
1
+ export declare const RECOGNIZED_SERVER_EXPORTS: readonly ["serverActions", "serverLoaders", "pageUse", "loaderUse", "actionUse"];
2
+ export type RecognizedServerExport = (typeof RECOGNIZED_SERVER_EXPORTS)[number];
3
+ export declare const RECOGNIZED_SERVER_EXPORTS_SET: ReadonlySet<string>;
4
+ export declare const RECOGNIZED_USE_EXPORTS: readonly ["pageUse", "loaderUse", "actionUse"];
5
+ export type RecognizedUseExport = (typeof RECOGNIZED_USE_EXPORTS)[number];
6
+ export declare const RECOGNIZED_USE_EXPORTS_SET: ReadonlySet<string>;
@@ -0,0 +1,43 @@
1
+ // Single source of truth for the recognized named exports a `.server.*`
2
+ // file may declare. Three plugins enforce this contract and used to keep
3
+ // independent copies of the list, which drifted silently when a new name
4
+ // was added:
5
+ //
6
+ // - `server-only.ts` rejects unknown specifiers when client code imports
7
+ // from a `.server.*` module.
8
+ // - `server-loader-validation.ts` runs at build time on the `.server.*`
9
+ // file itself and rejects unknown top-level named exports.
10
+ // - `server-loaders-parser.ts` documents the middleware-carrying subset
11
+ // (`pageUse`/`loaderUse`/`actionUse`) used by the route map builder.
12
+ //
13
+ // Centralizing the list here forces all three to agree by import.
14
+ //
15
+ // Status of each entry:
16
+ // - `serverLoaders` / `serverActions`: the two value-bearing exports the
17
+ // handlers read at runtime.
18
+ // - `pageUse`: page-layer middleware chain composed into the resolver map
19
+ // in `route-server-modules.ts`. Load-bearing.
20
+ // - `loaderUse` / `actionUse`: reserved names for future per-file
21
+ // middleware. The handlers do not yet read them; per-unit middleware
22
+ // rides on `defineLoader({ use })` / `defineAction({ use })`. Plan
23
+ // item E7 may drop them in a follow-up; until then they're recognized
24
+ // so users who write them get an array stub on the client and a
25
+ // passing build instead of an opaque "unknown export" error.
26
+ export const RECOGNIZED_SERVER_EXPORTS = [
27
+ 'serverActions',
28
+ 'serverLoaders',
29
+ 'pageUse',
30
+ 'loaderUse',
31
+ 'actionUse',
32
+ ];
33
+ export const RECOGNIZED_SERVER_EXPORTS_SET = new Set(RECOGNIZED_SERVER_EXPORTS);
34
+ // Subset of RECOGNIZED_SERVER_EXPORTS that names a middleware-carrying
35
+ // array. Used by the validation plugin to enforce that values are
36
+ // `ArrayExpression` literals (so a typo like `pageUse = singleMw` fails
37
+ // the build instead of silently disabling the gate at runtime).
38
+ export const RECOGNIZED_USE_EXPORTS = [
39
+ 'pageUse',
40
+ 'loaderUse',
41
+ 'actionUse',
42
+ ];
43
+ export const RECOGNIZED_USE_EXPORTS_SET = new Set(RECOGNIZED_USE_EXPORTS);
@@ -1,13 +1,8 @@
1
1
  import { parse } from '@babel/parser';
2
2
  import { BABEL_PARSER_PLUGINS } from './parser-options.js';
3
- const ALLOWED_NAMED_EXPORTS = new Set([
4
- 'serverActions',
5
- 'actionGuards',
6
- 'serverLoaders',
7
- ]);
8
- const ALLOWED_NAMED_EXPORTS_LIST = [...ALLOWED_NAMED_EXPORTS]
9
- .map((n) => `'${n}'`)
10
- .join(', ');
3
+ import { RECOGNIZED_SERVER_EXPORTS, RECOGNIZED_SERVER_EXPORTS_SET, } from './server-exports-contract.js';
4
+ import { findUseExports } from './server-loaders-parser.js';
5
+ const ALLOWED_NAMED_EXPORTS_LIST = RECOGNIZED_SERVER_EXPORTS.map((n) => `'${n}'`).join(', ');
11
6
  export function serverLoaderValidationPlugin() {
12
7
  return {
13
8
  name: 'server-loader-validation',
@@ -51,7 +46,39 @@ export function serverLoaderValidationPlugin() {
51
46
  }
52
47
  }
53
48
  }
54
- const disallowedExports = namedExports.filter((n) => !ALLOWED_NAMED_EXPORTS.has(n));
49
+ // F3: pageUse / loaderUse / actionUse must resolve to an array at
50
+ // runtime so the route-map builder and dispatcher receive a real
51
+ // ReadonlyArray. We can't statically prove that an identifier (e.g.
52
+ // `pageUse = requireSession` re-exporting an array from another
53
+ // module) holds an array, so we reject only the obviously-wrong
54
+ // literal shapes here and let the runtime guard in
55
+ // `makePageUseResolvers` catch indirect non-array values at first
56
+ // request. The literal denylist still catches the canonical typo
57
+ // case (`pageUse = singleMwObject`). findUseExports is shared with
58
+ // server-loaders-parser so the recognized-name list stays in one
59
+ // place.
60
+ const REJECTED_LITERAL_TYPES = new Set([
61
+ 'ObjectExpression',
62
+ 'NumericLiteral',
63
+ 'StringLiteral',
64
+ 'BooleanLiteral',
65
+ 'NullLiteral',
66
+ 'RegExpLiteral',
67
+ 'TemplateLiteral',
68
+ 'BigIntLiteral',
69
+ ]);
70
+ for (const useExport of findUseExports(ast.program)) {
71
+ if (useExport.init == null)
72
+ continue;
73
+ if (REJECTED_LITERAL_TYPES.has(useExport.init.type)) {
74
+ errors.push(`${id}: \`${useExport.name}\` must be an array literal or an ` +
75
+ `identifier that points to an array ` +
76
+ `(e.g. \`export const ${useExport.name} = [requireAuth]\` or ` +
77
+ `\`export const ${useExport.name} = requireSession\`). ` +
78
+ `A non-array value silently disables the middleware at runtime.`);
79
+ }
80
+ }
81
+ const disallowedExports = namedExports.filter((n) => !RECOGNIZED_SERVER_EXPORTS_SET.has(n));
55
82
  if (disallowedExports.length > 0) {
56
83
  errors.push(`${id}: .server files may only export ${ALLOWED_NAMED_EXPORTS_LIST} as named exports (found: ${disallowedExports.join(', ')}). ` +
57
84
  `Export the server loader as the default export only.`);
@@ -1,4 +1,20 @@
1
- import type { Program, CallExpression, ObjectExpression } from '@babel/types';
1
+ import type { Program, CallExpression, ObjectExpression, Expression } from '@babel/types';
2
+ export declare const RECOGNIZED_USE_EXPORTS: ReadonlySet<string>;
3
+ export declare function hasNamedUseExport(program: Program, name: string): boolean;
4
+ export type ParsedUseExport = {
5
+ /** The export name -- one of pageUse / loaderUse / actionUse. */
6
+ name: string;
7
+ /** The initializer expression, or null if `export const foo;` with no init. */
8
+ init: Expression | null;
9
+ };
10
+ /**
11
+ * Walk a parsed program for top-level `export const <use> = ...` declarations
12
+ * where `<use>` is one of the recognized middleware-carrying names
13
+ * (`pageUse` / `loaderUse` / `actionUse`). Returns each one with its
14
+ * initializer expression so callers can validate the shape (e.g. require
15
+ * an ArrayExpression literal).
16
+ */
17
+ export declare function findUseExports(program: Program): ParsedUseExport[];
2
18
  export type ParsedLoaderEntry = {
3
19
  /** Loader name (the key in serverLoaders, e.g. "summary"). */
4
20
  name: string;
@@ -1,3 +1,44 @@
1
+ import { RECOGNIZED_USE_EXPORTS_SET } from './server-exports-contract.js';
2
+ // Re-exported for backward compatibility. The canonical list now lives in
3
+ // `server-exports-contract.ts` so the server-only stub plugin, the
4
+ // validation plugin, and this parser all agree by import. New consumers
5
+ // should import directly from the contract file.
6
+ export const RECOGNIZED_USE_EXPORTS = RECOGNIZED_USE_EXPORTS_SET;
7
+ export function hasNamedUseExport(program, name) {
8
+ for (const stmt of program.body) {
9
+ if (stmt.type !== 'ExportNamedDeclaration' ||
10
+ stmt.declaration?.type !== 'VariableDeclaration')
11
+ continue;
12
+ for (const decl of stmt.declaration.declarations) {
13
+ if (decl.id.type === 'Identifier' && decl.id.name === name)
14
+ return true;
15
+ }
16
+ }
17
+ return false;
18
+ }
19
+ /**
20
+ * Walk a parsed program for top-level `export const <use> = ...` declarations
21
+ * where `<use>` is one of the recognized middleware-carrying names
22
+ * (`pageUse` / `loaderUse` / `actionUse`). Returns each one with its
23
+ * initializer expression so callers can validate the shape (e.g. require
24
+ * an ArrayExpression literal).
25
+ */
26
+ export function findUseExports(program) {
27
+ const found = [];
28
+ for (const stmt of program.body) {
29
+ if (stmt.type !== 'ExportNamedDeclaration' ||
30
+ stmt.declaration?.type !== 'VariableDeclaration')
31
+ continue;
32
+ for (const decl of stmt.declaration.declarations) {
33
+ if (decl.id.type !== 'Identifier')
34
+ continue;
35
+ if (!RECOGNIZED_USE_EXPORTS_SET.has(decl.id.name))
36
+ continue;
37
+ found.push({ name: decl.id.name, init: decl.init ?? null });
38
+ }
39
+ }
40
+ return found;
41
+ }
1
42
  /**
2
43
  * Walk a parsed program for `export const serverLoaders = { name: defineLoader(fn, opts?), ... }`.
3
44
  * Returns one entry per object property whose value is a defineLoader(...) call.
@@ -5,6 +5,11 @@ import MagicString from 'magic-string';
5
5
  import { deriveModuleKey } from './module-key.js';
6
6
  import { parseServerLoaders, readParamsOpt } from './server-loaders-parser.js';
7
7
  import { BABEL_PARSER_PLUGINS } from './parser-options.js';
8
+ import { RECOGNIZED_SERVER_EXPORTS } from './server-exports-contract.js';
9
+ // The unknown-specifier rejection message lists every recognized server
10
+ // export so a user can immediately see the valid set. The list is derived
11
+ // from the shared contract so it cannot drift from the validation plugin.
12
+ const ALLOWED_SPECIFIERS_LIST = RECOGNIZED_SERVER_EXPORTS.join(', ');
8
13
  // Symbol-keyed accessor used by unit tests to verify `configResolved` fires
9
14
  // and captures the root. Hidden behind a Symbol so it does not appear in IDE
10
15
  // autocomplete for the public Plugin surface.
@@ -184,13 +189,26 @@ export function serverOnlyPlugin() {
184
189
  }
185
190
  else if (specifier.type === 'ImportSpecifier' &&
186
191
  specifier.imported.type === 'Identifier' &&
187
- specifier.imported.name === 'actionGuards') {
192
+ (specifier.imported.name === 'pageUse' ||
193
+ specifier.imported.name === 'loaderUse' ||
194
+ specifier.imported.name === 'actionUse')) {
195
+ // Middleware-carrying named exports never run on the client; the
196
+ // client only needs the array to exist so user imports don't
197
+ // crash. The real `use` arrays live on the server side and are
198
+ // wired into the dispatcher via pageUse resolvers.
188
199
  stubs.push(`const ${specifier.local.name} = [];`);
189
200
  }
190
201
  else if (specifier.type === 'ImportSpecifier' &&
191
202
  specifier.imported.type === 'Identifier' &&
192
203
  specifier.imported.name === 'serverActions') {
193
204
  needsUseActionImport = true;
205
+ // F9: each `serverActions.<name>` read constructs a fresh
206
+ // stub object, so `serverActions.create !== serverActions.create`
207
+ // across two reads. Not new in the middleware refactor, but
208
+ // worth flagging: callers that store the stub in a variable
209
+ // and treat it as a stable identity (e.g. `Map` keys) will
210
+ // surprise themselves. The contract is "stubs are descriptor
211
+ // records, not singletons."
194
212
  stubs.push(`const ${specifier.local.name} = new Proxy({}, {\n` +
195
213
  ` get(_, action) {\n` +
196
214
  ` const stub = { __module: ${JSON.stringify(moduleKey)}, __action: String(action) };\n` +
@@ -207,7 +225,7 @@ export function serverOnlyPlugin() {
207
225
  ? '* as ' + specifier.local.name
208
226
  : '<unknown>';
209
227
  throw new Error(`${id}: \`${importedName}\` is not a recognized export from a *.server.* module. ` +
210
- `Allowed: serverLoaders, serverActions, actionGuards.`);
228
+ `Allowed: ${ALLOWED_SPECIFIERS_LIST}.`);
211
229
  }
212
230
  }
213
231
  if (stubs.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hono-preact",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Hono on the edge, Preact in the browser, manifest driven routes, typed RPC, streaming everywhere.",
5
5
  "keywords": [
6
6
  "hono",
@@ -36,6 +36,10 @@
36
36
  "types": "./dist/index.d.ts",
37
37
  "import": "./dist/index.js"
38
38
  },
39
+ "./page": {
40
+ "types": "./dist/page.d.ts",
41
+ "import": "./dist/page.js"
42
+ },
39
43
  "./server": {
40
44
  "types": "./dist/server.d.ts",
41
45
  "import": "./dist/server.js"
@@ -44,6 +48,14 @@
44
48
  "types": "./dist/vite.d.ts",
45
49
  "import": "./dist/vite.js"
46
50
  },
51
+ "./adapter-cloudflare": {
52
+ "types": "./dist/adapter-cloudflare.d.ts",
53
+ "import": "./dist/adapter-cloudflare.js"
54
+ },
55
+ "./adapter-node": {
56
+ "types": "./dist/adapter-node.d.ts",
57
+ "import": "./dist/adapter-node.js"
58
+ },
47
59
  "./internal": {
48
60
  "types": "./dist/internal.d.ts",
49
61
  "import": "./dist/internal.js"
@@ -52,18 +64,34 @@
52
64
  "dependencies": {
53
65
  "@babel/parser": "^7.29.2",
54
66
  "@babel/types": "^7.29.0",
55
- "@hono/vite-build": "^1.11.1",
56
- "@hono/vite-dev-server": "^0.25.1",
57
67
  "@preact/preset-vite": "^2.10.5",
58
68
  "magic-string": "^0.30.21"
59
69
  },
60
70
  "peerDependencies": {
71
+ "@cloudflare/vite-plugin": "^1.37.1",
72
+ "@hono/node-server": "^1.19.14",
73
+ "@hono/node-ws": "^1.3.1",
61
74
  "hono": ">=4.0.0",
62
75
  "hoofd": ">=1.0.0",
63
76
  "preact": ">=10.0.0",
64
77
  "preact-iso": ">=2.11.0",
65
78
  "preact-render-to-string": ">=6.0.0",
66
- "vite": ">=5.0.0"
79
+ "vite": ">=6.0.0",
80
+ "wrangler": "^4.92.0"
81
+ },
82
+ "peerDependenciesMeta": {
83
+ "@cloudflare/vite-plugin": {
84
+ "optional": true
85
+ },
86
+ "@hono/node-server": {
87
+ "optional": true
88
+ },
89
+ "@hono/node-ws": {
90
+ "optional": true
91
+ },
92
+ "wrangler": {
93
+ "optional": true
94
+ }
67
95
  },
68
96
  "devDependencies": {
69
97
  "typescript": "*",