@ubean/routes 0.2.1 → 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/dist/form-action-CZA0dSMO.js +511 -0
- package/dist/index.d.ts +382 -3
- package/dist/index.js +448 -94
- package/dist/invoke-CzODqUg-.d.ts +12 -0
- package/dist/runtime.d.ts +149 -0
- package/dist/runtime.js +278 -0
- package/package.json +15 -11
package/dist/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { C as registerAction, E as isValidActionId, S as listActions, T as createActionId, _ as parseActionInput, a as ACTION_RESPONSE_HEADER, b as getAction, c as unwrapServerFnResult, d as getActionContext, f as runWithActionContext, g as normalizeActionResult, h as defineServerFn, i as ACTIONS_ENDPOINT, l as bindActionContextStorage, m as defineAction, n as hasFormAction, o as invokeServerFn, p as buildActionContext, r as parseFormActionName, s as unwrapActionResult, t as buildFormActionUrl, u as createDetachedActionContext, v as validateActionInput, w as registerActions, x as hasAction, y as clearActions } from "./form-action-CZA0dSMO.js";
|
|
1
2
|
import { addRoute, createRouter, findRoute } from "rou3";
|
|
3
|
+
import { compileLocalePaths } from "@ubean/i18n";
|
|
2
4
|
import { validateParams } from "@ubean/scan";
|
|
3
|
-
import { isServerAction, matchAnyGlob } from "@ubean/shared";
|
|
4
|
-
import {
|
|
5
|
-
import { openAPIRouteHandler } from "hono-openapi";
|
|
5
|
+
import { ACTION_BRAND, ActionError, fail, isActionFailure, isServerAction, isServerAction as isServerAction$1, matchAnyGlob } from "@ubean/shared";
|
|
6
|
+
import { generateSpecs } from "hono-openapi";
|
|
6
7
|
//#region src/handler.ts
|
|
7
8
|
function isResponse(value) {
|
|
8
9
|
return value instanceof Response;
|
|
@@ -195,47 +196,80 @@ function isBotUserAgent(ua) {
|
|
|
195
196
|
return BOT_PATTERN.test(ua);
|
|
196
197
|
}
|
|
197
198
|
//#endregion
|
|
198
|
-
//#region src/
|
|
199
|
+
//#region src/path-transform.ts
|
|
199
200
|
/**
|
|
200
|
-
*
|
|
201
|
-
* `mod.action(c)` path and the `mod.actions` map path.
|
|
201
|
+
* Apply a rewrite/proxy target to a request path (Nuxt-style wildcards).
|
|
202
202
|
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
203
|
+
* - `/old` + `/new` → `/new`
|
|
204
|
+
* - rule `/blog/**`, request `/blog/a/b`, target `/news/**` → `/news/a/b`
|
|
205
|
+
* - target containing `$1` receives the captured suffix without a leading slash
|
|
206
|
+
* - absolute `https://…` targets keep the origin and transform the pathname
|
|
205
207
|
*/
|
|
206
|
-
function
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
headers: { "X-Ubean-Redirect": redirectUrl }
|
|
213
|
-
});
|
|
214
|
-
return response;
|
|
215
|
-
}
|
|
208
|
+
function applyPathTransform(requestPath, rulePath, target) {
|
|
209
|
+
const suffix = captureWildcardSuffix(requestPath, rulePath);
|
|
210
|
+
const absolute = parseAbsoluteTarget(target);
|
|
211
|
+
if (absolute) {
|
|
212
|
+
const nextPath = substitutePath(absolute.pathname || "/", suffix);
|
|
213
|
+
return `${absolute.origin}${nextPath}${absolute.search}`;
|
|
216
214
|
}
|
|
217
|
-
|
|
218
|
-
return null;
|
|
215
|
+
return substitutePath(target, suffix);
|
|
219
216
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
217
|
+
function parseAbsoluteTarget(target) {
|
|
218
|
+
if (!/^https?:\/\//.test(target)) return void 0;
|
|
219
|
+
try {
|
|
220
|
+
const url = new URL(target);
|
|
221
|
+
return {
|
|
222
|
+
origin: url.origin,
|
|
223
|
+
pathname: url.pathname,
|
|
224
|
+
search: url.search
|
|
225
|
+
};
|
|
226
|
+
} catch {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function captureWildcardSuffix(requestPath, rulePath) {
|
|
231
|
+
const doubleIdx = rulePath.indexOf("/**");
|
|
232
|
+
if (doubleIdx !== -1) {
|
|
233
|
+
const prefix = rulePath.slice(0, doubleIdx);
|
|
234
|
+
if (requestPath === prefix || requestPath === `${prefix}/`) return "";
|
|
235
|
+
if (requestPath.startsWith(`${prefix}/`)) return requestPath.slice(prefix.length);
|
|
236
|
+
return "";
|
|
237
|
+
}
|
|
238
|
+
const singleIdx = rulePath.indexOf("/*");
|
|
239
|
+
if (singleIdx !== -1 && !rulePath.includes("**")) {
|
|
240
|
+
const prefix = rulePath.slice(0, singleIdx);
|
|
241
|
+
if (requestPath.startsWith(`${prefix}/`)) return `/${requestPath.slice(prefix.length + 1).split("/")[0] ?? ""}`;
|
|
242
|
+
}
|
|
243
|
+
return "";
|
|
244
|
+
}
|
|
245
|
+
function substitutePath(targetPath, suffix) {
|
|
246
|
+
const trimmedSuffix = suffix.replace(/^\//, "");
|
|
247
|
+
if (targetPath.includes("$1")) return targetPath.replaceAll("$1", trimmedSuffix);
|
|
248
|
+
if (targetPath.includes("/**")) return normalizePath(targetPath.replace("/**", suffix || ""));
|
|
249
|
+
if (targetPath.includes("/*")) {
|
|
250
|
+
const first = suffix.split("/").filter(Boolean)[0];
|
|
251
|
+
return normalizePath(targetPath.replace("/*", first ? `/${first}` : ""));
|
|
252
|
+
}
|
|
253
|
+
return normalizePath(targetPath);
|
|
254
|
+
}
|
|
255
|
+
function normalizePath(path) {
|
|
256
|
+
if (!path) return "/";
|
|
257
|
+
return (path.startsWith("/") ? path : `/${path}`).replace(/\/{2,}/g, "/");
|
|
236
258
|
}
|
|
237
259
|
//#endregion
|
|
238
260
|
//#region src/route-rules.ts
|
|
261
|
+
const REWRITE_GUARD = "x-ubean-rewrite";
|
|
262
|
+
const HOP_BY_HOP = /* @__PURE__ */ new Set([
|
|
263
|
+
"connection",
|
|
264
|
+
"keep-alive",
|
|
265
|
+
"proxy-authenticate",
|
|
266
|
+
"proxy-authorization",
|
|
267
|
+
"te",
|
|
268
|
+
"trailers",
|
|
269
|
+
"transfer-encoding",
|
|
270
|
+
"upgrade",
|
|
271
|
+
"host"
|
|
272
|
+
]);
|
|
239
273
|
function compileRulePath(pattern) {
|
|
240
274
|
const DOUBLE = "__DWC__";
|
|
241
275
|
const SINGLE = "__SWC__";
|
|
@@ -256,7 +290,7 @@ function compileRulePath(pattern) {
|
|
|
256
290
|
* P9-04 扩展:`ppr` 字段同样参与 specificity 计算。
|
|
257
291
|
*/
|
|
258
292
|
function ruleSpecificity(rule) {
|
|
259
|
-
return (rule.redirect ? 3 : 0) + (rule.rewrite ? 2 : 0) + (rule.headers ? 1 : 0) + (rule.ssr !== void 0 ? 1 : 0) + (rule.isr !== void 0 ? 1 : 0) + (rule.prerender !== void 0 ? 1 : 0) + (rule.ppr !== void 0 ? 1 : 0);
|
|
293
|
+
return (rule.redirect ? 3 : 0) + (rule.rewrite ? 2 : 0) + (rule.proxy ? 2 : 0) + (rule.headers ? 1 : 0) + (rule.ssr !== void 0 ? 1 : 0) + (rule.isr !== void 0 ? 1 : 0) + (rule.prerender !== void 0 ? 1 : 0) + (rule.ppr !== void 0 ? 1 : 0);
|
|
260
294
|
}
|
|
261
295
|
/**
|
|
262
296
|
* 路径特异性计算:作为 rule specificity 的 tiebreaker。
|
|
@@ -303,6 +337,7 @@ function matchRouteRules(path, compiledRules) {
|
|
|
303
337
|
};
|
|
304
338
|
if (rule.redirect && !matched.redirect) matched.redirect = rule.redirect;
|
|
305
339
|
if (rule.rewrite && !matched.rewrite) matched.rewrite = rule.rewrite;
|
|
340
|
+
if (rule.proxy && !matched.proxy) matched.proxy = rule.proxy;
|
|
306
341
|
if (rule.cache) matched.cache = {
|
|
307
342
|
...matched.cache,
|
|
308
343
|
...rule.cache
|
|
@@ -314,6 +349,9 @@ function matchRouteRules(path, compiledRules) {
|
|
|
314
349
|
}
|
|
315
350
|
return matched;
|
|
316
351
|
}
|
|
352
|
+
function findFirstRulePath(path, compiledRules, field) {
|
|
353
|
+
for (const { pattern, rule, path: rulePath } of compiledRules) if (pattern.test(path) && rule[field]) return rulePath;
|
|
354
|
+
}
|
|
317
355
|
function resolveRedirectUrl(redirect, currentPath) {
|
|
318
356
|
if (typeof redirect === "string") return {
|
|
319
357
|
url: redirect,
|
|
@@ -338,7 +376,28 @@ function buildCacheControlHeader(cache) {
|
|
|
338
376
|
}
|
|
339
377
|
return parts.length > 0 ? parts.join(", ") : null;
|
|
340
378
|
}
|
|
341
|
-
function
|
|
379
|
+
function cloneForwardHeaders(source) {
|
|
380
|
+
const headers = new Headers();
|
|
381
|
+
source.forEach((value, key) => {
|
|
382
|
+
if (!HOP_BY_HOP.has(key.toLowerCase())) headers.set(key, value);
|
|
383
|
+
});
|
|
384
|
+
return headers;
|
|
385
|
+
}
|
|
386
|
+
function buildForwardRequest(c, dest, extraHeaders) {
|
|
387
|
+
const headers = cloneForwardHeaders(c.req.raw.headers);
|
|
388
|
+
if (extraHeaders) new Headers(extraHeaders).forEach((value, key) => headers.set(key, value));
|
|
389
|
+
const init = {
|
|
390
|
+
method: c.req.method,
|
|
391
|
+
headers,
|
|
392
|
+
redirect: "manual"
|
|
393
|
+
};
|
|
394
|
+
if (c.req.method !== "GET" && c.req.method !== "HEAD" && c.req.raw.body) {
|
|
395
|
+
init.body = c.req.raw.body;
|
|
396
|
+
init.duplex = "half";
|
|
397
|
+
}
|
|
398
|
+
return new Request(dest, init);
|
|
399
|
+
}
|
|
400
|
+
function createRouteRulesMiddleware(rules, options = {}) {
|
|
342
401
|
const compiled = compileRouteRules(rules);
|
|
343
402
|
return async function routeRulesMiddleware(c, next) {
|
|
344
403
|
const path = c.req.path;
|
|
@@ -355,6 +414,23 @@ function createRouteRulesMiddleware(rules) {
|
|
|
355
414
|
if (matched.headers) for (const [key, value] of Object.entries(matched.headers)) c.header(key, value);
|
|
356
415
|
const cacheHeader = buildCacheControlHeader(matched.cache);
|
|
357
416
|
if (cacheHeader) c.header("Cache-Control", cacheHeader);
|
|
417
|
+
if (matched.proxy) {
|
|
418
|
+
const dest = applyPathTransform(path, findFirstRulePath(path, compiled, "proxy") ?? path, matched.proxy);
|
|
419
|
+
const upstream = await fetch(buildForwardRequest(c, dest));
|
|
420
|
+
return new Response(upstream.body, {
|
|
421
|
+
status: upstream.status,
|
|
422
|
+
statusText: upstream.statusText,
|
|
423
|
+
headers: upstream.headers
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
if (matched.rewrite && options.dispatch && !c.req.header(REWRITE_GUARD)) {
|
|
427
|
+
const dest = applyPathTransform(path, findFirstRulePath(path, compiled, "rewrite") ?? path, matched.rewrite);
|
|
428
|
+
if (dest !== path) {
|
|
429
|
+
const url = new URL(c.req.url);
|
|
430
|
+
url.pathname = dest;
|
|
431
|
+
return options.dispatch(buildForwardRequest(c, url.toString(), { [REWRITE_GUARD]: "1" }));
|
|
432
|
+
}
|
|
433
|
+
}
|
|
358
434
|
await next();
|
|
359
435
|
};
|
|
360
436
|
}
|
|
@@ -439,7 +515,7 @@ function isIsrEntryStale(pathname, store) {
|
|
|
439
515
|
if (!memStore.store) return false;
|
|
440
516
|
const entry = memStore.store.get(buildIsrCacheKey(pathname));
|
|
441
517
|
if (!entry?.expiresAt) return false;
|
|
442
|
-
return Date.now()
|
|
518
|
+
return Date.now() >= entry.expiresAt;
|
|
443
519
|
}
|
|
444
520
|
/**
|
|
445
521
|
* ISR 主入口:检查缓存 → 命中则返回,未命中/过期则调用 render 重新生成。
|
|
@@ -457,7 +533,7 @@ async function serveIsr(_c, options) {
|
|
|
457
533
|
const key = buildIsrCacheKey(pathname);
|
|
458
534
|
const rawEntry = store.peek ? await store.peek(key) : await store.get(key);
|
|
459
535
|
if (rawEntry) {
|
|
460
|
-
const isStale = Date.now()
|
|
536
|
+
const isStale = Date.now() >= rawEntry.expiresAt;
|
|
461
537
|
const html = new TextDecoder().decode(rawEntry.body);
|
|
462
538
|
if (!isStale) {
|
|
463
539
|
const headers = new Headers(rawEntry.headers);
|
|
@@ -514,6 +590,268 @@ function getIsrRuleFromContext(c) {
|
|
|
514
590
|
return normalizeIsrRule(rule.isr);
|
|
515
591
|
}
|
|
516
592
|
//#endregion
|
|
593
|
+
//#region src/actions/openapi.ts
|
|
594
|
+
/**
|
|
595
|
+
* Optional OpenAPI fragment for the shared `POST /__actions` RPC.
|
|
596
|
+
*
|
|
597
|
+
* Per-action operations stay on the same endpoint (id in the body) so this
|
|
598
|
+
* does not create a second RPC surface.
|
|
599
|
+
*/
|
|
600
|
+
function describeActionsOpenApi() {
|
|
601
|
+
const names = listActions().map((action) => `${action.name} (${action.id})`);
|
|
602
|
+
return { paths: { "/__actions": { post: {
|
|
603
|
+
summary: "Server Actions / server functions RPC",
|
|
604
|
+
operationId: "ubeanActionsRpc",
|
|
605
|
+
description: names.length > 0 ? `Registered functions: ${names.join(", ")}.` : "No server functions registered yet. Define them with defineAction / defineServerFn."
|
|
606
|
+
} } } };
|
|
607
|
+
}
|
|
608
|
+
//#endregion
|
|
609
|
+
//#region src/actions/dispatch.ts
|
|
610
|
+
/**
|
|
611
|
+
* Dispatch a registered server action by ID.
|
|
612
|
+
*
|
|
613
|
+
* Used by the `/__actions` POST endpoint (RPC-style invocation from the
|
|
614
|
+
* client). The action ID is looked up in the global registry; the request
|
|
615
|
+
* body is parsed as JSON or FormData depending on `Content-Type`.
|
|
616
|
+
*
|
|
617
|
+
* When `preParsedInput` is provided (RPC path — the middleware has already
|
|
618
|
+
* read the body to extract the `{ id, args }` envelope), body parsing is
|
|
619
|
+
* skipped and `preParsedInput` is used as the handler input directly.
|
|
620
|
+
*
|
|
621
|
+
* Returns an `ActionResult` (serializable). When the action ID is unknown
|
|
622
|
+
* or invalid, returns a 404 error result without throwing.
|
|
623
|
+
*/
|
|
624
|
+
async function dispatchAction(actionId, c, preParsedInput) {
|
|
625
|
+
const action = getAction(actionId);
|
|
626
|
+
if (!action) return {
|
|
627
|
+
error: { message: `Action not found: ${actionId}` },
|
|
628
|
+
status: 404
|
|
629
|
+
};
|
|
630
|
+
return runAction(action, c, preParsedInput);
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Run an arbitrary `ServerAction` against the given Hono context.
|
|
634
|
+
*
|
|
635
|
+
* Used internally by `dispatchAction` and by `handlePageRequest` for
|
|
636
|
+
* page-level form actions (which are not in the global registry but
|
|
637
|
+
* are exported from the page module's `actions` map).
|
|
638
|
+
*
|
|
639
|
+
* When `preParsedInput` is provided, body parsing is skipped (the caller
|
|
640
|
+
* has already read the request body — e.g. the RPC middleware extracting
|
|
641
|
+
* the `{ id, args }` envelope).
|
|
642
|
+
*/
|
|
643
|
+
async function runAction(action, c, preParsedInput) {
|
|
644
|
+
const ctx = buildActionContext(c);
|
|
645
|
+
const rawInput = preParsedInput !== void 0 ? preParsedInput : await parseActionInput(c.req.raw);
|
|
646
|
+
let input = rawInput;
|
|
647
|
+
if (action.schema) {
|
|
648
|
+
const validation = validateActionInput(action.schema, rawInput);
|
|
649
|
+
if (!validation.success) return {
|
|
650
|
+
errors: validation.errors,
|
|
651
|
+
status: 400
|
|
652
|
+
};
|
|
653
|
+
input = validation.data;
|
|
654
|
+
}
|
|
655
|
+
let result;
|
|
656
|
+
let error;
|
|
657
|
+
try {
|
|
658
|
+
result = await action.handler(input, ctx);
|
|
659
|
+
} catch (err) {
|
|
660
|
+
if (err instanceof Response) return {
|
|
661
|
+
response: err,
|
|
662
|
+
status: err.status
|
|
663
|
+
};
|
|
664
|
+
error = err;
|
|
665
|
+
}
|
|
666
|
+
if (result instanceof Response) return {
|
|
667
|
+
response: result,
|
|
668
|
+
status: result.status
|
|
669
|
+
};
|
|
670
|
+
return normalizeActionResult(result, error);
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Run a page-level form action by name (SvelteKit-style `?/name` convention).
|
|
674
|
+
*
|
|
675
|
+
* Page modules may export an `actions` map:
|
|
676
|
+
*
|
|
677
|
+
* ```ts
|
|
678
|
+
* export const actions = {
|
|
679
|
+
* default: defineAction(async (input, ctx) => { ... }),
|
|
680
|
+
* login: defineAction(async (input, ctx) => { ... }),
|
|
681
|
+
* register: defineAction(async (input, ctx) => { ... })
|
|
682
|
+
* };
|
|
683
|
+
* ```
|
|
684
|
+
*
|
|
685
|
+
* - `POST /page` (no `?/`) → `actions.default`
|
|
686
|
+
* - `POST /page?/login` → `actions.login`
|
|
687
|
+
* - `POST /page?/register` → `actions.register`
|
|
688
|
+
*
|
|
689
|
+
* Returns `null` when:
|
|
690
|
+
* - No `actions` export exists (caller should fall back to `mod.action`).
|
|
691
|
+
* - The named action doesn't exist (caller should return 404).
|
|
692
|
+
*
|
|
693
|
+
* The caller (`handlePageRequest`) is responsible for converting the
|
|
694
|
+
* `ActionResult` into an HTTP response (HTML for browser navigation,
|
|
695
|
+
* JSON for SPA `submit()` calls).
|
|
696
|
+
*/
|
|
697
|
+
async function runPageAction(actions, actionName, c) {
|
|
698
|
+
const action = actions[actionName];
|
|
699
|
+
if (!action) return null;
|
|
700
|
+
return runAction(action, c);
|
|
701
|
+
}
|
|
702
|
+
//#endregion
|
|
703
|
+
//#region src/actions/middleware.ts
|
|
704
|
+
/**
|
|
705
|
+
* Create the actions middleware — a Hono handler that mounts the
|
|
706
|
+
* `/__actions` POST endpoint.
|
|
707
|
+
*
|
|
708
|
+
* Usage:
|
|
709
|
+
*
|
|
710
|
+
* ```ts
|
|
711
|
+
* import { createUbeanApp } from 'ubean/runtime/app';
|
|
712
|
+
* import { createActionsMiddleware } from '@ubean/routes';
|
|
713
|
+
*
|
|
714
|
+
* const app = createUbeanApp();
|
|
715
|
+
* app.on('POST', '/__actions', createActionsMiddleware());
|
|
716
|
+
* ```
|
|
717
|
+
*
|
|
718
|
+
* The middleware is auto-registered by ubean's dev server / production
|
|
719
|
+
* server setup — users don't need to mount it manually.
|
|
720
|
+
*/
|
|
721
|
+
function createActionsMiddleware() {
|
|
722
|
+
return async (c) => {
|
|
723
|
+
let actionId;
|
|
724
|
+
let args;
|
|
725
|
+
if ((c.req.header("Content-Type") || "").includes("application/json")) try {
|
|
726
|
+
const body = await c.req.json();
|
|
727
|
+
actionId = body?.id;
|
|
728
|
+
args = body?.args;
|
|
729
|
+
} catch {}
|
|
730
|
+
if (!actionId) actionId = c.req.query("id");
|
|
731
|
+
if (!actionId) return _jsonResult(c, {
|
|
732
|
+
error: { message: "Missing action id" },
|
|
733
|
+
status: 400
|
|
734
|
+
}, 400);
|
|
735
|
+
const preParsedInput = Array.isArray(args) && args.length > 0 ? args[0] : void 0;
|
|
736
|
+
const result = await dispatchAction(actionId, c, preParsedInput);
|
|
737
|
+
if (result.response) return result.response;
|
|
738
|
+
return _jsonResult(c, result, result.status);
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Serialize an `ActionResult` as a JSON Hono response with the action
|
|
743
|
+
* response header set.
|
|
744
|
+
*/
|
|
745
|
+
function _jsonResult(c, result, status) {
|
|
746
|
+
return c.json(result, status, {
|
|
747
|
+
[ACTION_RESPONSE_HEADER]: "true",
|
|
748
|
+
"Cache-Control": "no-store"
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Check whether a request targets the actions endpoint.
|
|
753
|
+
*
|
|
754
|
+
* Used by route registrars to skip registering regular API routes for
|
|
755
|
+
* the `/__actions` path.
|
|
756
|
+
*/
|
|
757
|
+
function isActionsRequest(c) {
|
|
758
|
+
return c.req.method === "POST" && c.req.path === "/__actions";
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Check whether a response came from the actions middleware.
|
|
762
|
+
*
|
|
763
|
+
* Used by the client `callAction()` to validate the response shape.
|
|
764
|
+
*/
|
|
765
|
+
function isActionResponse(response) {
|
|
766
|
+
return response.headers.get(ACTION_RESPONSE_HEADER) === "true";
|
|
767
|
+
}
|
|
768
|
+
//#endregion
|
|
769
|
+
//#region src/page-actions.ts
|
|
770
|
+
/**
|
|
771
|
+
* Shared redirect/non-redirect Response handling for both the legacy
|
|
772
|
+
* `mod.action(c)` path and the `mod.actions` map path.
|
|
773
|
+
*
|
|
774
|
+
* Returns a `Response` to return to the client, or `null` to fall through
|
|
775
|
+
* (caller leaves `actionResult` as the Response for downstream handling).
|
|
776
|
+
*/
|
|
777
|
+
function handleActionResponse(c, response, isPagesReq) {
|
|
778
|
+
if (response.status >= 300 && response.status < 400) {
|
|
779
|
+
const redirectUrl = response.headers.get("Location");
|
|
780
|
+
if (redirectUrl) {
|
|
781
|
+
if (isPagesReq(c)) return c.json({ redirect: redirectUrl }, {
|
|
782
|
+
status: 200,
|
|
783
|
+
headers: { "X-Ubean-Redirect": redirectUrl }
|
|
784
|
+
});
|
|
785
|
+
return response;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
if (isPagesReq(c)) return response;
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Run a `ServerAction` (from a page module's `actions` map) against the
|
|
793
|
+
* current Hono context via `runAction`.
|
|
794
|
+
*
|
|
795
|
+
* Returns one of:
|
|
796
|
+
* - `{ response }` — handler returned/threw a `Response` (redirect)
|
|
797
|
+
* - `{ errors }` — validation failure, `fail()` from the handler, or a
|
|
798
|
+
* thrown error (mapped to `{ _error: message }`)
|
|
799
|
+
* - `{ data }` — successful return value (merged into page props)
|
|
800
|
+
*/
|
|
801
|
+
async function runServerAction(action, c) {
|
|
802
|
+
const result = await runAction(action, c);
|
|
803
|
+
if (result.response) return { response: result.response };
|
|
804
|
+
if (result.errors) return { errors: result.errors ?? {} };
|
|
805
|
+
if (result.error) return { errors: { _error: result.error.message } };
|
|
806
|
+
return { data: result.data };
|
|
807
|
+
}
|
|
808
|
+
//#endregion
|
|
809
|
+
//#region src/select-ssr.ts
|
|
810
|
+
/**
|
|
811
|
+
* TanStack-style select SSR: page meta wins over routeRules, which win over
|
|
812
|
+
* the global exclude list. Glob exclude stays backward-compatible (CSR shell
|
|
813
|
+
* still runs loader). Explicit `ssr: false` skips the loader.
|
|
814
|
+
*/
|
|
815
|
+
function resolveSelectSsr(input) {
|
|
816
|
+
const ssr = input.pageSsr ?? input.routeRuleSsr;
|
|
817
|
+
if (ssr === false) return {
|
|
818
|
+
mode: "csr",
|
|
819
|
+
runLoader: false,
|
|
820
|
+
useRenderer: false
|
|
821
|
+
};
|
|
822
|
+
if (ssr === "data-only") return {
|
|
823
|
+
mode: "data-only",
|
|
824
|
+
runLoader: true,
|
|
825
|
+
useRenderer: false
|
|
826
|
+
};
|
|
827
|
+
if (ssr === "streaming" || input.ppr) return {
|
|
828
|
+
mode: "streaming",
|
|
829
|
+
runLoader: true,
|
|
830
|
+
useRenderer: true
|
|
831
|
+
};
|
|
832
|
+
if (ssr === true) return {
|
|
833
|
+
mode: "ssr",
|
|
834
|
+
runLoader: true,
|
|
835
|
+
useRenderer: true
|
|
836
|
+
};
|
|
837
|
+
if (input.excludedByGlob) return {
|
|
838
|
+
mode: "csr",
|
|
839
|
+
runLoader: true,
|
|
840
|
+
useRenderer: false
|
|
841
|
+
};
|
|
842
|
+
return {
|
|
843
|
+
mode: input.streaming ? "streaming" : "ssr",
|
|
844
|
+
runLoader: true,
|
|
845
|
+
useRenderer: true
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
function ssrModeHeader(mode) {
|
|
849
|
+
if (mode === "csr") return "csr";
|
|
850
|
+
if (mode === "data-only") return "data-only";
|
|
851
|
+
if (mode === "streaming") return "streaming";
|
|
852
|
+
return "ssr";
|
|
853
|
+
}
|
|
854
|
+
//#endregion
|
|
517
855
|
//#region src/router.ts
|
|
518
856
|
const HTTP_METHODS = [
|
|
519
857
|
"GET",
|
|
@@ -753,20 +1091,16 @@ async function registerPageRoutes(app, options) {
|
|
|
753
1091
|
function getMatchingMiddleware(routePath) {
|
|
754
1092
|
return loadedUserMiddleware.filter((mw) => matchMiddlewarePath(mw.mountPath, routePath)).map((mw) => mw.handler);
|
|
755
1093
|
}
|
|
756
|
-
function
|
|
757
|
-
if (!i18nConfig) return [
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
path: `/${locale}${cleanPath}`
|
|
767
|
-
});
|
|
768
|
-
}
|
|
769
|
-
return result;
|
|
1094
|
+
function getHonoLocalePaths(honoPath) {
|
|
1095
|
+
if (!i18nConfig?.locales?.length || i18nConfig.strategy === "no_prefix" || honoPath === "*") return [{
|
|
1096
|
+
locale: i18nConfig?.defaultLocale || "en",
|
|
1097
|
+
path: honoPath
|
|
1098
|
+
}];
|
|
1099
|
+
return compileLocalePaths(honoPath === "/" ? "/" : honoPath, {
|
|
1100
|
+
defaultLocale: i18nConfig.defaultLocale || "en",
|
|
1101
|
+
locales: i18nConfig.locales,
|
|
1102
|
+
strategy: i18nConfig.strategy || "prefix_except_default"
|
|
1103
|
+
}).hono;
|
|
770
1104
|
}
|
|
771
1105
|
const hasDefaultLayout = layouts.some((l) => l.isDefault);
|
|
772
1106
|
const { isPagesRequest, pageJsonResponse, renderPage, renderPageToStream } = pagesMod;
|
|
@@ -800,7 +1134,7 @@ async function registerPageRoutes(app, options) {
|
|
|
800
1134
|
const actionsExport = mod?.actions;
|
|
801
1135
|
if (actionsExport && typeof actionsExport === "object") {
|
|
802
1136
|
const action = actionsExport[parseFormActionName(c.req.url)];
|
|
803
|
-
if (action && isServerAction(action)) {
|
|
1137
|
+
if (action && isServerAction$1(action)) {
|
|
804
1138
|
const res = await runServerAction(action, c);
|
|
805
1139
|
if (res.response) {
|
|
806
1140
|
const handled = handleActionResponse(c, res.response, isPagesRequest);
|
|
@@ -829,7 +1163,19 @@ async function registerPageRoutes(app, options) {
|
|
|
829
1163
|
actionErrors = { _error: err instanceof Error ? err.message : String(err) };
|
|
830
1164
|
}
|
|
831
1165
|
}
|
|
832
|
-
|
|
1166
|
+
const routeRule = c.get("routeRule");
|
|
1167
|
+
const isrRule = normalizeIsrRule(routeRule?.isr);
|
|
1168
|
+
const pprEnabled = routeRule?.ppr === true;
|
|
1169
|
+
const selected = resolveSelectSsr({
|
|
1170
|
+
pageSsr: page.pageMeta?.ssr,
|
|
1171
|
+
routeRuleSsr: routeRule?.ssr,
|
|
1172
|
+
ppr: pprEnabled,
|
|
1173
|
+
excludedByGlob: matchAnyGlob(page.route, ssrExclude),
|
|
1174
|
+
streaming
|
|
1175
|
+
});
|
|
1176
|
+
const routeStreaming = selected.mode === "streaming";
|
|
1177
|
+
const renderer = selected.useRenderer ? pageRenderer ?? null : null;
|
|
1178
|
+
if (selected.runLoader && mod?.loader && typeof mod.loader === "function") try {
|
|
833
1179
|
loaderResult = await mod.loader(c);
|
|
834
1180
|
if (loaderResult instanceof Response) return loaderResult;
|
|
835
1181
|
} catch {}
|
|
@@ -851,28 +1197,26 @@ async function registerPageRoutes(app, options) {
|
|
|
851
1197
|
locale: currentLocale,
|
|
852
1198
|
localeDir: "ltr"
|
|
853
1199
|
};
|
|
1200
|
+
if (i18nConfig) {
|
|
1201
|
+
renderContext.routing = {
|
|
1202
|
+
defaultLocale: i18nConfig.defaultLocale || "en",
|
|
1203
|
+
locales: i18nConfig.locales || [],
|
|
1204
|
+
strategy: i18nConfig.strategy || "prefix_except_default"
|
|
1205
|
+
};
|
|
1206
|
+
if (i18nConfig.cookieName) renderContext.cookieName = i18nConfig.cookieName;
|
|
1207
|
+
if (i18nConfig.baseUrl) renderContext.baseUrl = i18nConfig.baseUrl;
|
|
1208
|
+
}
|
|
854
1209
|
const i18nMod = await loadI18n();
|
|
855
1210
|
if (i18nMod && currentLocale) {
|
|
856
1211
|
renderContext.localeDir = i18nMod.getLocaleDir(currentLocale);
|
|
857
1212
|
renderContext.messages = i18nMod.getLocaleMessages(currentLocale);
|
|
1213
|
+
const fallback = i18nMod.getFallbackLocale();
|
|
1214
|
+
renderContext.fallbackLocale = fallback;
|
|
1215
|
+
if (fallback !== currentLocale) renderContext.fallbackMessages = i18nMod.getLocaleMessages(fallback);
|
|
858
1216
|
renderContext.availableLocales = i18nMod.getRegisteredLocalesMeta();
|
|
859
1217
|
}
|
|
860
|
-
const
|
|
861
|
-
|
|
862
|
-
const isrRule = normalizeIsrRule(routeRule?.isr);
|
|
863
|
-
const pprEnabled = routeRule?.ppr === true;
|
|
864
|
-
let excluded = matchAnyGlob(page.route, ssrExclude);
|
|
865
|
-
let routeStreaming = streaming;
|
|
866
|
-
if (perRouteSsr === false) excluded = true;
|
|
867
|
-
else if (perRouteSsr === true) excluded = false;
|
|
868
|
-
else if (perRouteSsr === "streaming") {
|
|
869
|
-
excluded = false;
|
|
870
|
-
routeStreaming = true;
|
|
871
|
-
} else if (pprEnabled) {
|
|
872
|
-
excluded = false;
|
|
873
|
-
routeStreaming = true;
|
|
874
|
-
}
|
|
875
|
-
const renderer = excluded ? null : pageRenderer ?? null;
|
|
1218
|
+
const htmlHeaders = { "X-SSR-Mode": ssrModeHeader(selected.mode) };
|
|
1219
|
+
if (pprEnabled) htmlHeaders["X-PPR"] = "streaming";
|
|
876
1220
|
if (isrRule && cacheStore && method === "GET") {
|
|
877
1221
|
const pathname = new URL(c.req.url).pathname;
|
|
878
1222
|
const isrResponse = await serveIsr(c, {
|
|
@@ -883,7 +1227,10 @@ async function registerPageRoutes(app, options) {
|
|
|
883
1227
|
return {
|
|
884
1228
|
html: await renderPage(pageObj, pageAssetTags ?? {}, renderer, "app", renderContext),
|
|
885
1229
|
status: 200,
|
|
886
|
-
headers: {
|
|
1230
|
+
headers: {
|
|
1231
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1232
|
+
...htmlHeaders
|
|
1233
|
+
}
|
|
887
1234
|
};
|
|
888
1235
|
}
|
|
889
1236
|
});
|
|
@@ -893,15 +1240,19 @@ async function registerPageRoutes(app, options) {
|
|
|
893
1240
|
const isBot = botFallback && isBotUserAgent(userAgent);
|
|
894
1241
|
if (routeStreaming && renderer && !isBot) {
|
|
895
1242
|
const stream = renderPageToStream(pageObj, pageAssetTags ?? {}, renderer, "app", renderContext);
|
|
896
|
-
const headers = { "Content-Type": "text/html; charset=utf-8" };
|
|
897
|
-
if (pprEnabled) headers["X-PPR"] = "true";
|
|
898
1243
|
return c.body(stream, {
|
|
899
|
-
headers
|
|
1244
|
+
headers: {
|
|
1245
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1246
|
+
...htmlHeaders
|
|
1247
|
+
},
|
|
900
1248
|
...method === "POST" && actionErrors ? { status: 422 } : {}
|
|
901
1249
|
});
|
|
902
1250
|
}
|
|
903
1251
|
const html = await renderPage(pageObj, pageAssetTags ?? {}, renderer, "app", renderContext);
|
|
904
|
-
return c.html(injectColorMode(html),
|
|
1252
|
+
return c.html(injectColorMode(html), {
|
|
1253
|
+
headers: htmlHeaders,
|
|
1254
|
+
...method === "POST" && actionErrors ? { status: 422 } : {}
|
|
1255
|
+
});
|
|
905
1256
|
}
|
|
906
1257
|
const pageByName = /* @__PURE__ */ new Map();
|
|
907
1258
|
for (const p of pages) pageByName.set(p.name, p);
|
|
@@ -910,14 +1261,6 @@ async function registerPageRoutes(app, options) {
|
|
|
910
1261
|
const honoPath = convertUbeanRoutePath(page.route);
|
|
911
1262
|
const matchingMiddleware = getMatchingMiddleware(honoPath);
|
|
912
1263
|
const pageMeta = { requiresAuth: page.pageMeta?.requiresAuth ?? true };
|
|
913
|
-
const metaMiddleware = async (c, next) => {
|
|
914
|
-
c.set("route", {
|
|
915
|
-
meta: pageMeta,
|
|
916
|
-
path: c.req.path,
|
|
917
|
-
method: c.req.method
|
|
918
|
-
});
|
|
919
|
-
await next();
|
|
920
|
-
};
|
|
921
1264
|
const matcherMiddleware = async (c, next) => {
|
|
922
1265
|
if (!page.matchers) {
|
|
923
1266
|
await next();
|
|
@@ -933,9 +1276,7 @@ async function registerPageRoutes(app, options) {
|
|
|
933
1276
|
};
|
|
934
1277
|
const loaderKey = page.isReuse && page.reuseTarget ? pageByName.get(page.reuseTarget)?.relativePath ?? page.relativePath : page.relativePath;
|
|
935
1278
|
const pageHandler = (method) => async (c) => handlePageRequest(c, page, method, loaderKey);
|
|
936
|
-
|
|
937
|
-
app.on(["POST"], honoPath, metaMiddleware, matcherMiddleware, ...matchingMiddleware, pageHandler("POST"));
|
|
938
|
-
for (const { path: localePath } of getLocalePrefixedPaths(honoPath)) {
|
|
1279
|
+
for (const { path: mountPath } of getHonoLocalePaths(honoPath)) {
|
|
939
1280
|
const localeMetaMiddleware = async (c, next) => {
|
|
940
1281
|
c.set("route", {
|
|
941
1282
|
meta: pageMeta,
|
|
@@ -944,8 +1285,8 @@ async function registerPageRoutes(app, options) {
|
|
|
944
1285
|
});
|
|
945
1286
|
await next();
|
|
946
1287
|
};
|
|
947
|
-
app.on(["GET"],
|
|
948
|
-
app.on(["POST"],
|
|
1288
|
+
app.on(["GET"], mountPath, localeMetaMiddleware, matcherMiddleware, ...matchingMiddleware, pageHandler("GET"));
|
|
1289
|
+
app.on(["POST"], mountPath, localeMetaMiddleware, matcherMiddleware, ...matchingMiddleware, pageHandler("POST"));
|
|
949
1290
|
}
|
|
950
1291
|
}
|
|
951
1292
|
if (options.notFoundPage) {
|
|
@@ -978,6 +1319,9 @@ async function registerPageRoutes(app, options) {
|
|
|
978
1319
|
if (i18nMod && currentLocale) {
|
|
979
1320
|
renderContext.localeDir = i18nMod.getLocaleDir(currentLocale);
|
|
980
1321
|
renderContext.messages = i18nMod.getLocaleMessages(currentLocale);
|
|
1322
|
+
const fallback = i18nMod.getFallbackLocale();
|
|
1323
|
+
renderContext.fallbackLocale = fallback;
|
|
1324
|
+
if (fallback !== currentLocale) renderContext.fallbackMessages = i18nMod.getLocaleMessages(fallback);
|
|
981
1325
|
renderContext.availableLocales = i18nMod.getRegisteredLocalesMeta();
|
|
982
1326
|
}
|
|
983
1327
|
const pageObj = {
|
|
@@ -1085,15 +1429,25 @@ function createInternalAdapter(c, options) {
|
|
|
1085
1429
|
//#region src/openapi.ts
|
|
1086
1430
|
function registerOpenAPIRoutes(app, options = {}) {
|
|
1087
1431
|
const { scalarPath = "/_scalar", openAPIPath = "/_openapi.json", title = "API Reference" } = options;
|
|
1088
|
-
const
|
|
1432
|
+
const documentation = {
|
|
1089
1433
|
info: {
|
|
1090
1434
|
title: options.title || "UBEAN API",
|
|
1091
1435
|
version: options.version || "1.0.0",
|
|
1092
1436
|
description: options.description
|
|
1093
1437
|
},
|
|
1094
1438
|
servers: options.baseURL ? [{ url: options.baseURL }] : void 0
|
|
1095
|
-
}
|
|
1096
|
-
app.get(openAPIPath,
|
|
1439
|
+
};
|
|
1440
|
+
app.get(openAPIPath, async (c) => {
|
|
1441
|
+
const spec = await generateSpecs(app, { documentation });
|
|
1442
|
+
const { paths } = describeActionsOpenApi();
|
|
1443
|
+
return c.json({
|
|
1444
|
+
...spec,
|
|
1445
|
+
paths: {
|
|
1446
|
+
...spec.paths,
|
|
1447
|
+
...paths
|
|
1448
|
+
}
|
|
1449
|
+
});
|
|
1450
|
+
});
|
|
1097
1451
|
app.get(scalarPath, (c) => {
|
|
1098
1452
|
const scalarHTML = `<!doctype html>
|
|
1099
1453
|
<html>
|
|
@@ -1125,4 +1479,4 @@ function registerOpenAPIRoutes(app, options = {}) {
|
|
|
1125
1479
|
});
|
|
1126
1480
|
}
|
|
1127
1481
|
//#endregion
|
|
1128
|
-
export { UbeanRouter, buildIsrCacheKey, clearInternalFetcher, compileRouteRules, createInternalAdapter, createRouteLoader, createRouteRulesMiddleware, createServerRouter, defineHandler, defineHandlerMeta, defineMiddleware, extractRouteMeta, getInternalFetcher, getIsrCache, getIsrRuleFromContext, getStaleIsrCache, invalidateIsrCache, invalidateIsrCachePattern, isBotUserAgent, isHandlerChain, isIsrEntryStale, matchRouteRules, normalizeIsrRule, registerApiRoutes, registerOpenAPIRoutes, registerPageRoutes, registerRoutes, serveIsr, setInternalFetcher, setIsrCache, sortPagesForRegistration, useRouter };
|
|
1482
|
+
export { ACTIONS_ENDPOINT, ACTION_BRAND, ACTION_RESPONSE_HEADER, ActionError, UbeanRouter, applyPathTransform, bindActionContextStorage, buildActionContext, buildFormActionUrl, buildIsrCacheKey, clearActions, clearInternalFetcher, compileRouteRules, createActionId, createActionsMiddleware, createDetachedActionContext, createInternalAdapter, createRouteLoader, createRouteRulesMiddleware, createServerRouter, defineAction, defineHandler, defineHandlerMeta, defineMiddleware, defineServerFn, describeActionsOpenApi, dispatchAction, extractRouteMeta, fail, getAction, getActionContext, getInternalFetcher, getIsrCache, getIsrRuleFromContext, getStaleIsrCache, hasAction, hasFormAction, invalidateIsrCache, invalidateIsrCachePattern, invokeServerFn, isActionFailure, isActionResponse, isActionsRequest, isBotUserAgent, isHandlerChain, isIsrEntryStale, isServerAction, isValidActionId, listActions, matchRouteRules, normalizeActionResult, normalizeIsrRule, parseActionInput, parseFormActionName, registerAction, registerActions, registerApiRoutes, registerOpenAPIRoutes, registerPageRoutes, registerRoutes, resolveSelectSsr, runAction, runPageAction, runWithActionContext, serveIsr, setInternalFetcher, setIsrCache, sortPagesForRegistration, ssrModeHeader, unwrapActionResult, unwrapServerFnResult, useRouter, validateActionInput };
|