hono-preact 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/iso/action-result-context.d.ts +22 -0
  2. package/dist/iso/action-result-context.js +2 -0
  3. package/dist/iso/action.d.ts +52 -13
  4. package/dist/iso/action.js +204 -88
  5. package/dist/iso/cache.d.ts +9 -0
  6. package/dist/iso/cache.js +26 -0
  7. package/dist/iso/define-app.d.ts +7 -0
  8. package/dist/iso/define-loader.d.ts +12 -0
  9. package/dist/iso/define-loader.js +26 -16
  10. package/dist/iso/form.d.ts +13 -4
  11. package/dist/iso/form.js +115 -33
  12. package/dist/iso/index.d.ts +7 -4
  13. package/dist/iso/index.js +5 -2
  14. package/dist/iso/internal/action-envelope.d.ts +37 -0
  15. package/dist/iso/internal/action-envelope.js +47 -0
  16. package/dist/iso/internal/action-result-store.d.ts +28 -0
  17. package/dist/iso/internal/action-result-store.js +35 -0
  18. package/dist/iso/internal/envelope.js +1 -2
  19. package/dist/iso/internal/form-submit-store.d.ts +9 -0
  20. package/dist/iso/internal/form-submit-store.js +32 -0
  21. package/dist/iso/internal/loader-fetch.js +65 -34
  22. package/dist/iso/internal/loader.d.ts +3 -3
  23. package/dist/iso/internal/route-boundary.d.ts +4 -4
  24. package/dist/iso/internal/safe-redirect.d.ts +7 -0
  25. package/dist/iso/internal/safe-redirect.js +27 -0
  26. package/dist/iso/internal/sse-decoder.d.ts +1 -1
  27. package/dist/iso/internal/sse-decoder.js +40 -26
  28. package/dist/iso/internal.d.ts +7 -1
  29. package/dist/iso/internal.js +8 -1
  30. package/dist/iso/optimistic-action.d.ts +10 -1
  31. package/dist/iso/optimistic-action.js +11 -3
  32. package/dist/iso/optimistic.d.ts +10 -1
  33. package/dist/iso/optimistic.js +45 -5
  34. package/dist/iso/outcomes.d.ts +14 -2
  35. package/dist/iso/outcomes.js +14 -3
  36. package/dist/iso/use-action-result.d.ts +25 -0
  37. package/dist/iso/use-action-result.js +39 -0
  38. package/dist/iso/use-form-status.d.ts +5 -0
  39. package/dist/iso/use-form-status.js +13 -0
  40. package/dist/server/actions-handler.d.ts +7 -0
  41. package/dist/server/actions-handler.js +42 -9
  42. package/dist/server/index.d.ts +2 -1
  43. package/dist/server/index.js +2 -1
  44. package/dist/server/loaders-handler.d.ts +8 -0
  45. package/dist/server/loaders-handler.js +37 -4
  46. package/dist/server/page-action-handler.d.ts +63 -0
  47. package/dist/server/page-action-handler.js +274 -0
  48. package/dist/server/page-action-resolvers.d.ts +28 -0
  49. package/dist/server/page-action-resolvers.js +147 -0
  50. package/dist/server/render.js +41 -3
  51. package/dist/server/route-server-modules.d.ts +7 -8
  52. package/dist/server/route-server-modules.js +7 -8
  53. package/dist/server/speculation-rules.d.ts +3 -0
  54. package/dist/server/speculation-rules.js +8 -0
  55. package/dist/server/sse.d.ts +43 -28
  56. package/dist/server/sse.js +113 -88
  57. package/dist/vite/server-entry.js +10 -2
  58. package/package.json +2 -2
