hono-preact 0.1.0 → 0.3.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 (120) 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-result-context.d.ts +22 -0
  11. package/dist/iso/action-result-context.js +2 -0
  12. package/dist/iso/action.d.ts +60 -25
  13. package/dist/iso/action.js +210 -58
  14. package/dist/iso/cache.d.ts +9 -0
  15. package/dist/iso/cache.js +26 -0
  16. package/dist/iso/define-app.d.ts +14 -0
  17. package/dist/iso/define-app.js +3 -0
  18. package/dist/iso/define-loader.d.ts +31 -0
  19. package/dist/iso/define-loader.js +30 -16
  20. package/dist/iso/define-middleware.d.ts +43 -0
  21. package/dist/iso/define-middleware.js +6 -0
  22. package/dist/iso/define-page.d.ts +7 -2
  23. package/dist/iso/define-page.js +1 -1
  24. package/dist/iso/define-routes.d.ts +24 -1
  25. package/dist/iso/define-routes.js +34 -0
  26. package/dist/iso/define-stream-observer.d.ts +20 -0
  27. package/dist/iso/define-stream-observer.js +3 -0
  28. package/dist/iso/form.d.ts +13 -4
  29. package/dist/iso/form.js +115 -33
  30. package/dist/iso/index.d.ts +15 -7
  31. package/dist/iso/index.js +9 -4
  32. package/dist/iso/internal/action-envelope.d.ts +37 -0
  33. package/dist/iso/internal/action-envelope.js +47 -0
  34. package/dist/iso/internal/action-result-store.d.ts +28 -0
  35. package/dist/iso/internal/action-result-store.js +35 -0
  36. package/dist/iso/internal/contexts.d.ts +0 -2
  37. package/dist/iso/internal/contexts.js +0 -1
  38. package/dist/iso/internal/envelope.js +1 -2
  39. package/dist/iso/internal/form-submit-store.d.ts +9 -0
  40. package/dist/iso/internal/form-submit-store.js +32 -0
  41. package/dist/iso/internal/loader-fetch.js +102 -41
  42. package/dist/iso/internal/loader-runner.js +105 -8
  43. package/dist/iso/internal/loader.d.ts +3 -3
  44. package/dist/iso/internal/middleware-runner.d.ts +22 -0
  45. package/dist/iso/internal/middleware-runner.js +79 -0
  46. package/dist/iso/internal/page-middleware-host.d.ts +13 -0
  47. package/dist/iso/internal/page-middleware-host.js +119 -0
  48. package/dist/iso/internal/route-boundary.d.ts +5 -4
  49. package/dist/iso/internal/route-boundary.js +16 -0
  50. package/dist/iso/internal/safe-redirect.d.ts +7 -0
  51. package/dist/iso/internal/safe-redirect.js +27 -0
  52. package/dist/iso/internal/sse-decoder.d.ts +1 -1
  53. package/dist/iso/internal/sse-decoder.js +40 -26
  54. package/dist/iso/internal/stream-observer-runner.d.ts +13 -0
  55. package/dist/iso/internal/stream-observer-runner.js +48 -0
  56. package/dist/iso/internal/use-partitioner.d.ts +9 -0
  57. package/dist/iso/internal/use-partitioner.js +11 -0
  58. package/dist/iso/internal/use-types.d.ts +7 -0
  59. package/dist/iso/internal/use-types.js +1 -0
  60. package/dist/iso/internal.d.ts +12 -5
  61. package/dist/iso/internal.js +16 -7
  62. package/dist/iso/optimistic-action.d.ts +10 -1
  63. package/dist/iso/optimistic-action.js +11 -3
  64. package/dist/iso/optimistic.d.ts +10 -1
  65. package/dist/iso/optimistic.js +45 -5
  66. package/dist/iso/outcomes.d.ts +50 -0
  67. package/dist/iso/outcomes.js +67 -0
  68. package/dist/iso/page-only.d.ts +5 -0
  69. package/dist/iso/page-only.js +20 -0
  70. package/dist/iso/page.d.ts +3 -3
  71. package/dist/iso/page.js +3 -3
  72. package/dist/iso/use-action-result.d.ts +25 -0
  73. package/dist/iso/use-action-result.js +39 -0
  74. package/dist/iso/use-form-status.d.ts +5 -0
  75. package/dist/iso/use-form-status.js +13 -0
  76. package/dist/page.d.ts +1 -0
  77. package/dist/page.d.ts.map +1 -0
  78. package/dist/page.js +8 -0
  79. package/dist/server/actions-handler.d.ts +27 -6
  80. package/dist/server/actions-handler.js +121 -52
  81. package/dist/server/context.js +1 -1
  82. package/dist/server/index.d.ts +3 -2
  83. package/dist/server/index.js +3 -2
  84. package/dist/server/loaders-handler.d.ts +24 -0
  85. package/dist/server/loaders-handler.js +128 -18
  86. package/dist/server/page-action-handler.d.ts +63 -0
  87. package/dist/server/page-action-handler.js +274 -0
  88. package/dist/server/page-action-resolvers.d.ts +28 -0
  89. package/dist/server/page-action-resolvers.js +147 -0
  90. package/dist/server/render.d.ts +2 -0
  91. package/dist/server/render.js +142 -33
  92. package/dist/server/route-server-modules.d.ts +48 -8
  93. package/dist/server/route-server-modules.js +190 -7
  94. package/dist/server/speculation-rules.d.ts +3 -0
  95. package/dist/server/speculation-rules.js +8 -0
  96. package/dist/server/sse.d.ts +50 -12
  97. package/dist/server/sse.js +130 -53
  98. package/dist/vite/adapter-cloudflare.d.ts +2 -0
  99. package/dist/vite/adapter-cloudflare.js +25 -0
  100. package/dist/vite/adapter-node.d.ts +2 -0
  101. package/dist/vite/adapter-node.js +49 -0
  102. package/dist/vite/adapter.d.ts +29 -0
  103. package/dist/vite/adapter.js +1 -0
  104. package/dist/vite/client-shim.js +5 -4
  105. package/dist/vite/guard-strip.js +52 -27
  106. package/dist/vite/hono-preact.d.ts +6 -6
  107. package/dist/vite/hono-preact.js +48 -77
  108. package/dist/vite/index.d.ts +2 -1
  109. package/dist/vite/index.js +1 -1
  110. package/dist/vite/node-dev-server.d.ts +4 -0
  111. package/dist/vite/node-dev-server.js +121 -0
  112. package/dist/vite/server-entry.d.ts +30 -7
  113. package/dist/vite/server-entry.js +170 -79
  114. package/dist/vite/server-exports-contract.d.ts +6 -0
  115. package/dist/vite/server-exports-contract.js +43 -0
  116. package/dist/vite/server-loader-validation.js +36 -9
  117. package/dist/vite/server-loaders-parser.d.ts +17 -1
  118. package/dist/vite/server-loaders-parser.js +41 -0
  119. package/dist/vite/server-only.js +20 -2
  120. package/package.json +33 -5
