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
package/README.md CHANGED
@@ -37,7 +37,8 @@ Full walkthrough: https://framework.sbesh.com/docs/quick-start
37
37
 
38
38
  ## Subpaths
39
39
 
40
- - `hono-preact`: iso runtime exports (routes, pages, loaders, actions, forms, guards).
40
+ - `hono-preact`: iso runtime exports (routes, pages, loaders, actions, forms, middleware, outcomes).
41
+ - `hono-preact/page`: page-scope outcome kitchen sink (`redirect`, `deny`, `render`, predicates).
41
42
  - `hono-preact/server`: server entry, `renderPage`, SSR streaming helpers.
42
43
  - `hono-preact/vite`: `honoPreact()` plugin for Vite.
43
44
  - `hono-preact/internal`: advanced exports for tooling authors. No stability guarantee.
@@ -0,0 +1 @@
1
+ export * from './vite/adapter-cloudflare';
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter-cloudflare.d.ts","sourceRoot":"","sources":["../src/adapter-cloudflare.ts"],"names":[],"mappings":"AACA,cAAc,sCAAsC,CAAC"}
@@ -0,0 +1,2 @@
1
+ // packages/hono-preact/src/adapter-cloudflare.ts
2
+ export * from './vite/adapter-cloudflare.js';
@@ -0,0 +1 @@
1
+ export * from './vite/adapter-node';
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter-node.d.ts","sourceRoot":"","sources":["../src/adapter-node.ts"],"names":[],"mappings":"AACA,cAAc,gCAAgC,CAAC"}
@@ -0,0 +1,2 @@
1
+ // packages/hono-preact/src/adapter-node.ts
2
+ export * from './vite/adapter-node.js';
@@ -1 +1 @@
1
- export * from './iso/internal/index';
1
+ export * from './iso/internal';
package/dist/internal.js CHANGED
@@ -1 +1 @@
1
- export * from './iso/internal/index.js';
1
+ export * from './iso/internal.js';
@@ -0,0 +1,22 @@
1
+ export type ActionResultContextValue = {
2
+ module: string;
3
+ action: string;
4
+ kind: 'success';
5
+ data: unknown;
6
+ submittedPayload: unknown;
7
+ } | {
8
+ module: string;
9
+ action: string;
10
+ kind: 'deny';
11
+ status: number;
12
+ message: string;
13
+ data?: unknown;
14
+ submittedPayload: unknown;
15
+ } | {
16
+ module: string;
17
+ action: string;
18
+ kind: 'error';
19
+ message: string;
20
+ submittedPayload: unknown;
21
+ } | null;
22
+ export declare const ActionResultContext: import("preact").Context<ActionResultContextValue>;
@@ -0,0 +1,2 @@
1
+ import { createContext } from 'preact';
2
+ export const ActionResultContext = createContext(null);
@@ -1,6 +1,6 @@
1
1
  import type { Context } from 'hono';
2
- import type { ContentfulStatusCode } from 'hono/utils/http-status';
3
2
  import type { LoaderRef } from './define-loader.js';
