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
@@ -0,0 +1,56 @@
1
+ export function redirect(input) {
2
+ if (typeof input === 'string') {
3
+ return {
4
+ __outcome: 'redirect',
5
+ to: input,
6
+ status: 302,
7
+ headers: undefined,
8
+ };
9
+ }
10
+ return {
11
+ __outcome: 'redirect',
12
+ to: input.to,
13
+ status: input.status ?? 302,
14
+ headers: input.headers,
15
+ };
16
+ }
17
+ export function deny(a, b) {
18
+ // `JSON.stringify` drops `undefined` properties, so a deny outcome with no
19
+ // message would arrive at the client without a `message` field and the
20
+ // client decoders would fall back to a generic "Loader/Action failed with
21
+ // status N" string. Default to a status-aware message at construction time
22
+ // so the wire envelope always carries something useful. Callers can still
23
+ // pass a richer message; defense-in-depth on the client side fills in a
24
+ // similar fallback if a hand-rolled envelope ships without `message`.
25
+ if (typeof a === 'object') {
26
+ return {
27
+ __outcome: 'deny',
28
+ status: a.status,
29
+ message: a.message ?? `Request denied (${a.status})`,
30
+ headers: a.headers,
31
+ };
32
+ }
33
+ return {
34
+ __outcome: 'deny',
35
+ status: a,
36
+ message: b ?? `Request denied (${a})`,
37
+ headers: undefined,
38
+ };
39
+ }
40
+ export function isOutcome(value) {
41
+ if (typeof value !== 'object' || value === null)
42
+ return false;
43
+ if (!('__outcome' in value))
44
+ return false;
45
+ const tag = value.__outcome;
46
+ return tag === 'redirect' || tag === 'deny' || tag === 'render';
47
+ }
48
+ export function isRedirect(value) {
49
+ return isOutcome(value) && value.__outcome === 'redirect';
50
+ }
51
+ export function isDeny(value) {
52
+ return isOutcome(value) && value.__outcome === 'deny';
53
+ }
54
+ export function isRender(value) {
55
+ return isOutcome(value) && value.__outcome === 'render';
56
+ }
@@ -0,0 +1,5 @@
1
+ import type { FunctionComponent } from 'preact';
2
+ import type { RenderOutcome } from './outcomes.js';
3
+ export { redirect, deny, isOutcome, isRedirect, isDeny, isRender, } from './outcomes.js';
4
+ export type { Outcome, RedirectOutcome, DenyOutcome, RenderOutcome, RedirectStatusCode, ErrorStatusCode, } from './outcomes.js';
5
+ export declare function render(Component: FunctionComponent): RenderOutcome;
@@ -0,0 +1,20 @@
1
+ // @hono-preact/iso/page -- page-scope outcome kitchen sink.
2
+ //
3
+ // This subpath bundles every outcome a page-scope server middleware
4
+ // reaches for in one import. `render` is the page-scope-only constructor
5
+ // (loaders/actions can't replace the page tree), so it lives here and
6
+ // nowhere else; the docs steer users at this subpath when they need it.
7
+ // `redirect`/`deny` and the predicates (`isOutcome`/`isRedirect`/`isDeny`
8
+ // /`isRender`) are re-exported here too so a page-scope file can write a
9
+ // single `import { redirect, deny, render } from 'hono-preact/page'` line.
10
+ //
11
+ // Canonical export location for the cross-scope symbols is `./outcomes.js`.
12
+ // Both this subpath and `./index.js` re-export from there; nothing here
13
+ // hides surface. The predicates are scope-agnostic, so `index.ts` is their
14
+ // primary home for consumers that don't already need the page-scope
15
+ // subpath; the predicates are duplicated here only for the kitchen-sink
16
+ // import path.
17
+ export { redirect, deny, isOutcome, isRedirect, isDeny, isRender, } from './outcomes.js';
18
+ export function render(Component) {
19
+ return { __outcome: 'render', Component };
20
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ComponentChildren, ComponentType, JSX } from 'preact';
2
2
  import type { RouteHook } from 'preact-iso';