@@ -0,0 +1,50 @@
1
+ import type { FunctionComponent } from 'preact';
2
+ import type { RedirectStatusCode, ClientErrorStatusCode, ServerErrorStatusCode } from 'hono/utils/http-status';
3
+ export type ErrorStatusCode = ClientErrorStatusCode | ServerErrorStatusCode;
4
+ export type { RedirectStatusCode };
5
+ export type RedirectOutcome = {
6
+ __outcome: 'redirect';
7
+ to: string;
8
+ status: RedirectStatusCode;
9
+ headers: Record<string, string> | undefined;
10
+ };
11
+ export type DenyOutcome = {
12
+ __outcome: 'deny';
13
+ status: ErrorStatusCode;
14
+ message: string;
15
+ headers: Record<string, string> | undefined;
16
+ data?: unknown;
17
+ };
18
+ export type RenderOutcome = {
19
+ __outcome: 'render';
20
+ Component: FunctionComponent;
21
+ };
22
+ export type TimeoutOutcome = {
23
+ __outcome: 'timeout';
24
+ timeoutMs: number;
25
+ };
26
+ export type Outcome = RedirectOutcome | DenyOutcome | RenderOutcome | TimeoutOutcome;
27
+ type RedirectInput = string | {
28
+ to: string;
29
+ status?: RedirectStatusCode;
30
+ headers?: Record<string, string>;
31
+ };
32
+ export declare function redirect(input: RedirectInput): RedirectOutcome;
33
+ type DenyInput = {
34
+ status: ErrorStatusCode;
35
+ message?: string;
36
+ headers?: Record<string, string>;
37
+ data?: unknown;
38
+ };
39
+ type DenyOpts = {
40
+ headers?: Record<string, string>;
41
+ data?: unknown;
42
+ };
43
+ export declare function deny(status: ErrorStatusCode, message?: string, opts?: DenyOpts): DenyOutcome;
44
+ export declare function deny(spec: DenyInput): DenyOutcome;
45
+ export declare function isOutcome(value: unknown): value is Outcome;
46
+ export declare function isRedirect(value: unknown): value is RedirectOutcome;
47
+ export declare function isDeny(value: unknown): value is DenyOutcome;
48
+ export declare function isRender(value: unknown): value is RenderOutcome;
49
+ export declare function timeoutOutcome(timeoutMs: number): TimeoutOutcome;
50
+ export declare function isTimeout(value: unknown): value is TimeoutOutcome;
@@ -0,0 +1,67 @@
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, c) {
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
+ ...(a.data !== undefined ? { data: a.data } : {}),
32
+ };
33
+ }
34
+ return {
35
+ __outcome: 'deny',
36
+ status: a,
37
+ message: b ?? `Request denied (${a})`,
38
+ headers: c?.headers,
39
+ ...(c?.data !== undefined ? { data: c.data } : {}),
40
+ };
41
+ }
42
+ export function isOutcome(value) {
43
+ if (typeof value !== 'object' || value === null)
44
+ return false;
45
+ if (!('__outcome' in value))
46
+ return false;
47
+ const tag = value.__outcome;
48
+ return (tag === 'redirect' ||
49
+ tag === 'deny' ||
50
+ tag === 'render' ||
51
+ tag === 'timeout');
52
+ }
53
+ export function isRedirect(value) {
54
+ return isOutcome(value) && value.__outcome === 'redirect';
55
+ }
56
+ export function isDeny(value) {
57
+ return isOutcome(value) && value.__outcome === 'deny';
58
+ }
59
+ export function isRender(value) {
60
+ return isOutcome(value) && value.__outcome === 'render';
61
+ }
62
+ export function timeoutOutcome(timeoutMs) {
63
+ return { __outcome: 'timeout', timeoutMs };
64
+ }
65
+ export function isTimeout(value) {
66
+ return isOutcome(value) && value.__outcome === 'timeout';
67
+ }
@@ -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
  }
