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.
Files changed (58) hide show
  1. package/dist/iso/action-result-context.d.ts +22 -0
  2. package/dist/iso/action-result-context.js +2 -0
  3. package/dist/iso/action.d.ts +52 -13
  4. package/dist/iso/action.js +204 -88
  5. package/dist/iso/cache.d.ts +9 -0
  6. package/dist/iso/cache.js +26 -0
  7. package/dist/iso/define-app.d.ts +7 -0
  8. package/dist/iso/define-loader.d.ts +12 -0
  9. package/dist/iso/define-loader.js +26 -16
  10. package/dist/iso/form.d.ts +13 -4
  11. package/dist/iso/form.js +115 -33
  12. package/dist/iso/index.d.ts +7 -4
  13. package/dist/iso/index.js +5 -2
  14. package/dist/iso/internal/action-envelope.d.ts +37 -0
  15. package/dist/iso/internal/action-envelope.js +47 -0
  16. package/dist/iso/internal/action-result-store.d.ts +28 -0
  17. package/dist/iso/internal/action-result-store.js +35 -0
  18. package/dist/iso/internal/envelope.js +1 -2
  19. package/dist/iso/internal/form-submit-store.d.ts +9 -0
  20. package/dist/iso/internal/form-submit-store.js +32 -0
  21. package/dist/iso/internal/loader-fetch.js +65 -34
  22. package/dist/iso/internal/loader.d.ts +3 -3
  23. package/dist/iso/internal/route-boundary.d.ts +4 -4
  24. package/dist/iso/internal/safe-redirect.d.ts +7 -0
  25. package/dist/iso/internal/safe-redirect.js +27 -0
  26. package/dist/iso/internal/sse-decoder.d.ts +1 -1
  27. package/dist/iso/internal/sse-decoder.js +40 -26
  28. package/dist/iso/internal.d.ts +7 -1
  29. package/dist/iso/internal.js +8 -1
  30. package/dist/iso/optimistic-action.d.ts +10 -1
  31. package/dist/iso/optimistic-action.js +11 -3
  32. package/dist/iso/optimistic.d.ts +10 -1
  33. package/dist/iso/optimistic.js +45 -5
  34. package/dist/iso/outcomes.d.ts +14 -2
  35. package/dist/iso/outcomes.js +14 -3
  36. package/dist/iso/use-action-result.d.ts +25 -0
  37. package/dist/iso/use-action-result.js +39 -0
  38. package/dist/iso/use-form-status.d.ts +5 -0
  39. package/dist/iso/use-form-status.js +13 -0
  40. package/dist/server/actions-handler.d.ts +7 -0
  41. package/dist/server/actions-handler.js +42 -9
  42. package/dist/server/index.d.ts +2 -1
  43. package/dist/server/index.js +2 -1
  44. package/dist/server/loaders-handler.d.ts +8 -0
  45. package/dist/server/loaders-handler.js +37 -4
  46. package/dist/server/page-action-handler.d.ts +63 -0
  47. package/dist/server/page-action-handler.js +274 -0
  48. package/dist/server/page-action-resolvers.d.ts +28 -0
  49. package/dist/server/page-action-resolvers.js +147 -0
  50. package/dist/server/render.js +41 -3
  51. package/dist/server/route-server-modules.d.ts +7 -8
  52. package/dist/server/route-server-modules.js +7 -8
  53. package/dist/server/speculation-rules.d.ts +3 -0
  54. package/dist/server/speculation-rules.js +8 -0
  55. package/dist/server/sse.d.ts +43 -28
  56. package/dist/server/sse.js +113 -88
  57. package/dist/vite/server-entry.js +10 -2
  58. package/package.json +2 -2
@@ -1,6 +1,6 @@
1
1
  import { Component } from 'preact';
2
- import type { ComponentChildren, FunctionComponent, JSX } from 'preact';
3
- type ErrorFallback = JSX.Element | ((error: Error, reset: () => void) => JSX.Element);
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(): ComponentChildren;
19
+ render(): any;
20
20
  }
