hono-preact 0.2.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.
- package/dist/iso/action-result-context.d.ts +22 -0
- package/dist/iso/action-result-context.js +2 -0
- package/dist/iso/action.d.ts +52 -13
- package/dist/iso/action.js +204 -88
- package/dist/iso/cache.d.ts +9 -0
- package/dist/iso/cache.js +26 -0
- package/dist/iso/define-app.d.ts +7 -0
- package/dist/iso/define-loader.d.ts +12 -0
- package/dist/iso/define-loader.js +26 -16
- package/dist/iso/form.d.ts +13 -4
- package/dist/iso/form.js +115 -33
- package/dist/iso/index.d.ts +7 -4
- package/dist/iso/index.js +5 -2
- package/dist/iso/internal/action-envelope.d.ts +37 -0
- package/dist/iso/internal/action-envelope.js +47 -0
- package/dist/iso/internal/action-result-store.d.ts +28 -0
- package/dist/iso/internal/action-result-store.js +35 -0
- package/dist/iso/internal/envelope.js +1 -2
- package/dist/iso/internal/form-submit-store.d.ts +9 -0
- package/dist/iso/internal/form-submit-store.js +32 -0
- package/dist/iso/internal/loader-fetch.js +65 -34
- package/dist/iso/internal/loader.d.ts +3 -3
- package/dist/iso/internal/route-boundary.d.ts +4 -4
- package/dist/iso/internal/safe-redirect.d.ts +7 -0
- package/dist/iso/internal/safe-redirect.js +27 -0
- package/dist/iso/internal/sse-decoder.d.ts +1 -1
- package/dist/iso/internal/sse-decoder.js +40 -26
- package/dist/iso/internal.d.ts +7 -1
- package/dist/iso/internal.js +8 -1
- package/dist/iso/optimistic-action.d.ts +10 -1
- package/dist/iso/optimistic-action.js +11 -3
- package/dist/iso/optimistic.d.ts +10 -1
- package/dist/iso/optimistic.js +45 -5
- package/dist/iso/outcomes.d.ts +14 -2
- package/dist/iso/outcomes.js +14 -3
- package/dist/iso/use-action-result.d.ts +25 -0
- package/dist/iso/use-action-result.js +39 -0
- package/dist/iso/use-form-status.d.ts +5 -0
- package/dist/iso/use-form-status.js +13 -0
- package/dist/server/actions-handler.d.ts +7 -0
- package/dist/server/actions-handler.js +42 -9
- package/dist/server/index.d.ts +2 -1
- package/dist/server/index.js +2 -1
- package/dist/server/loaders-handler.d.ts +8 -0
- package/dist/server/loaders-handler.js +37 -4
- package/dist/server/page-action-handler.d.ts +63 -0
- package/dist/server/page-action-handler.js +274 -0
- package/dist/server/page-action-resolvers.d.ts +28 -0
- package/dist/server/page-action-resolvers.js +147 -0
- package/dist/server/render.js +41 -3
- package/dist/server/route-server-modules.d.ts +7 -8
- package/dist/server/route-server-modules.js +7 -8
- package/dist/server/speculation-rules.d.ts +3 -0
- package/dist/server/speculation-rules.js +8 -0
- package/dist/server/sse.d.ts +43 -28
- package/dist/server/sse.js +113 -88
- package/dist/vite/server-entry.js +10 -2
- package/package.json +2 -2
|
@@ -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>;
|
package/dist/iso/action.d.ts
CHANGED
|
@@ -15,13 +15,34 @@ export type ActionFn<TPayload, TResult, TChunk = never> = ((ctx: ActionCtx, payl
|
|
|
15
15
|
export type DefineActionOpts<TChunk = never, TResult = unknown> = {
|
|
16
16
|
/**
|
|
17
17
|
* Per-action middleware and (for streaming actions) stream observers.
|
|
18
|
-
* Attached to the function as a non-enumerable
|
|
19
|
-
*
|
|
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`).
|
|
20
21
|
*/
|
|
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;
|
|
22
38
|
};
|
|
39
|
+
export declare class TimeoutError extends Error {
|
|
40
|
+
readonly kind: "timeout";
|
|
41
|
+
readonly timeoutMs: number;
|
|
42
|
+
constructor(timeoutMs: number);
|
|
43
|
+
}
|
|
23
44
|
export declare function defineAction<TPayload, TResult, TChunk = never>(fn: ActionFn<TPayload, TResult, TChunk>, opts?: DefineActionOpts<TChunk, TResult>): ActionStub<TPayload, TResult, TChunk>;
|
|
24
|
-
|
|
45
|
+
type UseActionOptionsCommon<TChunk = never> = {
|
|
25
46
|
/**
|
|
26
47
|
* How to update loader caches after the action commits. Three modes:
|
|
27
48
|
*
|
|
@@ -37,30 +58,47 @@ export type UseActionOptions<TPayload, TResult, TChunk = never, TSnapshot = unkn
|
|
|
37
58
|
* See `/docs/reloading` for the full mental model.
|
|
38
59
|
*/
|
|
39
60
|
invalidate?: 'auto' | false | ReadonlyArray<LoaderRef<unknown>>;
|
|
40
|
-
onMutate?: (payload: TPayload) => TSnapshot;
|
|
41
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;
|
|
42
70
|
onError?: (err: Error, snapshot: TSnapshot) => void;
|
|
43
71
|
onSuccess?: (data: TResult, snapshot: TSnapshot) => void;
|
|
44
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>;
|
|
45
88
|
/**
|
|
46
89
|
* The value `mutate` resolves to. A discriminated union so callers can
|
|
47
90
|
* chain on success without awaiting then probing the hook's `data`/`error`
|
|
48
91
|
* state, and without leaking unhandled rejections in fire-and-forget callers.
|
|
49
92
|
*
|
|
50
|
-
* - Success: `{ ok: true, data }`.
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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`).
|
|
53
96
|
* - Failure: `{ ok: false, error }`. The same `Error` instance is also
|
|
54
97
|
* written to the hook's `error` state and passed to `onError`.
|
|
55
|
-
*
|
|
56
|
-
* Returning a union (rather than throwing) keeps `mutate(...)` ergonomic
|
|
57
|
-
* for non-awaiting call sites — the existing `error` state field is the
|
|
58
|
-
* idiomatic way to render an error UI — while still letting awaiting
|
|
59
|
-
* callers do `if (result.ok) navigate(...)`.
|
|
60
98
|
*/
|
|
61
99
|
export type MutateResult<TResult> = {
|
|
62
100
|
ok: true;
|
|
63
|
-
data: TResult;
|
|
101
|
+
data: TResult | undefined;
|
|
64
102
|
} | {
|
|
65
103
|
ok: false;
|
|
66
104
|
error: Error;
|
|
@@ -72,3 +110,4 @@ export type UseActionResult<TPayload, TResult> = {
|
|
|
72
110
|
data: TResult | null;
|
|
73
111
|
};
|
|
74
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>;
|
|
113
|
+
export {};
|
package/dist/iso/action.js
CHANGED
|
@@ -1,25 +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
|
+
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
|
+
}
|
|
4
23
|
export function defineAction(fn, opts) {
|
|
5
|
-
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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.
|
|
8
36
|
//
|
|
9
|
-
//
|
|
10
|
-
// module export (strict ESM, HMR-frozen modules)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
Object.defineProperty(fn, 'use', {
|
|
15
|
-
value: opts.use,
|
|
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,
|
|
16
42
|
configurable: true,
|
|
17
43
|
writable: true,
|
|
18
44
|
enumerable: false,
|
|
19
45
|
});
|
|
20
|
-
}
|
|
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);
|
|
21
55
|
return fn;
|
|
22
56
|
}
|
|
57
|
+
function recordOutcome(module, action, result) {
|
|
58
|
+
setLastActionResult(module, action, result);
|
|
59
|
+
}
|
|
23
60
|
function hasFileValues(payload) {
|
|
24
61
|
if (typeof File === 'undefined')
|
|
25
62
|
return false;
|
|
@@ -42,18 +79,49 @@ export function useAction(stub, options) {
|
|
|
42
79
|
setError(null);
|
|
43
80
|
const currentStub = stubRef.current;
|
|
44
81
|
const currentOptions = optionsRef.current;
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
+
: '/';
|
|
49
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);
|
|
50
119
|
try {
|
|
51
|
-
const stub = currentStub;
|
|
52
120
|
let response;
|
|
53
121
|
if (hasFileValues(payload)) {
|
|
54
122
|
const fd = new FormData();
|
|
55
|
-
fd.append('__module',
|
|
56
|
-
fd.append('__action',
|
|
123
|
+
fd.append('__module', currentStub.__module);
|
|
124
|
+
fd.append('__action', currentStub.__action);
|
|
57
125
|
for (const [key, value] of Object.entries(payload)) {
|
|
58
126
|
if (key === '__module' || key === '__action')
|
|
59
127
|
continue;
|
|
@@ -67,75 +135,27 @@ export function useAction(stub, options) {
|
|
|
67
135
|
fd.append(key, JSON.stringify(value));
|
|
68
136
|
}
|
|
69
137
|
}
|
|
70
|
-
response = await fetch(
|
|
138
|
+
response = await fetch(target, {
|
|
139
|
+
method: 'POST',
|
|
140
|
+
headers: { Accept: 'application/json, text/event-stream;q=0.9' },
|
|
141
|
+
body: fd,
|
|
142
|
+
});
|
|
71
143
|
}
|
|
72
144
|
else {
|
|
73
|
-
response = await fetch(
|
|
145
|
+
response = await fetch(target, {
|
|
74
146
|
method: 'POST',
|
|
75
|
-
headers: {
|
|
147
|
+
headers: {
|
|
148
|
+
'Content-Type': 'application/json',
|
|
149
|
+
Accept: 'application/json, text/event-stream;q=0.9',
|
|
150
|
+
},
|
|
76
151
|
body: JSON.stringify({
|
|
77
|
-
module:
|
|
78
|
-
action:
|
|
152
|
+
module: currentStub.__module,
|
|
153
|
+
action: currentStub.__action,
|
|
79
154
|
payload,
|
|
80
155
|
}),
|
|
81
156
|
});
|
|
82
157
|
}
|
|
83
|
-
if (!response.ok) {
|
|
84
|
-
const body = (await response.json().catch(() => ({})));
|
|
85
|
-
// Deny outcomes carry `message` instead of the legacy `error`
|
|
86
|
-
// field; prefer the descriptive message when present. The deny()
|
|
87
|
-
// constructor defaults the message for first-party callers, but a
|
|
88
|
-
// hand-rolled envelope from custom server middleware might still
|
|
89
|
-
// ship without one; fall back to a deny-aware label so the user
|
|
90
|
-
// sees a hint that the status came from an explicit deny rather
|
|
91
|
-
// than a generic transport failure.
|
|
92
|
-
let msg;
|
|
93
|
-
if (body.__outcome === 'deny') {
|
|
94
|
-
msg =
|
|
95
|
-
typeof body.message === 'string'
|
|
96
|
-
? body.message
|
|
97
|
-
: `Request denied (${response.status})`;
|
|
98
|
-
}
|
|
99
|
-
else {
|
|
100
|
-
msg = body.error ?? `Action failed with status ${response.status}`;
|
|
101
|
-
}
|
|
102
|
-
throw new Error(msg);
|
|
103
|
-
}
|
|
104
158
|
const contentType = response.headers.get('Content-Type') ?? '';
|
|
105
|
-
// Server-side middleware that throws `redirect(...)` comes back as
|
|
106
|
-
// a redirect outcome envelope. Hand off to the browser; the rest of
|
|
107
|
-
// this promise will never settle, but the page is navigating away
|
|
108
|
-
// anyway.
|
|
109
|
-
//
|
|
110
|
-
// Trust boundary: `to` is taken straight from the JSON body and
|
|
111
|
-
// passed to `window.location.assign`. The framework's own handlers
|
|
112
|
-
// emit safe (typically same-origin) values, but a compromised or
|
|
113
|
-
// misconfigured server (or a proxy injecting JSON) could push the
|
|
114
|
-
// client anywhere. We don't validate origin here for v0.1; treat
|
|
115
|
-
// your own server as part of the trusted boundary. A same-origin
|
|
116
|
-
// check is a deferred enhancement (see C4 in the middleware review).
|
|
117
|
-
//
|
|
118
|
-
// We use `response.clone().json()` to peek at the body without
|
|
119
|
-
// consuming it: if the response is NOT a redirect outcome the
|
|
120
|
-
// downstream `await response.json()` still needs to read it. Clone
|
|
121
|
-
// is cheap on a small JSON payload.
|
|
122
|
-
if (!contentType.includes('text/event-stream')) {
|
|
123
|
-
const peek = (await response
|
|
124
|
-
.clone()
|
|
125
|
-
.json()
|
|
126
|
-
.catch(() => undefined));
|
|
127
|
-
if (peek !== null &&
|
|
128
|
-
typeof peek === 'object' &&
|
|
129
|
-
peek.__outcome === 'redirect' &&
|
|
130
|
-
typeof peek.to === 'string') {
|
|
131
|
-
const to = peek.to;
|
|
132
|
-
if (typeof window !== 'undefined') {
|
|
133
|
-
window.location.assign(to);
|
|
134
|
-
}
|
|
135
|
-
// Cast through `as` because TS can't see this promise never settles.
|
|
136
|
-
return await new Promise(() => { });
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
159
|
if (contentType.includes('text/event-stream') && response.body) {
|
|
140
160
|
const { readSSE } = await import('./internal/sse-decoder.js');
|
|
141
161
|
let resultValue;
|
|
@@ -157,6 +177,15 @@ export function useAction(stub, options) {
|
|
|
157
177
|
streamError = new Error(`Malformed result event in stream: ${e instanceof Error ? e.message : String(e)}`);
|
|
158
178
|
}
|
|
159
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
|
+
}
|
|
160
189
|
else if (ev.event === 'error') {
|
|
161
190
|
try {
|
|
162
191
|
const parsed = JSON.parse(ev.data);
|
|
@@ -170,26 +199,101 @@ export function useAction(stub, options) {
|
|
|
170
199
|
}
|
|
171
200
|
}
|
|
172
201
|
if (streamError) {
|
|
202
|
+
recordOutcome(currentStub.__module, currentStub.__action, {
|
|
203
|
+
kind: 'error',
|
|
204
|
+
message: streamError.message,
|
|
205
|
+
submittedPayload: payload,
|
|
206
|
+
});
|
|
207
|
+
outcomeRecorded = true;
|
|
173
208
|
throw streamError;
|
|
174
209
|
}
|
|
175
210
|
if (resultValue !== undefined) {
|
|
176
211
|
setData(resultValue);
|
|
177
|
-
|
|
212
|
+
invokeSuccess(resultValue);
|
|
178
213
|
finalResult = resultValue;
|
|
214
|
+
recordOutcome(currentStub.__module, currentStub.__action, {
|
|
215
|
+
kind: 'success',
|
|
216
|
+
data: resultValue,
|
|
217
|
+
submittedPayload: payload,
|
|
218
|
+
});
|
|
219
|
+
outcomeRecorded = true;
|
|
179
220
|
}
|
|
180
221
|
else {
|
|
181
|
-
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
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).
|
|
185
227
|
finalResult = undefined;
|
|
186
228
|
}
|
|
187
229
|
}
|
|
188
230
|
else {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
+
}
|
|
193
297
|
}
|
|
194
298
|
if (currentOptions?.invalidate === 'auto') {
|
|
195
299
|
reloadCtx?.reload();
|
|
@@ -213,11 +317,23 @@ export function useAction(stub, options) {
|
|
|
213
317
|
}
|
|
214
318
|
catch (err) {
|
|
215
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
|
+
}
|
|
216
329
|
setError(e);
|
|
217
|
-
|
|
330
|
+
invokeError(e);
|
|
218
331
|
setPending(false);
|
|
219
332
|
return { ok: false, error: e };
|
|
220
333
|
}
|
|
334
|
+
finally {
|
|
335
|
+
endSubmit(currentStub.__module, currentStub.__action);
|
|
336
|
+
}
|
|
221
337
|
setPending(false);
|
|
222
338
|
return { ok: true, data: finalResult };
|
|
223
339
|
}, []);
|
package/dist/iso/cache.d.ts
CHANGED
|
@@ -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);
|
package/dist/iso/define-app.d.ts
CHANGED
|
@@ -3,5 +3,12 @@ import type { StreamObserver } from './define-stream-observer.js';
|
|
|
3
3
|
export type AppUseElement = ServerMiddleware<'page'> | ClientMiddleware | StreamObserver<unknown, never>;
|
|
4
4
|
export type AppConfig = {
|
|
5
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;
|
|
6
13
|
};
|
|
7
14
|
export declare function defineApp(config: AppConfig): AppConfig;
|
|
@@ -18,6 +18,12 @@ export interface LoaderRef<T> {
|
|
|
18
18
|
readonly fn: Loader<T>;
|
|
19
19
|
readonly cache: LoaderCache<T>;
|
|
20
20
|
readonly params: string[] | '*';
|
|
21
|
+
/**
|
|
22
|
+
* Raw value as authored on `defineLoader({ timeoutMs })`. `undefined`
|
|
23
|
+
* means "use the handler's configured default"; `false` means "no
|
|
24
|
+
* timeout, only the request signal aborts".
|
|
25
|
+
*/
|
|
26
|
+
readonly timeoutMs?: number | false;
|
|
21
27
|
/**
|
|
22
28
|
* Per-loader middleware and (for streaming loaders) stream observers,
|
|
23
29
|
* exactly as authored on `defineLoader({ use })`. The handler-side
|
|
@@ -55,6 +61,12 @@ export type DefineLoaderOpts<T> = {
|
|
|
55
61
|
__loaderName?: string;
|
|
56
62
|
cache?: LoaderCache<T>;
|
|
57
63
|
params?: string[] | '*';
|
|
64
|
+
/**
|
|
65
|
+
* Per-loader timeout in milliseconds. When omitted, the handler applies
|
|
66
|
+
* its configured default (30s). Pass `false` to disable the timeout for
|
|
67
|
+
* this loader (rely solely on the request signal).
|
|
68
|
+
*/
|
|
69
|
+
timeoutMs?: number | false;
|
|
58
70
|
/**
|
|
59
71
|
* Per-loader middleware and (for streaming loaders) stream observers.
|
|
60
72
|
* The element type LoaderUse<T, Streaming> structurally gates stream
|