@@ -0,0 +1,25 @@
1
+ import type { ActionStub } from './action.js';
2
+ export type ActionResult<TPayload, TResult> = {
3
+ kind: 'success';
4
+ data: TResult;
5
+ submittedPayload: TPayload;
6
+ } | {
7
+ kind: 'deny';
8
+ status: number;
9
+ message: string;
10
+ data?: unknown;
11
+ /**
12
+ * The payload as parsed from the request. For form submissions, this is
13
+ * a `Record<string, FormDataEntryValue | FormDataEntryValue[]>` where
14
+ * each value is a string or File (never a parsed primitive like `number`
15
+ * or `boolean`). The `TPayload` typing reflects the dev-declared shape,
16
+ * not the runtime structural shape. Read individual fields knowing they
17
+ * arrive as form-data entries.
18
+ */
19
+ submittedPayload: TPayload;
20
+ } | {
21
+ kind: 'error';
22
+ message: string;
23
+ submittedPayload: TPayload | null;
24
+ } | null;
25
+ export declare function useActionResult<TPayload = unknown, TResult = unknown>(stub?: ActionStub<TPayload, TResult, never>): ActionResult<TPayload, TResult>;
@@ -0,0 +1,39 @@
1
+ import { useContext } from 'preact/hooks';
2
+ import { useSyncExternalStore } from 'preact/compat';
3
+ import { ActionResultContext } from './action-result-context.js';
4
+ import { getLastActionResult, subscribeActionResults, } from './internal/action-result-store.js';
5
+ import { isBrowser } from './is-browser.js';
6
+ export function useActionResult(stub) {
7
+ const ssr = useContext(ActionResultContext);
8
+ const client = useSyncExternalStore(subscribeActionResults, () => isBrowser() ? getLastActionResult(stub) : null);
9
+ // Client store wins when populated: a JS-on submit has produced a result.
10
+ // SSR context is the fallback for the PE deny re-render path (no JS state).
11
+ const source = client ?? ssr;
12
+ if (!source)
13
+ return null;
14
+ if (stub &&
15
+ (source.module !== stub.__module || source.action !== stub.__action)) {
16
+ return null;
17
+ }
18
+ if (source.kind === 'success') {
19
+ return {
20
+ kind: 'success',
21
+ data: source.data,
22
+ submittedPayload: source.submittedPayload,
23
+ };
24
+ }
25
+ if (source.kind === 'deny') {
26
+ return {
27
+ kind: 'deny',
28
+ status: source.status,
29
+ message: source.message,
30
+ data: source.data,
31
+ submittedPayload: source.submittedPayload,
32
+ };
33
+ }
34
+ return {
35
+ kind: 'error',
36
+ message: source.message,
37
+ submittedPayload: source.submittedPayload,
38
+ };
39
+ }
@@ -0,0 +1,5 @@
1
+ import type { ActionStub } from './action.js';
2
+ export type FormStatus = {
3
+ pending: boolean;
4
+ };
5
+ export declare function useFormStatus<TPayload = unknown, TResult = unknown>(stub?: ActionStub<TPayload, TResult, never>): FormStatus;
@@ -0,0 +1,13 @@
1
+ import { useSyncExternalStore } from 'preact/compat';
2
+ import { isPending, subscribe } from './internal/form-submit-store.js';
3
+ import { isBrowser } from './is-browser.js';
4
+ // Generic over the stub's payload/result so callers can pass any
5
+ // `ActionStub<TPayload, TResult, never>` without contravariant-position
6
+ // assignment errors. The hook only reads `__module` and `__action`.
7
+ export function useFormStatus(stub) {
8
+ // preact/compat (10.29) ships only the 2-arg signature of useSyncExternalStore.
9
+ // The SSR "always idle" behavior that React 18's getServerSnapshot would
10
+ // provide is achieved via the isBrowser() guard inside getSnapshot.
11
+ const pending = useSyncExternalStore(subscribe, () => isBrowser() ? isPending(stub) : false);
12
+ return { pending };
13
+ }
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,37 @@ 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>>;
45
+ /**
46
+ * Default action timeout in milliseconds applied when an action does not
47
+ * declare its own `timeoutMs`. Defaults to 30000 (30 seconds). Pass
48
+ * `false` to disable the default (only action-level `timeoutMs` enforces
49
+ * a deadline).
50
+ */
51
+ defaultTimeoutMs?: number | false;
31
52
  }
