hono-preact 0.2.0 → 0.4.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/dist/iso/action-result-context.d.ts +22 -0
- package/dist/iso/action-result-context.js +2 -0
- package/dist/iso/action.d.ts +52 -13
- package/dist/iso/action.js +204 -88
- package/dist/iso/cache.d.ts +9 -0
- package/dist/iso/cache.js +26 -0
- package/dist/iso/define-app.d.ts +7 -0
- package/dist/iso/define-loader.d.ts +12 -0
- package/dist/iso/define-loader.js +26 -16
- package/dist/iso/form.d.ts +13 -4
- package/dist/iso/form.js +115 -33
- package/dist/iso/index.d.ts +13 -4
- package/dist/iso/index.js +14 -2
- 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/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/history-shim.d.ts +7 -0
- package/dist/iso/internal/history-shim.js +79 -0
- package/dist/iso/internal/loader-fetch.js +65 -34
- package/dist/iso/internal/loader.d.ts +3 -3
- package/dist/iso/internal/merge-refs.d.ts +4 -0
- package/dist/iso/internal/merge-refs.js +14 -0
- package/dist/iso/internal/persist-registry.d.ts +10 -0
- package/dist/iso/internal/persist-registry.js +24 -0
- package/dist/iso/internal/route-boundary.d.ts +4 -4
- package/dist/iso/internal/route-change.d.ts +8 -2
- package/dist/iso/internal/route-change.js +107 -12
- 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/use-render.d.ts +11 -0
- package/dist/iso/internal/use-render.js +47 -0
- package/dist/iso/internal/view-transition-event.d.ts +23 -0
- package/dist/iso/internal/view-transition-event.js +25 -0
- package/dist/iso/internal.d.ts +12 -1
- package/dist/iso/internal.js +13 -1
- 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 +14 -2
- package/dist/iso/outcomes.js +14 -3
- package/dist/iso/persist.d.ts +14 -0
- package/dist/iso/persist.js +56 -0
- 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/iso/view-transition-lifecycle.d.ts +9 -0
- package/dist/iso/view-transition-lifecycle.js +18 -0
- package/dist/iso/view-transition-name.d.ts +17 -0
- package/dist/iso/view-transition-name.js +79 -0
- package/dist/iso/view-transition-types.d.ts +8 -0
- package/dist/iso/view-transition-types.js +21 -0
- package/dist/server/actions-handler.d.ts +7 -0
- package/dist/server/actions-handler.js +42 -9
- package/dist/server/index.d.ts +2 -1
- package/dist/server/index.js +2 -1
- package/dist/server/loaders-handler.d.ts +8 -0
- package/dist/server/loaders-handler.js +37 -4
- 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.js +136 -55
- package/dist/server/route-server-modules.d.ts +7 -8
- package/dist/server/route-server-modules.js +7 -8
- package/dist/server/speculation-rules.d.ts +3 -0
- package/dist/server/speculation-rules.js +8 -0
- package/dist/server/sse.d.ts +43 -28
- package/dist/server/sse.js +113 -88
- package/dist/vite/client-entry.js +12 -3
- package/dist/vite/server-entry.js +10 -2
- package/package.json +2 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isOutcome, } from '../iso/index.js';
|
|
1
|
+
import { isOutcome, timeoutOutcome, } from '../iso/index.js';
|
|
2
2
|
import { runRequestScope, dispatchServer, partitionUse, } from '../iso/internal.js';
|
|
3
3
|
import { sseGeneratorResponse, sseReadableStreamResponse, isAsyncGenerator, } from './sse.js';
|
|
4
4
|
async function buildActionsMap(glob) {
|
|
@@ -8,11 +8,25 @@ async function buildActionsMap(glob) {
|
|
|
8
8
|
? await moduleOrLoader()
|
|
9
9
|
: moduleOrLoader;
|
|
10
10
|
const key = mod.__moduleKey;
|
|
11
|
-
if (typeof key
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
if (typeof key !== 'string' || !mod.serverActions)
|
|
12
|
+
continue;
|
|
13
|
+
const actions = {};
|
|
14
|
+
for (const [name, val] of Object.entries(mod.serverActions)) {
|
|
15
|
+
if (typeof val !== 'function')
|
|
16
|
+
continue;
|
|
17
|
+
// `defineAction` attaches `use` and `timeoutMs` as non-enumerable
|
|
18
|
+
// properties on the function (see `packages/iso/src/action.ts`). The
|
|
19
|
+
// structural read below is the single deserialization boundary; the
|
|
20
|
+
// handler body reads `entry.fn`, `entry.use`, `entry.timeoutMs`
|
|
21
|
+
// directly through the typed `ActionEntry` shape from here on.
|
|
22
|
+
const metadata = val;
|
|
23
|
+
actions[name] = {
|
|
24
|
+
fn: val,
|
|
25
|
+
use: metadata.use ?? [],
|
|
26
|
+
timeoutMs: metadata.timeoutMs,
|
|
14
27
|
};
|
|
15
28
|
}
|
|
29
|
+
result[key] = { actions };
|
|
16
30
|
}
|
|
17
31
|
return result;
|
|
18
32
|
}
|
|
@@ -39,6 +53,9 @@ function translateOutcomeForAction(c, outcome) {
|
|
|
39
53
|
}
|
|
40
54
|
return c.json({ __outcome: 'deny', message: outcome.message }, outcome.status);
|
|
41
55
|
}
|
|
56
|
+
if (outcome.__outcome === 'timeout') {
|
|
57
|
+
return c.json({ __outcome: 'timeout', timeoutMs: outcome.timeoutMs }, 504);
|
|
58
|
+
}
|
|
42
59
|
// render outcome should never reach the action RPC.
|
|
43
60
|
return c.json({
|
|
44
61
|
__outcome: 'error',
|
|
@@ -46,7 +63,7 @@ function translateOutcomeForAction(c, outcome) {
|
|
|
46
63
|
}, 500);
|
|
47
64
|
}
|
|
48
65
|
export function actionsHandler(glob, opts = {}) {
|
|
49
|
-
const { dev = false, onError, appConfig, resolvePageUse } = opts;
|
|
66
|
+
const { dev = false, onError, appConfig, resolvePageUse, defaultTimeoutMs = 30_000, } = opts;
|
|
50
67
|
let cachedMapPromise = null;
|
|
51
68
|
return async (c) => {
|
|
52
69
|
const actionsMapPromise = dev
|
|
@@ -118,11 +135,18 @@ export function actionsHandler(glob, opts = {}) {
|
|
|
118
135
|
if (!entry) {
|
|
119
136
|
return c.json({ error: `Module '${module}' not found` }, 404);
|
|
120
137
|
}
|
|
121
|
-
const
|
|
122
|
-
if (
|
|
138
|
+
const actionEntry = entry.actions[action];
|
|
139
|
+
if (!actionEntry) {
|
|
123
140
|
return c.json({ error: `Action '${action}' not found in module '${module}'` }, 404);
|
|
124
141
|
}
|
|
125
|
-
const
|
|
142
|
+
const { fn, use: actionUse, timeoutMs: actionTimeoutMs } = actionEntry;
|
|
143
|
+
const resolvedTimeoutMs = actionTimeoutMs !== undefined ? actionTimeoutMs : defaultTimeoutMs;
|
|
144
|
+
const timeoutSignal = resolvedTimeoutMs === false
|
|
145
|
+
? undefined
|
|
146
|
+
: AbortSignal.timeout(resolvedTimeoutMs);
|
|
147
|
+
const signal = timeoutSignal
|
|
148
|
+
? AbortSignal.any([c.req.raw.signal, timeoutSignal])
|
|
149
|
+
: c.req.raw.signal;
|
|
126
150
|
const actionCtx = { c, signal };
|
|
127
151
|
// Chain ordering is outer -> inner: app-level middleware wraps every
|
|
128
152
|
// request, page-level wraps actions owned by that page, and per-action
|
|
@@ -133,7 +157,6 @@ export function actionsHandler(glob, opts = {}) {
|
|
|
133
157
|
// page-layer lookup keys by module rather than by location path.
|
|
134
158
|
const rootUse = appConfig?.use ?? [];
|
|
135
159
|
const pageUse = (await resolvePageUse?.(module)) ?? [];
|
|
136
|
-
const actionUse = fn.use ?? [];
|
|
137
160
|
const fullUse = [...rootUse, ...pageUse, ...actionUse];
|
|
138
161
|
const { middleware: allMiddleware, observers } = partitionUse(fullUse);
|
|
139
162
|
const serverMw = allMiddleware.filter((m) => m.runs === 'server');
|
|
@@ -173,6 +196,12 @@ export function actionsHandler(glob, opts = {}) {
|
|
|
173
196
|
if (isOutcome(err)) {
|
|
174
197
|
return translateOutcomeForAction(c, err);
|
|
175
198
|
}
|
|
199
|
+
if (timeoutSignal?.aborted &&
|
|
200
|
+
timeoutSignal.reason instanceof DOMException &&
|
|
201
|
+
timeoutSignal.reason.name === 'TimeoutError' &&
|
|
202
|
+
typeof resolvedTimeoutMs === 'number') {
|
|
203
|
+
return translateOutcomeForAction(c, timeoutOutcome(resolvedTimeoutMs));
|
|
204
|
+
}
|
|
176
205
|
onError?.(err, { module, action });
|
|
177
206
|
const message = dev && err instanceof Error ? err.message : 'Action failed';
|
|
178
207
|
return c.json({ error: message }, 500);
|
|
@@ -182,12 +211,16 @@ export function actionsHandler(glob, opts = {}) {
|
|
|
182
211
|
emitResult: true,
|
|
183
212
|
observers,
|
|
184
213
|
observerCtx: ctx,
|
|
214
|
+
signal: timeoutSignal,
|
|
215
|
+
timeoutMs: typeof resolvedTimeoutMs === 'number' ? resolvedTimeoutMs : undefined,
|
|
185
216
|
});
|
|
186
217
|
}
|
|
187
218
|
if (result instanceof ReadableStream) {
|
|
188
219
|
return sseReadableStreamResponse(c, result, {
|
|
189
220
|
observers,
|
|
190
221
|
observerCtx: ctx,
|
|
222
|
+
signal: timeoutSignal,
|
|
223
|
+
timeoutMs: typeof resolvedTimeoutMs === 'number' ? resolvedTimeoutMs : undefined,
|
|
191
224
|
});
|
|
192
225
|
}
|
|
193
226
|
return c.json(result);
|
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
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
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';
|
|
@@ -3,6 +3,7 @@ import { type AppConfig } from '../iso/index';
|
|
|
3
3
|
type GlobModule = {
|
|
4
4
|
default?: unknown;
|
|
5
5
|
__moduleKey?: unknown;
|
|
6
|
+
serverLoaders?: unknown;
|
|
6
7
|
[key: string]: unknown;
|
|
7
8
|
};
|
|
8
9
|
type LazyGlob = Record<string, () => Promise<unknown>>;
|
|
@@ -41,6 +42,13 @@ export interface LoadersHandlerOptions {
|
|
|
41
42
|
* handler awaits the result either way. Default returns an empty array.
|
|
42
43
|
*/
|
|
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;
|
|
44
52
|
}
|
|
45
53
|
export declare function loadersHandler(glob: LazyGlob | EagerGlob, opts?: LoadersHandlerOptions): MiddlewareHandler;
|
|
46
54
|
export {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isOutcome, } from '../iso/index.js';
|
|
1
|
+
import { isOutcome, timeoutOutcome, } from '../iso/index.js';
|
|
2
2
|
import { runRequestScope, dispatchServer, partitionUse, } from '../iso/internal.js';
|
|
3
3
|
import { sseGeneratorResponse, sseReadableStreamResponse, isAsyncGenerator, } from './sse.js';
|
|
4
4
|
async function buildLoadersMap(glob) {
|
|
@@ -23,7 +23,11 @@ async function buildLoadersMap(glob) {
|
|
|
23
23
|
}
|
|
24
24
|
else if (val && typeof val.fn === 'function') {
|
|
25
25
|
const ref = val;
|
|
26
|
-
result[`${moduleKey}::${name}`] = {
|
|
26
|
+
result[`${moduleKey}::${name}`] = {
|
|
27
|
+
fn: ref.fn,
|
|
28
|
+
use: ref.use ?? [],
|
|
29
|
+
timeoutMs: ref.timeoutMs,
|
|
30
|
+
};
|
|
27
31
|
}
|
|
28
32
|
}
|
|
29
33
|
}
|
|
@@ -69,6 +73,9 @@ function translateOutcomeForLoader(c, outcome) {
|
|
|
69
73
|
}
|
|
70
74
|
return c.json({ __outcome: 'deny', message: outcome.message }, outcome.status);
|
|
71
75
|
}
|
|
76
|
+
if (outcome.__outcome === 'timeout') {
|
|
77
|
+
return c.json({ __outcome: 'timeout', timeoutMs: outcome.timeoutMs }, 504);
|
|
78
|
+
}
|
|
72
79
|
// render outcome should never reach the loader RPC; this is defense in depth.
|
|
73
80
|
return c.json({
|
|
74
81
|
__outcome: 'error',
|
|
@@ -76,7 +83,7 @@ function translateOutcomeForLoader(c, outcome) {
|
|
|
76
83
|
}, 500);
|
|
77
84
|
}
|
|
78
85
|
export function loadersHandler(glob, opts = {}) {
|
|
79
|
-
const { dev = false, onError, appConfig, resolvePageUse } = opts;
|
|
86
|
+
const { dev = false, onError, appConfig, resolvePageUse, defaultTimeoutMs = 30_000, } = opts;
|
|
80
87
|
let cachedMapPromise = null;
|
|
81
88
|
return async (c) => {
|
|
82
89
|
const loadersMapPromise = dev
|
|
@@ -117,7 +124,13 @@ export function loadersHandler(glob, opts = {}) {
|
|
|
117
124
|
if (!entry) {
|
|
118
125
|
return c.json({ error: `Loader '${module}::${loaderName}' not found` }, 404);
|
|
119
126
|
}
|
|
120
|
-
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;
|
|
121
134
|
// Chain ordering is outer -> inner: app-level middleware wraps every
|
|
122
135
|
// request, page-level wraps loaders owned by that page, and per-loader
|
|
123
136
|
// middleware wraps just this call. Outer middleware runs first on the
|
|
@@ -168,12 +181,20 @@ export function loadersHandler(glob, opts = {}) {
|
|
|
168
181
|
emitResult: false,
|
|
169
182
|
observers,
|
|
170
183
|
observerCtx: ctx,
|
|
184
|
+
signal: timeoutSignal,
|
|
185
|
+
timeoutMs: typeof resolvedTimeoutMs === 'number'
|
|
186
|
+
? resolvedTimeoutMs
|
|
187
|
+
: undefined,
|
|
171
188
|
});
|
|
172
189
|
}
|
|
173
190
|
if (result instanceof ReadableStream) {
|
|
174
191
|
return sseReadableStreamResponse(c, result, {
|
|
175
192
|
observers,
|
|
176
193
|
observerCtx: ctx,
|
|
194
|
+
signal: timeoutSignal,
|
|
195
|
+
timeoutMs: typeof resolvedTimeoutMs === 'number'
|
|
196
|
+
? resolvedTimeoutMs
|
|
197
|
+
: undefined,
|
|
177
198
|
});
|
|
178
199
|
}
|
|
179
200
|
return c.json(result);
|
|
@@ -182,6 +203,18 @@ export function loadersHandler(glob, opts = {}) {
|
|
|
182
203
|
if (isOutcome(err)) {
|
|
183
204
|
return translateOutcomeForLoader(c, err);
|
|
184
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));
|
|
217
|
+
}
|
|
185
218
|
onError?.(err, { module, loader: loaderName });
|
|
186
219
|
// In production we never leak the loader's error message: it may
|
|
187
220
|
// carry PII, internal stack hints, or details that help an attacker
|
|
@@ -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 {};
|