hono-preact 0.1.0 → 0.2.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.d.ts +10 -14
- package/dist/iso/action.js +57 -21
- package/dist/iso/define-app.d.ts +7 -0
- package/dist/iso/define-app.js +3 -0
- package/dist/iso/define-loader.d.ts +19 -0
- package/dist/iso/define-loader.js +4 -0
- 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/index.d.ts +10 -5
- package/dist/iso/index.js +5 -3
- package/dist/iso/internal/contexts.d.ts +0 -2
- package/dist/iso/internal/contexts.js +0 -1
- package/dist/iso/internal/loader-fetch.js +37 -7
- package/dist/iso/internal/loader-runner.js +105 -8
- 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 +1 -0
- package/dist/iso/internal/route-boundary.js +16 -0
- 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 +5 -4
- package/dist/iso/internal.js +8 -6
- package/dist/iso/outcomes.d.ts +38 -0
- package/dist/iso/outcomes.js +56 -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/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 +20 -6
- package/dist/server/actions-handler.js +83 -47
- package/dist/server/context.js +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/loaders-handler.d.ts +16 -0
- package/dist/server/loaders-handler.js +94 -17
- package/dist/server/render.d.ts +2 -0
- package/dist/server/render.js +104 -33
- package/dist/server/route-server-modules.d.ts +42 -1
- package/dist/server/route-server-modules.js +184 -0
- package/dist/server/sse.d.ts +24 -1
- package/dist/server/sse.js +56 -4
- 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 +161 -78
- 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 +32 -4
|
@@ -0,0 +1,56 @@
|
|
|
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) {
|
|
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
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
__outcome: 'deny',
|
|
35
|
+
status: a,
|
|
36
|
+
message: b ?? `Request denied (${a})`,
|
|
37
|
+
headers: undefined,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function isOutcome(value) {
|
|
41
|
+
if (typeof value !== 'object' || value === null)
|
|
42
|
+
return false;
|
|
43
|
+
if (!('__outcome' in value))
|
|
44
|
+
return false;
|
|
45
|
+
const tag = value.__outcome;
|
|
46
|
+
return tag === 'redirect' || tag === 'deny' || tag === 'render';
|
|
47
|
+
}
|
|
48
|
+
export function isRedirect(value) {
|
|
49
|
+
return isOutcome(value) && value.__outcome === 'redirect';
|
|
50
|
+
}
|
|
51
|
+
export function isDeny(value) {
|
|
52
|
+
return isOutcome(value) && value.__outcome === 'deny';
|
|
53
|
+
}
|
|
54
|
+
export function isRender(value) {
|
|
55
|
+
return isOutcome(value) && value.__outcome === 'render';
|
|
56
|
+
}
|
|
@@ -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
|
}
|
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,30 @@ 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>>;
|
|
31
45
|
}
|
|
32
46
|
export declare function actionsHandler(glob: LazyGlob | EagerGlob, opts?: ActionsHandlerOptions): MiddlewareHandler;
|
|
33
47
|
export {};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { runRequestScope } from '../iso/internal
|
|
1
|
+
import { isOutcome, } 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 = {};
|
|
@@ -11,39 +11,42 @@ async function buildActionsMap(glob) {
|
|
|
11
11
|
if (typeof key === 'string' && mod.serverActions) {
|
|
12
12
|
result[key] = {
|
|
13
13
|
actions: mod.serverActions,
|
|
14
|
-
guards: mod.actionGuards ?? [],
|
|
15
14
|
};
|
|
16
15
|
}
|
|
17
16
|
}
|
|
18
17
|
return result;
|
|
19
18
|
}
|
|
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.`);
|
|
19
|
+
function translateOutcomeForAction(c, outcome) {
|
|
20
|
+
if (outcome.__outcome === 'redirect') {
|
|
21
|
+
// Headers from the outcome ride the HTTP response via `c.header()`. They
|
|
22
|
+
// are deliberately NOT embedded in the JSON envelope: the client only
|
|
23
|
+
// reads `to` and calls `window.location.assign(to)`; any embedded
|
|
24
|
+
// headers would be dead bytes the client never inspects.
|
|
25
|
+
if (outcome.headers) {
|
|
26
|
+
for (const [k, v] of Object.entries(outcome.headers))
|
|
27
|
+
c.header(k, v);
|
|
41
28
|
}
|
|
42
|
-
|
|
43
|
-
|
|
29
|
+
return c.json({
|
|
30
|
+
__outcome: 'redirect',
|
|
31
|
+
to: outcome.to,
|
|
32
|
+
status: outcome.status,
|
|
33
|
+
}, 200);
|
|
34
|
+
}
|
|
35
|
+
if (outcome.__outcome === 'deny') {
|
|
36
|
+
if (outcome.headers) {
|
|
37
|
+
for (const [k, v] of Object.entries(outcome.headers))
|
|
38
|
+
c.header(k, v);
|
|
39
|
+
}
|
|
40
|
+
return c.json({ __outcome: 'deny', message: outcome.message }, outcome.status);
|
|
41
|
+
}
|
|
42
|
+
// render outcome should never reach the action RPC.
|
|
43
|
+
return c.json({
|
|
44
|
+
__outcome: 'error',
|
|
45
|
+
message: 'render outcome is page-scope only',
|
|
46
|
+
}, 500);
|
|
44
47
|
}
|
|
45
48
|
export function actionsHandler(glob, opts = {}) {
|
|
46
|
-
const { dev = false, onError } = opts;
|
|
49
|
+
const { dev = false, onError, appConfig, resolvePageUse } = opts;
|
|
47
50
|
let cachedMapPromise = null;
|
|
48
51
|
return async (c) => {
|
|
49
52
|
const actionsMapPromise = dev
|
|
@@ -115,44 +118,77 @@ export function actionsHandler(glob, opts = {}) {
|
|
|
115
118
|
if (!entry) {
|
|
116
119
|
return c.json({ error: `Module '${module}' not found` }, 404);
|
|
117
120
|
}
|
|
118
|
-
try {
|
|
119
|
-
await runActionGuards(entry.guards, { c, module, action, payload });
|
|
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
121
|
const fn = entry.actions[action];
|
|
131
122
|
if (typeof fn !== 'function') {
|
|
132
123
|
return c.json({ error: `Action '${action}' not found in module '${module}'` }, 404);
|
|
133
124
|
}
|
|
134
125
|
const signal = c.req.raw.signal;
|
|
135
126
|
const actionCtx = { c, signal };
|
|
127
|
+
// Chain ordering is outer -> inner: app-level middleware wraps every
|
|
128
|
+
// request, page-level wraps actions owned by that page, and per-action
|
|
129
|
+
// middleware (attached via defineAction(fn, { use })) wraps just this
|
|
130
|
+
// call. Outer middleware runs first on the way in and last on the way
|
|
131
|
+
// out, matching every middleware system users have seen (Hono, Express,
|
|
132
|
+
// Koa). The action's owning page is unambiguous from `module`, so the
|
|
133
|
+
// page-layer lookup keys by module rather than by location path.
|
|
134
|
+
const rootUse = appConfig?.use ?? [];
|
|
135
|
+
const pageUse = (await resolvePageUse?.(module)) ?? [];
|
|
136
|
+
const actionUse = fn.use ?? [];
|
|
137
|
+
const fullUse = [...rootUse, ...pageUse, ...actionUse];
|
|
138
|
+
const { middleware: allMiddleware, observers } = partitionUse(fullUse);
|
|
139
|
+
const serverMw = allMiddleware.filter((m) => m.runs === 'server');
|
|
140
|
+
const ctx = {
|
|
141
|
+
scope: 'action',
|
|
142
|
+
c,
|
|
143
|
+
signal,
|
|
144
|
+
module,
|
|
145
|
+
action,
|
|
146
|
+
payload,
|
|
147
|
+
};
|
|
136
148
|
let result;
|
|
137
149
|
try {
|
|
138
|
-
result = await runRequestScope(() =>
|
|
150
|
+
result = await runRequestScope(async () => {
|
|
151
|
+
const dispatch = await dispatchServer({
|
|
152
|
+
middleware: serverMw,
|
|
153
|
+
ctx,
|
|
154
|
+
inner: async () => {
|
|
155
|
+
const inner = await fn(actionCtx, payload);
|
|
156
|
+
// An action that does `return redirect('/login')` instead of
|
|
157
|
+
// `throw redirect('/login')` would otherwise ship the outcome
|
|
158
|
+
// JSON shape as a normal 200 response and bypass envelope
|
|
159
|
+
// translation. Normalize by re-throwing so the existing
|
|
160
|
+
// outcome-catching path translates it.
|
|
161
|
+
if (isOutcome(inner))
|
|
162
|
+
throw inner;
|
|
163
|
+
return inner;
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
if (dispatch.kind === 'outcome') {
|
|
167
|
+
throw dispatch.outcome;
|
|
168
|
+
}
|
|
169
|
+
return dispatch.value;
|
|
170
|
+
});
|
|
139
171
|
}
|
|
140
172
|
catch (err) {
|
|
141
|
-
if (err
|
|
142
|
-
return c
|
|
143
|
-
}
|
|
144
|
-
if (err instanceof GuardRedirect) {
|
|
145
|
-
return c.json({ __redirect: err.location });
|
|
173
|
+
if (isOutcome(err)) {
|
|
174
|
+
return translateOutcomeForAction(c, err);
|
|
146
175
|
}
|
|
147
176
|
onError?.(err, { module, action });
|
|
148
177
|
const message = dev && err instanceof Error ? err.message : 'Action failed';
|
|
149
178
|
return c.json({ error: message }, 500);
|
|
150
179
|
}
|
|
151
180
|
if (isAsyncGenerator(result)) {
|
|
152
|
-
return sseGeneratorResponse(c, result, {
|
|
181
|
+
return sseGeneratorResponse(c, result, {
|
|
182
|
+
emitResult: true,
|
|
183
|
+
observers,
|
|
184
|
+
observerCtx: ctx,
|
|
185
|
+
});
|
|
153
186
|
}
|
|
154
187
|
if (result instanceof ReadableStream) {
|
|
155
|
-
return sseReadableStreamResponse(c, result
|
|
188
|
+
return sseReadableStreamResponse(c, result, {
|
|
189
|
+
observers,
|
|
190
|
+
observerCtx: ctx,
|
|
191
|
+
});
|
|
156
192
|
}
|
|
157
193
|
return c.json(result);
|
|
158
194
|
};
|
package/dist/server/context.js
CHANGED
package/dist/server/index.d.ts
CHANGED
|
@@ -2,4 +2,4 @@ export { HonoContext, useHonoContext } from './context.js';
|
|
|
2
2
|
export { renderPage } from './render.js';
|
|
3
3
|
export { actionsHandler } from './actions-handler.js';
|
|
4
4
|
export { loadersHandler } from './loaders-handler.js';
|
|
5
|
-
export { routeServerModules } from './route-server-modules.js';
|
|
5
|
+
export { routeServerModules, makePageUseResolvers, } from './route-server-modules.js';
|
package/dist/server/index.js
CHANGED
|
@@ -2,4 +2,4 @@ export { HonoContext, useHonoContext } from './context.js';
|
|
|
2
2
|
export { renderPage } from './render.js';
|
|
3
3
|
export { actionsHandler } from './actions-handler.js';
|
|
4
4
|
export { loadersHandler } from './loaders-handler.js';
|
|
5
|
-
export { routeServerModules } from './route-server-modules.js';
|
|
5
|
+
export { routeServerModules, makePageUseResolvers, } from './route-server-modules.js';
|
|
@@ -1,4 +1,5 @@
|
|
|
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;
|
|
@@ -25,6 +26,21 @@ export interface LoadersHandlerOptions {
|
|
|
25
26
|
module: string;
|
|
26
27
|
loader: string;
|
|
27
28
|
}) => void;
|
|
29
|
+
/**
|
|
30
|
+
* Root layer of the middleware chain. The framework's generated server
|
|
31
|
+
* entry threads the user's `defineApp({ use })` result here. Each loader
|
|
32
|
+
* request composes the chain as
|
|
33
|
+
* `[...appConfig.use, ...resolvePageUse(path), ...loader.use]`.
|
|
34
|
+
*/
|
|
35
|
+
appConfig?: AppConfig;
|
|
36
|
+
/**
|
|
37
|
+
* Per-page layer lookup keyed by the matched route's location path.
|
|
38
|
+
* Returns the `use` array declared on the matching page's `.server.*`
|
|
39
|
+
* module (as `export const pageUse = [...]`). The lookup may be sync
|
|
40
|
+
* (an in-memory map) or async (loaded lazily on first request). The
|
|
41
|
+
* handler awaits the result either way. Default returns an empty array.
|
|
42
|
+
*/
|
|
43
|
+
resolvePageUse?: (path: string) => ReadonlyArray<unknown> | Promise<ReadonlyArray<unknown>>;
|
|
28
44
|
}
|
|
29
45
|
export declare function loadersHandler(glob: LazyGlob | EagerGlob, opts?: LoadersHandlerOptions): MiddlewareHandler;
|
|
30
46
|
export {};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { runRequestScope } from '../iso/internal
|
|
1
|
+
import { isOutcome, } 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 buildLoadersMap(glob) {
|
|
5
5
|
const result = {};
|
|
@@ -16,12 +16,14 @@ async function buildLoadersMap(glob) {
|
|
|
16
16
|
// Two accepted shapes:
|
|
17
17
|
// 1. a raw loader function `(ctx) => ...` (used by unit-test fixtures)
|
|
18
18
|
// 2. a `LoaderRef` returned by `defineLoader(fn)`, whose `.fn`
|
|
19
|
-
// property carries the original loader
|
|
19
|
+
// property carries the original loader and `.use` carries any
|
|
20
|
+
// attached middleware/observers.
|
|
20
21
|
if (typeof val === 'function') {
|
|
21
|
-
result[`${moduleKey}::${name}`] = val;
|
|
22
|
+
result[`${moduleKey}::${name}`] = { fn: val, use: [] };
|
|
22
23
|
}
|
|
23
24
|
else if (val && typeof val.fn === 'function') {
|
|
24
|
-
|
|
25
|
+
const ref = val;
|
|
26
|
+
result[`${moduleKey}::${name}`] = { fn: ref.fn, use: ref.use ?? [] };
|
|
25
27
|
}
|
|
26
28
|
}
|
|
27
29
|
}
|
|
@@ -44,8 +46,37 @@ function validateLocation(loc) {
|
|
|
44
46
|
searchParams: o.searchParams,
|
|
45
47
|
};
|
|
46
48
|
}
|
|
49
|
+
function translateOutcomeForLoader(c, outcome) {
|
|
50
|
+
if (outcome.__outcome === 'redirect') {
|
|
51
|
+
// Headers from the outcome ride the HTTP response via `c.header()`. They
|
|
52
|
+
// are deliberately NOT embedded in the JSON envelope: the client only
|
|
53
|
+
// reads `to` and calls `window.location.assign(to)`; any embedded
|
|
54
|
+
// headers would be dead bytes the client never inspects.
|
|
55
|
+
if (outcome.headers) {
|
|
56
|
+
for (const [k, v] of Object.entries(outcome.headers))
|
|
57
|
+
c.header(k, v);
|
|
58
|
+
}
|
|
59
|
+
return c.json({
|
|
60
|
+
__outcome: 'redirect',
|
|
61
|
+
to: outcome.to,
|
|
62
|
+
status: outcome.status,
|
|
63
|
+
}, 200);
|
|
64
|
+
}
|
|
65
|
+
if (outcome.__outcome === 'deny') {
|
|
66
|
+
if (outcome.headers) {
|
|
67
|
+
for (const [k, v] of Object.entries(outcome.headers))
|
|
68
|
+
c.header(k, v);
|
|
69
|
+
}
|
|
70
|
+
return c.json({ __outcome: 'deny', message: outcome.message }, outcome.status);
|
|
71
|
+
}
|
|
72
|
+
// render outcome should never reach the loader RPC; this is defense in depth.
|
|
73
|
+
return c.json({
|
|
74
|
+
__outcome: 'error',
|
|
75
|
+
message: 'render outcome is page-scope only',
|
|
76
|
+
}, 500);
|
|
77
|
+
}
|
|
47
78
|
export function loadersHandler(glob, opts = {}) {
|
|
48
|
-
const { dev = false, onError } = opts;
|
|
79
|
+
const { dev = false, onError, appConfig, resolvePageUse } = opts;
|
|
49
80
|
let cachedMapPromise = null;
|
|
50
81
|
return async (c) => {
|
|
51
82
|
const loadersMapPromise = dev
|
|
@@ -82,28 +113,74 @@ export function loadersHandler(glob, opts = {}) {
|
|
|
82
113
|
error: 'Request body must include object field: location with shape { path: string, pathParams: object, searchParams: object }',
|
|
83
114
|
}, 400);
|
|
84
115
|
}
|
|
85
|
-
const
|
|
86
|
-
if (!
|
|
116
|
+
const entry = loadersMap[`${module}::${loaderName}`];
|
|
117
|
+
if (!entry) {
|
|
87
118
|
return c.json({ error: `Loader '${module}::${loaderName}' not found` }, 404);
|
|
88
119
|
}
|
|
89
120
|
const signal = c.req.raw.signal;
|
|
121
|
+
// Chain ordering is outer -> inner: app-level middleware wraps every
|
|
122
|
+
// request, page-level wraps loaders owned by that page, and per-loader
|
|
123
|
+
// middleware wraps just this call. Outer middleware runs first on the
|
|
124
|
+
// way in and last on the way out, matching every middleware system
|
|
125
|
+
// users have seen (Hono, Express, Koa).
|
|
126
|
+
const rootUse = appConfig?.use ?? [];
|
|
127
|
+
const pageUse = (await resolvePageUse?.(validatedLocation.path)) ?? [];
|
|
128
|
+
const fullUse = [...rootUse, ...pageUse, ...entry.use];
|
|
129
|
+
const { middleware: allMiddleware, observers } = partitionUse(fullUse);
|
|
130
|
+
const serverMw = allMiddleware.filter((m) => m.runs === 'server');
|
|
131
|
+
const ctx = {
|
|
132
|
+
scope: 'loader',
|
|
133
|
+
c,
|
|
134
|
+
signal,
|
|
135
|
+
location: validatedLocation,
|
|
136
|
+
module,
|
|
137
|
+
loader: loaderName,
|
|
138
|
+
};
|
|
90
139
|
try {
|
|
91
|
-
const result = await runRequestScope(() =>
|
|
140
|
+
const result = await runRequestScope(async () => {
|
|
141
|
+
const dispatch = await dispatchServer({
|
|
142
|
+
middleware: serverMw,
|
|
143
|
+
ctx,
|
|
144
|
+
inner: async () => {
|
|
145
|
+
const inner = await entry.fn({
|
|
146
|
+
c,
|
|
147
|
+
location: validatedLocation,
|
|
148
|
+
signal,
|
|
149
|
+
});
|
|
150
|
+
// A loader that does `return redirect('/login')` instead of
|
|
151
|
+
// `throw redirect('/login')` would otherwise ship the outcome
|
|
152
|
+
// JSON shape as a normal 200 response and bypass envelope
|
|
153
|
+
// translation. Normalize by re-throwing so the existing
|
|
154
|
+
// outcome-catching path translates it.
|
|
155
|
+
if (isOutcome(inner))
|
|
156
|
+
throw inner;
|
|
157
|
+
return inner;
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
if (dispatch.kind === 'outcome') {
|
|
161
|
+
// Throw to unify with non-outcome error translation below.
|
|
162
|
+
throw dispatch.outcome;
|
|
163
|
+
}
|
|
164
|
+
return dispatch.value;
|
|
165
|
+
}, { honoContext: c });
|
|
92
166
|
if (isAsyncGenerator(result)) {
|
|
93
|
-
return sseGeneratorResponse(c, result, {
|
|
167
|
+
return sseGeneratorResponse(c, result, {
|
|
168
|
+
emitResult: false,
|
|
169
|
+
observers,
|
|
170
|
+
observerCtx: ctx,
|
|
171
|
+
});
|
|
94
172
|
}
|
|
95
173
|
if (result instanceof ReadableStream) {
|
|
96
|
-
return sseReadableStreamResponse(c, result
|
|
174
|
+
return sseReadableStreamResponse(c, result, {
|
|
175
|
+
observers,
|
|
176
|
+
observerCtx: ctx,
|
|
177
|
+
});
|
|
97
178
|
}
|
|
98
179
|
return c.json(result);
|
|
99
180
|
}
|
|
100
181
|
catch (err) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
// recognizes the `__redirect` envelope and navigates the browser
|
|
104
|
-
// rather than surfacing this as a thrown error in user code.
|
|
105
|
-
if (err instanceof GuardRedirect) {
|
|
106
|
-
return c.json({ __redirect: err.location });
|
|
182
|
+
if (isOutcome(err)) {
|
|
183
|
+
return translateOutcomeForLoader(c, err);
|
|
107
184
|
}
|
|
108
185
|
onError?.(err, { module, loader: loaderName });
|
|
109
186
|
// In production we never leak the loader's error message: it may
|
package/dist/server/render.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Context } from 'hono';
|
|
2
2
|
import type { VNode } from 'preact';
|
|
3
|
+
import { type AppConfig } from '../iso/index';
|
|
3
4
|
export declare function renderPage(c: Context, node: VNode, options?: {
|
|
4
5
|
defaultTitle?: string;
|
|
6
|
+
appConfig?: AppConfig;
|
|
5
7
|
}): Promise<Response>;
|