@@ -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';
@@ -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}`] = { fn: ref.fn, use: ref.use ?? [] };
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 signal = c.req.raw.signal;
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 {};
@@ -0,0 +1,147 @@
1
+ function extractActions(mod) {
2
+ const moduleKey = mod.__moduleKey;
3
+ if (typeof moduleKey !== 'string' || !mod.serverActions)
4
+ return [];
5
+ const out = [];
6
+ for (const [name, val] of Object.entries(mod.serverActions)) {
7
+ if (typeof val !== 'function')
8
+ continue;
9
+ // `defineAction` attaches `use` and `timeoutMs` as non-enumerable
10
+ // properties on the function (see packages/iso/src/action.ts). Read
11
+ // them here as the single deserialization boundary; the handler reads
12
+ // `entry.fn`, `entry.use`, `entry.timeoutMs` through the typed
13
+ // ActionEntry shape from this point on.
14
+ const metadata = val;
15
+ out.push({
16
+ name,
17
+ entry: {
18
+ fn: val,
19
+ use: metadata.use ?? [],
20
+ timeoutMs: metadata.timeoutMs,
21
+ moduleKey,
22
+ },
23
+ });
24
+ }
25
+ return out;
26
+ }
27
+ function segmentsOf(p) {
28
+ return p.split('/').filter((s) => s !== '');
29
+ }
30
+ function urlPathMatchesPattern(urlPath, pattern) {
31
+ const ps = segmentsOf(pattern);
32
+ const us = segmentsOf(urlPath);
33
+ for (let i = 0; i < ps.length; i++) {
34
+ const p = ps[i];
35
+ if (p === '*')
36
+ return true;
37
+ if (i >= us.length)
38
+ return false;
39
+ if (p.startsWith(':'))
40
+ continue;
41
+ if (p !== us[i])
42
+ return false;
43
+ }
44
+ return ps.length === us.length;
45
+ }
46
+ function patternScore(pattern) {
47
+ let score = 0;
48
+ for (const seg of segmentsOf(pattern)) {
49
+ if (seg === '*')
50
+ score += 0;
51
+ else if (seg.startsWith(':'))
52
+ score += 1;
53
+ else
54
+ score += 2;
55
+ }
56
+ return score;
57
+ }
58
+ /**
59
+ * Build action resolvers keyed by route path and by module key. Each
60
+ * ServerRoute contributes its own serverActions and its ancestors' serverActions
61
+ * to the merged map for that path. Ancestor entries are written first so that
62
+ * a page-level action shadows a same-named layout action when names collide.
63
+ *
64
+ * Lazy semantics: the first call triggers loading all server modules. The result
65
+ * is cached for the process lifetime (unless dev=true, which rebuilds on every
66
+ * call so edits take effect without restarting the server).
67
+ *
68
+ * NOTE: framework-private. Intended consumer is the generated server entry and
69
+ * pageActionHandler.
70
+ */
71
+ export function makePageActionResolvers(serverRoutes, options = {}) {
72
+ const dev = options.dev ?? false;
73
+ let buildPromise = null;
74
+ const build = async () => {
75
+ // Load each distinct server thunk once; a thunk may appear as `server`
76
+ // on one route and as an `ancestor` on its children.
77
+ const thunkCache = new Map();
78
+ const load = (thunk) => {
79
+ let p = thunkCache.get(thunk);
80
+ if (!p) {
81
+ p = thunk().then((m) => m);
82
+ thunkCache.set(thunk, p);
83
+ }
84
+ return p;
85
+ };
86
+ const byPathMap = new Map();
87
+ const byModuleKeyMap = new Map();
88
+ await Promise.all(serverRoutes.map(async (route) => {
89
+ const ancestorMods = await Promise.all(route.ancestors.map(load));
90
+ const selfMod = await load(route.server);
91
+ const merged = new Map();
92
+ // Write ancestors first (outer -> inner), then self. Later writes
93
+ // shadow earlier ones, so a page-level action wins over a layout
94
+ // action of the same name.
95
+ for (const mod of [...ancestorMods, selfMod]) {
96
+ for (const { name, entry } of extractActions(mod)) {
97
+ merged.set(name, entry);
98
+ let m = byModuleKeyMap.get(entry.moduleKey);
99
+ if (!m) {
100
+ m = new Map();
101
+ byModuleKeyMap.set(entry.moduleKey, m);
102
+ }
103
+ m.set(name, entry);
104
+ }
105
+ }
106
+ byPathMap.set(route.path, merged);
107
+ }));
108
+ return { byPathMap, byModuleKeyMap };
109
+ };
110
+ const get = () => {
111
+ if (dev)
112
+ return build();
113
+ if (buildPromise)
114
+ return buildPromise;
115
+ buildPromise = build().catch((err) => {
116
+ buildPromise = null;
117
+ return Promise.reject(err);
118
+ });
119
+ return buildPromise;
120
+ };
121
+ return {
122
+ async byPath(path) {
123
+ const { byPathMap } = await get();
124
+ let bestPattern = null;
125
+ let bestScore = -1;
126
+ let bestDepth = -1;
127
+ for (const pattern of byPathMap.keys()) {
128
+ if (!urlPathMatchesPattern(path, pattern))
129
+ continue;
130
+ const score = patternScore(pattern);
131
+ const depth = segmentsOf(pattern).length;
132
+ if (score > bestScore || (score === bestScore && depth > bestDepth)) {
133
+ bestPattern = pattern;
134
+ bestScore = score;
135
+ bestDepth = depth;
136
+ }
137
+ }
138
+ return bestPattern
139
+ ? (byPathMap.get(bestPattern) ?? new Map())
140
+ : new Map();
141
+ },
142
+ async byModuleKey(moduleKey, actionName) {
143
+ const { byModuleKeyMap } = await get();
144
+ return byModuleKeyMap.get(moduleKey)?.get(actionName);
145
+ },
146
+ };
147
+ }