hono-preact 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +2 -1
  2. package/dist/adapter-cloudflare.d.ts +1 -0
  3. package/dist/adapter-cloudflare.d.ts.map +1 -0
  4. package/dist/adapter-cloudflare.js +2 -0
  5. package/dist/adapter-node.d.ts +1 -0
  6. package/dist/adapter-node.d.ts.map +1 -0
  7. package/dist/adapter-node.js +2 -0
  8. package/dist/internal.d.ts +1 -1
  9. package/dist/internal.js +1 -1
  10. package/dist/iso/action-result-context.d.ts +22 -0
  11. package/dist/iso/action-result-context.js +2 -0
  12. package/dist/iso/action.d.ts +60 -25
  13. package/dist/iso/action.js +210 -58
  14. package/dist/iso/cache.d.ts +9 -0
  15. package/dist/iso/cache.js +26 -0
  16. package/dist/iso/define-app.d.ts +14 -0
  17. package/dist/iso/define-app.js +3 -0
  18. package/dist/iso/define-loader.d.ts +31 -0
  19. package/dist/iso/define-loader.js +30 -16
  20. package/dist/iso/define-middleware.d.ts +43 -0
  21. package/dist/iso/define-middleware.js +6 -0
  22. package/dist/iso/define-page.d.ts +7 -2
  23. package/dist/iso/define-page.js +1 -1
  24. package/dist/iso/define-routes.d.ts +24 -1
  25. package/dist/iso/define-routes.js +34 -0
  26. package/dist/iso/define-stream-observer.d.ts +20 -0
  27. package/dist/iso/define-stream-observer.js +3 -0
  28. package/dist/iso/form.d.ts +13 -4
  29. package/dist/iso/form.js +115 -33
  30. package/dist/iso/index.d.ts +15 -7
  31. package/dist/iso/index.js +9 -4
  32. package/dist/iso/internal/action-envelope.d.ts +37 -0
  33. package/dist/iso/internal/action-envelope.js +47 -0
  34. package/dist/iso/internal/action-result-store.d.ts +28 -0
  35. package/dist/iso/internal/action-result-store.js +35 -0
  36. package/dist/iso/internal/contexts.d.ts +0 -2
  37. package/dist/iso/internal/contexts.js +0 -1
  38. package/dist/iso/internal/envelope.js +1 -2
  39. package/dist/iso/internal/form-submit-store.d.ts +9 -0
  40. package/dist/iso/internal/form-submit-store.js +32 -0
  41. package/dist/iso/internal/loader-fetch.js +102 -41
  42. package/dist/iso/internal/loader-runner.js +105 -8
  43. package/dist/iso/internal/loader.d.ts +3 -3
  44. package/dist/iso/internal/middleware-runner.d.ts +22 -0
  45. package/dist/iso/internal/middleware-runner.js +79 -0
  46. package/dist/iso/internal/page-middleware-host.d.ts +13 -0
  47. package/dist/iso/internal/page-middleware-host.js +119 -0
  48. package/dist/iso/internal/route-boundary.d.ts +5 -4
  49. package/dist/iso/internal/route-boundary.js +16 -0
  50. package/dist/iso/internal/safe-redirect.d.ts +7 -0
  51. package/dist/iso/internal/safe-redirect.js +27 -0
  52. package/dist/iso/internal/sse-decoder.d.ts +1 -1
  53. package/dist/iso/internal/sse-decoder.js +40 -26
  54. package/dist/iso/internal/stream-observer-runner.d.ts +13 -0
  55. package/dist/iso/internal/stream-observer-runner.js +48 -0
  56. package/dist/iso/internal/use-partitioner.d.ts +9 -0
  57. package/dist/iso/internal/use-partitioner.js +11 -0
  58. package/dist/iso/internal/use-types.d.ts +7 -0
  59. package/dist/iso/internal/use-types.js +1 -0
  60. package/dist/iso/internal.d.ts +12 -5
  61. package/dist/iso/internal.js +16 -7
  62. package/dist/iso/optimistic-action.d.ts +10 -1
  63. package/dist/iso/optimistic-action.js +11 -3
  64. package/dist/iso/optimistic.d.ts +10 -1
  65. package/dist/iso/optimistic.js +45 -5
  66. package/dist/iso/outcomes.d.ts +50 -0
  67. package/dist/iso/outcomes.js +67 -0
  68. package/dist/iso/page-only.d.ts +5 -0
  69. package/dist/iso/page-only.js +20 -0
  70. package/dist/iso/page.d.ts +3 -3
  71. package/dist/iso/page.js +3 -3
  72. package/dist/iso/use-action-result.d.ts +25 -0
  73. package/dist/iso/use-action-result.js +39 -0
  74. package/dist/iso/use-form-status.d.ts +5 -0
  75. package/dist/iso/use-form-status.js +13 -0
  76. package/dist/page.d.ts +1 -0
  77. package/dist/page.d.ts.map +1 -0
  78. package/dist/page.js +8 -0
  79. package/dist/server/actions-handler.d.ts +27 -6
  80. package/dist/server/actions-handler.js +121 -52
  81. package/dist/server/context.js +1 -1
  82. package/dist/server/index.d.ts +3 -2
  83. package/dist/server/index.js +3 -2
  84. package/dist/server/loaders-handler.d.ts +24 -0
  85. package/dist/server/loaders-handler.js +128 -18
  86. package/dist/server/page-action-handler.d.ts +63 -0
  87. package/dist/server/page-action-handler.js +274 -0
  88. package/dist/server/page-action-resolvers.d.ts +28 -0
  89. package/dist/server/page-action-resolvers.js +147 -0
  90. package/dist/server/render.d.ts +2 -0
  91. package/dist/server/render.js +142 -33
  92. package/dist/server/route-server-modules.d.ts +48 -8
  93. package/dist/server/route-server-modules.js +190 -7
  94. package/dist/server/speculation-rules.d.ts +3 -0
  95. package/dist/server/speculation-rules.js +8 -0
  96. package/dist/server/sse.d.ts +50 -12
  97. package/dist/server/sse.js +130 -53
  98. package/dist/vite/adapter-cloudflare.d.ts +2 -0
  99. package/dist/vite/adapter-cloudflare.js +25 -0
  100. package/dist/vite/adapter-node.d.ts +2 -0
  101. package/dist/vite/adapter-node.js +49 -0
  102. package/dist/vite/adapter.d.ts +29 -0
  103. package/dist/vite/adapter.js +1 -0
  104. package/dist/vite/client-shim.js +5 -4
  105. package/dist/vite/guard-strip.js +52 -27
  106. package/dist/vite/hono-preact.d.ts +6 -6
  107. package/dist/vite/hono-preact.js +48 -77
  108. package/dist/vite/index.d.ts +2 -1
  109. package/dist/vite/index.js +1 -1
  110. package/dist/vite/node-dev-server.d.ts +4 -0
  111. package/dist/vite/node-dev-server.js +121 -0
  112. package/dist/vite/server-entry.d.ts +30 -7
  113. package/dist/vite/server-entry.js +170 -79
  114. package/dist/vite/server-exports-contract.d.ts +6 -0
  115. package/dist/vite/server-exports-contract.js +43 -0
  116. package/dist/vite/server-loader-validation.js +36 -9
  117. package/dist/vite/server-loaders-parser.d.ts +17 -1
  118. package/dist/vite/server-loaders-parser.js +41 -0
  119. package/dist/vite/server-only.js +20 -2
  120. package/package.json +33 -5
