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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Component } from 'preact';
|
|
2
|
-
import type { ComponentChildren, FunctionComponent
|
|
3
|
-
type ErrorFallback =
|
|
2
|
+
import type { ComponentChildren, FunctionComponent } from 'preact';
|
|
3
|
+
type ErrorFallback = ComponentChildren | ((error: Error, reset: () => void) => ComponentChildren);
|
|
4
4
|
type ErrorBoundaryProps = {
|
|
5
5
|
fallback?: ErrorFallback;
|
|
6
6
|
children: ComponentChildren;
|
|
@@ -16,10 +16,10 @@ export declare class ErrorBoundary extends Component<ErrorBoundaryProps, {
|
|
|
16
16
|
};
|
|
17
17
|
componentDidCatch(error: unknown): void;
|
|
18
18
|
reset: () => void;
|
|
19
|
-
render():
|
|
19
|
+
render(): any;
|
|
20
20
|
}
|
|
21
21
|
export declare const RouteBoundary: FunctionComponent<{
|
|
22
|
-
fallback?:
|
|
22
|
+
fallback?: ComponentChildren;
|
|
23
23
|
errorFallback?: ErrorFallback;
|
|
24
24
|
children: ComponentChildren;
|
|
25
25
|
}>;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare function isSameOrigin(target: string): boolean;
|
|
2
|
+
/**
|
|
3
|
+
* Navigate to `target` only when it resolves same-origin against the current
|
|
4
|
+
* window. Cross-origin targets log a console error and are not followed.
|
|
5
|
+
* Returns true when the navigation was issued.
|
|
6
|
+
*/
|
|
7
|
+
export declare function assignSafeRedirect(target: string): boolean;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export function isSameOrigin(target) {
|
|
2
|
+
if (typeof window === 'undefined')
|
|
3
|
+
return true;
|
|
4
|
+
try {
|
|
5
|
+
// Relative URLs resolve against the current origin and are always same-origin.
|
|
6
|
+
const resolved = new URL(target, window.location.href);
|
|
7
|
+
return resolved.origin === window.location.origin;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Navigate to `target` only when it resolves same-origin against the current
|
|
15
|
+
* window. Cross-origin targets log a console error and are not followed.
|
|
16
|
+
* Returns true when the navigation was issued.
|
|
17
|
+
*/
|
|
18
|
+
export function assignSafeRedirect(target) {
|
|
19
|
+
if (typeof window === 'undefined')
|
|
20
|
+
return false;
|
|
21
|
+
if (!isSameOrigin(target)) {
|
|
22
|
+
console.error(`[hono-preact] refusing to navigate to cross-origin redirect target: ${target}`);
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
window.location.assign(target);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
@@ -2,4 +2,4 @@ export type SSEEvent = {
|
|
|
2
2
|
event: string;
|
|
3
3
|
data: string;
|
|
4
4
|
};
|
|
5
|
-
export declare function readSSE(stream: ReadableStream<
|
|
5
|
+
export declare function readSSE(stream: ReadableStream<BufferSource>): AsyncGenerator<SSEEvent, void, unknown>;
|
|
@@ -1,39 +1,53 @@
|
|
|
1
|
-
|
|
2
|
-
const reader = stream.getReader();
|
|
3
|
-
const decoder = new TextDecoder();
|
|
1
|
+
function lineSplitTransform() {
|
|
4
2
|
let buffer = '';
|
|
3
|
+
return new TransformStream({
|
|
4
|
+
transform(chunk, controller) {
|
|
5
|
+
buffer += chunk;
|
|
6
|
+
let nl;
|
|
7
|
+
while ((nl = buffer.indexOf('\n')) !== -1) {
|
|
8
|
+
controller.enqueue(buffer.slice(0, nl).replace(/\r$/, ''));
|
|
9
|
+
buffer = buffer.slice(nl + 1);
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
flush(controller) {
|
|
13
|
+
if (buffer.length) {
|
|
14
|
+
controller.enqueue(buffer.replace(/\r$/, ''));
|
|
15
|
+
buffer = '';
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
export async function* readSSE(stream) {
|
|
21
|
+
const lines = stream
|
|
22
|
+
.pipeThrough(new TextDecoderStream())
|
|
23
|
+
.pipeThrough(lineSplitTransform());
|
|
5
24
|
let event = 'message';
|
|
6
25
|
let dataLines = [];
|
|
26
|
+
const reader = lines.getReader();
|
|
7
27
|
try {
|
|
8
28
|
while (true) {
|
|
9
|
-
const { done, value } = await reader.read();
|
|
29
|
+
const { done, value: line } = await reader.read();
|
|
10
30
|
if (done) {
|
|
11
|
-
|
|
12
|
-
if (dataLines.length)
|
|
31
|
+
if (dataLines.length) {
|
|
13
32
|
yield { event, data: dataLines.join('\n') };
|
|
33
|
+
}
|
|
14
34
|
return;
|
|
15
35
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const line = buffer.slice(0, nl).replace(/\r$/, '');
|
|
20
|
-
buffer = buffer.slice(nl + 1);
|
|
21
|
-
if (line === '') {
|
|
22
|
-
if (dataLines.length) {
|
|
23
|
-
yield { event, data: dataLines.join('\n') };
|
|
24
|
-
}
|
|
25
|
-
event = 'message';
|
|
26
|
-
dataLines = [];
|
|
27
|
-
}
|
|
28
|
-
else if (line.startsWith(':')) {
|
|
29
|
-
// SSE comment / keepalive, ignore
|
|
30
|
-
}
|
|
31
|
-
else if (line.startsWith('event:')) {
|
|
32
|
-
event = line.slice(6).trim();
|
|
33
|
-
}
|
|
34
|
-
else if (line.startsWith('data:')) {
|
|
35
|
-
dataLines.push(line.slice(5).replace(/^ /, ''));
|
|
36
|
+
if (line === '') {
|
|
37
|
+
if (dataLines.length) {
|
|
38
|
+
yield { event, data: dataLines.join('\n') };
|
|
36
39
|
}
|
|
40
|
+
event = 'message';
|
|
41
|
+
dataLines = [];
|
|
42
|
+
}
|
|
43
|
+
else if (line.startsWith(':')) {
|
|
44
|
+
// SSE comment / keepalive, ignore
|
|
45
|
+
}
|
|
46
|
+
else if (line.startsWith('event:')) {
|
|
47
|
+
event = line.slice(6).trim();
|
|
48
|
+
}
|
|
49
|
+
else if (line.startsWith('data:')) {
|
|
50
|
+
dataLines.push(line.slice(5).replace(/^ /, ''));
|
|
37
51
|
}
|
|
38
52
|
}
|
|
39
53
|
}
|
package/dist/iso/internal.d.ts
CHANGED
|
@@ -2,11 +2,12 @@ export { Loader } from './internal/loader.js';
|
|
|
2
2
|
export { Envelope } from './internal/envelope.js';
|
|
3
3
|
export { RouteBoundary } from './internal/route-boundary.js';
|
|
4
4
|
export { OptimisticOverlay } from './internal/optimistic-overlay.js';
|
|
5
|
+
export { serializeActionOutcome, type ActionEnvelope, type ActionResolution, type SerializedEnvelope, } from './internal/action-envelope.js';
|
|
5
6
|
export { LoaderIdContext, LoaderDataContext } from './internal/contexts.js';
|
|
6
7
|
export { ReloadContext } from './reload-context.js';
|
|
7
8
|
export { RouteLocationsContext, RouteLocationsProvider, } from './internal/route-locations.js';
|
|
8
9
|
export { getPreloadedData, deletePreloadedData } from './internal/preload.js';
|
|
9
|
-
export { runRequestScope, getRequestStore, captureRequestScope, } from './cache.js';
|
|
10
|
+
export { runRequestScope, getRequestStore, captureRequestScope, getActionResultSlot, setActionResultSlot, type ActionResultSlot, } from './cache.js';
|
|
10
11
|
export { default as wrapPromise } from './internal/wrap-promise.js';
|
|
11
12
|
export { HonoRequestContext } from './internal/contexts.js';
|
|
12
13
|
export { PageMiddlewareHost } from './internal/page-middleware-host.js';
|
|
@@ -14,7 +15,12 @@ export { __dispatchRouteChange, __subscribeRouteChange, } from './internal/route
|
|
|
14
15
|
export { installStreamRegistry, subscribeToLoaderStream, } from './internal/stream-registry.js';
|
|
15
16
|
export { registerServerStreamingLoader, takeServerStreamingLoaders, } from './internal/streaming-ssr.js';
|
|
16
17
|
export type { ServerLoaderStream } from './internal/streaming-ssr.js';
|
|
18
|
+
export { beginSubmit, endSubmit, isPending, subscribe as subscribeFormSubmit, } from './internal/form-submit-store.js';
|
|
19
|
+
export { assignSafeRedirect, isSameOrigin } from './internal/safe-redirect.js';
|
|
20
|
+
export { setLastActionResult, clearLastActionResult, getLastActionResult, subscribeActionResults, type StoredActionResult, } from './internal/action-result-store.js';
|
|
17
21
|
export { dispatchServer, dispatchClient, type DispatchResult, } from './internal/middleware-runner.js';
|
|
18
22
|
export { partitionUse } from './internal/use-partitioner.js';
|
|
19
23
|
export { fanStart, fanChunk, fanEnd, fanError, fanAbort, } from './internal/stream-observer-runner.js';
|
|
20
24
|
export { __$createLoaderStub_hpiso } from './internal/loader-stub.js';
|
|
25
|
+
export { readSSE } from './internal/sse-decoder.js';
|
|
26
|
+
export type { SSEEvent } from './internal/sse-decoder.js';
|
package/dist/iso/internal.js
CHANGED
|
@@ -28,17 +28,21 @@ export { Loader } from './internal/loader.js';
|
|
|
28
28
|
export { Envelope } from './internal/envelope.js';
|
|
29
29
|
export { RouteBoundary } from './internal/route-boundary.js';
|
|
30
30
|
export { OptimisticOverlay } from './internal/optimistic-overlay.js';
|
|
31
|
+
export { serializeActionOutcome, } from './internal/action-envelope.js';
|
|
31
32
|
export { LoaderIdContext, LoaderDataContext } from './internal/contexts.js';
|
|
32
33
|
export { ReloadContext } from './reload-context.js';
|
|
33
34
|
export { RouteLocationsContext, RouteLocationsProvider, } from './internal/route-locations.js';
|
|
34
35
|
export { getPreloadedData, deletePreloadedData } from './internal/preload.js';
|
|
35
|
-
export { runRequestScope, getRequestStore, captureRequestScope, } from './cache.js';
|
|
36
|
+
export { runRequestScope, getRequestStore, captureRequestScope, getActionResultSlot, setActionResultSlot, } from './cache.js';
|
|
36
37
|
export { default as wrapPromise } from './internal/wrap-promise.js';
|
|
37
38
|
export { HonoRequestContext } from './internal/contexts.js';
|
|
38
39
|
export { PageMiddlewareHost } from './internal/page-middleware-host.js';
|
|
39
40
|
export { __dispatchRouteChange, __subscribeRouteChange, } from './internal/route-change.js';
|
|
40
41
|
export { installStreamRegistry, subscribeToLoaderStream, } from './internal/stream-registry.js';
|
|
41
42
|
export { registerServerStreamingLoader, takeServerStreamingLoaders, } from './internal/streaming-ssr.js';
|
|
43
|
+
export { beginSubmit, endSubmit, isPending, subscribe as subscribeFormSubmit, } from './internal/form-submit-store.js';
|
|
44
|
+
export { assignSafeRedirect, isSameOrigin } from './internal/safe-redirect.js';
|
|
45
|
+
export { setLastActionResult, clearLastActionResult, getLastActionResult, subscribeActionResults, } from './internal/action-result-store.js';
|
|
42
46
|
// Middleware dispatcher + observer fanout. Internal-stability subpath.
|
|
43
47
|
export { dispatchServer, dispatchClient, } from './internal/middleware-runner.js';
|
|
44
48
|
export { partitionUse } from './internal/use-partitioner.js';
|
|
@@ -49,3 +53,6 @@ export { fanStart, fanChunk, fanEnd, fanError, fanAbort, } from './internal/stre
|
|
|
49
53
|
// (serverOnlyPlugin's loader stubs, guardStripPlugin's no-op replacement).
|
|
50
54
|
// User code that imports them couples to plugin internals.
|
|
51
55
|
export { __$createLoaderStub_hpiso } from './internal/loader-stub.js';
|
|
56
|
+
// SSE decoder; useful in tests and advanced consumers that need to read
|
|
57
|
+
// a streaming loader/action response as a sequence of typed SSE events.
|
|
58
|
+
export { readSSE } from './internal/sse-decoder.js';
|
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
import { type UseActionOptions, type UseActionResult, type ActionStub } from './action.js';
|
|
2
|
+
import { type OptimisticHandle } from './optimistic.js';
|
|
2
3
|
import type { LoaderRef } from './define-loader.js';
|
|
4
|
+
export declare const OPTIMISTIC_BRAND: unique symbol;
|
|
5
|
+
export type OptimisticBinding<TPayload, TBase> = {
|
|
6
|
+
apply: (current: TBase, payload: TPayload) => TBase;
|
|
7
|
+
addOptimistic: (payload: TPayload) => OptimisticHandle;
|
|
8
|
+
};
|
|
3
9
|
export type UseOptimisticActionOptions<TPayload, TResult, TBase, TChunk = never> = Omit<UseActionOptions<TPayload, TResult, TChunk>, 'invalidate' | 'onMutate' | 'onError' | 'onSuccess'> & {
|
|
4
10
|
base: TBase;
|
|
5
11
|
apply: (current: TBase, payload: TPayload) => TBase;
|
|
6
12
|
invalidate?: 'auto' | ReadonlyArray<LoaderRef<unknown>>;
|
|
7
13
|
onSuccess?: (data: TResult) => void;
|
|
8
14
|
onError?: (err: Error) => void;
|
|
15
|
+
/** Forwarded to the internal `useOptimistic` call. */
|
|
16
|
+
transition?: boolean;
|
|
9
17
|
};
|
|
10
|
-
export type UseOptimisticActionResult<TPayload, TResult, TBase> = UseActionResult<TPayload, TResult> & {
|
|
18
|
+
export type UseOptimisticActionResult<TPayload, TResult, TBase> = ActionStub<TPayload, TResult, never> & UseActionResult<TPayload, TResult> & {
|
|
11
19
|
value: TBase;
|
|
20
|
+
readonly [OPTIMISTIC_BRAND]: OptimisticBinding<TPayload, TBase>;
|
|
12
21
|
};
|
|
13
22
|
/**
|
|
14
23
|
* Like `useAction`, but with an optimistic-update wrapper. `TChunk` defaults
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useAction, } from './action.js';
|
|
2
2
|
import { useOptimistic } from './optimistic.js';
|
|
3
|
+
export const OPTIMISTIC_BRAND = Symbol('hono-preact.optimistic');
|
|
3
4
|
/**
|
|
4
5
|
* Like `useAction`, but with an optimistic-update wrapper. `TChunk` defaults
|
|
5
6
|
* to `never` so existing non-streaming call sites are unaffected. Pass the
|
|
@@ -7,8 +8,8 @@ import { useOptimistic } from './optimistic.js';
|
|
|
7
8
|
* when the action is streaming and you need a typed `onChunk` callback.
|
|
8
9
|
*/
|
|
9
10
|
export function useOptimisticAction(stub, options) {
|
|
10
|
-
const { base, apply, onSuccess, onError, ...actionOpts } = options;
|
|
11
|
-
const [value, addOptimistic] = useOptimistic(base, apply);
|
|
11
|
+
const { base, apply, onSuccess, onError, transition, ...actionOpts } = options;
|
|
12
|
+
const [value, addOptimistic] = useOptimistic(base, apply, { transition });
|
|
12
13
|
const action = useAction(stub, {
|
|
13
14
|
...actionOpts,
|
|
14
15
|
onMutate: (payload) => addOptimistic(payload),
|
|
@@ -21,5 +22,12 @@ export function useOptimisticAction(stub, options) {
|
|
|
21
22
|
onError?.(err);
|
|
22
23
|
},
|
|
23
24
|
});
|
|
24
|
-
return {
|
|
25
|
+
return {
|
|
26
|
+
__module: stub.__module,
|
|
27
|
+
__action: stub.__action,
|
|
28
|
+
useAction: stub.useAction,
|
|
29
|
+
...action,
|
|
30
|
+
value,
|
|
31
|
+
[OPTIMISTIC_BRAND]: { apply, addOptimistic },
|
|
32
|
+
};
|
|
25
33
|
}
|
package/dist/iso/optimistic.d.ts
CHANGED
|
@@ -2,4 +2,13 @@ export type OptimisticHandle = {
|
|
|
2
2
|
settle: () => void;
|
|
3
3
|
revert: () => void;
|
|
4
4
|
};
|
|
5
|
-
export
|
|
5
|
+
export type UseOptimisticOptions = {
|
|
6
|
+
/**
|
|
7
|
+
* When true, the settle and revert paths are wrapped in
|
|
8
|
+
* `document.startViewTransition`. The initial optimistic update is never
|
|
9
|
+
* wrapped (it must paint same-frame). Falls back to a synchronous update
|
|
10
|
+
* when `document.startViewTransition` is unavailable.
|
|
11
|
+
*/
|
|
12
|
+
transition?: boolean;
|
|
13
|
+
};
|
|
14
|
+
export declare function useOptimistic<TBase, TPayload>(base: TBase, reducer: (current: TBase, payload: TPayload) => TBase, options?: UseOptimisticOptions): [TBase, (payload: TPayload) => OptimisticHandle];
|
package/dist/iso/optimistic.js
CHANGED
|
@@ -1,14 +1,50 @@
|
|
|
1
1
|
import { useCallback, useReducer, useRef } from 'preact/hooks';
|
|
2
|
-
export function useOptimistic(base, reducer) {
|
|
2
|
+
export function useOptimistic(base, reducer, options) {
|
|
3
3
|
const queueRef = useRef([]);
|
|
4
4
|
const lastBaseRef = useRef(base);
|
|
5
5
|
const idRef = useRef(0);
|
|
6
6
|
const [, forceRender] = useReducer((c) => c + 1, 0);
|
|
7
|
+
const transitionRef = useRef(options?.transition === true);
|
|
8
|
+
transitionRef.current = options?.transition === true;
|
|
7
9
|
if (!Object.is(lastBaseRef.current, base)) {
|
|
8
10
|
queueRef.current = queueRef.current.filter((e) => e.status !== 'ready');
|
|
9
11
|
lastBaseRef.current = base;
|
|
10
12
|
}
|
|
11
13
|
const value = queueRef.current.reduce((acc, e) => reducer(acc, e.payload), base);
|
|
14
|
+
// Reads `transitionRef.current` at invocation time, not capture time, so it
|
|
15
|
+
// is safe to close over from the memoized `addOptimistic` (useCallback([]))
|
|
16
|
+
// below. Each render rebinds this function and writes the latest option
|
|
17
|
+
// value into the ref; settle/revert created by the stale memoized callback
|
|
18
|
+
// still see the up-to-date `transition` setting through the ref.
|
|
19
|
+
//
|
|
20
|
+
// The callback returns a promise that resolves on the next animation frame
|
|
21
|
+
// so the browser snapshots Preact's POST-render DOM as "new state". Without
|
|
22
|
+
// the rAF wait, `forceRender()` (an async dispatch through useReducer) has
|
|
23
|
+
// not yet flushed when `startViewTransition` snapshots, and the transition
|
|
24
|
+
// captures identical before/after frames with no visible animation.
|
|
25
|
+
const runWithTransition = (mutator) => {
|
|
26
|
+
if (transitionRef.current &&
|
|
27
|
+
typeof document !== 'undefined' &&
|
|
28
|
+
typeof document.startViewTransition === 'function') {
|
|
29
|
+
document.startViewTransition(async () => {
|
|
30
|
+
mutator();
|
|
31
|
+
await new Promise((resolve) => {
|
|
32
|
+
if (typeof requestAnimationFrame === 'function') {
|
|
33
|
+
requestAnimationFrame(() => resolve());
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
// Non-DOM environment (shouldn't reach this branch given the
|
|
37
|
+
// outer check, but defensive). Resolve on next microtask so
|
|
38
|
+
// Preact's scheduled render runs.
|
|
39
|
+
queueMicrotask(resolve);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
mutator();
|
|
46
|
+
}
|
|
47
|
+
};
|
|
12
48
|
const addOptimistic = useCallback((payload) => {
|
|
13
49
|
const id = ++idRef.current;
|
|
14
50
|
queueRef.current = [...queueRef.current, { id, payload, status: 'active' }];
|
|
@@ -17,13 +53,17 @@ export function useOptimistic(base, reducer) {
|
|
|
17
53
|
settle: () => {
|
|
18
54
|
const entry = queueRef.current.find((e) => e.id === id);
|
|
19
55
|
if (entry && entry.status === 'active') {
|
|
20
|
-
|
|
21
|
-
|
|
56
|
+
runWithTransition(() => {
|
|
57
|
+
entry.status = 'ready';
|
|
58
|
+
forceRender();
|
|
59
|
+
});
|
|
22
60
|
}
|
|
23
61
|
},
|
|
24
62
|
revert: () => {
|
|
25
|
-
|
|
26
|
-
|
|
63
|
+
runWithTransition(() => {
|
|
64
|
+
queueRef.current = queueRef.current.filter((e) => e.id !== id);
|
|
65
|
+
forceRender();
|
|
66
|
+
});
|
|
27
67
|
},
|
|
28
68
|
};
|
|
29
69
|
}, []);
|
package/dist/iso/outcomes.d.ts
CHANGED
|
@@ -13,12 +13,17 @@ export type DenyOutcome = {
|
|
|
13
13
|
status: ErrorStatusCode;
|
|
14
14
|
message: string;
|
|
15
15
|
headers: Record<string, string> | undefined;
|
|
16
|
+
data?: unknown;
|
|
16
17
|
};
|
|
17
18
|
export type RenderOutcome = {
|
|
18
19
|
__outcome: 'render';
|
|
19
20
|
Component: FunctionComponent;
|
|
20
21
|
};
|
|
21
|
-
export type
|
|
22
|
+
export type TimeoutOutcome = {
|
|
23
|
+
__outcome: 'timeout';
|
|
24
|
+
timeoutMs: number;
|
|
25
|
+
};
|
|
26
|
+
export type Outcome = RedirectOutcome | DenyOutcome | RenderOutcome | TimeoutOutcome;
|
|
22
27
|
type RedirectInput = string | {
|
|
23
28
|
to: string;
|
|
24
29
|
status?: RedirectStatusCode;
|
|
@@ -29,10 +34,17 @@ type DenyInput = {
|
|
|
29
34
|
status: ErrorStatusCode;
|
|
30
35
|
message?: string;
|
|
31
36
|
headers?: Record<string, string>;
|
|
37
|
+
data?: unknown;
|
|
38
|
+
};
|
|
39
|
+
type DenyOpts = {
|
|
40
|
+
headers?: Record<string, string>;
|
|
41
|
+
data?: unknown;
|
|
32
42
|
};
|
|
33
|
-
export declare function deny(status: ErrorStatusCode, message?: string): DenyOutcome;
|
|
43
|
+
export declare function deny(status: ErrorStatusCode, message?: string, opts?: DenyOpts): DenyOutcome;
|
|
34
44
|
export declare function deny(spec: DenyInput): DenyOutcome;
|
|
35
45
|
export declare function isOutcome(value: unknown): value is Outcome;
|
|
36
46
|
export declare function isRedirect(value: unknown): value is RedirectOutcome;
|
|
37
47
|
export declare function isDeny(value: unknown): value is DenyOutcome;
|
|
38
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;
|
package/dist/iso/outcomes.js
CHANGED
|
@@ -14,7 +14,7 @@ export function redirect(input) {
|
|
|
14
14
|
headers: input.headers,
|
|
15
15
|
};
|
|
16
16
|
}
|
|
17
|
-
export function deny(a, b) {
|
|
17
|
+
export function deny(a, b, c) {
|
|
18
18
|
// `JSON.stringify` drops `undefined` properties, so a deny outcome with no
|
|
19
19
|
// message would arrive at the client without a `message` field and the
|
|
20
20
|
// client decoders would fall back to a generic "Loader/Action failed with
|
|
@@ -28,13 +28,15 @@ export function deny(a, b) {
|
|
|
28
28
|
status: a.status,
|
|
29
29
|
message: a.message ?? `Request denied (${a.status})`,
|
|
30
30
|
headers: a.headers,
|
|
31
|
+
...(a.data !== undefined ? { data: a.data } : {}),
|
|
31
32
|
};
|
|
32
33
|
}
|
|
33
34
|
return {
|
|
34
35
|
__outcome: 'deny',
|
|
35
36
|
status: a,
|
|
36
37
|
message: b ?? `Request denied (${a})`,
|
|
37
|
-
headers:
|
|
38
|
+
headers: c?.headers,
|
|
39
|
+
...(c?.data !== undefined ? { data: c.data } : {}),
|
|
38
40
|
};
|
|
39
41
|
}
|
|
40
42
|
export function isOutcome(value) {
|
|
@@ -43,7 +45,10 @@ export function isOutcome(value) {
|
|
|
43
45
|
if (!('__outcome' in value))
|
|
44
46
|
return false;
|
|
45
47
|
const tag = value.__outcome;
|
|
46
|
-
return tag === 'redirect' ||
|
|
48
|
+
return (tag === 'redirect' ||
|
|
49
|
+
tag === 'deny' ||
|
|
50
|
+
tag === 'render' ||
|
|
51
|
+
tag === 'timeout');
|
|
47
52
|
}
|
|
48
53
|
export function isRedirect(value) {
|
|
49
54
|
return isOutcome(value) && value.__outcome === 'redirect';
|
|
@@ -54,3 +59,9 @@ export function isDeny(value) {
|
|
|
54
59
|
export function isRender(value) {
|
|
55
60
|
return isOutcome(value) && value.__outcome === 'render';
|
|
56
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,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,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
|
+
}
|
|
@@ -42,6 +42,13 @@ export interface ActionsHandlerOptions {
|
|
|
42
42
|
* awaits the result either way. Default returns an empty array.
|
|
43
43
|
*/
|
|
44
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;
|
|
45
52
|
}
|
|
46
53
|
export declare function actionsHandler(glob: LazyGlob | EagerGlob, opts?: ActionsHandlerOptions): MiddlewareHandler;
|
|
47
54
|
export {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isOutcome, } from '../iso/index.js';
|
|
1
|
+
import { isOutcome, timeoutOutcome, } from '../iso/index.js';
|
|
2
2
|
import { runRequestScope, dispatchServer, partitionUse, } from '../iso/internal.js';
|
|
3
3
|
import { sseGeneratorResponse, sseReadableStreamResponse, isAsyncGenerator, } from './sse.js';
|
|
4
4
|
async function buildActionsMap(glob) {
|
|
@@ -8,11 +8,25 @@ async function buildActionsMap(glob) {
|
|
|
8
8
|
? await moduleOrLoader()
|
|
9
9
|
: moduleOrLoader;
|
|
10
10
|
const key = mod.__moduleKey;
|
|
11
|
-
if (typeof key
|
|
12
|
-
|
|
13
|
-
|
|
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,
|
|
14
27
|
};
|
|
15
28
|
}
|
|
29
|
+
result[key] = { actions };
|
|
16
30
|
}
|
|
17
31
|
return result;
|
|
18
32
|
}
|
|
@@ -39,6 +53,9 @@ function translateOutcomeForAction(c, outcome) {
|
|
|
39
53
|
}
|
|
40
54
|
return c.json({ __outcome: 'deny', message: outcome.message }, outcome.status);
|
|
41
55
|
}
|
|
56
|
+
if (outcome.__outcome === 'timeout') {
|
|
57
|
+
return c.json({ __outcome: 'timeout', timeoutMs: outcome.timeoutMs }, 504);
|
|
58
|
+
}
|
|
42
59
|
// render outcome should never reach the action RPC.
|
|
43
60
|
return c.json({
|
|
44
61
|
__outcome: 'error',
|
|
@@ -46,7 +63,7 @@ function translateOutcomeForAction(c, outcome) {
|
|
|
46
63
|
}, 500);
|
|
47
64
|
}
|
|
48
65
|
export function actionsHandler(glob, opts = {}) {
|
|
49
|
-
const { dev = false, onError, appConfig, resolvePageUse } = opts;
|
|
66
|
+
const { dev = false, onError, appConfig, resolvePageUse, defaultTimeoutMs = 30_000, } = opts;
|
|
50
67
|
let cachedMapPromise = null;
|
|
51
68
|
return async (c) => {
|
|
52
69
|
const actionsMapPromise = dev
|
|
@@ -118,11 +135,18 @@ export function actionsHandler(glob, opts = {}) {
|
|
|
118
135
|
if (!entry) {
|
|
119
136
|
return c.json({ error: `Module '${module}' not found` }, 404);
|
|
120
137
|
}
|
|
121
|
-
const
|
|
122
|
-
if (
|
|
138
|
+
const actionEntry = entry.actions[action];
|
|
139
|
+
if (!actionEntry) {
|
|
123
140
|
return c.json({ error: `Action '${action}' not found in module '${module}'` }, 404);
|
|
124
141
|
}
|
|
125
|
-
const
|
|
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;
|
|
126
150
|
const actionCtx = { c, signal };
|
|
127
151
|
// Chain ordering is outer -> inner: app-level middleware wraps every
|
|
128
152
|
// request, page-level wraps actions owned by that page, and per-action
|
|
@@ -133,7 +157,6 @@ export function actionsHandler(glob, opts = {}) {
|
|
|
133
157
|
// page-layer lookup keys by module rather than by location path.
|
|
134
158
|
const rootUse = appConfig?.use ?? [];
|
|
135
159
|
const pageUse = (await resolvePageUse?.(module)) ?? [];
|
|
136
|
-
const actionUse = fn.use ?? [];
|
|
137
160
|
const fullUse = [...rootUse, ...pageUse, ...actionUse];
|
|
138
161
|
const { middleware: allMiddleware, observers } = partitionUse(fullUse);
|
|
139
162
|
const serverMw = allMiddleware.filter((m) => m.runs === 'server');
|
|
@@ -173,6 +196,12 @@ export function actionsHandler(glob, opts = {}) {
|
|
|
173
196
|
if (isOutcome(err)) {
|
|
174
197
|
return translateOutcomeForAction(c, err);
|
|
175
198
|
}
|
|
199
|
+
if (timeoutSignal?.aborted &&
|
|
200
|
+
timeoutSignal.reason instanceof DOMException &&
|
|
201
|
+
timeoutSignal.reason.name === 'TimeoutError' &&
|
|
202
|
+
typeof resolvedTimeoutMs === 'number') {
|
|
203
|
+
return translateOutcomeForAction(c, timeoutOutcome(resolvedTimeoutMs));
|
|
204
|
+
}
|
|
176
205
|
onError?.(err, { module, action });
|
|
177
206
|
const message = dev && err instanceof Error ? err.message : 'Action failed';
|
|
178
207
|
return c.json({ error: message }, 500);
|
|
@@ -182,12 +211,16 @@ export function actionsHandler(glob, opts = {}) {
|
|
|
182
211
|
emitResult: true,
|
|
183
212
|
observers,
|
|
184
213
|
observerCtx: ctx,
|
|
214
|
+
signal: timeoutSignal,
|
|
215
|
+
timeoutMs: typeof resolvedTimeoutMs === 'number' ? resolvedTimeoutMs : undefined,
|
|
185
216
|
});
|
|
186
217
|
}
|
|
187
218
|
if (result instanceof ReadableStream) {
|
|
188
219
|
return sseReadableStreamResponse(c, result, {
|
|
189
220
|
observers,
|
|
190
221
|
observerCtx: ctx,
|
|
222
|
+
signal: timeoutSignal,
|
|
223
|
+
timeoutMs: typeof resolvedTimeoutMs === 'number' ? resolvedTimeoutMs : undefined,
|
|
191
224
|
});
|
|
192
225
|
}
|
|
193
226
|
return c.json(result);
|