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.
- package/README.md +2 -1
- package/dist/adapter-cloudflare.d.ts +1 -0
- package/dist/adapter-cloudflare.d.ts.map +1 -0
- package/dist/adapter-cloudflare.js +2 -0
- package/dist/adapter-node.d.ts +1 -0
- package/dist/adapter-node.d.ts.map +1 -0
- package/dist/adapter-node.js +2 -0
- package/dist/internal.d.ts +1 -1
- package/dist/internal.js +1 -1
- 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 +60 -25
- package/dist/iso/action.js +210 -58
- package/dist/iso/cache.d.ts +9 -0
- package/dist/iso/cache.js +26 -0
- package/dist/iso/define-app.d.ts +14 -0
- package/dist/iso/define-app.js +3 -0
- package/dist/iso/define-loader.d.ts +31 -0
- package/dist/iso/define-loader.js +30 -16
- package/dist/iso/define-middleware.d.ts +43 -0
- package/dist/iso/define-middleware.js +6 -0
- package/dist/iso/define-page.d.ts +7 -2
- package/dist/iso/define-page.js +1 -1
- package/dist/iso/define-routes.d.ts +24 -1
- package/dist/iso/define-routes.js +34 -0
- package/dist/iso/define-stream-observer.d.ts +20 -0
- package/dist/iso/define-stream-observer.js +3 -0
- package/dist/iso/form.d.ts +13 -4
- package/dist/iso/form.js +115 -33
- package/dist/iso/index.d.ts +15 -7
- package/dist/iso/index.js +9 -4
- 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/contexts.d.ts +0 -2
- package/dist/iso/internal/contexts.js +0 -1
- 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 +102 -41
- package/dist/iso/internal/loader-runner.js +105 -8
- package/dist/iso/internal/loader.d.ts +3 -3
- package/dist/iso/internal/middleware-runner.d.ts +22 -0
- package/dist/iso/internal/middleware-runner.js +79 -0
- package/dist/iso/internal/page-middleware-host.d.ts +13 -0
- package/dist/iso/internal/page-middleware-host.js +119 -0
- package/dist/iso/internal/route-boundary.d.ts +5 -4
- package/dist/iso/internal/route-boundary.js +16 -0
- 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/stream-observer-runner.d.ts +13 -0
- package/dist/iso/internal/stream-observer-runner.js +48 -0
- package/dist/iso/internal/use-partitioner.d.ts +9 -0
- package/dist/iso/internal/use-partitioner.js +11 -0
- package/dist/iso/internal/use-types.d.ts +7 -0
- package/dist/iso/internal/use-types.js +1 -0
- package/dist/iso/internal.d.ts +12 -5
- package/dist/iso/internal.js +16 -7
- 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 +50 -0
- package/dist/iso/outcomes.js +67 -0
- package/dist/iso/page-only.d.ts +5 -0
- package/dist/iso/page-only.js +20 -0
- package/dist/iso/page.d.ts +3 -3
- package/dist/iso/page.js +3 -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/page.d.ts +1 -0
- package/dist/page.d.ts.map +1 -0
- package/dist/page.js +8 -0
- package/dist/server/actions-handler.d.ts +27 -6
- package/dist/server/actions-handler.js +121 -52
- package/dist/server/context.js +1 -1
- package/dist/server/index.d.ts +3 -2
- package/dist/server/index.js +3 -2
- package/dist/server/loaders-handler.d.ts +24 -0
- package/dist/server/loaders-handler.js +128 -18
- 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.d.ts +2 -0
- package/dist/server/render.js +142 -33
- package/dist/server/route-server-modules.d.ts +48 -8
- package/dist/server/route-server-modules.js +190 -7
- package/dist/server/speculation-rules.d.ts +3 -0
- package/dist/server/speculation-rules.js +8 -0
- package/dist/server/sse.d.ts +50 -12
- package/dist/server/sse.js +130 -53
- package/dist/vite/adapter-cloudflare.d.ts +2 -0
- package/dist/vite/adapter-cloudflare.js +25 -0
- package/dist/vite/adapter-node.d.ts +2 -0
- package/dist/vite/adapter-node.js +49 -0
- package/dist/vite/adapter.d.ts +29 -0
- package/dist/vite/adapter.js +1 -0
- package/dist/vite/client-shim.js +5 -4
- package/dist/vite/guard-strip.js +52 -27
- package/dist/vite/hono-preact.d.ts +6 -6
- package/dist/vite/hono-preact.js +48 -77
- package/dist/vite/index.d.ts +2 -1
- package/dist/vite/index.js +1 -1
- package/dist/vite/node-dev-server.d.ts +4 -0
- package/dist/vite/node-dev-server.js +121 -0
- package/dist/vite/server-entry.d.ts +30 -7
- package/dist/vite/server-entry.js +170 -79
- package/dist/vite/server-exports-contract.d.ts +6 -0
- package/dist/vite/server-exports-contract.js +43 -0
- package/dist/vite/server-loader-validation.js +36 -9
- package/dist/vite/server-loaders-parser.d.ts +17 -1
- package/dist/vite/server-loaders-parser.js +41 -0
- package/dist/vite/server-only.js +20 -2
- package/package.json +33 -5
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { FunctionComponent } from 'preact';
|
|
2
|
+
import type { RedirectStatusCode, ClientErrorStatusCode, ServerErrorStatusCode } from 'hono/utils/http-status';
|
|
3
|
+
export type ErrorStatusCode = ClientErrorStatusCode | ServerErrorStatusCode;
|
|
4
|
+
export type { RedirectStatusCode };
|
|
5
|
+
export type RedirectOutcome = {
|
|
6
|
+
__outcome: 'redirect';
|
|
7
|
+
to: string;
|
|
8
|
+
status: RedirectStatusCode;
|
|
9
|
+
headers: Record<string, string> | undefined;
|
|
10
|
+
};
|
|
11
|
+
export type DenyOutcome = {
|
|
12
|
+
__outcome: 'deny';
|
|
13
|
+
status: ErrorStatusCode;
|
|
14
|
+
message: string;
|
|
15
|
+
headers: Record<string, string> | undefined;
|
|
16
|
+
data?: unknown;
|
|
17
|
+
};
|
|
18
|
+
export type RenderOutcome = {
|
|
19
|
+
__outcome: 'render';
|
|
20
|
+
Component: FunctionComponent;
|
|
21
|
+
};
|
|
22
|
+
export type TimeoutOutcome = {
|
|
23
|
+
__outcome: 'timeout';
|
|
24
|
+
timeoutMs: number;
|
|
25
|
+
};
|
|
26
|
+
export type Outcome = RedirectOutcome | DenyOutcome | RenderOutcome | TimeoutOutcome;
|
|
27
|
+
type RedirectInput = string | {
|
|
28
|
+
to: string;
|
|
29
|
+
status?: RedirectStatusCode;
|
|
30
|
+
headers?: Record<string, string>;
|
|
31
|
+
};
|
|
32
|
+
export declare function redirect(input: RedirectInput): RedirectOutcome;
|
|
33
|
+
type DenyInput = {
|
|
34
|
+
status: ErrorStatusCode;
|
|
35
|
+
message?: string;
|
|
36
|
+
headers?: Record<string, string>;
|
|
37
|
+
data?: unknown;
|
|
38
|
+
};
|
|
39
|
+
type DenyOpts = {
|
|
40
|
+
headers?: Record<string, string>;
|
|
41
|
+
data?: unknown;
|
|
42
|
+
};
|
|
43
|
+
export declare function deny(status: ErrorStatusCode, message?: string, opts?: DenyOpts): DenyOutcome;
|
|
44
|
+
export declare function deny(spec: DenyInput): DenyOutcome;
|
|
45
|
+
export declare function isOutcome(value: unknown): value is Outcome;
|
|
46
|
+
export declare function isRedirect(value: unknown): value is RedirectOutcome;
|
|
47
|
+
export declare function isDeny(value: unknown): value is DenyOutcome;
|
|
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;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export function redirect(input) {
|
|
2
|
+
if (typeof input === 'string') {
|
|
3
|
+
return {
|
|
4
|
+
__outcome: 'redirect',
|
|
5
|
+
to: input,
|
|
6
|
+
status: 302,
|
|
7
|
+
headers: undefined,
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
return {
|
|
11
|
+
__outcome: 'redirect',
|
|
12
|
+
to: input.to,
|
|
13
|
+
status: input.status ?? 302,
|
|
14
|
+
headers: input.headers,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export function deny(a, b, c) {
|
|
18
|
+
// `JSON.stringify` drops `undefined` properties, so a deny outcome with no
|
|
19
|
+
// message would arrive at the client without a `message` field and the
|
|
20
|
+
// client decoders would fall back to a generic "Loader/Action failed with
|
|
21
|
+
// status N" string. Default to a status-aware message at construction time
|
|
22
|
+
// so the wire envelope always carries something useful. Callers can still
|
|
23
|
+
// pass a richer message; defense-in-depth on the client side fills in a
|
|
24
|
+
// similar fallback if a hand-rolled envelope ships without `message`.
|
|
25
|
+
if (typeof a === 'object') {
|
|
26
|
+
return {
|
|
27
|
+
__outcome: 'deny',
|
|
28
|
+
status: a.status,
|
|
29
|
+
message: a.message ?? `Request denied (${a.status})`,
|
|
30
|
+
headers: a.headers,
|
|
31
|
+
...(a.data !== undefined ? { data: a.data } : {}),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
__outcome: 'deny',
|
|
36
|
+
status: a,
|
|
37
|
+
message: b ?? `Request denied (${a})`,
|
|
38
|
+
headers: c?.headers,
|
|
39
|
+
...(c?.data !== undefined ? { data: c.data } : {}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export function isOutcome(value) {
|
|
43
|
+
if (typeof value !== 'object' || value === null)
|
|
44
|
+
return false;
|
|
45
|
+
if (!('__outcome' in value))
|
|
46
|
+
return false;
|
|
47
|
+
const tag = value.__outcome;
|
|
48
|
+
return (tag === 'redirect' ||
|
|
49
|
+
tag === 'deny' ||
|
|
50
|
+
tag === 'render' ||
|
|
51
|
+
tag === 'timeout');
|
|
52
|
+
}
|
|
53
|
+
export function isRedirect(value) {
|
|
54
|
+
return isOutcome(value) && value.__outcome === 'redirect';
|
|
55
|
+
}
|
|
56
|
+
export function isDeny(value) {
|
|
57
|
+
return isOutcome(value) && value.__outcome === 'deny';
|
|
58
|
+
}
|
|
59
|
+
export function isRender(value) {
|
|
60
|
+
return isOutcome(value) && value.__outcome === 'render';
|
|
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,5 @@
|
|
|
1
|
+
import type { FunctionComponent } from 'preact';
|
|
2
|
+
import type { RenderOutcome } from './outcomes.js';
|
|
3
|
+
export { redirect, deny, isOutcome, isRedirect, isDeny, isRender, } from './outcomes.js';
|
|
4
|
+
export type { Outcome, RedirectOutcome, DenyOutcome, RenderOutcome, RedirectStatusCode, ErrorStatusCode, } from './outcomes.js';
|
|
5
|
+
export declare function render(Component: FunctionComponent): RenderOutcome;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// @hono-preact/iso/page -- page-scope outcome kitchen sink.
|
|
2
|
+
//
|
|
3
|
+
// This subpath bundles every outcome a page-scope server middleware
|
|
4
|
+
// reaches for in one import. `render` is the page-scope-only constructor
|
|
5
|
+
// (loaders/actions can't replace the page tree), so it lives here and
|
|
6
|
+
// nowhere else; the docs steer users at this subpath when they need it.
|
|
7
|
+
// `redirect`/`deny` and the predicates (`isOutcome`/`isRedirect`/`isDeny`
|
|
8
|
+
// /`isRender`) are re-exported here too so a page-scope file can write a
|
|
9
|
+
// single `import { redirect, deny, render } from 'hono-preact/page'` line.
|
|
10
|
+
//
|
|
11
|
+
// Canonical export location for the cross-scope symbols is `./outcomes.js`.
|
|
12
|
+
// Both this subpath and `./index.js` re-export from there; nothing here
|
|
13
|
+
// hides surface. The predicates are scope-agnostic, so `index.ts` is their
|
|
14
|
+
// primary home for consumers that don't already need the page-scope
|
|
15
|
+
// subpath; the predicates are duplicated here only for the kitchen-sink
|
|
16
|
+
// import path.
|
|
17
|
+
export { redirect, deny, isOutcome, isRedirect, isDeny, isRender, } from './outcomes.js';
|
|
18
|
+
export function render(Component) {
|
|
19
|
+
return { __outcome: 'render', Component };
|
|
20
|
+
}
|
package/dist/iso/page.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ComponentChildren, ComponentType, JSX } from 'preact';
|
|
2
2
|
import type { RouteHook } from 'preact-iso';
|
|
3
|
-
import type {
|
|
3
|
+
import type { PageUse } from './internal/use-types.js';
|
|
4
4
|
export type WrapperProps = {
|
|
5
5
|
id: string;
|
|
6
6
|
'data-loader': string;
|
|
@@ -8,9 +8,9 @@ export type WrapperProps = {
|
|
|
8
8
|
};
|
|
9
9
|
export type PageProps = {
|
|
10
10
|
location: RouteHook;
|
|
11
|
-
|
|
11
|
+
use?: PageUse;
|
|
12
12
|
errorFallback?: JSX.Element | ((error: Error, reset: () => void) => JSX.Element);
|
|
13
13
|
Wrapper?: ComponentType<WrapperProps>;
|
|
14
14
|
children: ComponentChildren;
|
|
15
15
|
};
|
|
16
|
-
export declare function Page({ location,
|
|
16
|
+
export declare function Page({ location, use, errorFallback, Wrapper, children, }: PageProps): JSX.Element;
|
package/dist/iso/page.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx } from "preact/jsx-runtime";
|
|
2
2
|
import { useId } from 'preact/hooks';
|
|
3
|
-
import {
|
|
3
|
+
import { PageMiddlewareHost } from './internal/page-middleware-host.js';
|
|
4
4
|
import { RouteBoundary } from './internal/route-boundary.js';
|
|
5
5
|
const DefaultWrapper = (props) => (_jsx("section", { ...props }));
|
|
6
|
-
export function Page({ location,
|
|
6
|
+
export function Page({ location, use, errorFallback, Wrapper, children, }) {
|
|
7
7
|
const id = useId();
|
|
8
8
|
const W = Wrapper ?? DefaultWrapper;
|
|
9
|
-
return (_jsx(RouteBoundary, { errorFallback: errorFallback, children: _jsx(
|
|
9
|
+
return (_jsx(RouteBoundary, { errorFallback: errorFallback, children: _jsx(PageMiddlewareHost, { use: use, location: location, children: _jsx(W, { id: id, "data-loader": "null", children: children }) }) }));
|
|
10
10
|
}
|
|
@@ -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
|
+
}
|
package/dist/page.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './iso/page-only';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"page.d.ts","sourceRoot":"","sources":["../src/page.ts"],"names":[],"mappings":"AAOA,cAAc,uBAAuB,CAAC"}
|
package/dist/page.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// hono-preact/page -- page-scope outcome kitchen sink (umbrella re-export).
|
|
2
|
+
//
|
|
3
|
+
// Forwards to @hono-preact/iso/page so consumers using the published
|
|
4
|
+
// umbrella can write `import { redirect, deny, render } from 'hono-preact/page'`
|
|
5
|
+
// exactly as the docs show. The iso subpath is where the constructors and
|
|
6
|
+
// predicates actually live; this file exists so the umbrella's exports map
|
|
7
|
+
// matches the consolidated subpath after `scripts/consolidate.mjs` runs.
|
|
8
|
+
export * from './iso/page-only.js';
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import type { MiddlewareHandler } from 'hono';
|
|
2
|
-
import { type
|
|
2
|
+
import { type AppConfig } from '../iso/index';
|
|
3
3
|
type GlobModule = {
|
|
4
4
|
__moduleKey?: unknown;
|
|
5
5
|
serverActions?: Record<string, unknown>;
|
|
6
|
-
actionGuards?: ActionGuardFn[];
|
|
7
6
|
[key: string]: unknown;
|
|
8
7
|
};
|
|
9
8
|
type LazyGlob = Record<string, () => Promise<unknown>>;
|
|
@@ -19,15 +18,37 @@ export interface ActionsHandlerOptions {
|
|
|
19
18
|
*/
|
|
20
19
|
dev?: boolean;
|
|
21
20
|
/**
|
|
22
|
-
* Called for every error an action throws (other than
|
|
23
|
-
* which is
|
|
24
|
-
* observability stack (Sentry, console, etc.). The handler
|
|
25
|
-
* responds with a sanitized 500; the hook is purely a side channel.
|
|
21
|
+
* Called for every error an action throws (other than an outcome thrown
|
|
22
|
+
* by middleware, which is translated to its wire shape). Use it to hook
|
|
23
|
+
* into your observability stack (Sentry, console, etc.). The handler
|
|
24
|
+
* still responds with a sanitized 500; the hook is purely a side channel.
|
|
26
25
|
*/
|
|
27
26
|
onError?: (err: unknown, ctx: {
|
|
28
27
|
module: string;
|
|
29
28
|
action: string;
|
|
30
29
|
}) => void;
|
|
30
|
+
/**
|
|
31
|
+
* Root layer of the middleware chain. The framework's generated server
|
|
32
|
+
* entry threads the user's `defineApp({ use })` result here. Each action
|
|
33
|
+
* request composes the chain as
|
|
34
|
+
* `[...appConfig.use, ...resolvePageUse(module), ...action.use]`.
|
|
35
|
+
*/
|
|
36
|
+
appConfig?: AppConfig;
|
|
37
|
+
/**
|
|
38
|
+
* Per-page layer lookup keyed by the action's owning module key (since an
|
|
39
|
+
* action always belongs unambiguously to one page module). Returns the
|
|
40
|
+
* `use` array declared on the matching page's `.server.*` module (as
|
|
41
|
+
* `export const pageUse = [...]`). May be sync or async; the handler
|
|
42
|
+
* awaits the result either way. Default returns an empty array.
|
|
43
|
+
*/
|
|
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;
|
|
31
52
|
}
|
|
32
53
|
export declare function actionsHandler(glob: LazyGlob | EagerGlob, opts?: ActionsHandlerOptions): MiddlewareHandler;
|
|
33
54
|
export {};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { runRequestScope } from '../iso/internal
|
|
1
|
+
import { isOutcome, timeoutOutcome, } from '../iso/index.js';
|
|
2
|
+
import { runRequestScope, dispatchServer, partitionUse, } from '../iso/internal.js';
|
|
3
3
|
import { sseGeneratorResponse, sseReadableStreamResponse, isAsyncGenerator, } from './sse.js';
|
|
4
4
|
async function buildActionsMap(glob) {
|
|
5
5
|
const result = {};
|
|
@@ -8,42 +8,62 @@ async function buildActionsMap(glob) {
|
|
|
8
8
|
? await moduleOrLoader()
|
|
9
9
|
: moduleOrLoader;
|
|
10
10
|
const key = mod.__moduleKey;
|
|
11
|
-
if (typeof key
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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,
|
|
15
27
|
};
|
|
16
28
|
}
|
|
29
|
+
result[key] = { actions };
|
|
17
30
|
}
|
|
18
31
|
return result;
|
|
19
32
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
// silently insecure. To block, throw ActionGuardError. To pass, await
|
|
30
|
-
// (or return) next().
|
|
31
|
-
let nextCalled = false;
|
|
32
|
-
await guards[index](ctx, () => {
|
|
33
|
-
nextCalled = true;
|
|
34
|
-
return run(index + 1);
|
|
35
|
-
});
|
|
36
|
-
if (!nextCalled) {
|
|
37
|
-
throw new Error(`ActionGuard for '${ctx.module}.${ctx.action}' returned without ` +
|
|
38
|
-
`calling next() or throwing. Guards must either: (a) await/return ` +
|
|
39
|
-
`next() to pass control on, or (b) throw ActionGuardError to block. ` +
|
|
40
|
-
`Returning silently is ambiguous and would let the action run.`);
|
|
33
|
+
function translateOutcomeForAction(c, outcome) {
|
|
34
|
+
if (outcome.__outcome === 'redirect') {
|
|
35
|
+
// Headers from the outcome ride the HTTP response via `c.header()`. They
|
|
36
|
+
// are deliberately NOT embedded in the JSON envelope: the client only
|
|
37
|
+
// reads `to` and calls `window.location.assign(to)`; any embedded
|
|
38
|
+
// headers would be dead bytes the client never inspects.
|
|
39
|
+
if (outcome.headers) {
|
|
40
|
+
for (const [k, v] of Object.entries(outcome.headers))
|
|
41
|
+
c.header(k, v);
|
|
41
42
|
}
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
return c.json({
|
|
44
|
+
__outcome: 'redirect',
|
|
45
|
+
to: outcome.to,
|
|
46
|
+
status: outcome.status,
|
|
47
|
+
}, 200);
|
|
48
|
+
}
|
|
49
|
+
if (outcome.__outcome === 'deny') {
|
|
50
|
+
if (outcome.headers) {
|
|
51
|
+
for (const [k, v] of Object.entries(outcome.headers))
|
|
52
|
+
c.header(k, v);
|
|
53
|
+
}
|
|
54
|
+
return c.json({ __outcome: 'deny', message: outcome.message }, outcome.status);
|
|
55
|
+
}
|
|
56
|
+
if (outcome.__outcome === 'timeout') {
|
|
57
|
+
return c.json({ __outcome: 'timeout', timeoutMs: outcome.timeoutMs }, 504);
|
|
58
|
+
}
|
|
59
|
+
// render outcome should never reach the action RPC.
|
|
60
|
+
return c.json({
|
|
61
|
+
__outcome: 'error',
|
|
62
|
+
message: 'render outcome is page-scope only',
|
|
63
|
+
}, 500);
|
|
44
64
|
}
|
|
45
65
|
export function actionsHandler(glob, opts = {}) {
|
|
46
|
-
const { dev = false, onError } = opts;
|
|
66
|
+
const { dev = false, onError, appConfig, resolvePageUse, defaultTimeoutMs = 30_000, } = opts;
|
|
47
67
|
let cachedMapPromise = null;
|
|
48
68
|
return async (c) => {
|
|
49
69
|
const actionsMapPromise = dev
|
|
@@ -115,44 +135,93 @@ export function actionsHandler(glob, opts = {}) {
|
|
|
115
135
|
if (!entry) {
|
|
116
136
|
return c.json({ error: `Module '${module}' not found` }, 404);
|
|
117
137
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
catch (err) {
|
|
122
|
-
if (err instanceof ActionGuardError) {
|
|
123
|
-
return c.json({ error: err.message }, err.status);
|
|
124
|
-
}
|
|
125
|
-
if (err instanceof GuardRedirect) {
|
|
126
|
-
return c.json({ __redirect: err.location });
|
|
127
|
-
}
|
|
128
|
-
throw err;
|
|
129
|
-
}
|
|
130
|
-
const fn = entry.actions[action];
|
|
131
|
-
if (typeof fn !== 'function') {
|
|
138
|
+
const actionEntry = entry.actions[action];
|
|
139
|
+
if (!actionEntry) {
|
|
132
140
|
return c.json({ error: `Action '${action}' not found in module '${module}'` }, 404);
|
|
133
141
|
}
|
|
134
|
-
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;
|
|
135
150
|
const actionCtx = { c, signal };
|
|
151
|
+
// Chain ordering is outer -> inner: app-level middleware wraps every
|
|
152
|
+
// request, page-level wraps actions owned by that page, and per-action
|
|
153
|
+
// middleware (attached via defineAction(fn, { use })) wraps just this
|
|
154
|
+
// call. Outer middleware runs first on the way in and last on the way
|
|
155
|
+
// out, matching every middleware system users have seen (Hono, Express,
|
|
156
|
+
// Koa). The action's owning page is unambiguous from `module`, so the
|
|
157
|
+
// page-layer lookup keys by module rather than by location path.
|
|
158
|
+
const rootUse = appConfig?.use ?? [];
|
|
159
|
+
const pageUse = (await resolvePageUse?.(module)) ?? [];
|
|
160
|
+
const fullUse = [...rootUse, ...pageUse, ...actionUse];
|
|
161
|
+
const { middleware: allMiddleware, observers } = partitionUse(fullUse);
|
|
162
|
+
const serverMw = allMiddleware.filter((m) => m.runs === 'server');
|
|
163
|
+
const ctx = {
|
|
164
|
+
scope: 'action',
|
|
165
|
+
c,
|
|
166
|
+
signal,
|
|
167
|
+
module,
|
|
168
|
+
action,
|
|
169
|
+
payload,
|
|
170
|
+
};
|
|
136
171
|
let result;
|
|
137
172
|
try {
|
|
138
|
-
result = await runRequestScope(() =>
|
|
173
|
+
result = await runRequestScope(async () => {
|
|
174
|
+
const dispatch = await dispatchServer({
|
|
175
|
+
middleware: serverMw,
|
|
176
|
+
ctx,
|
|
177
|
+
inner: async () => {
|
|
178
|
+
const inner = await fn(actionCtx, payload);
|
|
179
|
+
// An action that does `return redirect('/login')` instead of
|
|
180
|
+
// `throw redirect('/login')` would otherwise ship the outcome
|
|
181
|
+
// JSON shape as a normal 200 response and bypass envelope
|
|
182
|
+
// translation. Normalize by re-throwing so the existing
|
|
183
|
+
// outcome-catching path translates it.
|
|
184
|
+
if (isOutcome(inner))
|
|
185
|
+
throw inner;
|
|
186
|
+
return inner;
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
if (dispatch.kind === 'outcome') {
|
|
190
|
+
throw dispatch.outcome;
|
|
191
|
+
}
|
|
192
|
+
return dispatch.value;
|
|
193
|
+
});
|
|
139
194
|
}
|
|
140
195
|
catch (err) {
|
|
141
|
-
if (err
|
|
142
|
-
return c
|
|
196
|
+
if (isOutcome(err)) {
|
|
197
|
+
return translateOutcomeForAction(c, err);
|
|
143
198
|
}
|
|
144
|
-
if (
|
|
145
|
-
|
|
199
|
+
if (timeoutSignal?.aborted &&
|
|
200
|
+
timeoutSignal.reason instanceof DOMException &&
|
|
201
|
+
timeoutSignal.reason.name === 'TimeoutError' &&
|
|
202
|
+
typeof resolvedTimeoutMs === 'number') {
|
|
203
|
+
return translateOutcomeForAction(c, timeoutOutcome(resolvedTimeoutMs));
|
|
146
204
|
}
|
|
147
205
|
onError?.(err, { module, action });
|
|
148
206
|
const message = dev && err instanceof Error ? err.message : 'Action failed';
|
|
149
207
|
return c.json({ error: message }, 500);
|
|
150
208
|
}
|
|
151
209
|
if (isAsyncGenerator(result)) {
|
|
152
|
-
return sseGeneratorResponse(c, result, {
|
|
210
|
+
return sseGeneratorResponse(c, result, {
|
|
211
|
+
emitResult: true,
|
|
212
|
+
observers,
|
|
213
|
+
observerCtx: ctx,
|
|
214
|
+
signal: timeoutSignal,
|
|
215
|
+
timeoutMs: typeof resolvedTimeoutMs === 'number' ? resolvedTimeoutMs : undefined,
|
|
216
|
+
});
|
|
153
217
|
}
|
|
154
218
|
if (result instanceof ReadableStream) {
|
|
155
|
-
return sseReadableStreamResponse(c, result
|
|
219
|
+
return sseReadableStreamResponse(c, result, {
|
|
220
|
+
observers,
|
|
221
|
+
observerCtx: ctx,
|
|
222
|
+
signal: timeoutSignal,
|
|
223
|
+
timeoutMs: typeof resolvedTimeoutMs === 'number' ? resolvedTimeoutMs : undefined,
|
|
224
|
+
});
|
|
156
225
|
}
|
|
157
226
|
return c.json(result);
|
|
158
227
|
};
|
package/dist/server/context.js
CHANGED
package/dist/server/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { HonoContext, useHonoContext } from './context.js';
|
|
2
2
|
export { renderPage } from './render.js';
|
|
3
|
-
export { actionsHandler } from './actions-handler.js';
|
|
4
3
|
export { loadersHandler } from './loaders-handler.js';
|
|
5
|
-
export { routeServerModules } from './route-server-modules.js';
|
|
4
|
+
export { routeServerModules, makePageUseResolvers, } from './route-server-modules.js';
|
|
5
|
+
export { makePageActionResolvers, type ActionEntry, } from './page-action-resolvers.js';
|
|
6
|
+
export { pageActionHandler, type PageActionHandlerOptions, } from './page-action-handler.js';
|
package/dist/server/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { HonoContext, useHonoContext } from './context.js';
|
|
2
2
|
export { renderPage } from './render.js';
|
|
3
|
-
export { actionsHandler } from './actions-handler.js';
|
|
4
3
|
export { loadersHandler } from './loaders-handler.js';
|
|
5
|
-
export { routeServerModules } from './route-server-modules.js';
|
|
4
|
+
export { routeServerModules, makePageUseResolvers, } from './route-server-modules.js';
|
|
5
|
+
export { makePageActionResolvers, } from './page-action-resolvers.js';
|
|
6
|
+
export { pageActionHandler, } from './page-action-handler.js';
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { MiddlewareHandler } from 'hono';
|
|
2
|
+
import { type AppConfig } from '../iso/index';
|
|
2
3
|
type GlobModule = {
|
|
3
4
|
default?: unknown;
|
|
4
5
|
__moduleKey?: unknown;
|
|
6
|
+
serverLoaders?: unknown;
|
|
5
7
|
[key: string]: unknown;
|
|
6
8
|
};
|
|
7
9
|
type LazyGlob = Record<string, () => Promise<unknown>>;
|
|
@@ -25,6 +27,28 @@ export interface LoadersHandlerOptions {
|
|
|
25
27
|
module: string;
|
|
26
28
|
loader: string;
|
|
27
29
|
}) => void;
|
|
30
|
+
/**
|
|
31
|
+
* Root layer of the middleware chain. The framework's generated server
|
|
32
|
+
* entry threads the user's `defineApp({ use })` result here. Each loader
|
|
33
|
+
* request composes the chain as
|
|
34
|
+
* `[...appConfig.use, ...resolvePageUse(path), ...loader.use]`.
|
|
35
|
+
*/
|
|
36
|
+
appConfig?: AppConfig;
|
|
37
|
+
/**
|
|
38
|
+
* Per-page layer lookup keyed by the matched route's location path.
|
|
39
|
+
* Returns the `use` array declared on the matching page's `.server.*`
|
|
40
|
+
* module (as `export const pageUse = [...]`). The lookup may be sync
|
|
41
|
+
* (an in-memory map) or async (loaded lazily on first request). The
|
|
42
|
+
* handler awaits the result either way. Default returns an empty array.
|
|
43
|
+
*/
|
|
44
|
+
resolvePageUse?: (path: string) => ReadonlyArray<unknown> | Promise<ReadonlyArray<unknown>>;
|
|
45
|
+
/**
|
|
46
|
+
* Default loader timeout in milliseconds applied when a loader does not
|
|
47
|
+
* declare its own `timeoutMs`. Defaults to 30000 (30 seconds). Pass
|
|
48
|
+
* `false` to disable the default (only loader-level `timeoutMs` enforces
|
|
49
|
+
* a deadline).
|
|
50
|
+
*/
|
|
51
|
+
defaultTimeoutMs?: number | false;
|
|
28
52
|
}
|
|
29
53
|
export declare function loadersHandler(glob: LazyGlob | EagerGlob, opts?: LoadersHandlerOptions): MiddlewareHandler;
|
|
30
54
|
export {};
|