21
21
  export declare const RouteBoundary: FunctionComponent<{
22
- fallback?: JSX.Element;
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<Uint8Array>): AsyncGenerator<SSEEvent, void, unknown>;
5
+ export declare function readSSE(stream: ReadableStream<BufferSource>): AsyncGenerator<SSEEvent, void, unknown>;
@@ -1,39 +1,53 @@
1
- export async function* readSSE(stream) {
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
- buffer += decoder.decode();
12
- if (dataLines.length)
31
+ if (dataLines.length) {
13
32
  yield { event, data: dataLines.join('\n') };
33
+ }
14
34
  return;
15
35
  }
16
- buffer += decoder.decode(value, { stream: true });
17
- let nl;
18
- while ((nl = buffer.indexOf('\n')) !== -1) {
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
  }
@@ -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';
@@ -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 { ...action, value };
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
  }
@@ -2,4 +2,13 @@ export type OptimisticHandle = {
2
2
  settle: () => void;
3
3
  revert: () => void;
4
4
  };
5
- export declare function useOptimistic<TBase, TPayload>(base: TBase, reducer: (current: TBase, payload: TPayload) => TBase): [TBase, (payload: TPayload) => OptimisticHandle];
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];
@@ -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
- entry.status = 'ready';
21
- forceRender();
56
+ runWithTransition(() => {
57
+ entry.status = 'ready';
58
+ forceRender();
59
+ });
22
60
  }
23
61
  },
24
62
  revert: () => {
25
- queueRef.current = queueRef.current.filter((e) => e.id !== id);
26
- forceRender();
63
+ runWithTransition(() => {
64
+ queueRef.current = queueRef.current.filter((e) => e.id !== id);
65
+ forceRender();
66
+ });
27
67
  },
28
68
  };
29
69
  }, []);
@@ -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 Outcome = RedirectOutcome | DenyOutcome | RenderOutcome;
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;
@@ -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: undefined,
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' || tag === 'deny' || tag === 'render';
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,5 @@
1
+ import type { ActionStub } from './action.js';
2
+ export type FormStatus = {
3
+ pending: boolean;
4
+ };
5
+ export declare function useFormStatus<TPayload = unknown, TResult = unknown>(stub?: ActionStub<TPayload, TResult, never>): FormStatus;
@@ -0,0 +1,13 @@
1
+ import { useSyncExternalStore } from 'preact/compat';
2
+ import { isPending, subscribe } from './internal/form-submit-store.js';
3
+ import { isBrowser } from './is-browser.js';
4
+ // Generic over the stub's payload/result so callers can pass any
5
+ // `ActionStub<TPayload, TResult, never>` without contravariant-position
6
+ // assignment errors. The hook only reads `__module` and `__action`.
7
+ export function useFormStatus(stub) {
8
+ // preact/compat (10.29) ships only the 2-arg signature of useSyncExternalStore.
9
+ // The SSR "always idle" behavior that React 18's getServerSnapshot would
10
+ // provide is achieved via the isBrowser() guard inside getSnapshot.
11
+ const pending = useSyncExternalStore(subscribe, () => isBrowser() ? isPending(stub) : false);
12
+ return { pending };
13
+ }
@@ -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 === 'string' && mod.serverActions) {
12
- result[key] = {
13
- actions: mod.serverActions,
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 fn = entry.actions[action];
122
- if (typeof fn !== 'function') {
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 signal = c.req.raw.signal;
142
+ const { fn, use: actionUse, timeoutMs: actionTimeoutMs } = actionEntry;
143
+ const resolvedTimeoutMs = actionTimeoutMs !== undefined ? actionTimeoutMs : defaultTimeoutMs;
144
+ const timeoutSignal = resolvedTimeoutMs === false
145
+ ? undefined
146
+ : AbortSignal.timeout(resolvedTimeoutMs);
147
+ const signal = timeoutSignal
148
+ ? AbortSignal.any([c.req.raw.signal, timeoutSignal])
149
+ : c.req.raw.signal;
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);