3
+ import type { ActionUse } from './internal/use-types.js';
4
4
  export type ActionStub<TPayload, TResult, TChunk = never> = {
5
5
  readonly __module: string;
6
6
  readonly __action: string;
@@ -12,8 +12,37 @@ export type ActionCtx = {
12
12
  signal: AbortSignal;
13
13
  };
14
14
  export type ActionFn<TPayload, TResult, TChunk = never> = ((ctx: ActionCtx, payload: TPayload) => Promise<TResult>) | ((ctx: ActionCtx, payload: TPayload) => Promise<ReadableStream<TChunk>>) | ((ctx: ActionCtx, payload: TPayload) => AsyncGenerator<TChunk, TResult, unknown>);
15
- export declare function defineAction<TPayload, TResult, TChunk = never>(fn: ActionFn<TPayload, TResult, TChunk>): ActionStub<TPayload, TResult, TChunk>;
16
- export type UseActionOptions<TPayload, TResult, TChunk = never, TSnapshot = unknown> = {
15
+ export type DefineActionOpts<TChunk = never, TResult = unknown> = {
16
+ /**
17
+ * Per-action middleware and (for streaming actions) stream observers.
18
+ * Attached to the function as a non-enumerable property; the
19
+ * page-action-handler reads it through the typed `ActionEntry` map built at
20
+ * module-load time (`packages/server/src/page-action-handler.ts`).
21
+ */
22
+ use?: ActionUse<TChunk, TResult, boolean>;
23
+ /**
24
+ * Per-action timeout in milliseconds. When omitted, the handler applies
25
+ * its configured default (30s). Pass `false` to disable the timeout for
26
+ * this action.
27
+ */
28
+ timeoutMs?: number | false;
29
+ /**
30
+ * The module key the client-side `useAction` hook will reference in its
31
+ * RPC envelope. Production wires this through the Vite plugin's
32
+ * client-stub emission; test code can pass it directly to construct a
33
+ * properly-shaped stub without bypassing the type system.
34
+ */
35
+ __module?: string;
36
+ /** The action name the client-side `useAction` hook will reference. */
37
+ __action?: string;
38
+ };
39
+ export declare class TimeoutError extends Error {
40
+ readonly kind: "timeout";
41
+ readonly timeoutMs: number;
42
+ constructor(timeoutMs: number);
43
+ }
44
+ export declare function defineAction<TPayload, TResult, TChunk = never>(fn: ActionFn<TPayload, TResult, TChunk>, opts?: DefineActionOpts<TChunk, TResult>): ActionStub<TPayload, TResult, TChunk>;
45
+ type UseActionOptionsCommon<TChunk = never> = {
17
46
  /**
18
47
  * How to update loader caches after the action commits. Three modes:
19
48
  *
@@ -29,30 +58,47 @@ export type UseActionOptions<TPayload, TResult, TChunk = never, TSnapshot = unkn
29
58
  * See `/docs/reloading` for the full mental model.
30
59
  */
31
60
  invalidate?: 'auto' | false | ReadonlyArray<LoaderRef<unknown>>;
32
- onMutate?: (payload: TPayload) => TSnapshot;
33
61
  onChunk?: (chunk: TChunk) => void;
62
+ };
63
+ /**
64
+ * Options when `onMutate` is provided. `onSuccess` / `onError` receive the
65
+ * value `onMutate` returned for this specific mutation as the second
66
+ * parameter, so concurrent calls can be paired with their own snapshot.
67
+ */
68
+ type UseActionWithMutate<TPayload, TResult, TChunk, TSnapshot> = UseActionOptionsCommon<TChunk> & {
69
+ onMutate: (payload: TPayload) => TSnapshot;
34
70
  onError?: (err: Error, snapshot: TSnapshot) => void;
35
71
  onSuccess?: (data: TResult, snapshot: TSnapshot) => void;
36
72
  };
73
+ /**
74
+ * Options when `onMutate` is not provided. `onSuccess` / `onError` take
75
+ * only the result / error — there is no snapshot to thread through.
76
+ */
77
+ type UseActionWithoutMutate<TResult, TChunk> = UseActionOptionsCommon<TChunk> & {
78
+ onMutate?: undefined;
79
+ onError?: (err: Error) => void;
80
+ onSuccess?: (data: TResult) => void;
81
+ };
82
+ /**
83
+ * Discriminated by `onMutate`. Providing `onMutate` requires the
84
+ * `onSuccess` / `onError` callbacks to accept the snapshot; omitting
85
+ * `onMutate` types those callbacks as single-argument.
86
+ */
87
+ export type UseActionOptions<TPayload, TResult, TChunk = never, TSnapshot = unknown> = UseActionWithMutate<TPayload, TResult, TChunk, TSnapshot> | UseActionWithoutMutate<TResult, TChunk>;
37
88
  /**
38
89
  * The value `mutate` resolves to. A discriminated union so callers can
39
90
  * chain on success without awaiting then probing the hook's `data`/`error`
40
91
  * state, and without leaking unhandled rejections in fire-and-forget callers.
41
92
  *
42
- * - Success: `{ ok: true, data }`. For streaming actions that emit no
43
- * `result` SSE event, `data` is `undefined`; declare `TResult = void` (or
44
- * include `undefined` in its union) if your action doesn't emit a result.
93
+ * - Success: `{ ok: true, data }`. `data` is `undefined` for streaming
94
+ * actions that close without emitting a `result` SSE event (the type
95
+ * reflects this honestly: callers must narrow before using `data`).
45
96
  * - Failure: `{ ok: false, error }`. The same `Error` instance is also
46
97
  * written to the hook's `error` state and passed to `onError`.
47
- *
48
- * Returning a union (rather than throwing) keeps `mutate(...)` ergonomic
49
- * for non-awaiting call sites — the existing `error` state field is the
50
- * idiomatic way to render an error UI — while still letting awaiting
51
- * callers do `if (result.ok) navigate(...)`.
52
98
  */
53
99
  export type MutateResult<TResult> = {
54
100
  ok: true;
55
- data: TResult;
101
+ data: TResult | undefined;
56
102
  } | {
57
103
  ok: false;
58
104
  error: Error;
@@ -64,15 +110,4 @@ export type UseActionResult<TPayload, TResult> = {
64
110
  data: TResult | null;
65
111
  };
66
112
  export declare function useAction<TPayload, TResult, TChunk = never, TSnapshot = unknown>(stub: ActionStub<TPayload, TResult, TChunk>, options?: UseActionOptions<TPayload, TResult, TChunk, TSnapshot>): UseActionResult<TPayload, TResult>;
67
- export type ActionGuardContext = {
68
- c: Context;
69
- module: string;
70
- action: string;
71
- payload: unknown;
72
- };
73
- export type ActionGuardFn = (ctx: ActionGuardContext, next: () => Promise<void>) => Promise<void>;
74
- export declare class ActionGuardError extends Error {
75
- readonly status: ContentfulStatusCode;
76
- constructor(message: string, status?: ContentfulStatusCode);
77
- }
78
- export declare const defineActionGuard: (fn: ActionGuardFn) => ActionGuardFn;
113
+ export {};
@@ -1,11 +1,62 @@
1
1
  import { useCallback, useContext, useRef, useState } from 'preact/hooks';
2
2
  import { ReloadContext } from './reload-context.js';
3
3
  import { ActiveLoaderIdContext } from './internal/contexts.js';
4
- export function defineAction(fn) {
5
- // Runtime no-op: returns fn as-is. actionsHandler casts it back to a function.
6
- // The ActionStub type is enforced only by TypeScript and the Vite plugin.
4
+ import { beginSubmit, endSubmit } from './internal/form-submit-store.js';
5
+ import { assignSafeRedirect } from './internal/safe-redirect.js';
6
+ import { setLastActionResult, } from './internal/action-result-store.js';
7
+ export class TimeoutError extends Error {
8
+ kind = 'timeout';
9
+ timeoutMs;
10
+ constructor(timeoutMs) {
11
+ super(`Request timed out after ${timeoutMs}ms`);
12
+ this.name = 'TimeoutError';
13
+ this.timeoutMs = timeoutMs;
14
+ }
15
+ }
16
+ function validateTimeoutMs(value, context) {
17
+ if (value === undefined || value === false)
18
+ return;
19
+ if (!Number.isFinite(value) || value < 0) {
20
+ throw new RangeError(`${context}: timeoutMs must be a non-negative finite number or false, got ${String(value)}`);
21
+ }
22
+ }
23
+ export function defineAction(fn, opts) {
24
+ validateTimeoutMs(opts?.timeoutMs, 'defineAction');
25
+ // SHAPE NOTE: `ActionStub` describes the CLIENT-side shape produced by the
26
+ // Vite plugin (`packages/vite/src/server-only.ts`) — an object with
27
+ // `__module`, `__action`, and a `useAction` method. `defineAction` runs on
28
+ // the SERVER side and returns the raw function with metadata attached via
29
+ // `Object.defineProperty`. These are two different runtime shapes unified
30
+ // under one type so consumers can import a server action and use it
31
+ // identically on both sides; the plugin handles the substitution at the
32
+ // value level. The `as unknown as` cast at the return is the single
33
+ // bounded acknowledgement of this dual-shape contract. A future cleanup
34
+ // could split into `ServerActionImpl` / `ActionStub` types if the lie
35
+ // starts to bite, but for now it's localized and documented.
36
+ //
37
+ // `Object.defineProperty` is used instead of direct assignment so a frozen
38
+ // module export (strict ESM, HMR-frozen modules) does not throw.
39
+ const attach = (key, value) => {
40
+ Object.defineProperty(fn, key, {
41
+ value,
42
+ configurable: true,
43
+ writable: true,
44
+ enumerable: false,
45
+ });
46
+ };
47
+ if (opts?.use)
48
+ attach('use', opts.use);
49
+ if (opts?.timeoutMs !== undefined)
50
+ attach('timeoutMs', opts.timeoutMs);
51
+ if (opts?.__module !== undefined)
52
+ attach('__module', opts.__module);
53
+ if (opts?.__action !== undefined)
54
+ attach('__action', opts.__action);
7
55
  return fn;
8
56
  }
57
+ function recordOutcome(module, action, result) {
58
+ setLastActionResult(module, action, result);
59
+ }
9
60
  function hasFileValues(payload) {
10
61
  if (typeof File === 'undefined')
11
62
  return false;
@@ -28,18 +79,49 @@ export function useAction(stub, options) {
28
79
  setError(null);
29
80
  const currentStub = stubRef.current;
30
81
  const currentOptions = optionsRef.current;
31
- let snapshot;
32
- if (currentOptions?.onMutate) {
33
- snapshot = currentOptions.onMutate(payload);
34
- }
82
+ const callbacks = currentOptions?.onMutate
83
+ ? {
84
+ kind: 'with-mutate',
85
+ snapshot: currentOptions.onMutate(payload),
86
+ onSuccess: currentOptions.onSuccess,
87
+ onError: currentOptions.onError,
88
+ }
89
+ : {
90
+ kind: 'without-mutate',
91
+ onSuccess: currentOptions?.onSuccess,
92
+ onError: currentOptions?.onError,
93
+ };
94
+ const invokeSuccess = (data) => {
95
+ if (callbacks.kind === 'with-mutate') {
96
+ callbacks.onSuccess?.(data, callbacks.snapshot);
97
+ }
98
+ else {
99
+ callbacks.onSuccess?.(data);
100
+ }
101
+ };
102
+ const invokeError = (err) => {
103
+ if (callbacks.kind === 'with-mutate') {
104
+ callbacks.onError?.(err, callbacks.snapshot);
105
+ }
106
+ else {
107
+ callbacks.onError?.(err);
108
+ }
109
+ };
110
+ const target = typeof window !== 'undefined'
111
+ ? window.location.pathname + window.location.search
112
+ : '/';
35
113
  let finalResult;
114
+ // Tracks whether a branch has already written to the action-result store.
115
+ // The outer catch writes for unclassified errors (network failures, parse
116
+ // errors) only when no branch has already recorded the outcome.
117
+ let outcomeRecorded = false;
118
+ beginSubmit(currentStub.__module, currentStub.__action);
36
119
  try {
37
- const stub = currentStub;
38
120
  let response;
39
121
  if (hasFileValues(payload)) {
40
122
  const fd = new FormData();
41
- fd.append('__module', stub.__module);
42
- fd.append('__action', stub.__action);
123
+ fd.append('__module', currentStub.__module);
124
+ fd.append('__action', currentStub.__action);
43
125
  for (const [key, value] of Object.entries(payload)) {
44
126
  if (key === '__module' || key === '__action')
45
127
  continue;
@@ -53,44 +135,27 @@ export function useAction(stub, options) {
53
135
  fd.append(key, JSON.stringify(value));
54
136
  }
55
137
  }
56
- response = await fetch('/__actions', { method: 'POST', body: fd });
138
+ response = await fetch(target, {
139
+ method: 'POST',
140
+ headers: { Accept: 'application/json, text/event-stream;q=0.9' },
141
+ body: fd,
142
+ });
57
143
  }
58
144
  else {
59
- response = await fetch('/__actions', {
145
+ response = await fetch(target, {
60
146
  method: 'POST',
61
- headers: { 'Content-Type': 'application/json' },
147
+ headers: {
148
+ 'Content-Type': 'application/json',
149
+ Accept: 'application/json, text/event-stream;q=0.9',
150
+ },
62
151
  body: JSON.stringify({
63
- module: stub.__module,
64
- action: stub.__action,
152
+ module: currentStub.__module,
153
+ action: currentStub.__action,
65
154
  payload,
66
155
  }),
67
156
  });
68
157
  }
69
- if (!response.ok) {
70
- const body = (await response.json());
71
- throw new Error(body.error ?? `Action failed with status ${response.status}`);
72
- }
73
158
  const contentType = response.headers.get('Content-Type') ?? '';
74
- // Server-side `GuardRedirect` thrown from an action (or its guards) comes
75
- // back as `{ __redirect }`. Hand off to the browser; the rest of this
76
- // promise will never settle, but the page is navigating away anyway.
77
- if (!contentType.includes('text/event-stream')) {
78
- const peek = (await response
79
- .clone()
80
- .json()
81
- .catch(() => undefined));
82
- if (peek !== null &&
83
- typeof peek === 'object' &&
84
- peek !== undefined &&
85
- '__redirect' in peek &&
86
- typeof peek.__redirect === 'string') {
87
- if (typeof window !== 'undefined') {
88
- window.location.assign(peek.__redirect);
89
- }
90
- // Cast through `as` because TS can't see this promise never settles.
91
- return await new Promise(() => { });
92
- }
93
- }
94
159
  if (contentType.includes('text/event-stream') && response.body) {
95
160
  const { readSSE } = await import('./internal/sse-decoder.js');
96
161
  let resultValue;
@@ -112,6 +177,15 @@ export function useAction(stub, options) {
112
177
  streamError = new Error(`Malformed result event in stream: ${e instanceof Error ? e.message : String(e)}`);
113
178
  }
114
179
  }
180
+ else if (ev.event === 'timeout') {
181
+ try {
182
+ const parsed = JSON.parse(ev.data);
183
+ streamError = new TimeoutError(parsed.timeoutMs ?? 0);
184
+ }
185
+ catch (e) {
186
+ streamError = new Error(`Malformed timeout event in stream: ${e instanceof Error ? e.message : String(e)}`);
187
+ }
188
+ }
115
189
  else if (ev.event === 'error') {
116
190
  try {
117
191
  const parsed = JSON.parse(ev.data);
@@ -125,26 +199,101 @@ export function useAction(stub, options) {
125
199
  }
126
200
  }
127
201
  if (streamError) {
202
+ recordOutcome(currentStub.__module, currentStub.__action, {
203
+ kind: 'error',
204
+ message: streamError.message,
205
+ submittedPayload: payload,
206
+ });
207
+ outcomeRecorded = true;
128
208
  throw streamError;
129
209
  }
130
210
  if (resultValue !== undefined) {
131
211
  setData(resultValue);
132
- currentOptions?.onSuccess?.(resultValue, snapshot);
212
+ invokeSuccess(resultValue);
133
213
  finalResult = resultValue;
214
+ recordOutcome(currentStub.__module, currentStub.__action, {
215
+ kind: 'success',
216
+ data: resultValue,
217
+ submittedPayload: payload,
218
+ });
219
+ outcomeRecorded = true;
134
220
  }
135
221
  else {
136
- currentOptions?.onSuccess?.(undefined, snapshot);
137
- // Streaming actions with no `result` event resolve with undefined.
138
- // Consumers should type `TResult = void` (or include `undefined`)
139
- // when their action doesn't emit a result.
222
+ // Streaming action closed without emitting a `result` event;
223
+ // resolve with `data: undefined`. `onSuccess` is not called
224
+ // in this branch since there is no result value to deliver
225
+ // (matches the static-action path where onSuccess only fires
226
+ // with a real value).
140
227
  finalResult = undefined;
141
228
  }
142
229
  }
143
230
  else {
144
- const result = (await response.json());
145
- setData(result);
146
- currentOptions?.onSuccess?.(result, snapshot);
147
- finalResult = result;
231
+ // Uniform envelope path. All non-streaming responses carry a JSON
232
+ // body shaped as { __outcome, ... } regardless of HTTP status.
233
+ let env;
234
+ try {
235
+ env = (await response.json());
236
+ }
237
+ catch {
238
+ throw new Error(`Malformed envelope (HTTP ${response.status})`);
239
+ }
240
+ if (env.__outcome === 'success') {
241
+ const data = env.data;
242
+ setData(data);
243
+ invokeSuccess(data);
244
+ finalResult = data;
245
+ recordOutcome(currentStub.__module, currentStub.__action, {
246
+ kind: 'success',
247
+ data,
248
+ submittedPayload: payload,
249
+ });
250
+ outcomeRecorded = true;
251
+ }
252
+ else if (env.__outcome === 'redirect' &&
253
+ typeof env.to === 'string') {
254
+ if (assignSafeRedirect(env.to)) {
255
+ // Navigation issued; this promise never settles.
256
+ return await new Promise(() => { });
257
+ }
258
+ // Cross-origin: surface as an error so the caller can handle it.
259
+ throw new Error(`Refused cross-origin redirect to ${env.to}`);
260
+ }
261
+ else if (env.__outcome === 'deny') {
262
+ const msg = env.message ??
263
+ `Request denied (${env.status ?? response.status})`;
264
+ recordOutcome(currentStub.__module, currentStub.__action, {
265
+ kind: 'deny',
266
+ status: env.status ?? response.status,
267
+ message: msg,
268
+ data: env.data,
269
+ submittedPayload: payload,
270
+ });
271
+ outcomeRecorded = true;
272
+ throw new Error(msg);
273
+ }
274
+ else if (env.__outcome === 'timeout' &&
275
+ typeof env.timeoutMs === 'number') {
276
+ recordOutcome(currentStub.__module, currentStub.__action, {
277
+ kind: 'error',
278
+ message: `Request timed out after ${env.timeoutMs}ms`,
279
+ submittedPayload: payload,
280
+ });
281
+ outcomeRecorded = true;
282
+ throw new TimeoutError(env.timeoutMs);
283
+ }
284
+ else if (env.__outcome === 'error') {
285
+ const msg = env.message ?? 'Action failed';
286
+ recordOutcome(currentStub.__module, currentStub.__action, {
287
+ kind: 'error',
288
+ message: msg,
289
+ submittedPayload: payload,
290
+ });
291
+ outcomeRecorded = true;
292
+ throw new Error(msg);
293
+ }
294
+ else {
295
+ throw new Error(`Unknown action outcome: ${env.__outcome}`);
296
+ }
148
297
  }
149
298
  if (currentOptions?.invalidate === 'auto') {
150
299
  reloadCtx?.reload();
@@ -168,22 +317,25 @@ export function useAction(stub, options) {
168
317
  }
169
318
  catch (err) {
170
319
  const e = err instanceof Error ? err : new Error(String(err));
320
+ // Write to the store only for unclassified errors (network failures,
321
+ // parse errors). Per-branch errors set outcomeRecorded before throwing.
322
+ if (!outcomeRecorded) {
323
+ recordOutcome(currentStub.__module, currentStub.__action, {
324
+ kind: 'error',
325
+ message: e.message,
326
+ submittedPayload: payload,
327
+ });
328
+ }
171
329
  setError(e);
172
- currentOptions?.onError?.(e, snapshot);
330
+ invokeError(e);
173
331
  setPending(false);
174
332
  return { ok: false, error: e };
175
333
  }
334
+ finally {
335
+ endSubmit(currentStub.__module, currentStub.__action);
336
+ }
176
337
  setPending(false);
177
338
  return { ok: true, data: finalResult };
178
339
  }, []);
179
340
  return { mutate, pending, error, data };
180
341
  }
181
- export class ActionGuardError extends Error {
182
- status;
183
- constructor(message, status = 403) {
184
- super(message);
185
- this.status = status;
186
- this.name = 'ActionGuardError';
187
- }
188
- }
189
- export const defineActionGuard = (fn) => fn;
@@ -1,4 +1,5 @@
1
1
  import type { Loader } from './define-loader.js';
2
+ import type { ActionResolution } from './internal/action-envelope.js';
2
3
  export interface LoaderCache<T> {
3
4
  get(locKey?: string): T | null;
4
5
  set(value: T, locKey?: string): void;
@@ -9,6 +10,14 @@ export interface LoaderCache<T> {
9
10
  type RequestStore = Map<symbol, unknown>;
10
11
  export declare function getRequestStore(): RequestStore | undefined;
11
12
  export declare function getRequestHonoContext<T = unknown>(): T | undefined;
13
+ export type ActionResultSlot = {
14
+ module: string;
15
+ action: string;
16
+ resolution: ActionResolution;
17
+ submittedPayload: unknown;
18
+ };
19
+ export declare function getActionResultSlot(): ActionResultSlot | null;
20
+ export declare function setActionResultSlot(slot: ActionResultSlot): void;
12
21
  export declare function runRequestScope<R>(fn: () => R | Promise<R>, initial?: {
13
22
  honoContext?: unknown;
14
23
  }): R | Promise<R>;
package/dist/iso/cache.js CHANGED
@@ -18,6 +18,7 @@ if (!looksLikeBrowser) {
18
18
  }
19
19
  }
20
20
  const HONO_CONTEXT_KEY = Symbol('@hono-preact/iso/honoContext');
21
+ const ACTION_RESULT_KEY = Symbol('@hono-preact/iso/actionResult');
21
22
  export function getRequestStore() {
22
23
  return alsInstance?.getStore();
23
24
  }
@@ -36,9 +37,34 @@ export function getRequestHonoContext() {
36
37
  }
37
38
  return ctx;
38
39
  }
40
+ export function getActionResultSlot() {
41
+ const store = getRequestStore();
42
+ if (!store)
43
+ return null;
44
+ const slot = store.get(ACTION_RESULT_KEY);
45
+ return (slot ?? null);
46
+ }
47
+ export function setActionResultSlot(slot) {
48
+ const store = getRequestStore();
49
+ if (!store) {
50
+ throw new Error('setActionResultSlot must be called inside runRequestScope');
51
+ }
52
+ store.set(ACTION_RESULT_KEY, slot);
53
+ }
39
54
  export function runRequestScope(fn, initial) {
40
55
  if (!alsInstance)
41
56
  return fn();
57
+ const existing = alsInstance.getStore();
58
+ if (existing) {
59
+ // Nested call: inherit the parent store. Seeded values are written
60
+ // additively so the inner caller's overrides take effect without
61
+ // wiping the parent's per-request state (e.g. the action-result
62
+ // slot set by pageActionHandler before it invokes renderPage).
63
+ if (initial?.honoContext !== undefined) {
64
+ existing.set(HONO_CONTEXT_KEY, initial.honoContext);
65
+ }
66
+ return fn();
67
+ }
42
68
  const store = new Map();
43
69
  if (initial?.honoContext !== undefined) {
44
70
  store.set(HONO_CONTEXT_KEY, initial.honoContext);
@@ -0,0 +1,14 @@
1
+ import type { ServerMiddleware, ClientMiddleware } from './define-middleware.js';
2
+ import type { StreamObserver } from './define-stream-observer.js';
3
+ export type AppUseElement = ServerMiddleware<'page'> | ClientMiddleware | StreamObserver<unknown, never>;
4
+ export type AppConfig = {
5
+ use?: ReadonlyArray<AppUseElement>;
6
+ /**
7
+ * When `true`, the server emits a `<script type="speculationrules">` tag
8
+ * into `<head>` that instructs supporting browsers to prefetch same-origin
9
+ * `<a href>` links on moderate eagerness. Defaults to `false`. Individual
10
+ * links opt out with `data-no-prefetch`.
11
+ */
12
+ speculation?: boolean;
13
+ };
14
+ export declare function defineApp(config: AppConfig): AppConfig;
@@ -0,0 +1,3 @@
1
+ export function defineApp(config) {
2
+ return config;
3
+ }