32
53
  export declare function actionsHandler(glob: LazyGlob | EagerGlob, opts?: ActionsHandlerOptions): MiddlewareHandler;
33
54
  export {};
@@ -1,5 +1,5 @@
1
- import { ActionGuardError, GuardRedirect, } from '../iso/index.js';
2
- import { runRequestScope } from '../iso/internal/index.js';
1
+ import { isOutcome, timeoutOutcome, } 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 = {};
@@ -8,42 +8,62 @@ async function buildActionsMap(glob) {
8
8
  ? await moduleOrLoader()
9
9
  : moduleOrLoader;
10
10
  const key = mod.__moduleKey;
11
- if (typeof key === 'string' && mod.serverActions) {
12
- result[key] = {
13
- actions: mod.serverActions,
14
- guards: mod.actionGuards ?? [],
11
+ if (typeof key !== 'string' || !mod.serverActions)
12
+ continue;
13
+ const actions = {};
14
+ for (const [name, val] of Object.entries(mod.serverActions)) {
15
+ if (typeof val !== 'function')
16
+ continue;
17
+ // `defineAction` attaches `use` and `timeoutMs` as non-enumerable
18
+ // properties on the function (see `packages/iso/src/action.ts`). The
19
+ // structural read below is the single deserialization boundary; the
20
+ // handler body reads `entry.fn`, `entry.use`, `entry.timeoutMs`
21
+ // directly through the typed `ActionEntry` shape from here on.
22
+ const metadata = val;
23
+ actions[name] = {
24
+ fn: val,
25
+ use: metadata.use ?? [],
26
+ timeoutMs: metadata.timeoutMs,
15
27
  };
16
28
  }
29
+ result[key] = { actions };
17
30
  }
18
31
  return result;
19
32
  }
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.`);
33
+ function translateOutcomeForAction(c, outcome) {
34
+ if (outcome.__outcome === 'redirect') {
35
+ // Headers from the outcome ride the HTTP response via `c.header()`. They
36
+ // are deliberately NOT embedded in the JSON envelope: the client only
37
+ // reads `to` and calls `window.location.assign(to)`; any embedded
38
+ // headers would be dead bytes the client never inspects.
39
+ if (outcome.headers) {
40
+ for (const [k, v] of Object.entries(outcome.headers))
41
+ c.header(k, v);
41
42
  }
42
- };
43
- await run(0);
43
+ return c.json({
44
+ __outcome: 'redirect',
45
+ to: outcome.to,
46
+ status: outcome.status,
47
+ }, 200);
48
+ }
49
+ if (outcome.__outcome === 'deny') {
50
+ if (outcome.headers) {
51
+ for (const [k, v] of Object.entries(outcome.headers))
52
+ c.header(k, v);
53
+ }
54
+ return c.json({ __outcome: 'deny', message: outcome.message }, outcome.status);
55
+ }
56
+ if (outcome.__outcome === 'timeout') {
57
+ return c.json({ __outcome: 'timeout', timeoutMs: outcome.timeoutMs }, 504);
58
+ }
59
+ // render outcome should never reach the action RPC.
60
+ return c.json({
61
+ __outcome: 'error',
62
+ message: 'render outcome is page-scope only',
63
+ }, 500);
44
64
  }
45
65
  export function actionsHandler(glob, opts = {}) {
46
- const { dev = false, onError } = opts;
66
+ const { dev = false, onError, appConfig, resolvePageUse, defaultTimeoutMs = 30_000, } = opts;
47
67
  let cachedMapPromise = null;
48
68
  return async (c) => {
49
69
  const actionsMapPromise = dev
@@ -115,44 +135,93 @@ export function actionsHandler(glob, opts = {}) {
115
135
  if (!entry) {
116
136
  return c.json({ error: `Module '${module}' not found` }, 404);
117
137
  }
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
- const fn = entry.actions[action];
131
- if (typeof fn !== 'function') {
138
+ const actionEntry = entry.actions[action];
139
+ if (!actionEntry) {
132
140
  return c.json({ error: `Action '${action}' not found in module '${module}'` }, 404);
133
141
  }
134
- const signal = c.req.raw.signal;
142
+ const { fn, use: actionUse, timeoutMs: actionTimeoutMs } = actionEntry;
143
+ const resolvedTimeoutMs = actionTimeoutMs !== undefined ? actionTimeoutMs : defaultTimeoutMs;
144
+ const timeoutSignal = resolvedTimeoutMs === false
145
+ ? undefined
146
+ : AbortSignal.timeout(resolvedTimeoutMs);
147
+ const signal = timeoutSignal
148
+ ? AbortSignal.any([c.req.raw.signal, timeoutSignal])
149
+ : c.req.raw.signal;
135
150
  const actionCtx = { c, signal };
151
+ // Chain ordering is outer -> inner: app-level middleware wraps every
152
+ // request, page-level wraps actions owned by that page, and per-action
153
+ // middleware (attached via defineAction(fn, { use })) wraps just this
154
+ // call. Outer middleware runs first on the way in and last on the way
155
+ // out, matching every middleware system users have seen (Hono, Express,
156
+ // Koa). The action's owning page is unambiguous from `module`, so the
157
+ // page-layer lookup keys by module rather than by location path.
158
+ const rootUse = appConfig?.use ?? [];
159
+ const pageUse = (await resolvePageUse?.(module)) ?? [];
160
+ const fullUse = [...rootUse, ...pageUse, ...actionUse];
161
+ const { middleware: allMiddleware, observers } = partitionUse(fullUse);
162
+ const serverMw = allMiddleware.filter((m) => m.runs === 'server');
163
+ const ctx = {
164
+ scope: 'action',
165
+ c,
166
+ signal,
167
+ module,
168
+ action,
169
+ payload,
170
+ };
136
171
  let result;
137
172
  try {
138
- result = await runRequestScope(() => fn(actionCtx, payload));
173
+ result = await runRequestScope(async () => {
174
+ const dispatch = await dispatchServer({
175
+ middleware: serverMw,
176
+ ctx,
177
+ inner: async () => {
178
+ const inner = await fn(actionCtx, payload);
179
+ // An action that does `return redirect('/login')` instead of
180
+ // `throw redirect('/login')` would otherwise ship the outcome
181
+ // JSON shape as a normal 200 response and bypass envelope
182
+ // translation. Normalize by re-throwing so the existing
183
+ // outcome-catching path translates it.
184
+ if (isOutcome(inner))
185
+ throw inner;
186
+ return inner;
187
+ },
188
+ });
189
+ if (dispatch.kind === 'outcome') {
190
+ throw dispatch.outcome;
191
+ }
192
+ return dispatch.value;
193
+ });
139
194
  }
140
195
  catch (err) {
141
- if (err instanceof ActionGuardError) {
142
- return c.json({ error: err.message }, err.status);
196
+ if (isOutcome(err)) {
197
+ return translateOutcomeForAction(c, err);
143
198
  }
144
- if (err instanceof GuardRedirect) {
145
- return c.json({ __redirect: err.location });
199
+ if (timeoutSignal?.aborted &&
200
+ timeoutSignal.reason instanceof DOMException &&
201
+ timeoutSignal.reason.name === 'TimeoutError' &&
202
+ typeof resolvedTimeoutMs === 'number') {
203
+ return translateOutcomeForAction(c, timeoutOutcome(resolvedTimeoutMs));
146
204
  }
147
205
  onError?.(err, { module, action });
148
206
  const message = dev && err instanceof Error ? err.message : 'Action failed';
149
207
  return c.json({ error: message }, 500);
150
208
  }
151
209
  if (isAsyncGenerator(result)) {
152
- return sseGeneratorResponse(c, result, { emitResult: true });
210
+ return sseGeneratorResponse(c, result, {
211
+ emitResult: true,
212
+ observers,
213
+ observerCtx: ctx,
214
+ signal: timeoutSignal,
215
+ timeoutMs: typeof resolvedTimeoutMs === 'number' ? resolvedTimeoutMs : undefined,
216
+ });
153
217
  }
154
218
  if (result instanceof ReadableStream) {
155
- return sseReadableStreamResponse(c, result);
219
+ return sseReadableStreamResponse(c, result, {
220
+ observers,
221
+ observerCtx: ctx,
222
+ signal: timeoutSignal,
223
+ timeoutMs: typeof resolvedTimeoutMs === 'number' ? resolvedTimeoutMs : undefined,
224
+ });
156
225
  }
157
226
  return c.json(result);
158
227
  };
@@ -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() {
@@ -1,5 +1,6 @@
1
1
  export { HonoContext, useHonoContext } from './context.js';
2
2
  export { renderPage } from './render.js';
3
- export { actionsHandler } from './actions-handler.js';
4
3
  export { loadersHandler } from './loaders-handler.js';
5
- export { routeServerModules } from './route-server-modules.js';
4
+ export { routeServerModules, makePageUseResolvers, } from './route-server-modules.js';
5
+ export { makePageActionResolvers, type ActionEntry, } from './page-action-resolvers.js';
6
+ export { pageActionHandler, type PageActionHandlerOptions, } from './page-action-handler.js';
@@ -1,5 +1,6 @@
1
1
  export { HonoContext, useHonoContext } from './context.js';
2
2
  export { renderPage } from './render.js';
3
- export { actionsHandler } from './actions-handler.js';
4
3
  export { loadersHandler } from './loaders-handler.js';
5
- export { routeServerModules } from './route-server-modules.js';
4
+ export { routeServerModules, makePageUseResolvers, } from './route-server-modules.js';
5
+ export { makePageActionResolvers, } from './page-action-resolvers.js';
6
+ export { pageActionHandler, } from './page-action-handler.js';
@@ -1,7 +1,9 @@
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;
6
+ serverLoaders?: unknown;
5
7
  [key: string]: unknown;
6
8
  };
7
9
  type LazyGlob = Record<string, () => Promise<unknown>>;
@@ -25,6 +27,28 @@ export interface LoadersHandlerOptions {
25
27
  module: string;
26
28
  loader: string;
27
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 loader
33
+ * request composes the chain as
34
+ * `[...appConfig.use, ...resolvePageUse(path), ...loader.use]`.
35
+ */
36
+ appConfig?: AppConfig;
37
+ /**
38
+ * Per-page layer lookup keyed by the matched route's location path.
39
+ * Returns the `use` array declared on the matching page's `.server.*`
40
+ * module (as `export const pageUse = [...]`). The lookup may be sync
41
+ * (an in-memory map) or async (loaded lazily on first request). The
42
+ * handler awaits the result either way. Default returns an empty array.
43
+ */
44
+ resolvePageUse?: (path: string) => ReadonlyArray<unknown> | Promise<ReadonlyArray<unknown>>;
45
+ /**
46
+ * Default loader timeout in milliseconds applied when a loader does not
47
+ * declare its own `timeoutMs`. Defaults to 30000 (30 seconds). Pass
48
+ * `false` to disable the default (only loader-level `timeoutMs` enforces
49
+ * a deadline).
50
+ */
51
+ defaultTimeoutMs?: number | false;
28
52
  }
29
53
  export declare function loadersHandler(glob: LazyGlob | EagerGlob, opts?: LoadersHandlerOptions): MiddlewareHandler;
30
54
  export {};