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
|
@@ -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 buildLoadersMap(glob) {
|
|
5
5
|
const result = {};
|
|
@@ -16,12 +16,18 @@ 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}`] = {
|
|
27
|
+
fn: ref.fn,
|
|
28
|
+
use: ref.use ?? [],
|
|
29
|
+
timeoutMs: ref.timeoutMs,
|
|
30
|
+
};
|
|
25
31
|
}
|
|
26
32
|
}
|
|
27
33
|
}
|
|
@@ -44,8 +50,40 @@ function validateLocation(loc) {
|
|
|
44
50
|
searchParams: o.searchParams,
|
|
45
51
|
};
|
|
46
52
|
}
|
|
53
|
+
function translateOutcomeForLoader(c, outcome) {
|
|
54
|
+
if (outcome.__outcome === 'redirect') {
|
|
55
|
+
// Headers from the outcome ride the HTTP response via `c.header()`. They
|
|
56
|
+
// are deliberately NOT embedded in the JSON envelope: the client only
|
|
57
|
+
// reads `to` and calls `window.location.assign(to)`; any embedded
|
|
58
|
+
// headers would be dead bytes the client never inspects.
|
|
59
|
+
if (outcome.headers) {
|
|
60
|
+
for (const [k, v] of Object.entries(outcome.headers))
|
|
61
|
+
c.header(k, v);
|
|
62
|
+
}
|
|
63
|
+
return c.json({
|
|
64
|
+
__outcome: 'redirect',
|
|
65
|
+
to: outcome.to,
|
|
66
|
+
status: outcome.status,
|
|
67
|
+
}, 200);
|
|
68
|
+
}
|
|
69
|
+
if (outcome.__outcome === 'deny') {
|
|
70
|
+
if (outcome.headers) {
|
|
71
|
+
for (const [k, v] of Object.entries(outcome.headers))
|
|
72
|
+
c.header(k, v);
|
|
73
|
+
}
|
|
74
|
+
return c.json({ __outcome: 'deny', message: outcome.message }, outcome.status);
|
|
75
|
+
}
|
|
76
|
+
if (outcome.__outcome === 'timeout') {
|
|
77
|
+
return c.json({ __outcome: 'timeout', timeoutMs: outcome.timeoutMs }, 504);
|
|
78
|
+
}
|
|
79
|
+
// render outcome should never reach the loader RPC; this is defense in depth.
|
|
80
|
+
return c.json({
|
|
81
|
+
__outcome: 'error',
|
|
82
|
+
message: 'render outcome is page-scope only',
|
|
83
|
+
}, 500);
|
|
84
|
+
}
|
|
47
85
|
export function loadersHandler(glob, opts = {}) {
|
|
48
|
-
const { dev = false, onError } = opts;
|
|
86
|
+
const { dev = false, onError, appConfig, resolvePageUse, defaultTimeoutMs = 30_000, } = opts;
|
|
49
87
|
let cachedMapPromise = null;
|
|
50
88
|
return async (c) => {
|
|
51
89
|
const loadersMapPromise = dev
|
|
@@ -82,28 +120,100 @@ export function loadersHandler(glob, opts = {}) {
|
|
|
82
120
|
error: 'Request body must include object field: location with shape { path: string, pathParams: object, searchParams: object }',
|
|
83
121
|
}, 400);
|
|
84
122
|
}
|
|
85
|
-
const
|
|
86
|
-
if (!
|
|
123
|
+
const entry = loadersMap[`${module}::${loaderName}`];
|
|
124
|
+
if (!entry) {
|
|
87
125
|
return c.json({ error: `Loader '${module}::${loaderName}' not found` }, 404);
|
|
88
126
|
}
|
|
89
|
-
const
|
|
127
|
+
const resolvedTimeoutMs = entry.timeoutMs !== undefined ? entry.timeoutMs : defaultTimeoutMs;
|
|
128
|
+
const timeoutSignal = resolvedTimeoutMs === false
|
|
129
|
+
? undefined
|
|
130
|
+
: AbortSignal.timeout(resolvedTimeoutMs);
|
|
131
|
+
const signal = timeoutSignal
|
|
132
|
+
? AbortSignal.any([c.req.raw.signal, timeoutSignal])
|
|
133
|
+
: c.req.raw.signal;
|
|
134
|
+
// Chain ordering is outer -> inner: app-level middleware wraps every
|
|
135
|
+
// request, page-level wraps loaders owned by that page, and per-loader
|
|
136
|
+
// middleware wraps just this call. Outer middleware runs first on the
|
|
137
|
+
// way in and last on the way out, matching every middleware system
|
|
138
|
+
// users have seen (Hono, Express, Koa).
|
|
139
|
+
const rootUse = appConfig?.use ?? [];
|
|
140
|
+
const pageUse = (await resolvePageUse?.(validatedLocation.path)) ?? [];
|
|
141
|
+
const fullUse = [...rootUse, ...pageUse, ...entry.use];
|
|
142
|
+
const { middleware: allMiddleware, observers } = partitionUse(fullUse);
|
|
143
|
+
const serverMw = allMiddleware.filter((m) => m.runs === 'server');
|
|
144
|
+
const ctx = {
|
|
145
|
+
scope: 'loader',
|
|
146
|
+
c,
|
|
147
|
+
signal,
|
|
148
|
+
location: validatedLocation,
|
|
149
|
+
module,
|
|
150
|
+
loader: loaderName,
|
|
151
|
+
};
|
|
90
152
|
try {
|
|
91
|
-
const result = await runRequestScope(() =>
|
|
153
|
+
const result = await runRequestScope(async () => {
|
|
154
|
+
const dispatch = await dispatchServer({
|
|
155
|
+
middleware: serverMw,
|
|
156
|
+
ctx,
|
|
157
|
+
inner: async () => {
|
|
158
|
+
const inner = await entry.fn({
|
|
159
|
+
c,
|
|
160
|
+
location: validatedLocation,
|
|
161
|
+
signal,
|
|
162
|
+
});
|
|
163
|
+
// A loader that does `return redirect('/login')` instead of
|
|
164
|
+
// `throw redirect('/login')` would otherwise ship the outcome
|
|
165
|
+
// JSON shape as a normal 200 response and bypass envelope
|
|
166
|
+
// translation. Normalize by re-throwing so the existing
|
|
167
|
+
// outcome-catching path translates it.
|
|
168
|
+
if (isOutcome(inner))
|
|
169
|
+
throw inner;
|
|
170
|
+
return inner;
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
if (dispatch.kind === 'outcome') {
|
|
174
|
+
// Throw to unify with non-outcome error translation below.
|
|
175
|
+
throw dispatch.outcome;
|
|
176
|
+
}
|
|
177
|
+
return dispatch.value;
|
|
178
|
+
}, { honoContext: c });
|
|
92
179
|
if (isAsyncGenerator(result)) {
|
|
93
|
-
return sseGeneratorResponse(c, result, {
|
|
180
|
+
return sseGeneratorResponse(c, result, {
|
|
181
|
+
emitResult: false,
|
|
182
|
+
observers,
|
|
183
|
+
observerCtx: ctx,
|
|
184
|
+
signal: timeoutSignal,
|
|
185
|
+
timeoutMs: typeof resolvedTimeoutMs === 'number'
|
|
186
|
+
? resolvedTimeoutMs
|
|
187
|
+
: undefined,
|
|
188
|
+
});
|
|
94
189
|
}
|
|
95
190
|
if (result instanceof ReadableStream) {
|
|
96
|
-
return sseReadableStreamResponse(c, result
|
|
191
|
+
return sseReadableStreamResponse(c, result, {
|
|
192
|
+
observers,
|
|
193
|
+
observerCtx: ctx,
|
|
194
|
+
signal: timeoutSignal,
|
|
195
|
+
timeoutMs: typeof resolvedTimeoutMs === 'number'
|
|
196
|
+
? resolvedTimeoutMs
|
|
197
|
+
: undefined,
|
|
198
|
+
});
|
|
97
199
|
}
|
|
98
200
|
return c.json(result);
|
|
99
201
|
}
|
|
100
202
|
catch (err) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
203
|
+
if (isOutcome(err)) {
|
|
204
|
+
return translateOutcomeForLoader(c, err);
|
|
205
|
+
}
|
|
206
|
+
// Distinguish a deadline-driven abort from any other thrown error.
|
|
207
|
+
// AbortSignal.timeout sets signal.reason to a DOMException named
|
|
208
|
+
// 'TimeoutError'; AbortSignal.any propagates that reason. Re-check the
|
|
209
|
+
// composed signal because the loader's own throw may be the
|
|
210
|
+
// *consequence* of the signal aborting (e.g. fetch rejecting with the
|
|
211
|
+
// abort reason).
|
|
212
|
+
if (timeoutSignal?.aborted &&
|
|
213
|
+
timeoutSignal.reason instanceof DOMException &&
|
|
214
|
+
timeoutSignal.reason.name === 'TimeoutError' &&
|
|
215
|
+
typeof resolvedTimeoutMs === 'number') {
|
|
216
|
+
return translateOutcomeForLoader(c, timeoutOutcome(resolvedTimeoutMs));
|
|
107
217
|
}
|
|
108
218
|
onError?.(err, { module, loader: loaderName });
|
|
109
219
|
// In production we never leak the loader's error message: it may
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Context, MiddlewareHandler } from 'hono';
|
|
2
|
+
import { type AppConfig } from '../iso/index';
|
|
3
|
+
import type { ActionEntry } from './page-action-resolvers.js';
|
|
4
|
+
import type { VNode } from 'preact';
|
|
5
|
+
export interface PageActionHandlerOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Resolves the action map for the page at the given URL path. Returns a
|
|
8
|
+
* Map of action name to ActionEntry, merging actions from the page and
|
|
9
|
+
* all ancestor layouts.
|
|
10
|
+
*/
|
|
11
|
+
resolverByPath: (path: string) => Promise<Map<string, ActionEntry>>;
|
|
12
|
+
/**
|
|
13
|
+
* Optional per-page middleware resolver, keyed by URL path. The handler
|
|
14
|
+
* composes the chain as [appConfig.use, resolvePageUseByPath(path), action.use].
|
|
15
|
+
* Pass `pageUseResolvers.byPath` from makePageUseResolvers (same resolver
|
|
16
|
+
* loadersHandler uses). Defaults to returning empty so the option is
|
|
17
|
+
* additive: handlers wired without it lose page-level middleware (matches
|
|
18
|
+
* the previous behavior of this handler, which dropped them entirely).
|
|
19
|
+
*/
|
|
20
|
+
resolvePageUseByPath?: (path: string) => ReadonlyArray<unknown> | Promise<ReadonlyArray<unknown>>;
|
|
21
|
+
/**
|
|
22
|
+
* Re-renders the page after a deny or error outcome. The handler calls
|
|
23
|
+
* this inside a fresh runRequestScope after injecting the action result
|
|
24
|
+
* slot so the page tree can read it via useActionResult().
|
|
25
|
+
*/
|
|
26
|
+
renderPage: (c: Context, node: VNode, opts: {
|
|
27
|
+
appConfig?: AppConfig;
|
|
28
|
+
}) => Promise<Response>;
|
|
29
|
+
/**
|
|
30
|
+
* Resolves the VNode to render for the given URL path. Returns null when
|
|
31
|
+
* no page is registered; the handler passes null through to renderPage,
|
|
32
|
+
* which is expected to produce a graceful error response in that case.
|
|
33
|
+
*/
|
|
34
|
+
resolvePageNode: (path: string) => VNode | null;
|
|
35
|
+
/** App-level middleware/observer array from defineApp({ use }). */
|
|
36
|
+
appConfig?: AppConfig;
|
|
37
|
+
/**
|
|
38
|
+
* Default timeout in milliseconds for actions that don't declare their
|
|
39
|
+
* own timeoutMs. Defaults to 30000. Pass false to disable the default.
|
|
40
|
+
*/
|
|
41
|
+
defaultTimeoutMs?: number | false;
|
|
42
|
+
/**
|
|
43
|
+
* Called for every unexpected error an action throws. Use it to hook into
|
|
44
|
+
* your observability stack. The handler still responds with a sanitized
|
|
45
|
+
* 500; this is a side channel only.
|
|
46
|
+
*/
|
|
47
|
+
onError?: (err: unknown, ctx: {
|
|
48
|
+
module: string;
|
|
49
|
+
action: string;
|
|
50
|
+
}) => void;
|
|
51
|
+
}
|
|
52
|
+
type Accept = 'html' | 'json' | 'event-stream';
|
|
53
|
+
/**
|
|
54
|
+
* Parse the Accept header and return the highest-priority recognized type.
|
|
55
|
+
* Follows RFC 9110 quality values (;q=0.9 etc) so that
|
|
56
|
+
* "application/json, text/event-stream;q=0.9" correctly resolves to 'json',
|
|
57
|
+
* not 'event-stream'. Unspecified quality defaults to 1.0.
|
|
58
|
+
*
|
|
59
|
+
* Internal: exported for unit testing only; not part of the public API.
|
|
60
|
+
*/
|
|
61
|
+
export declare function pickAccept(header: string | undefined): Accept;
|
|
62
|
+
export declare function pageActionHandler(opts: PageActionHandlerOptions): MiddlewareHandler;
|
|
63
|
+
export {};
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { isOutcome, timeoutOutcome, } from '../iso/index.js';
|
|
2
|
+
import { runRequestScope, setActionResultSlot, dispatchServer, partitionUse, serializeActionOutcome, } from '../iso/internal.js';
|
|
3
|
+
import { sseGeneratorResponse, sseReadableStreamResponse, isAsyncGenerator, } from './sse.js';
|
|
4
|
+
/**
|
|
5
|
+
* Parse the Accept header and return the highest-priority recognized type.
|
|
6
|
+
* Follows RFC 9110 quality values (;q=0.9 etc) so that
|
|
7
|
+
* "application/json, text/event-stream;q=0.9" correctly resolves to 'json',
|
|
8
|
+
* not 'event-stream'. Unspecified quality defaults to 1.0.
|
|
9
|
+
*
|
|
10
|
+
* Internal: exported for unit testing only; not part of the public API.
|
|
11
|
+
*/
|
|
12
|
+
export function pickAccept(header) {
|
|
13
|
+
const h = header ?? '';
|
|
14
|
+
const candidates = [];
|
|
15
|
+
for (const part of h.split(',')) {
|
|
16
|
+
const [mediaType, ...params] = part.trim().split(';');
|
|
17
|
+
const mt = (mediaType ?? '').trim().toLowerCase();
|
|
18
|
+
let q = 1.0;
|
|
19
|
+
for (const p of params) {
|
|
20
|
+
const kv = p.trim().split('=');
|
|
21
|
+
if (kv[0]?.trim() === 'q' && kv[1] !== undefined) {
|
|
22
|
+
const parsed = Number(kv[1].trim());
|
|
23
|
+
if (!Number.isNaN(parsed))
|
|
24
|
+
q = parsed;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (mt === 'text/event-stream')
|
|
28
|
+
candidates.push({ type: 'event-stream', q });
|
|
29
|
+
else if (mt === 'application/json')
|
|
30
|
+
candidates.push({ type: 'json', q });
|
|
31
|
+
else if (mt === 'text/html' || mt === '*/*')
|
|
32
|
+
candidates.push({ type: 'html', q });
|
|
33
|
+
}
|
|
34
|
+
if (candidates.length === 0)
|
|
35
|
+
return 'html';
|
|
36
|
+
candidates.sort((a, b) => b.q - a.q);
|
|
37
|
+
return candidates[0].type;
|
|
38
|
+
}
|
|
39
|
+
async function parseBody(c) {
|
|
40
|
+
const ct = (c.req.header('Content-Type') ?? '').toLowerCase();
|
|
41
|
+
if (ct.startsWith('application/json')) {
|
|
42
|
+
let body;
|
|
43
|
+
try {
|
|
44
|
+
body = await c.req.json();
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return { error: 'Invalid JSON body', status: 400 };
|
|
48
|
+
}
|
|
49
|
+
const { module: m, action: a, payload: p } = body;
|
|
50
|
+
if (typeof m !== 'string' || typeof a !== 'string') {
|
|
51
|
+
return {
|
|
52
|
+
error: 'JSON body must include string fields: module, action',
|
|
53
|
+
status: 400,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return { module: m, action: a, payload: p };
|
|
57
|
+
}
|
|
58
|
+
if (ct.startsWith('multipart/form-data') ||
|
|
59
|
+
ct.startsWith('application/x-www-form-urlencoded')) {
|
|
60
|
+
let fd;
|
|
61
|
+
try {
|
|
62
|
+
fd = await c.req.formData();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return { error: 'Invalid form data', status: 400 };
|
|
66
|
+
}
|
|
67
|
+
const m = fd.get('__module');
|
|
68
|
+
const a = fd.get('__action');
|
|
69
|
+
if (typeof m !== 'string' || typeof a !== 'string') {
|
|
70
|
+
return {
|
|
71
|
+
error: 'Form data must include __module and __action fields',
|
|
72
|
+
status: 400,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const payload = {};
|
|
76
|
+
for (const [key, value] of fd.entries()) {
|
|
77
|
+
if (key === '__module' || key === '__action')
|
|
78
|
+
continue;
|
|
79
|
+
const existing = payload[key];
|
|
80
|
+
if (existing !== undefined) {
|
|
81
|
+
payload[key] = Array.isArray(existing)
|
|
82
|
+
? [...existing, value]
|
|
83
|
+
: [existing, value];
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
payload[key] = value;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { module: m, action: a, payload };
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
error: `Unsupported Content-Type: ${ct || '(empty)'}`,
|
|
93
|
+
status: 415,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
export function pageActionHandler(opts) {
|
|
97
|
+
const { resolverByPath, resolvePageUseByPath, renderPage, resolvePageNode, appConfig, defaultTimeoutMs = 30_000, onError, } = opts;
|
|
98
|
+
return async (c) => {
|
|
99
|
+
const accept = pickAccept(c.req.header('Accept'));
|
|
100
|
+
const parsed = await parseBody(c);
|
|
101
|
+
if ('error' in parsed) {
|
|
102
|
+
return accept === 'json'
|
|
103
|
+
? c.json({ __outcome: 'error', message: parsed.error }, parsed.status)
|
|
104
|
+
: c.text(parsed.error, parsed.status);
|
|
105
|
+
}
|
|
106
|
+
const { module, action, payload } = parsed;
|
|
107
|
+
const urlPath = new URL(c.req.url).pathname;
|
|
108
|
+
const map = await resolverByPath(urlPath);
|
|
109
|
+
const entry = map.get(action);
|
|
110
|
+
if (!entry || entry.moduleKey !== module) {
|
|
111
|
+
const msg = `Action '${action}' not found on '${urlPath}'`;
|
|
112
|
+
return accept === 'json'
|
|
113
|
+
? c.json({ __outcome: 'error', message: msg }, 404)
|
|
114
|
+
: c.text(msg, 404);
|
|
115
|
+
}
|
|
116
|
+
const { fn, use: actionUse, timeoutMs } = entry;
|
|
117
|
+
const resolvedTimeoutMs = timeoutMs !== undefined ? timeoutMs : defaultTimeoutMs;
|
|
118
|
+
const timeoutSignal = resolvedTimeoutMs === false
|
|
119
|
+
? undefined
|
|
120
|
+
: AbortSignal.timeout(resolvedTimeoutMs);
|
|
121
|
+
const signal = timeoutSignal
|
|
122
|
+
? AbortSignal.any([c.req.raw.signal, timeoutSignal])
|
|
123
|
+
: c.req.raw.signal;
|
|
124
|
+
const actionCtx = { c, signal };
|
|
125
|
+
// Chain order: app-level (outermost) -> page-level (from the page's
|
|
126
|
+
// `.server.ts` and ancestor layouts' pageUse arrays) -> action-level (from
|
|
127
|
+
// defineAction's `use` option). Outer middleware runs first on the way in,
|
|
128
|
+
// last on the way out, matching the convention every middleware system
|
|
129
|
+
// users have seen (Hono, Express, Koa).
|
|
130
|
+
const rootUse = appConfig?.use ?? [];
|
|
131
|
+
const pageUse = (await resolvePageUseByPath?.(urlPath)) ?? [];
|
|
132
|
+
const fullUse = [...rootUse, ...pageUse, ...actionUse];
|
|
133
|
+
const { middleware: allMiddleware, observers } = partitionUse(fullUse);
|
|
134
|
+
const serverMw = allMiddleware.filter((m) => m.runs === 'server');
|
|
135
|
+
const ctx = {
|
|
136
|
+
scope: 'action',
|
|
137
|
+
c,
|
|
138
|
+
signal,
|
|
139
|
+
module,
|
|
140
|
+
action,
|
|
141
|
+
payload,
|
|
142
|
+
};
|
|
143
|
+
let resolution;
|
|
144
|
+
let streamingResult;
|
|
145
|
+
try {
|
|
146
|
+
const value = await runRequestScope(async () => {
|
|
147
|
+
const dispatch = await dispatchServer({
|
|
148
|
+
middleware: serverMw,
|
|
149
|
+
ctx,
|
|
150
|
+
inner: async () => {
|
|
151
|
+
const inner = await fn(actionCtx, payload);
|
|
152
|
+
// Normalize return-style outcomes to throw-style so the catch
|
|
153
|
+
// path handles all outcomes uniformly.
|
|
154
|
+
if (isOutcome(inner))
|
|
155
|
+
throw inner;
|
|
156
|
+
return inner;
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
if (dispatch.kind === 'outcome')
|
|
160
|
+
throw dispatch.outcome;
|
|
161
|
+
return dispatch.value;
|
|
162
|
+
});
|
|
163
|
+
if (isAsyncGenerator(value) || value instanceof ReadableStream) {
|
|
164
|
+
streamingResult = value;
|
|
165
|
+
if (accept !== 'event-stream') {
|
|
166
|
+
const message = 'Streaming actions require Accept: text/event-stream';
|
|
167
|
+
return accept === 'json'
|
|
168
|
+
? c.json({ __outcome: 'error', message }, 405)
|
|
169
|
+
: c.text(message, 405);
|
|
170
|
+
}
|
|
171
|
+
resolution = { kind: 'success', data: undefined };
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
resolution = { kind: 'success', data: value };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
if (isOutcome(err)) {
|
|
179
|
+
resolution = { kind: 'outcome', outcome: err };
|
|
180
|
+
}
|
|
181
|
+
else if (timeoutSignal?.aborted &&
|
|
182
|
+
timeoutSignal.reason instanceof DOMException &&
|
|
183
|
+
timeoutSignal.reason.name === 'TimeoutError' &&
|
|
184
|
+
typeof resolvedTimeoutMs === 'number') {
|
|
185
|
+
resolution = {
|
|
186
|
+
kind: 'outcome',
|
|
187
|
+
outcome: timeoutOutcome(resolvedTimeoutMs),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
onError?.(err, { module, action });
|
|
192
|
+
resolution = { kind: 'error', message: 'Action failed' };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Streaming success: hand off to SSE responders.
|
|
196
|
+
if (streamingResult) {
|
|
197
|
+
const sseOpts = {
|
|
198
|
+
observers,
|
|
199
|
+
observerCtx: ctx,
|
|
200
|
+
signal: timeoutSignal,
|
|
201
|
+
timeoutMs: typeof resolvedTimeoutMs === 'number' ? resolvedTimeoutMs : undefined,
|
|
202
|
+
};
|
|
203
|
+
if (isAsyncGenerator(streamingResult)) {
|
|
204
|
+
return sseGeneratorResponse(c, streamingResult, {
|
|
205
|
+
...sseOpts,
|
|
206
|
+
emitResult: true,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
if (streamingResult instanceof ReadableStream) {
|
|
210
|
+
return sseReadableStreamResponse(c, streamingResult, sseOpts);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// JSON path: serialize the resolution into the uniform envelope.
|
|
214
|
+
if (accept === 'json') {
|
|
215
|
+
const env = serializeActionOutcome(resolution);
|
|
216
|
+
if (env.headers) {
|
|
217
|
+
for (const [k, v] of Object.entries(env.headers))
|
|
218
|
+
c.header(k, v);
|
|
219
|
+
}
|
|
220
|
+
return c.json(env.body, env.status);
|
|
221
|
+
}
|
|
222
|
+
// HTML / PE path.
|
|
223
|
+
// Redirect: issue a real HTTP redirect so the browser follows it.
|
|
224
|
+
if (resolution.kind === 'outcome' &&
|
|
225
|
+
resolution.outcome.__outcome === 'redirect') {
|
|
226
|
+
const { to, status, headers } = resolution.outcome;
|
|
227
|
+
if (headers) {
|
|
228
|
+
for (const [k, v] of Object.entries(headers))
|
|
229
|
+
c.header(k, v);
|
|
230
|
+
}
|
|
231
|
+
return c.redirect(to, status);
|
|
232
|
+
}
|
|
233
|
+
// Success: POST-Redirect-GET pattern. Auto 303 to the same URL so the
|
|
234
|
+
// browser re-GETs the page and loaders run fresh.
|
|
235
|
+
if (resolution.kind === 'success') {
|
|
236
|
+
return c.redirect(urlPath, 303);
|
|
237
|
+
}
|
|
238
|
+
// Timeout: plain text, no re-render needed.
|
|
239
|
+
if (resolution.kind === 'outcome' &&
|
|
240
|
+
resolution.outcome.__outcome === 'timeout') {
|
|
241
|
+
return c.text(`Action timed out after ${resolution.outcome.timeoutMs}ms`, 504);
|
|
242
|
+
}
|
|
243
|
+
// Deny or unexpected error: re-render the page with the resolution
|
|
244
|
+
// injected into the request scope so useActionResult() reads it.
|
|
245
|
+
return await runRequestScope(async () => {
|
|
246
|
+
setActionResultSlot({
|
|
247
|
+
module,
|
|
248
|
+
action,
|
|
249
|
+
resolution,
|
|
250
|
+
submittedPayload: payload,
|
|
251
|
+
});
|
|
252
|
+
const node = resolvePageNode(urlPath);
|
|
253
|
+
if (!node) {
|
|
254
|
+
if (resolution.kind === 'outcome' &&
|
|
255
|
+
resolution.outcome.__outcome === 'deny') {
|
|
256
|
+
return c.text(resolution.outcome.message, resolution.outcome.status);
|
|
257
|
+
}
|
|
258
|
+
return c.text('Action failed', 500);
|
|
259
|
+
}
|
|
260
|
+
const rendered = await renderPage(c, node, { appConfig });
|
|
261
|
+
if (resolution.kind === 'outcome' &&
|
|
262
|
+
resolution.outcome.__outcome === 'deny') {
|
|
263
|
+
return new Response(rendered.body, {
|
|
264
|
+
status: resolution.outcome.status,
|
|
265
|
+
headers: rendered.headers,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
return new Response(rendered.body, {
|
|
269
|
+
status: 500,
|
|
270
|
+
headers: rendered.headers,
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
};
|
|
274
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ServerRoute } from '../iso/index';
|
|
2
|
+
type ActionFn = (ctx: unknown, payload: unknown) => Promise<unknown>;
|
|
3
|
+
export type ActionEntry = {
|
|
4
|
+
fn: ActionFn;
|
|
5
|
+
use: ReadonlyArray<unknown>;
|
|
6
|
+
timeoutMs?: number | false;
|
|
7
|
+
moduleKey: string;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Build action resolvers keyed by route path and by module key. Each
|
|
11
|
+
* ServerRoute contributes its own serverActions and its ancestors' serverActions
|
|
12
|
+
* to the merged map for that path. Ancestor entries are written first so that
|
|
13
|
+
* a page-level action shadows a same-named layout action when names collide.
|
|
14
|
+
*
|
|
15
|
+
* Lazy semantics: the first call triggers loading all server modules. The result
|
|
16
|
+
* is cached for the process lifetime (unless dev=true, which rebuilds on every
|
|
17
|
+
* call so edits take effect without restarting the server).
|
|
18
|
+
*
|
|
19
|
+
* NOTE: framework-private. Intended consumer is the generated server entry and
|
|
20
|
+
* pageActionHandler.
|
|
21
|
+
*/
|
|
22
|
+
export declare function makePageActionResolvers(serverRoutes: ReadonlyArray<ServerRoute>, options?: {
|
|
23
|
+
dev?: boolean;
|
|
24
|
+
}): {
|
|
25
|
+
byPath: (path: string) => Promise<Map<string, ActionEntry>>;
|
|
26
|
+
byModuleKey: (moduleKey: string, actionName: string) => Promise<ActionEntry | undefined>;
|
|
27
|
+
};
|
|
28
|
+
export {};
|