@@ -2,6 +2,9 @@ import type { ComponentChildren, ComponentType, FunctionComponent } from 'preact
2
2
  import type { Context } from 'hono';
3
3
  import type { RouteHook } from 'preact-iso';
4
4
  import { type LoaderCache } from './cache.js';
5
+ import type { LoaderUse } from './internal/use-types.js';
6
+ import type { Middleware } from './define-middleware.js';
7
+ import type { StreamObserver } from './define-stream-observer.js';
5
8
  export type LoaderCtx = {
6
9
  c: Context;
7
10
  location: RouteHook;
@@ -15,6 +18,21 @@ export interface LoaderRef<T> {
15
18
  readonly fn: Loader<T>;
16
19
  readonly cache: LoaderCache<T>;
17
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;
27
+ /**
28
+ * Per-loader middleware and (for streaming loaders) stream observers,
29
+ * exactly as authored on `defineLoader({ use })`. The handler-side
30
+ * dispatcher calls `partitionUse(ref.use)` to split middleware from
31
+ * observers; both partitions flow through the SSR/RPC streaming pump.
32
+ * Typed as the union the partitioner accepts so the contract is
33
+ * advertised at the consumer rather than hidden behind `unknown`.
34
+ */
35
+ readonly use: ReadonlyArray<Middleware | StreamObserver<unknown, never>>;
18
36
  useData(): T;
19
37
  useError(): Error | null;
20
38
  invalidate(): void;
@@ -43,5 +61,18 @@ export type DefineLoaderOpts<T> = {
43
61
  __loaderName?: string;
44
62
  cache?: LoaderCache<T>;
45
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;
70
+ /**
71
+ * Per-loader middleware and (for streaming loaders) stream observers.
72
+ * The element type LoaderUse<T, Streaming> structurally gates stream
73
+ * observers off non-streaming loaders, but a tighter compile-time gate
74
+ * via defineLoader overloads can be added in a follow-up if needed.
75
+ */
76
+ use?: LoaderUse<T, boolean>;
46
77
  };
47
78
  export declare function defineLoader<T>(fn: Loader<T>, opts?: DefineLoaderOpts<T>): LoaderRef<T>;
@@ -42,7 +42,15 @@ function ViewRenderer({ loaderRef, props, render, }) {
42
42
  const reload = reloadCtx?.reload ?? (() => { });
43
43
  return render({ data, error, reload, ...props });
44
44
  }
45
+ function validateTimeoutMs(value, context) {
46
+ if (value === undefined || value === false)
47
+ return;
48
+ if (!Number.isFinite(value) || value < 0) {
49
+ throw new RangeError(`${context}: timeoutMs must be a non-negative finite number or false, got ${String(value)}`);
50
+ }
51
+ }
45
52
  export function defineLoader(fn, opts) {
53
+ validateTimeoutMs(opts?.timeoutMs, 'defineLoader');
46
54
  const idKey = opts?.__moduleKey
47
55
  ? opts.__loaderName
48
56
  ? `${opts.__moduleKey}::${opts.__loaderName}`
@@ -80,6 +88,11 @@ export function defineLoader(fn, opts) {
80
88
  fn,
81
89
  cache: cache,
82
90
  params: opts?.params ?? [],
91
+ timeoutMs: opts?.timeoutMs,
92
+ // LoaderUse<T, boolean> structurally collapses to the same shape the
93
+ // partitioner accepts; the cast hides only the generic narrowing on
94
+ // StreamObserver's TChunk/TResult which is invariant. Identity-preserving.
95
+ use: (opts?.use ?? []),
83
96
  useData() {
84
97
  const ctx = useContext(LoaderDataContext);
85
98
  if (!ctx) {
@@ -93,26 +106,27 @@ export function defineLoader(fn, opts) {
93
106
  invalidate() {
94
107
  cache.invalidate();
95
108
  },
96
- Boundary: null,
97
- View: null,
98
- };
99
- const Boundary = ({ fallback, errorFallback, children, }) => {
100
- return h(LoaderHost, {
109
+ // `Boundary` and `View` close over `ref`. The captures are by reference
110
+ // and only deref at call time (component render), so the cycle is safe;
111
+ // both are fully initialized before any consumer can invoke them.
112
+ Boundary: ({ fallback, errorFallback, children }) => h((LoaderHost), {
101
113
  loader: ref,
102
114
  fallback,
103
115
  errorFallback,
104
116
  children,
105
- });
106
- };
107
- ref.Boundary = Boundary;
108
- const View = (render, viewOpts) => {
109
- const Wrapped = (props) => h(ref.Boundary, {
110
- fallback: viewOpts?.fallback,
111
- errorFallback: viewOpts?.errorFallback,
112
- children: h((ViewRenderer), { loaderRef: ref, props, render }),
113
- });
114
- return Wrapped;
117
+ }),
118
+ View: (render, viewOpts) => {
119
+ const Wrapped = (props) => h(ref.Boundary, {
120
+ fallback: viewOpts?.fallback,
121
+ errorFallback: viewOpts?.errorFallback,
122
+ children: h((ViewRenderer), {
123
+ loaderRef: ref,
124
+ props,
125
+ render,
126
+ }),
127
+ });
128
+ return Wrapped;
129
+ },
115
130
  };
116
- ref.View = View;
117
131
  return ref;
118
132
  }
@@ -0,0 +1,43 @@
1
+ import type { Context } from 'hono';
2
+ import type { RouteHook } from 'preact-iso';
3
+ import type { Outcome } from './outcomes.js';
4
+ export type Scope = 'page' | 'loader' | 'action';
5
+ export type ServerBaseCtx = {
6
+ c: Context;
7
+ signal: AbortSignal;
8
+ };
9
+ export type ServerPageCtx = ServerBaseCtx & {
10
+ scope: 'page';
11
+ location: RouteHook;
12
+ };
13
+ export type ServerLoaderCtx = ServerBaseCtx & {
14
+ scope: 'loader';
15
+ location: RouteHook;
16
+ module: string;
17
+ loader: string;
18
+ };
19
+ export type ServerActionCtx = ServerBaseCtx & {
20
+ scope: 'action';
21
+ module: string;
22
+ action: string;
23
+ payload: unknown;
24
+ };
25
+ export type ServerCtx<S extends Scope = Scope> = S extends 'page' ? ServerPageCtx : S extends 'loader' ? ServerLoaderCtx : S extends 'action' ? ServerActionCtx : ServerPageCtx | ServerLoaderCtx | ServerActionCtx;
26
+ export type ClientPageCtx = {
27
+ scope: 'page';
28
+ location: RouteHook;
29
+ };
30
+ export type Next = () => Promise<unknown>;
31
+ export type ServerMiddleware<S extends Scope = Scope> = {
32
+ __kind: 'middleware';
33
+ runs: 'server';
34
+ fn: (ctx: ServerCtx<S>, next: Next) => Promise<void | Outcome>;
35
+ };
36
+ export type ClientMiddleware = {
37
+ __kind: 'middleware';
38
+ runs: 'client';
39
+ fn: (ctx: ClientPageCtx, next: Next) => Promise<void | Outcome>;
40
+ };
41
+ export type Middleware = ServerMiddleware | ClientMiddleware;
42
+ export declare function defineServerMiddleware<S extends Scope = Scope>(fn: ServerMiddleware<S>['fn']): ServerMiddleware<S>;
43
+ export declare function defineClientMiddleware(fn: ClientMiddleware['fn']): ClientMiddleware;
@@ -0,0 +1,6 @@
1
+ export function defineServerMiddleware(fn) {
2
+ return { __kind: 'middleware', runs: 'server', fn };
3
+ }
4
+ export function defineClientMiddleware(fn) {
5
+ return { __kind: 'middleware', runs: 'client', fn };
6
+ }
@@ -1,10 +1,15 @@
1
1
  import type { ComponentType, FunctionComponent, JSX } from 'preact';
2
2
  import type { RouteHook } from 'preact-iso';
3
- import type { GuardFn } from './guard.js';
3
+ import type { PageUse } from './internal/use-types.js';
4
4
  import { type WrapperProps } from './page.js';
5
5
  export type PageBindings = {
6
6
  Wrapper?: ComponentType<WrapperProps>;
7
7
  errorFallback?: JSX.Element | ((error: Error, reset: () => void) => JSX.Element);
8
- guards?: GuardFn[];
8
+ /**
9
+ * Page-scope middleware and stream observers. The dispatcher partitions
10
+ * server vs client members by their `runs` tag, so mixed arrays of
11
+ * defineServerMiddleware + defineClientMiddleware work as one list.
12
+ */
13
+ use?: PageUse;
9
14
  };
10
15
  export declare function definePage(Component: ComponentType, bindings?: PageBindings): FunctionComponent<RouteHook>;
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "preact/jsx-runtime";
2
2
  import { Page } from './page.js';
3
3
  export function definePage(Component, bindings) {
4
- const PageRoute = (location) => (_jsx(Page, { Wrapper: bindings?.Wrapper, errorFallback: bindings?.errorFallback, guards: bindings?.guards, location: location, children: _jsx(Component, {}) }));
4
+ const PageRoute = (location) => (_jsx(Page, { Wrapper: bindings?.Wrapper, errorFallback: bindings?.errorFallback, use: bindings?.use, location: location, children: _jsx(Component, {}) }));
5
5
  PageRoute.displayName = `definePage(${Component.displayName ?? Component.name ?? 'Anonymous'})`;
6
6
  return PageRoute;
7
7
  }
@@ -7,7 +7,7 @@ export type ViewProps = RouteHook;
7
7
  type LazyImport<T> = () => Promise<{
8
8
  default: T;
9
9
  }>;
10
- type LazyServerImport = () => Promise<unknown>;
10
+ export type LazyServerImport = () => Promise<unknown>;
11
11
  export type RouteDef = {
12
12
  path: string;
13
13
  view?: LazyImport<ComponentType<ViewProps>>;
@@ -20,10 +20,33 @@ export type FlatRoute = {
20
20
  component: ComponentType<ViewProps>;
21
21
  key: string;
22
22
  };
23
+ export type ServerRoute = {
24
+ /** Absolute route path the server module belongs to. */
25
+ path: string;
26
+ /** Lazy `.server.*` module loader. */
27
+ server: LazyServerImport;
28
+ /**
29
+ * Lazy server-module loaders for every server-bearing ancestor in the
30
+ * route tree, outermost first, NOT including this route's own server.
31
+ * Used by the server-side pageUse resolver to compose page-layer
32
+ * middleware along the actual tree of layouts rather than relying on
33
+ * URL-prefix matching (which conflates siblings that share a path
34
+ * prefix, e.g. `/demo/projects` and `/demo/projects/:projectId/...`).
35
+ */
36
+ ancestors: ReadonlyArray<LazyServerImport>;
37
+ };
23
38
  export type RoutesManifest = {
24
39
  tree: ReadonlyArray<RouteDef>;
25
40
  flat: ReadonlyArray<FlatRoute>;
26
41
  serverImports: ReadonlyArray<LazyServerImport>;
42
+ /**
43
+ * Path-keyed view of every server module in the tree. Lets server-side
44
+ * consumers (e.g. the page-layer `use` resolver) load a per-page module
45
+ * by the route path that matched, without re-walking the tree. The order
46
+ * mirrors `serverImports`; the two arrays exist side-by-side because most
47
+ * existing call sites only need the lazy thunks.
48
+ */
49
+ serverRoutes: ReadonlyArray<ServerRoute>;
27
50
  };
28
51
  export declare function defineRoutes(tree: RouteDef[]): RoutesManifest;
29
52
  export type RoutesProps = {
@@ -67,6 +67,39 @@ function collectServerImports(routes) {
67
67
  walk(routes);
68
68
  return out;
69
69
  }
70
+ function collectServerRoutes(routes, parentPath = '') {
71
+ const out = [];
72
+ // `serverStack` tracks the lazy server-thunks for every server-bearing
73
+ // route on the path from the tree root down to (but not including) the
74
+ // node being emitted. Pushing on the way in / popping on the way out
75
+ // means each emitted ServerRoute captures its TRUE tree-walk ancestry,
76
+ // not whichever other patterns happen to share a URL prefix.
77
+ const walk = (rs, pp, serverStack) => {
78
+ for (const r of rs) {
79
+ const here = pp === '' ? r.path : pp + (r.path === '' ? '' : '/' + r.path);
80
+ if (r.server) {
81
+ // Capture the stack BEFORE pushing self -- ancestors exclude self.
82
+ out.push({
83
+ path: here,
84
+ server: r.server,
85
+ ancestors: serverStack.slice(),
86
+ });
87
+ }
88
+ if (r.children) {
89
+ if (r.server) {
90
+ serverStack.push(r.server);
91
+ walk(r.children, here, serverStack);
92
+ serverStack.pop();
93
+ }
94
+ else {
95
+ walk(r.children, here, serverStack);
96
+ }
97
+ }
98
+ }
99
+ };
100
+ walk(routes, parentPath, []);
101
+ return out;
102
+ }
70
103
  /**
71
104
  * Memoize `lazy(view)` per view-thunk identity. When the same `view` thunk is
72
105
  * referenced by multiple route registrations (e.g. `/docs` and `/docs/*`),
@@ -240,6 +273,7 @@ export function defineRoutes(tree) {
240
273
  tree,
241
274
  flat: flattenTree(tree, viewCache, keyCache),
242
275
  serverImports: collectServerImports(tree),
276
+ serverRoutes: collectServerRoutes(tree),
243
277
  };
244
278
  }
245
279
  export const Routes = ({ routes, onRouteChange, }) => {
@@ -0,0 +1,20 @@
1
+ import type { ServerLoaderCtx, ServerActionCtx } from './define-middleware.js';
2
+ export type ServerStreamCtx = ServerLoaderCtx | ServerActionCtx;
3
+ export type StreamObserver<TChunk = unknown, TResult = void> = {
4
+ __kind: 'observer';
5
+ onStart?: (ctx: ServerStreamCtx) => void;
6
+ onChunk?: (ctx: ServerStreamCtx, chunk: TChunk, index: number) => void;
7
+ onEnd?: (ctx: ServerStreamCtx, info: {
8
+ chunks: number;
9
+ result: TResult;
10
+ }) => void;
11
+ onError?: (ctx: ServerStreamCtx, err: unknown, info: {
12
+ chunks: number;
13
+ }) => void;
14
+ onAbort?: (ctx: ServerStreamCtx, info: {
15
+ chunks: number;
16
+ }) => void;
17
+ };
18
+ type Spec<TChunk, TResult> = Omit<StreamObserver<TChunk, TResult>, '__kind'>;
19
+ export declare function defineStreamObserver<TChunk = unknown, TResult = void>(spec: Spec<TChunk, TResult>): StreamObserver<TChunk, TResult>;
20
+ export {};
@@ -0,0 +1,3 @@
1
+ export function defineStreamObserver(spec) {
2
+ return { __kind: 'observer', ...spec };
3
+ }
@@ -1,7 +1,16 @@
1
1
  import type { JSX, ComponentChildren } from 'preact';
2
- export type FormProps<TPayload extends Record<string, unknown>> = Omit<JSX.HTMLAttributes<HTMLFormElement>, 'onSubmit'> & {
3
- mutate: (payload: TPayload) => Promise<unknown> | unknown;
4
- pending?: boolean;
2
+ import type { ActionStub } from './action.js';
3
+ import { type UseOptimisticActionResult } from './optimistic-action.js';
4
+ /**
5
+ * The `action` prop accepts either a plain action stub or the branded value
6
+ * returned by `useOptimisticAction`. The union lets `<Form>` discover the
7
+ * optimistic apply via `OPTIMISTIC_BRAND in action` narrowing without
8
+ * casting away the type.
9
+ */
10
+ type FormActionInput<TPayload, TResult> = ActionStub<TPayload, TResult, never> | UseOptimisticActionResult<TPayload, TResult, unknown>;
11
+ export type FormProps<TPayload, TResult> = Omit<JSX.HTMLAttributes<HTMLFormElement>, 'action' | 'method' | 'onSubmit' | 'enctype'> & {
12
+ action: FormActionInput<TPayload, TResult>;
5
13
  children?: ComponentChildren;
6
14
  };
7
- export declare function Form<TPayload extends Record<string, unknown>>({ mutate, pending, children, ...rest }: FormProps<TPayload>): JSX.Element;
15
+ export declare function Form<TPayload, TResult>({ action, children, ...rest }: FormProps<TPayload, TResult>): JSX.Element;
16
+ export {};
package/dist/iso/form.js CHANGED
@@ -1,40 +1,122 @@
1
- import { jsx as _jsx } from "preact/jsx-runtime";
2
- /**
3
- * Collect a FormData into a plain payload object.
4
- *
5
- * Repeated field names (checkboxes sharing a name, multi-select, multiple
6
- * `<input type="file" multiple>` entries) collect into an array. The old
7
- * `Object.fromEntries(fd)` produced the LAST value only, which was silent
8
- * data loss: a four-checkbox group would submit one value with no warning.
9
- *
10
- * Single-value fields stay scalar. Files survive as `File` instances.
11
- *
12
- * Consumers should type their `defineAction<TPayload, ...>` to match:
13
- * `tags: string[]`, `photos: File[]`, etc. for fields that may have multiple
14
- * values; scalar types for fields that won't.
15
- */
1
+ import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
2
+ import { useState, useCallback, useMemo } from 'preact/hooks';
3
+ import { OPTIMISTIC_BRAND, } from './optimistic-action.js';
4
+ import { beginSubmit, endSubmit } from './internal/form-submit-store.js';
5
+ import { setLastActionResult } from './internal/action-result-store.js';
6
+ import { assignSafeRedirect } from './internal/safe-redirect.js';
7
+ function hasOptimisticBrand(action) {
8
+ return OPTIMISTIC_BRAND in action;
9
+ }
16
10
  function collectFormData(fd) {
17
- const payload = {};
11
+ const out = {};
18
12
  for (const [key, value] of fd.entries()) {
19
- if (key in payload) {
20
- const existing = payload[key];
21
- payload[key] = Array.isArray(existing)
22
- ? [...existing, value]
23
- : [existing, value];
24
- }
25
- else {
26
- payload[key] = value;
27
- }
13
+ if (key === '__module' || key === '__action')
14
+ continue;
15
+ const existing = out[key];
16
+ out[key] =
17
+ existing === undefined
18
+ ? value
19
+ : Array.isArray(existing)
20
+ ? [...existing, value]
21
+ : [existing, value];
28
22
  }
29
- return payload;
23
+ return out;
30
24
  }
31
- export function Form({ mutate, pending, children, ...rest }) {
32
- const handleSubmit = (e) => {
25
+ export function Form({ action, children, ...rest }) {
26
+ const [pending, setPending] = useState(false);
27
+ const moduleKey = action.__module;
28
+ const actionName = action.__action;
29
+ const optimistic = useMemo(() => (hasOptimisticBrand(action) ? action[OPTIMISTIC_BRAND] : undefined), [action]);
30
+ const handleSubmit = useCallback(async (e) => {
33
31
  e.preventDefault();
34
32
  const formEl = e.currentTarget;
35
- const formData = new FormData(formEl);
36
- const payload = collectFormData(formData);
37
- void mutate(payload);
38
- };
39
- return (_jsx("form", { ...rest, onSubmit: handleSubmit, children: _jsx("fieldset", { disabled: pending, class: "hp-form-fieldset", children: children }) }));
33
+ const target = typeof window !== 'undefined'
34
+ ? window.location.pathname + window.location.search
35
+ : '/';
36
+ const fd = new FormData(formEl);
37
+ const payload = collectFormData(fd);
38
+ let handle;
39
+ if (optimistic)
40
+ handle = optimistic.addOptimistic(payload);
41
+ setPending(true);
42
+ beginSubmit(moduleKey, actionName);
43
+ try {
44
+ const res = await fetch(target, {
45
+ method: 'POST',
46
+ body: fd,
47
+ headers: { Accept: 'application/json' },
48
+ });
49
+ const env = (await res.json().catch(() => null));
50
+ if (!env) {
51
+ handle?.revert();
52
+ if (typeof window !== 'undefined')
53
+ window.location.reload();
54
+ return;
55
+ }
56
+ if (env.__outcome === 'redirect' && typeof env.to === 'string') {
57
+ const navigated = assignSafeRedirect(env.to);
58
+ if (navigated) {
59
+ handle?.settle();
60
+ return;
61
+ }
62
+ // Cross-origin: revert optimistic, surface as error result so useActionResult sees it.
63
+ handle?.revert();
64
+ setLastActionResult(moduleKey, actionName, {
65
+ kind: 'error',
66
+ message: `Refused cross-origin redirect to ${env.to}`,
67
+ submittedPayload: payload,
68
+ });
69
+ return;
70
+ }
71
+ if (env.__outcome === 'success') {
72
+ handle?.settle();
73
+ setLastActionResult(moduleKey, actionName, {
74
+ kind: 'success',
75
+ data: env.data,
76
+ submittedPayload: payload,
77
+ });
78
+ return;
79
+ }
80
+ if (env.__outcome === 'deny') {
81
+ handle?.revert();
82
+ setLastActionResult(moduleKey, actionName, {
83
+ kind: 'deny',
84
+ status: env.status ?? res.status,
85
+ message: env.message ?? `Request denied (${env.status ?? res.status})`,
86
+ data: env.data,
87
+ submittedPayload: payload,
88
+ });
89
+ return;
90
+ }
91
+ if (env.__outcome === 'error') {
92
+ handle?.revert();
93
+ setLastActionResult(moduleKey, actionName, {
94
+ kind: 'error',
95
+ message: env.message ?? 'Action failed',
96
+ submittedPayload: payload,
97
+ });
98
+ return;
99
+ }
100
+ // Unknown outcome (e.g. 'timeout'): treat as error.
101
+ handle?.revert();
102
+ setLastActionResult(moduleKey, actionName, {
103
+ kind: 'error',
104
+ message: env.message ?? `Unexpected outcome: ${env.__outcome ?? 'unknown'}`,
105
+ submittedPayload: payload,
106
+ });
107
+ }
108
+ catch (err) {
109
+ handle?.revert();
110
+ setLastActionResult(moduleKey, actionName, {
111
+ kind: 'error',
112
+ message: err instanceof Error ? err.message : String(err),
113
+ submittedPayload: payload,
114
+ });
115
+ }
116
+ finally {
117
+ setPending(false);
118
+ endSubmit(moduleKey, actionName);
119
+ }
120
+ }, [moduleKey, actionName, optimistic]);
121
+ return (_jsxs("form", { ...rest, method: "post", enctype: "multipart/form-data", onSubmit: handleSubmit, children: [_jsx("input", { type: "hidden", name: "__module", value: moduleKey }), _jsx("input", { type: "hidden", name: "__action", value: actionName }), _jsx("fieldset", { disabled: pending, class: "hp-form-fieldset", children: children })] }));
40
122
  }
@@ -4,23 +4,31 @@ export { definePage } from './define-page.js';
4
4
  export type { PageBindings } from './define-page.js';
5
5
  export { Route, Router, lazy, useLocation, useRoute } from 'preact-iso';
6
6
  export { defineRoutes, Routes } from './define-routes.js';
7
- export type { RouteDef, RoutesManifest, FlatRoute, LayoutProps, ViewProps, } from './define-routes.js';
7
+ export type { RouteDef, RoutesManifest, FlatRoute, ServerRoute, LayoutProps, ViewProps, } from './define-routes.js';
8
8
  export { defineLoader } from './define-loader.js';
9
9
  export type { LoaderRef, LoaderCtx, Loader as LoaderFn, } from './define-loader.js';
10
- export { defineAction, useAction } from './action.js';
11
- export type { ActionStub, UseActionOptions, UseActionResult, MutateResult, ActionGuardContext, ActionGuardFn, } from './action.js';
12
- export { ActionGuardError, defineActionGuard } from './action.js';
10
+ export { defineAction, useAction, TimeoutError } from './action.js';
11
+ export type { ActionStub, UseActionOptions, UseActionResult, MutateResult, } from './action.js';
13
12
  export type { ContentfulStatusCode } from 'hono/utils/http-status';
14
13
  export { useReload } from './reload-context.js';
15
14
  export { useOptimistic } from './optimistic.js';
16
- export type { OptimisticHandle } from './optimistic.js';
15
+ export type { OptimisticHandle, UseOptimisticOptions } from './optimistic.js';
17
16
  export { useOptimisticAction } from './optimistic-action.js';
18
17
  export type { UseOptimisticActionOptions, UseOptimisticActionResult, } from './optimistic-action.js';
19
18
  export { Form } from './form.js';
19
+ export { useActionResult, type ActionResult } from './use-action-result.js';
20
+ export { ActionResultContext, type ActionResultContextValue, } from './action-result-context.js';
21
+ export { useFormStatus, type FormStatus } from './use-form-status.js';
20
22
  export { createCache } from './cache.js';
21
23
  export type { LoaderCache } from './cache.js';
22
- export { defineServerGuard, defineClientGuard, GuardRedirect, runServerGuards, runClientGuards, } from './guard.js';
23
- export type { GuardFn, ServerGuardFn, ClientGuardFn, GuardResult, ServerGuardContext, ClientGuardContext, GuardRunsOn, } from './guard.js';
24
+ export { defineServerMiddleware, defineClientMiddleware, } from './define-middleware.js';
25
+ export type { ServerMiddleware, ClientMiddleware, Middleware, ServerBaseCtx, ServerPageCtx, ServerLoaderCtx, ServerActionCtx, ServerCtx, ClientPageCtx, Scope, Next, } from './define-middleware.js';
26
+ export { defineStreamObserver } from './define-stream-observer.js';
27
+ export type { StreamObserver, ServerStreamCtx, } from './define-stream-observer.js';
28
+ export { defineApp } from './define-app.js';
29
+ export type { AppConfig, AppUseElement } from './define-app.js';
30
+ export { redirect, deny, timeoutOutcome, isOutcome, isRedirect, isDeny, isRender, isTimeout, } from './outcomes.js';
31
+ export type { Outcome, RedirectOutcome, DenyOutcome, RenderOutcome, TimeoutOutcome, RedirectStatusCode, ErrorStatusCode, } from './outcomes.js';
24
32
  export { prefetch } from './prefetch.js';
25
33
  export { isBrowser, env } from './is-browser.js';
26
34
  export { useRouteChange } from './route-change.js';
package/dist/iso/index.js CHANGED
@@ -8,18 +8,23 @@ export { Route, Router, lazy, useLocation, useRoute } from 'preact-iso';
8
8
  export { defineRoutes, Routes } from './define-routes.js';
9
9
  // Server bindings.
10
10
  export { defineLoader } from './define-loader.js';
11
- export { defineAction, useAction } from './action.js';
12
- export { ActionGuardError, defineActionGuard } from './action.js';
11
+ export { defineAction, useAction, TimeoutError } from './action.js';
13
12
  // Hooks.
14
13
  export { useReload } from './reload-context.js';
15
14
  export { useOptimistic } from './optimistic.js';
16
15
  export { useOptimisticAction } from './optimistic-action.js';
17
16
  // Forms.
18
17
  export { Form } from './form.js';
18
+ export { useActionResult } from './use-action-result.js';
19
+ export { ActionResultContext, } from './action-result-context.js';
20
+ export { useFormStatus } from './use-form-status.js';
19
21
  // Cache + invalidation.
20
22
  export { createCache } from './cache.js';
21
- // Guards.
22
- export { defineServerGuard, defineClientGuard, GuardRedirect, runServerGuards, runClientGuards, } from './guard.js';
23
+ // Middleware + outcomes (the new system).
24
+ export { defineServerMiddleware, defineClientMiddleware, } from './define-middleware.js';
25
+ export { defineStreamObserver } from './define-stream-observer.js';
26
+ export { defineApp } from './define-app.js';
27
+ export { redirect, deny, timeoutOutcome, isOutcome, isRedirect, isDeny, isRender, isTimeout, } from './outcomes.js';
23
28
  // Utilities.
24
29
  export { prefetch } from './prefetch.js';
25
30
  export { isBrowser, env } from './is-browser.js';
@@ -0,0 +1,37 @@
1
+ import type { ContentfulStatusCode } from 'hono/utils/http-status';
2
+ import type { Outcome } from '../outcomes.js';
3
+ export type ActionEnvelope = {
4
+ __outcome: 'success';
5
+ data: unknown;
6
+ } | {
7
+ __outcome: 'redirect';
8
+ to: string;
9
+ status: number;
10
+ } | {
11
+ __outcome: 'deny';
12
+ status: number;
13
+ message: string;
14
+ data?: unknown;
15
+ } | {
16
+ __outcome: 'error';
17
+ message: string;
18
+ } | {
19
+ __outcome: 'timeout';
20
+ timeoutMs: number;
21
+ };
22
+ export type ActionResolution = {
23
+ kind: 'success';
24
+ data: unknown;
25
+ } | {
26
+ kind: 'outcome';
27
+ outcome: Outcome;
28
+ } | {
29
+ kind: 'error';
30
+ message: string;
31
+ };
32
+ export type SerializedEnvelope = {
33
+ body: ActionEnvelope;
34
+ status: ContentfulStatusCode;
35
+ headers: Record<string, string> | undefined;
36
+ };
37
+ export declare function serializeActionOutcome(resolution: ActionResolution): SerializedEnvelope;
@@ -0,0 +1,47 @@
1
+ export function serializeActionOutcome(resolution) {
2
+ if (resolution.kind === 'success') {
3
+ return {
4
+ body: { __outcome: 'success', data: resolution.data },
5
+ status: 200,
6
+ headers: undefined,
7
+ };
8
+ }
9
+ if (resolution.kind === 'error') {
10
+ return {
11
+ body: { __outcome: 'error', message: resolution.message },
12
+ status: 500,
13
+ headers: undefined,
14
+ };
15
+ }
16
+ const { outcome } = resolution;
17
+ if (outcome.__outcome === 'redirect') {
18
+ return {
19
+ body: { __outcome: 'redirect', to: outcome.to, status: outcome.status },
20
+ status: 200,
21
+ headers: outcome.headers,
22
+ };
23
+ }
24
+ if (outcome.__outcome === 'deny') {
25
+ const body = {
26
+ __outcome: 'deny',
27
+ status: outcome.status,
28
+ message: outcome.message,
29
+ };
30
+ if (outcome.data !== undefined)
31
+ body.data = outcome.data;
32
+ return { body, status: outcome.status, headers: outcome.headers };
33
+ }
34
+ if (outcome.__outcome === 'timeout') {
35
+ return {
36
+ body: { __outcome: 'timeout', timeoutMs: outcome.timeoutMs },
37
+ status: 504,
38
+ headers: undefined,
39
+ };
40
+ }
41
+ // 'render' outcome is page-scope only; should never reach an action.
42
+ return {
43
+ body: { __outcome: 'error', message: 'render outcome is page-scope only' },
44
+ status: 500,
45
+ headers: undefined,
46
+ };
47
+ }