@finesoft/front 0.1.38 → 0.1.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,6 +50,9 @@ $$
50
50
  - `HttpClient`
51
51
  - `LruMap`
52
52
  - `buildUrl`
53
+ - `next` / `redirect` / `rewrite` / `deny`
54
+ - `createBrowserContext` / `createServerContext`
55
+ - `BeforeLoadGuard` / `AfterLoadGuard`
53
56
 
54
57
  ### Browser
55
58
 
@@ -275,6 +278,73 @@ export function bootstrap(framework: Framework): void {
275
278
  export { routes };
276
279
  ```
277
280
 
281
+ ### 3.1 可选:注册导航守卫
282
+
283
+ 框架提供两阶段守卫:
284
+
285
+ - `beforeLoad`:路由匹配后、数据加载前执行。适合认证、权限校验、提前重定向。
286
+ - `afterLoad`:数据加载后、渲染前执行。适合基于页面数据做 URL 规范化、二次校验。**这个阶段可以直接拿到 `ctx.page`。**
287
+
288
+ 既支持**全局守卫**,也支持**路由级守卫**。
289
+
290
+ ```ts
291
+ import {
292
+ Framework,
293
+ defineRoutes,
294
+ next,
295
+ redirect,
296
+ rewrite,
297
+ type AfterLoadGuard,
298
+ type BeforeLoadGuard,
299
+ type RouteDefinition,
300
+ } from "@finesoft/front";
301
+
302
+ const requireLogin: BeforeLoadGuard = (ctx) => {
303
+ if (!ctx.getCookie("session")) {
304
+ return redirect("/login");
305
+ }
306
+ return next();
307
+ };
308
+
309
+ const canonicalizeProductUrl: AfterLoadGuard = (ctx) => {
310
+ const slug = ctx.page.title.toLowerCase().replace(/\s+/g, "-");
311
+ const expected = `/product/${slug}/${ctx.page.id}`;
312
+
313
+ return ctx.path === expected ? next() : rewrite(expected);
314
+ };
315
+
316
+ const routes: RouteDefinition[] = [
317
+ {
318
+ path: "/account",
319
+ intentId: "account-page",
320
+ controller: new AccountController(),
321
+ beforeLoad: [requireLogin],
322
+ },
323
+ {
324
+ path: "/product/:id",
325
+ intentId: "product-page",
326
+ controller: new ProductController(),
327
+ afterLoad: [canonicalizeProductUrl],
328
+ },
329
+ ];
330
+
331
+ export function bootstrap(framework: Framework): void {
332
+ framework.beforeLoad((ctx) => {
333
+ console.log("global beforeLoad:", ctx.path);
334
+ return next();
335
+ });
336
+
337
+ framework.afterLoad((ctx) => {
338
+ console.log("global afterLoad page:", ctx.page.pageType);
339
+ return next();
340
+ });
341
+
342
+ defineRoutes(framework, routes);
343
+ }
344
+ ```
345
+
346
+ 执行顺序为:**全局守卫 → 路由级守卫**。按注册顺序依次执行,遇到第一个非 `next()` 结果会立即短路。
347
+
278
348
  #### 为什么 `static` 模式建议导出 `routes`
279
349
 
280
350
  `staticAdapter()` 会在构建期读取你的路由定义,并自动预渲染无参数路由。
@@ -0,0 +1,10 @@
1
+ import {
2
+ createSSRApp
3
+ } from "./chunk-OYTIGVEG.js";
4
+ import "./chunk-XQ3UWZOS.js";
5
+ import "./chunk-SDPWQT2T.js";
6
+ import "./chunk-PSPVIVC2.js";
7
+ export {
8
+ createSSRApp
9
+ };
10
+ //# sourceMappingURL=app-XEMPAI6H.js.map
package/dist/browser.cjs CHANGED
@@ -39,8 +39,11 @@ __export(browser_exports, {
39
39
  PrefetchedIntents: () => PrefetchedIntents,
40
40
  Router: () => Router,
41
41
  buildUrl: () => buildUrl,
42
+ createBrowserContext: () => createBrowserContext,
42
43
  createPrefetchedIntentsFromDom: () => createPrefetchedIntentsFromDom,
44
+ createServerContext: () => createServerContext,
43
45
  defineRoutes: () => defineRoutes,
46
+ deny: () => deny,
44
47
  deserializeServerData: () => deserializeServerData,
45
48
  generateUuid: () => generateUuid,
46
49
  getBaseUrl: () => getBaseUrl,
@@ -53,8 +56,10 @@ __export(browser_exports, {
53
56
  makeExternalUrlAction: () => makeExternalUrlAction,
54
57
  makeFlowAction: () => makeFlowAction,
55
58
  mapEach: () => mapEach,
59
+ next: () => next,
56
60
  pipe: () => pipe,
57
61
  pipeAsync: () => pipeAsync,
62
+ redirect: () => redirect,
58
63
  registerActionHandlers: () => registerActionHandlers,
59
64
  registerExternalUrlHandler: () => registerExternalUrlHandler,
60
65
  registerFlowActionHandler: () => registerFlowActionHandler,
@@ -62,6 +67,9 @@ __export(browser_exports, {
62
67
  removeQueryParams: () => removeQueryParams,
63
68
  removeScheme: () => removeScheme,
64
69
  resetFilterCache: () => resetFilterCache,
70
+ rewrite: () => rewrite,
71
+ runAfterLoadGuards: () => runAfterLoadGuards,
72
+ runBeforeLoadGuards: () => runBeforeLoadGuards,
65
73
  shouldLog: () => shouldLog,
66
74
  stableStringify: () => stableStringify,
67
75
  startBrowserApp: () => startBrowserApp,
@@ -379,7 +387,8 @@ function makeDependencies(container, options = {}) {
379
387
  var Router = class {
380
388
  routes = [];
381
389
  /** 添加路由规则 */
382
- add(pattern, intentId, renderMode) {
390
+ add(pattern, intentId, renderModeOrOptions) {
391
+ const opts = typeof renderModeOrOptions === "string" ? { renderMode: renderModeOrOptions } : renderModeOrOptions ?? {};
383
392
  const paramNames = [];
384
393
  const regexStr = pattern.split(/(\/:[\w]+\??)/).map((segment) => {
385
394
  const paramMatch = segment.match(/^\/:(\w+)(\?)?$/);
@@ -394,7 +403,9 @@ var Router = class {
394
403
  intentId,
395
404
  regex: new RegExp(`^${regexStr}/?$`),
396
405
  paramNames,
397
- renderMode
406
+ renderMode: opts.renderMode,
407
+ beforeGuards: opts.beforeGuards,
408
+ afterGuards: opts.afterGuards
398
409
  });
399
410
  return this;
400
411
  }
@@ -416,7 +427,9 @@ var Router = class {
416
427
  return {
417
428
  intent: { id: route.intentId, params },
418
429
  action: makeFlowAction(urlOrPath),
419
- renderMode: route.renderMode
430
+ renderMode: route.renderMode,
431
+ beforeGuards: route.beforeGuards,
432
+ afterGuards: route.afterGuards
420
433
  };
421
434
  }
422
435
  }
@@ -483,6 +496,22 @@ var CompositeLogger = class {
483
496
  }
484
497
  };
485
498
 
499
+ // ../core/src/middleware/pipeline.ts
500
+ async function runBeforeLoadGuards(guards, ctx) {
501
+ for (const guard of guards) {
502
+ const result = await guard(ctx);
503
+ if (result.kind !== "next") return result;
504
+ }
505
+ return { kind: "next" };
506
+ }
507
+ async function runAfterLoadGuards(guards, ctx) {
508
+ for (const guard of guards) {
509
+ const result = await guard(ctx);
510
+ if (result.kind !== "next") return result;
511
+ }
512
+ return { kind: "next" };
513
+ }
514
+
486
515
  // ../core/src/prefetched-intents/stable-stringify.ts
487
516
  function stableStringify(obj, _seen) {
488
517
  if (obj === null || obj === void 0) return String(obj);
@@ -551,6 +580,8 @@ var Framework = class _Framework {
551
580
  actionDispatcher;
552
581
  router;
553
582
  prefetchedIntents;
583
+ beforeGuards = [];
584
+ afterGuards = [];
554
585
  constructor(container, prefetchedIntents) {
555
586
  this.container = container;
556
587
  this.intentDispatcher = new IntentDispatcher();
@@ -614,6 +645,25 @@ var Framework = class _Framework {
614
645
  registerIntent(controller) {
615
646
  this.intentDispatcher.register(controller);
616
647
  }
648
+ // ===== Navigation Middleware =====
649
+ /** 注册 beforeLoad 守卫(路由匹配后、数据加载前) */
650
+ beforeLoad(guard) {
651
+ this.beforeGuards.push(guard);
652
+ }
653
+ /** 注册 afterLoad 守卫(数据加载后、渲染前) */
654
+ afterLoad(guard) {
655
+ this.afterGuards.push(guard);
656
+ }
657
+ /** 执行所有 beforeLoad 守卫(全局 → 路由级) */
658
+ runBeforeLoad(ctx, routeGuards) {
659
+ const guards = routeGuards?.length ? [...this.beforeGuards, ...routeGuards] : this.beforeGuards;
660
+ return runBeforeLoadGuards(guards, ctx);
661
+ }
662
+ /** 执行所有 afterLoad 守卫(全局 → 路由级) */
663
+ runAfterLoad(ctx, routeGuards) {
664
+ const guards = routeGuards?.length ? [...this.afterGuards, ...routeGuards] : this.afterGuards;
665
+ return runAfterLoadGuards(guards, ctx);
666
+ }
617
667
  /** 销毁 Framework 实例 */
618
668
  dispose() {
619
669
  this.container.dispose();
@@ -758,7 +808,11 @@ function defineRoutes(framework, definitions) {
758
808
  framework.registerIntent(def.controller);
759
809
  registeredIntents.add(def.intentId);
760
810
  }
761
- framework.router.add(def.path, def.intentId, def.renderMode);
811
+ framework.router.add(def.path, def.intentId, {
812
+ renderMode: def.renderMode,
813
+ beforeGuards: def.beforeLoad,
814
+ afterGuards: def.afterLoad
815
+ });
762
816
  }
763
817
  }
764
818
 
@@ -852,6 +906,64 @@ function generateUuid() {
852
906
  });
853
907
  }
854
908
 
909
+ // ../core/src/middleware/context.ts
910
+ function parseCookieString(str) {
911
+ const map = /* @__PURE__ */ new Map();
912
+ if (!str) return map;
913
+ for (const pair of str.split(";")) {
914
+ const idx = pair.indexOf("=");
915
+ if (idx === -1) continue;
916
+ const key = pair.slice(0, idx).trim();
917
+ const val = pair.slice(idx + 1).trim();
918
+ if (key) map.set(key, val);
919
+ }
920
+ return map;
921
+ }
922
+ function createServerContext(options) {
923
+ const { url, intent, container, request } = options;
924
+ const parsed = new URL(url, "http://localhost");
925
+ const cookieHeader = request?.headers.get("cookie") ?? "";
926
+ const cookies = parseCookieString(cookieHeader);
927
+ return {
928
+ url,
929
+ path: parsed.pathname,
930
+ params: intent.params ?? {},
931
+ intent,
932
+ isServer: true,
933
+ container,
934
+ getCookie: (name) => cookies.get(name),
935
+ getHeader: (name) => request?.headers.get(name) ?? void 0
936
+ };
937
+ }
938
+ function createBrowserContext(options) {
939
+ const { url, intent, container } = options;
940
+ const parsed = new URL(url, window.location.origin);
941
+ return {
942
+ url,
943
+ path: parsed.pathname,
944
+ params: intent.params ?? {},
945
+ intent,
946
+ isServer: false,
947
+ container,
948
+ getCookie: (name) => parseCookieString(document.cookie).get(name),
949
+ getHeader: () => void 0
950
+ };
951
+ }
952
+
953
+ // ../core/src/middleware/types.ts
954
+ function next() {
955
+ return { kind: "next" };
956
+ }
957
+ function redirect(url, status = 302) {
958
+ return { kind: "redirect", url, status };
959
+ }
960
+ function rewrite(url) {
961
+ return { kind: "rewrite", url };
962
+ }
963
+ function deny(status = 403, message = "Forbidden") {
964
+ return { kind: "deny", status, message };
965
+ }
966
+
855
967
  // ../browser/src/action-handlers/external-url-action.ts
856
968
  function registerExternalUrlHandler(deps) {
857
969
  const { framework, log } = deps;
@@ -1014,31 +1126,49 @@ function registerFlowActionHandler(deps) {
1014
1126
  const { framework, log, callbacks, updateApp } = deps;
1015
1127
  let isFirstPage = true;
1016
1128
  let navigationId = 0;
1129
+ const MAX_REDIRECTS = 5;
1017
1130
  const defaultGetScrollable = () => document.getElementById("scrollable-page-override") || document.getElementById("scrollable-page") || document.documentElement;
1018
1131
  const history = new History(log, {
1019
1132
  getScrollablePageElement: deps.getScrollablePageElement ?? defaultGetScrollable
1020
1133
  });
1021
- framework.onAction(ACTION_KINDS.FLOW, async (action) => {
1022
- const flowAction = action;
1023
- const url = flowAction.url;
1024
- log.debug(`FlowAction \u2192 ${url}`);
1025
- if (flowAction.presentationContext === "modal") {
1026
- const match2 = framework.routeUrl(url);
1027
- if (match2) {
1028
- const page = await framework.dispatch(
1029
- match2.intent
1030
- );
1031
- callbacks.onModal(page);
1032
- }
1134
+ async function navigateTo(url, redirectCount, thisNav) {
1135
+ if (redirectCount >= MAX_REDIRECTS) {
1136
+ log.error(
1137
+ `Navigation redirect loop detected (${MAX_REDIRECTS} redirects), stopping at: ${url}`
1138
+ );
1033
1139
  return;
1034
1140
  }
1035
- const thisNav = ++navigationId;
1036
1141
  const shouldReplace = isFirstPage || url === window.location.pathname + window.location.search;
1037
1142
  const match = framework.routeUrl(url);
1038
1143
  if (!match) {
1039
1144
  log.warn(`FlowAction: no route for ${url}`);
1040
1145
  return;
1041
1146
  }
1147
+ const navCtx = createBrowserContext({
1148
+ url,
1149
+ intent: match.intent,
1150
+ container: framework.container
1151
+ });
1152
+ const beforeResult = await framework.runBeforeLoad(
1153
+ navCtx,
1154
+ match.beforeGuards
1155
+ );
1156
+ if (beforeResult.kind === "redirect") {
1157
+ log.debug(`beforeLoad \u2192 redirect to ${beforeResult.url}`);
1158
+ await navigateTo(beforeResult.url, redirectCount + 1, thisNav);
1159
+ return;
1160
+ }
1161
+ if (beforeResult.kind === "deny") {
1162
+ log.warn(
1163
+ `beforeLoad \u2192 denied (${beforeResult.status}): ${beforeResult.message}`
1164
+ );
1165
+ return;
1166
+ }
1167
+ if (beforeResult.kind === "rewrite") {
1168
+ log.debug(`beforeLoad \u2192 rewrite to ${beforeResult.url}`);
1169
+ await navigateTo(beforeResult.url, redirectCount + 1, thisNav);
1170
+ return;
1171
+ }
1042
1172
  const pagePromise = framework.dispatch(
1043
1173
  match.intent
1044
1174
  );
@@ -1054,12 +1184,33 @@ function registerFlowActionHandler(deps) {
1054
1184
  history.beforeTransition();
1055
1185
  updateApp({
1056
1186
  page: pagePromise.then(
1057
- (page) => {
1187
+ async (page) => {
1058
1188
  if (thisNav !== navigationId) {
1059
1189
  log.info("FlowAction commit superseded", url);
1060
1190
  return page;
1061
1191
  }
1062
- const canonicalURL = url;
1192
+ const postCtx = {
1193
+ ...navCtx,
1194
+ page
1195
+ };
1196
+ const afterResult = await framework.runAfterLoad(
1197
+ postCtx,
1198
+ match.afterGuards
1199
+ );
1200
+ if (afterResult.kind === "redirect") {
1201
+ log.debug(`afterLoad \u2192 redirect to ${afterResult.url}`);
1202
+ navigateTo(afterResult.url, redirectCount + 1, thisNav);
1203
+ return page;
1204
+ }
1205
+ let canonicalURL = url;
1206
+ if (afterResult.kind === "rewrite") {
1207
+ canonicalURL = afterResult.url;
1208
+ log.debug(`afterLoad \u2192 rewrite URL to ${canonicalURL}`);
1209
+ }
1210
+ if (afterResult.kind === "deny") {
1211
+ log.warn(`afterLoad \u2192 denied (${afterResult.status})`);
1212
+ return page;
1213
+ }
1063
1214
  if (shouldReplace) {
1064
1215
  history.replaceState({ page }, canonicalURL);
1065
1216
  } else {
@@ -1089,6 +1240,23 @@ function registerFlowActionHandler(deps) {
1089
1240
  isFirstPage
1090
1241
  });
1091
1242
  isFirstPage = false;
1243
+ }
1244
+ framework.onAction(ACTION_KINDS.FLOW, async (action) => {
1245
+ const flowAction = action;
1246
+ const url = flowAction.url;
1247
+ log.debug(`FlowAction \u2192 ${url}`);
1248
+ if (flowAction.presentationContext === "modal") {
1249
+ const match = framework.routeUrl(url);
1250
+ if (match) {
1251
+ const page = await framework.dispatch(
1252
+ match.intent
1253
+ );
1254
+ callbacks.onModal(page);
1255
+ }
1256
+ return;
1257
+ }
1258
+ const thisNav = ++navigationId;
1259
+ await navigateTo(url, 0, thisNav);
1092
1260
  });
1093
1261
  history.onPopState(async (url, cachedState) => {
1094
1262
  log.debug(`popstate \u2192 ${url}, cached=${!!cachedState}`);
@@ -1113,6 +1281,30 @@ function registerFlowActionHandler(deps) {
1113
1281
  });
1114
1282
  return;
1115
1283
  }
1284
+ const navCtx = createBrowserContext({
1285
+ url: parsed.pathname + parsed.search,
1286
+ intent: routeMatch.intent,
1287
+ container: framework.container
1288
+ });
1289
+ const beforeResult = await framework.runBeforeLoad(
1290
+ navCtx,
1291
+ routeMatch.beforeGuards
1292
+ );
1293
+ if (beforeResult.kind === "redirect") {
1294
+ log.debug(`popstate beforeLoad \u2192 redirect to ${beforeResult.url}`);
1295
+ const thisNav = ++navigationId;
1296
+ await navigateTo(beforeResult.url, 0, thisNav);
1297
+ return;
1298
+ }
1299
+ if (beforeResult.kind === "deny" || beforeResult.kind === "rewrite") {
1300
+ if (beforeResult.kind === "deny") {
1301
+ log.warn(`popstate beforeLoad \u2192 denied`);
1302
+ } else {
1303
+ const thisNav = ++navigationId;
1304
+ await navigateTo(beforeResult.url, 0, thisNav);
1305
+ }
1306
+ return;
1307
+ }
1116
1308
  const pagePromise = framework.dispatch(
1117
1309
  routeMatch.intent
1118
1310
  );
@@ -1234,8 +1426,11 @@ async function startBrowserApp(config) {
1234
1426
  PrefetchedIntents,
1235
1427
  Router,
1236
1428
  buildUrl,
1429
+ createBrowserContext,
1237
1430
  createPrefetchedIntentsFromDom,
1431
+ createServerContext,
1238
1432
  defineRoutes,
1433
+ deny,
1239
1434
  deserializeServerData,
1240
1435
  generateUuid,
1241
1436
  getBaseUrl,
@@ -1248,8 +1443,10 @@ async function startBrowserApp(config) {
1248
1443
  makeExternalUrlAction,
1249
1444
  makeFlowAction,
1250
1445
  mapEach,
1446
+ next,
1251
1447
  pipe,
1252
1448
  pipeAsync,
1449
+ redirect,
1253
1450
  registerActionHandlers,
1254
1451
  registerExternalUrlHandler,
1255
1452
  registerFlowActionHandler,
@@ -1257,6 +1454,9 @@ async function startBrowserApp(config) {
1257
1454
  removeQueryParams,
1258
1455
  removeScheme,
1259
1456
  resetFilterCache,
1457
+ rewrite,
1458
+ runAfterLoadGuards,
1459
+ runBeforeLoadGuards,
1260
1460
  shouldLog,
1261
1461
  stableStringify,
1262
1462
  startBrowserApp,