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.
Files changed (120) hide show
  1. package/README.md +2 -1
  2. package/dist/adapter-cloudflare.d.ts +1 -0
  3. package/dist/adapter-cloudflare.d.ts.map +1 -0
  4. package/dist/adapter-cloudflare.js +2 -0
  5. package/dist/adapter-node.d.ts +1 -0
  6. package/dist/adapter-node.d.ts.map +1 -0
  7. package/dist/adapter-node.js +2 -0
  8. package/dist/internal.d.ts +1 -1
  9. package/dist/internal.js +1 -1
  10. package/dist/iso/action-result-context.d.ts +22 -0
  11. package/dist/iso/action-result-context.js +2 -0
  12. package/dist/iso/action.d.ts +60 -25
  13. package/dist/iso/action.js +210 -58
  14. package/dist/iso/cache.d.ts +9 -0
  15. package/dist/iso/cache.js +26 -0
  16. package/dist/iso/define-app.d.ts +14 -0
  17. package/dist/iso/define-app.js +3 -0
  18. package/dist/iso/define-loader.d.ts +31 -0
  19. package/dist/iso/define-loader.js +30 -16
  20. package/dist/iso/define-middleware.d.ts +43 -0
  21. package/dist/iso/define-middleware.js +6 -0
  22. package/dist/iso/define-page.d.ts +7 -2
  23. package/dist/iso/define-page.js +1 -1
  24. package/dist/iso/define-routes.d.ts +24 -1
  25. package/dist/iso/define-routes.js +34 -0
  26. package/dist/iso/define-stream-observer.d.ts +20 -0
  27. package/dist/iso/define-stream-observer.js +3 -0
  28. package/dist/iso/form.d.ts +13 -4
  29. package/dist/iso/form.js +115 -33
  30. package/dist/iso/index.d.ts +15 -7
  31. package/dist/iso/index.js +9 -4
  32. package/dist/iso/internal/action-envelope.d.ts +37 -0
  33. package/dist/iso/internal/action-envelope.js +47 -0
  34. package/dist/iso/internal/action-result-store.d.ts +28 -0
  35. package/dist/iso/internal/action-result-store.js +35 -0
  36. package/dist/iso/internal/contexts.d.ts +0 -2
  37. package/dist/iso/internal/contexts.js +0 -1
  38. package/dist/iso/internal/envelope.js +1 -2
  39. package/dist/iso/internal/form-submit-store.d.ts +9 -0
  40. package/dist/iso/internal/form-submit-store.js +32 -0
  41. package/dist/iso/internal/loader-fetch.js +102 -41
  42. package/dist/iso/internal/loader-runner.js +105 -8
  43. package/dist/iso/internal/loader.d.ts +3 -3
  44. package/dist/iso/internal/middleware-runner.d.ts +22 -0
  45. package/dist/iso/internal/middleware-runner.js +79 -0
  46. package/dist/iso/internal/page-middleware-host.d.ts +13 -0
  47. package/dist/iso/internal/page-middleware-host.js +119 -0
  48. package/dist/iso/internal/route-boundary.d.ts +5 -4
  49. package/dist/iso/internal/route-boundary.js +16 -0
  50. package/dist/iso/internal/safe-redirect.d.ts +7 -0
  51. package/dist/iso/internal/safe-redirect.js +27 -0
  52. package/dist/iso/internal/sse-decoder.d.ts +1 -1
  53. package/dist/iso/internal/sse-decoder.js +40 -26
  54. package/dist/iso/internal/stream-observer-runner.d.ts +13 -0
  55. package/dist/iso/internal/stream-observer-runner.js +48 -0
  56. package/dist/iso/internal/use-partitioner.d.ts +9 -0
  57. package/dist/iso/internal/use-partitioner.js +11 -0
  58. package/dist/iso/internal/use-types.d.ts +7 -0
  59. package/dist/iso/internal/use-types.js +1 -0
  60. package/dist/iso/internal.d.ts +12 -5
  61. package/dist/iso/internal.js +16 -7
  62. package/dist/iso/optimistic-action.d.ts +10 -1
  63. package/dist/iso/optimistic-action.js +11 -3
  64. package/dist/iso/optimistic.d.ts +10 -1
  65. package/dist/iso/optimistic.js +45 -5
  66. package/dist/iso/outcomes.d.ts +50 -0
  67. package/dist/iso/outcomes.js +67 -0
  68. package/dist/iso/page-only.d.ts +5 -0
  69. package/dist/iso/page-only.js +20 -0
  70. package/dist/iso/page.d.ts +3 -3
  71. package/dist/iso/page.js +3 -3
  72. package/dist/iso/use-action-result.d.ts +25 -0
  73. package/dist/iso/use-action-result.js +39 -0
  74. package/dist/iso/use-form-status.d.ts +5 -0
  75. package/dist/iso/use-form-status.js +13 -0
  76. package/dist/page.d.ts +1 -0
  77. package/dist/page.d.ts.map +1 -0
  78. package/dist/page.js +8 -0
  79. package/dist/server/actions-handler.d.ts +27 -6
  80. package/dist/server/actions-handler.js +121 -52
  81. package/dist/server/context.js +1 -1
  82. package/dist/server/index.d.ts +3 -2
  83. package/dist/server/index.js +3 -2
  84. package/dist/server/loaders-handler.d.ts +24 -0
  85. package/dist/server/loaders-handler.js +128 -18
  86. package/dist/server/page-action-handler.d.ts +63 -0
  87. package/dist/server/page-action-handler.js +274 -0
  88. package/dist/server/page-action-resolvers.d.ts +28 -0
  89. package/dist/server/page-action-resolvers.js +147 -0
  90. package/dist/server/render.d.ts +2 -0
  91. package/dist/server/render.js +142 -33
  92. package/dist/server/route-server-modules.d.ts +48 -8
  93. package/dist/server/route-server-modules.js +190 -7
  94. package/dist/server/speculation-rules.d.ts +3 -0
  95. package/dist/server/speculation-rules.js +8 -0
  96. package/dist/server/sse.d.ts +50 -12
  97. package/dist/server/sse.js +130 -53
  98. package/dist/vite/adapter-cloudflare.d.ts +2 -0
  99. package/dist/vite/adapter-cloudflare.js +25 -0
  100. package/dist/vite/adapter-node.d.ts +2 -0
  101. package/dist/vite/adapter-node.js +49 -0
  102. package/dist/vite/adapter.d.ts +29 -0
  103. package/dist/vite/adapter.js +1 -0
  104. package/dist/vite/client-shim.js +5 -4
  105. package/dist/vite/guard-strip.js +52 -27
  106. package/dist/vite/hono-preact.d.ts +6 -6
  107. package/dist/vite/hono-preact.js +48 -77
  108. package/dist/vite/index.d.ts +2 -1
  109. package/dist/vite/index.js +1 -1
  110. package/dist/vite/node-dev-server.d.ts +4 -0
  111. package/dist/vite/node-dev-server.js +121 -0
  112. package/dist/vite/server-entry.d.ts +30 -7
  113. package/dist/vite/server-entry.js +170 -79
  114. package/dist/vite/server-exports-contract.d.ts +6 -0
  115. package/dist/vite/server-exports-contract.js +43 -0
  116. package/dist/vite/server-loader-validation.js +36 -9
  117. package/dist/vite/server-loaders-parser.d.ts +17 -1
  118. package/dist/vite/server-loaders-parser.js +41 -0
  119. package/dist/vite/server-only.js +20 -2
  120. package/package.json +33 -5
@@ -1,5 +1,5 @@
1
- import { GuardRedirect } from '../iso/index.js';
2
- import { runRequestScope } from '../iso/internal/index.js';
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 (used by user code)
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
- result[`${moduleKey}::${name}`] = val.fn;
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 loaderFn = loadersMap[`${module}::${loaderName}`];
86
- if (!loaderFn) {
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 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;
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(() => Promise.resolve(loaderFn({ c, location: validatedLocation, signal })), { honoContext: c });
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, { emitResult: false });
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
- // GuardRedirect thrown from a loader (or a guard that runs inside it)
102
- // is a control-flow signal, not an error. The client RPC stub
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 });
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 {};