3
- import type { GuardFn } from './guard.js';
3
+ import type { PageUse } from './internal/use-types.js';
4
4
  export type WrapperProps = {
5
5
  id: string;
6
6
  'data-loader': string;
@@ -8,9 +8,9 @@ export type WrapperProps = {
8
8
  };
9
9
  export type PageProps = {
10
10
  location: RouteHook;
11
- guards?: GuardFn[];
11
+ use?: PageUse;
12
12
  errorFallback?: JSX.Element | ((error: Error, reset: () => void) => JSX.Element);
13
13
  Wrapper?: ComponentType<WrapperProps>;
14
14
  children: ComponentChildren;
15
15
  };
16
- export declare function Page({ location, guards, errorFallback, Wrapper, children, }: PageProps): JSX.Element;
16
+ export declare function Page({ location, use, errorFallback, Wrapper, children, }: PageProps): JSX.Element;
package/dist/iso/page.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { jsx as _jsx } from "preact/jsx-runtime";
2
2
  import { useId } from 'preact/hooks';
3
- import { Guards } from './internal/guards.js';
3
+ import { PageMiddlewareHost } from './internal/page-middleware-host.js';
4
4
  import { RouteBoundary } from './internal/route-boundary.js';
5
5
  const DefaultWrapper = (props) => (_jsx("section", { ...props }));
6
- export function Page({ location, guards, errorFallback, Wrapper, children, }) {
6
+ export function Page({ location, use, errorFallback, Wrapper, children, }) {
7
7
  const id = useId();
8
8
  const W = Wrapper ?? DefaultWrapper;
9
- return (_jsx(RouteBoundary, { errorFallback: errorFallback, children: _jsx(Guards, { guards: guards, location: location, children: _jsx(W, { id: id, "data-loader": "null", children: children }) }) }));
9
+ return (_jsx(RouteBoundary, { errorFallback: errorFallback, children: _jsx(PageMiddlewareHost, { use: use, location: location, children: _jsx(W, { id: id, "data-loader": "null", children: children }) }) }));
10
10
  }
package/dist/page.d.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './iso/page-only';
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page.d.ts","sourceRoot":"","sources":["../src/page.ts"],"names":[],"mappings":"AAOA,cAAc,uBAAuB,CAAC"}
package/dist/page.js ADDED
@@ -0,0 +1,8 @@
1
+ // hono-preact/page -- page-scope outcome kitchen sink (umbrella re-export).
2
+ //
3
+ // Forwards to @hono-preact/iso/page so consumers using the published
4
+ // umbrella can write `import { redirect, deny, render } from 'hono-preact/page'`
5
+ // exactly as the docs show. The iso subpath is where the constructors and
6
+ // predicates actually live; this file exists so the umbrella's exports map
7
+ // matches the consolidated subpath after `scripts/consolidate.mjs` runs.
8
+ export * from './iso/page-only.js';
@@ -1,9 +1,8 @@
1
1
  import type { MiddlewareHandler } from 'hono';
2
- import { type ActionGuardFn } from '../iso/index';
2
+ import { type AppConfig } from '../iso/index';
3
3
  type GlobModule = {
4
4
  __moduleKey?: unknown;
5
5
  serverActions?: Record<string, unknown>;
6
- actionGuards?: ActionGuardFn[];
7
6
  [key: string]: unknown;
8
7
  };
9
8
  type LazyGlob = Record<string, () => Promise<unknown>>;
@@ -19,15 +18,30 @@ export interface ActionsHandlerOptions {
19
18
  */
20
19
  dev?: boolean;
21
20
  /**
22
- * Called for every error an action throws (other than `ActionGuardError`,
23
- * which is treated as a structured response). Use it to hook into your
24
- * observability stack (Sentry, console, etc.). The handler still
25
- * responds with a sanitized 500; the hook is purely a side channel.
21
+ * Called for every error an action throws (other than an outcome thrown
22
+ * by middleware, which is translated to its wire shape). Use it to hook
23
+ * into your observability stack (Sentry, console, etc.). The handler
24
+ * still responds with a sanitized 500; the hook is purely a side channel.
26
25
  */
27
26
  onError?: (err: unknown, ctx: {
28
27
  module: string;
29
28
  action: string;
30
29
  }) => void;
30
+ /**
31
+ * Root layer of the middleware chain. The framework's generated server
32
+ * entry threads the user's `defineApp({ use })` result here. Each action
33
+ * request composes the chain as
34
+ * `[...appConfig.use, ...resolvePageUse(module), ...action.use]`.
35
+ */
36
+ appConfig?: AppConfig;
37
+ /**
38
+ * Per-page layer lookup keyed by the action's owning module key (since an
39
+ * action always belongs unambiguously to one page module). Returns the
40
+ * `use` array declared on the matching page's `.server.*` module (as
41
+ * `export const pageUse = [...]`). May be sync or async; the handler
42
+ * awaits the result either way. Default returns an empty array.
43
+ */
44
+ resolvePageUse?: (moduleKey: string) => ReadonlyArray<unknown> | Promise<ReadonlyArray<unknown>>;
31
45
  }
32
46
  export declare function actionsHandler(glob: LazyGlob | EagerGlob, opts?: ActionsHandlerOptions): MiddlewareHandler;
33
47
  export {};
@@ -1,5 +1,5 @@
1
- import { ActionGuardError, GuardRedirect, } from '../iso/index.js';
2
- import { runRequestScope } from '../iso/internal/index.js';
1
+ import { isOutcome, } from '../iso/index.js';
2
+ import { runRequestScope, dispatchServer, partitionUse, } from '../iso/internal.js';
3
3
  import { sseGeneratorResponse, sseReadableStreamResponse, isAsyncGenerator, } from './sse.js';
4
4
  async function buildActionsMap(glob) {
5
5
  const result = {};
@@ -11,39 +11,42 @@ async function buildActionsMap(glob) {
11
11
  if (typeof key === 'string' && mod.serverActions) {
12
12
  result[key] = {
13
13
  actions: mod.serverActions,
14
- guards: mod.actionGuards ?? [],
15
14
  };
16
15
  }
17
16
  }
18
17
  return result;
19
18
  }
20
- async function runActionGuards(guards, ctx) {
21
- const run = async (index) => {
22
- if (index >= guards.length)
23
- return;
24
- // Track whether the guard explicitly opted to pass control on. Without
25
- // this check a guard that forgets `return next()` (or just `next()`)
26
- // would silently fall through to the action body — the OPPOSITE of every
27
- // other middleware system users have seen (Express, Hono, Koa: blocked
28
- // by default; opt in to pass). Make ambiguous returns loud instead of
29
- // silently insecure. To block, throw ActionGuardError. To pass, await
30
- // (or return) next().
31
- let nextCalled = false;
32
- await guards[index](ctx, () => {
33
- nextCalled = true;
34
- return run(index + 1);
35
- });
36
- if (!nextCalled) {
37
- throw new Error(`ActionGuard for '${ctx.module}.${ctx.action}' returned without ` +
38
- `calling next() or throwing. Guards must either: (a) await/return ` +
39
- `next() to pass control on, or (b) throw ActionGuardError to block. ` +
40
- `Returning silently is ambiguous and would let the action run.`);
19
+ function translateOutcomeForAction(c, outcome) {
20
+ if (outcome.__outcome === 'redirect') {
21
+ // Headers from the outcome ride the HTTP response via `c.header()`. They
22
+ // are deliberately NOT embedded in the JSON envelope: the client only
23
+ // reads `to` and calls `window.location.assign(to)`; any embedded
24
+ // headers would be dead bytes the client never inspects.
25
+ if (outcome.headers) {
26
+ for (const [k, v] of Object.entries(outcome.headers))
27
+ c.header(k, v);
41
28
  }
42
- };
43
- await run(0);
29
+ return c.json({
30
+ __outcome: 'redirect',
31
+ to: outcome.to,
32
+ status: outcome.status,
33
+ }, 200);
34
+ }
35
+ if (outcome.__outcome === 'deny') {
36
+ if (outcome.headers) {
37
+ for (const [k, v] of Object.entries(outcome.headers))
38
+ c.header(k, v);
39
+ }
40
+ return c.json({ __outcome: 'deny', message: outcome.message }, outcome.status);
41
+ }
42
+ // render outcome should never reach the action RPC.
43
+ return c.json({
44
+ __outcome: 'error',
45
+ message: 'render outcome is page-scope only',
46
+ }, 500);
44
47
  }
45
48
  export function actionsHandler(glob, opts = {}) {
46
- const { dev = false, onError } = opts;
49
+ const { dev = false, onError, appConfig, resolvePageUse } = opts;
47
50
  let cachedMapPromise = null;
48
51
  return async (c) => {
49
52
  const actionsMapPromise = dev
@@ -115,44 +118,77 @@ export function actionsHandler(glob, opts = {}) {
115
118
  if (!entry) {
116
119
  return c.json({ error: `Module '${module}' not found` }, 404);
117
120
  }
118
- try {
119
- await runActionGuards(entry.guards, { c, module, action, payload });
120
- }
121
- catch (err) {
122
- if (err instanceof ActionGuardError) {
123
- return c.json({ error: err.message }, err.status);
124
- }
125
- if (err instanceof GuardRedirect) {
126
- return c.json({ __redirect: err.location });
127
- }
128
- throw err;
129
- }
130
121
  const fn = entry.actions[action];
131
122
  if (typeof fn !== 'function') {
132
123
  return c.json({ error: `Action '${action}' not found in module '${module}'` }, 404);
133
124
  }
134
125
  const signal = c.req.raw.signal;
135
126
  const actionCtx = { c, signal };
127
+ // Chain ordering is outer -> inner: app-level middleware wraps every
128
+ // request, page-level wraps actions owned by that page, and per-action
129
+ // middleware (attached via defineAction(fn, { use })) wraps just this
130
+ // call. Outer middleware runs first on the way in and last on the way
131
+ // out, matching every middleware system users have seen (Hono, Express,
132
+ // Koa). The action's owning page is unambiguous from `module`, so the
133
+ // page-layer lookup keys by module rather than by location path.
134
+ const rootUse = appConfig?.use ?? [];
135
+ const pageUse = (await resolvePageUse?.(module)) ?? [];
136
+ const actionUse = fn.use ?? [];
137
+ const fullUse = [...rootUse, ...pageUse, ...actionUse];
138
+ const { middleware: allMiddleware, observers } = partitionUse(fullUse);
139
+ const serverMw = allMiddleware.filter((m) => m.runs === 'server');
140
+ const ctx = {
141
+ scope: 'action',
142
+ c,
143
+ signal,
144
+ module,
145
+ action,
146
+ payload,
147
+ };
136
148
  let result;
137
149
  try {
138
- result = await runRequestScope(() => fn(actionCtx, payload));
150
+ result = await runRequestScope(async () => {
151
+ const dispatch = await dispatchServer({
152
+ middleware: serverMw,
153
+ ctx,
154
+ inner: async () => {
155
+ const inner = await fn(actionCtx, payload);
156
+ // An action that does `return redirect('/login')` instead of
157
+ // `throw redirect('/login')` would otherwise ship the outcome
158
+ // JSON shape as a normal 200 response and bypass envelope
159
+ // translation. Normalize by re-throwing so the existing
160
+ // outcome-catching path translates it.
161
+ if (isOutcome(inner))
162
+ throw inner;
163
+ return inner;
164
+ },
165
+ });
166
+ if (dispatch.kind === 'outcome') {
167
+ throw dispatch.outcome;
168
+ }
169
+ return dispatch.value;
170
+ });
139
171
  }
140
172
  catch (err) {
141
- if (err instanceof ActionGuardError) {
142
- return c.json({ error: err.message }, err.status);
143
- }
144
- if (err instanceof GuardRedirect) {
145
- return c.json({ __redirect: err.location });
173
+ if (isOutcome(err)) {
174
+ return translateOutcomeForAction(c, err);
146
175
  }
147
176
  onError?.(err, { module, action });
148
177
  const message = dev && err instanceof Error ? err.message : 'Action failed';
149
178
  return c.json({ error: message }, 500);
150
179
  }
151
180
  if (isAsyncGenerator(result)) {
152
- return sseGeneratorResponse(c, result, { emitResult: true });
181
+ return sseGeneratorResponse(c, result, {
182
+ emitResult: true,
183
+ observers,
184
+ observerCtx: ctx,
185
+ });
153
186
  }
154
187
  if (result instanceof ReadableStream) {
155
- return sseReadableStreamResponse(c, result);
188
+ return sseReadableStreamResponse(c, result, {
189
+ observers,
190
+ observerCtx: ctx,
191
+ });
156
192
  }
157
193
  return c.json(result);
158
194
  };
@@ -1,4 +1,4 @@
1
- import { HonoRequestContext } from '../iso/internal/index.js';
1
+ import { HonoRequestContext } from '../iso/internal.js';
2
2
  import { useContext } from 'preact/hooks';
3
3
  export const HonoContext = HonoRequestContext;
4
4
  export function useHonoContext() {
@@ -2,4 +2,4 @@ export { HonoContext, useHonoContext } from './context.js';
2
2
  export { renderPage } from './render.js';
3
3
  export { actionsHandler } from './actions-handler.js';
4
4
  export { loadersHandler } from './loaders-handler.js';
5
- export { routeServerModules } from './route-server-modules.js';
5
+ export { routeServerModules, makePageUseResolvers, } from './route-server-modules.js';
@@ -2,4 +2,4 @@ export { HonoContext, useHonoContext } from './context.js';
2
2
  export { renderPage } from './render.js';
3
3
  export { actionsHandler } from './actions-handler.js';
4
4
  export { loadersHandler } from './loaders-handler.js';
5
- export { routeServerModules } from './route-server-modules.js';
5
+ export { routeServerModules, makePageUseResolvers, } from './route-server-modules.js';
@@ -1,4 +1,5 @@
1
1
  import type { MiddlewareHandler } from 'hono';
2
+ import { type AppConfig } from '../iso/index';
2
3
  type GlobModule = {
3
4
  default?: unknown;
4
5
  __moduleKey?: unknown;
@@ -25,6 +26,21 @@ export interface LoadersHandlerOptions {
25
26
  module: string;
26
27
  loader: string;
27
28
  }) => void;
29
+ /**
30
+ * Root layer of the middleware chain. The framework's generated server
31
+ * entry threads the user's `defineApp({ use })` result here. Each loader
32
+ * request composes the chain as
33
+ * `[...appConfig.use, ...resolvePageUse(path), ...loader.use]`.
34
+ */
35
+ appConfig?: AppConfig;
36
+ /**
37
+ * Per-page layer lookup keyed by the matched route's location path.
38
+ * Returns the `use` array declared on the matching page's `.server.*`
39
+ * module (as `export const pageUse = [...]`). The lookup may be sync
40
+ * (an in-memory map) or async (loaded lazily on first request). The
41
+ * handler awaits the result either way. Default returns an empty array.
42
+ */
43
+ resolvePageUse?: (path: string) => ReadonlyArray<unknown> | Promise<ReadonlyArray<unknown>>;
28
44
  }
29
45
  export declare function loadersHandler(glob: LazyGlob | EagerGlob, opts?: LoadersHandlerOptions): MiddlewareHandler;
30
46
  export {};
@@ -1,5 +1,5 @@
1
- import { GuardRedirect } from '../iso/index.js';
2
- import { runRequestScope } from '../iso/internal/index.js';
1
+ import { isOutcome, } from '../iso/index.js';
2
+ import { runRequestScope, dispatchServer, partitionUse, } from '../iso/internal.js';
3
3
  import { sseGeneratorResponse, sseReadableStreamResponse, isAsyncGenerator, } from './sse.js';
4
4
  async function buildLoadersMap(glob) {
5
5
  const result = {};
@@ -16,12 +16,14 @@ async function buildLoadersMap(glob) {
16
16
  // Two accepted shapes:
17
17
  // 1. a raw loader function `(ctx) => ...` (used by unit-test fixtures)
18
18
  // 2. a `LoaderRef` returned by `defineLoader(fn)`, whose `.fn`
19
- // property carries the original loader (used by user code)
19
+ // property carries the original loader and `.use` carries any
20
+ // attached middleware/observers.
20
21
  if (typeof val === 'function') {
21
- result[`${moduleKey}::${name}`] = val;
22
+ result[`${moduleKey}::${name}`] = { fn: val, use: [] };
22
23
  }
23
24
  else if (val && typeof val.fn === 'function') {
24
- result[`${moduleKey}::${name}`] = val.fn;
25
+ const ref = val;
26
+ result[`${moduleKey}::${name}`] = { fn: ref.fn, use: ref.use ?? [] };
25
27
  }
26
28
  }
27
29
  }
@@ -44,8 +46,37 @@ function validateLocation(loc) {
44
46
  searchParams: o.searchParams,
45
47
  };
46
48
  }
49
+ function translateOutcomeForLoader(c, outcome) {
50
+ if (outcome.__outcome === 'redirect') {
51
+ // Headers from the outcome ride the HTTP response via `c.header()`. They
52
+ // are deliberately NOT embedded in the JSON envelope: the client only
53
+ // reads `to` and calls `window.location.assign(to)`; any embedded
54
+ // headers would be dead bytes the client never inspects.
55
+ if (outcome.headers) {
56
+ for (const [k, v] of Object.entries(outcome.headers))
57
+ c.header(k, v);
58
+ }
59
+ return c.json({
60
+ __outcome: 'redirect',
61
+ to: outcome.to,
62
+ status: outcome.status,
63
+ }, 200);
64
+ }
65
+ if (outcome.__outcome === 'deny') {
66
+ if (outcome.headers) {
67
+ for (const [k, v] of Object.entries(outcome.headers))
68
+ c.header(k, v);
69
+ }
70
+ return c.json({ __outcome: 'deny', message: outcome.message }, outcome.status);
71
+ }
72
+ // render outcome should never reach the loader RPC; this is defense in depth.
73
+ return c.json({
74
+ __outcome: 'error',
75
+ message: 'render outcome is page-scope only',
76
+ }, 500);
77
+ }
47
78
  export function loadersHandler(glob, opts = {}) {
48
- const { dev = false, onError } = opts;
79
+ const { dev = false, onError, appConfig, resolvePageUse } = opts;
49
80
  let cachedMapPromise = null;
50
81
  return async (c) => {
51
82
  const loadersMapPromise = dev
@@ -82,28 +113,74 @@ export function loadersHandler(glob, opts = {}) {
82
113
  error: 'Request body must include object field: location with shape { path: string, pathParams: object, searchParams: object }',
83
114
  }, 400);
84
115
  }
85
- const loaderFn = loadersMap[`${module}::${loaderName}`];
86
- if (!loaderFn) {
116
+ const entry = loadersMap[`${module}::${loaderName}`];
117
+ if (!entry) {
87
118
  return c.json({ error: `Loader '${module}::${loaderName}' not found` }, 404);
88
119
  }
89
120
  const signal = c.req.raw.signal;
121
+ // Chain ordering is outer -> inner: app-level middleware wraps every
122
+ // request, page-level wraps loaders owned by that page, and per-loader
123
+ // middleware wraps just this call. Outer middleware runs first on the
124
+ // way in and last on the way out, matching every middleware system
125
+ // users have seen (Hono, Express, Koa).
126
+ const rootUse = appConfig?.use ?? [];
127
+ const pageUse = (await resolvePageUse?.(validatedLocation.path)) ?? [];
128
+ const fullUse = [...rootUse, ...pageUse, ...entry.use];
129
+ const { middleware: allMiddleware, observers } = partitionUse(fullUse);
130
+ const serverMw = allMiddleware.filter((m) => m.runs === 'server');
131
+ const ctx = {
132
+ scope: 'loader',
133
+ c,
134
+ signal,
135
+ location: validatedLocation,
136
+ module,
137
+ loader: loaderName,
138
+ };
90
139
  try {
91
- const result = await runRequestScope(() => Promise.resolve(loaderFn({ c, location: validatedLocation, signal })), { honoContext: c });
140
+ const result = await runRequestScope(async () => {
141
+ const dispatch = await dispatchServer({
142
+ middleware: serverMw,
143
+ ctx,
144
+ inner: async () => {
145
+ const inner = await entry.fn({
146
+ c,
147
+ location: validatedLocation,
148
+ signal,
149
+ });
150
+ // A loader that does `return redirect('/login')` instead of
151
+ // `throw redirect('/login')` would otherwise ship the outcome
152
+ // JSON shape as a normal 200 response and bypass envelope
153
+ // translation. Normalize by re-throwing so the existing
154
+ // outcome-catching path translates it.
155
+ if (isOutcome(inner))
156
+ throw inner;
157
+ return inner;
158
+ },
159
+ });
160
+ if (dispatch.kind === 'outcome') {
161
+ // Throw to unify with non-outcome error translation below.
162
+ throw dispatch.outcome;
163
+ }
164
+ return dispatch.value;
165
+ }, { honoContext: c });
92
166
  if (isAsyncGenerator(result)) {
93
- return sseGeneratorResponse(c, result, { emitResult: false });
167
+ return sseGeneratorResponse(c, result, {
168
+ emitResult: false,
169
+ observers,
170
+ observerCtx: ctx,
171
+ });
94
172
  }
95
173
  if (result instanceof ReadableStream) {
96
- return sseReadableStreamResponse(c, result);
174
+ return sseReadableStreamResponse(c, result, {
175
+ observers,
176
+ observerCtx: ctx,
177
+ });
97
178
  }
98
179
  return c.json(result);
99
180
  }
100
181
  catch (err) {
101
- // GuardRedirect thrown from a loader (or a guard that runs inside it)
102
- // is a control-flow signal, not an error. The client RPC stub
103
- // recognizes the `__redirect` envelope and navigates the browser
104
- // rather than surfacing this as a thrown error in user code.
105
- if (err instanceof GuardRedirect) {
106
- return c.json({ __redirect: err.location });
182
+ if (isOutcome(err)) {
183
+ return translateOutcomeForLoader(c, err);
107
184
  }
108
185
  onError?.(err, { module, loader: loaderName });
109
186
  // In production we never leak the loader's error message: it may
@@ -1,5 +1,7 @@
1
1
  import type { Context } from 'hono';
2
2
  import type { VNode } from 'preact';
3
+ import { type AppConfig } from '../iso/index';
3
4
  export declare function renderPage(c: Context, node: VNode, options?: {
4
5
  defaultTitle?: string;
6
+ appConfig?: AppConfig;
5
7
  }): Promise<Response>;