@finesoft/front 0.1.37 → 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;
@@ -967,6 +1079,22 @@ var History = class {
967
1079
  tryScroll(this.log, () => this.getScrollablePageElement(), scrollY);
968
1080
  });
969
1081
  }
1082
+ /** 仅推入 URL,不缓存页面状态(用于页面加载失败场景) */
1083
+ pushUrl(url) {
1084
+ const id = generateUuid();
1085
+ window.history.pushState({ id }, "", url);
1086
+ this.currentStateId = id;
1087
+ this.scrollTop = 0;
1088
+ this.log.info("pushUrl (no state)", url, id);
1089
+ }
1090
+ /** 仅替换 URL,不缓存页面状态(用于页面加载失败场景) */
1091
+ replaceUrl(url) {
1092
+ const id = generateUuid();
1093
+ window.history.replaceState({ id }, "", url);
1094
+ this.currentStateId = id;
1095
+ this.scrollTop = 0;
1096
+ this.log.info("replaceUrl (no state)", url, id);
1097
+ }
970
1098
  updateState(update) {
971
1099
  if (!this.currentStateId) {
972
1100
  this.log.warn(
@@ -998,31 +1126,49 @@ function registerFlowActionHandler(deps) {
998
1126
  const { framework, log, callbacks, updateApp } = deps;
999
1127
  let isFirstPage = true;
1000
1128
  let navigationId = 0;
1129
+ const MAX_REDIRECTS = 5;
1001
1130
  const defaultGetScrollable = () => document.getElementById("scrollable-page-override") || document.getElementById("scrollable-page") || document.documentElement;
1002
1131
  const history = new History(log, {
1003
1132
  getScrollablePageElement: deps.getScrollablePageElement ?? defaultGetScrollable
1004
1133
  });
1005
- framework.onAction(ACTION_KINDS.FLOW, async (action) => {
1006
- const flowAction = action;
1007
- const url = flowAction.url;
1008
- log.debug(`FlowAction \u2192 ${url}`);
1009
- if (flowAction.presentationContext === "modal") {
1010
- const match2 = framework.routeUrl(url);
1011
- if (match2) {
1012
- const page = await framework.dispatch(
1013
- match2.intent
1014
- );
1015
- callbacks.onModal(page);
1016
- }
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
+ );
1017
1139
  return;
1018
1140
  }
1019
- const thisNav = ++navigationId;
1020
- const shouldReplace = isFirstPage;
1141
+ const shouldReplace = isFirstPage || url === window.location.pathname + window.location.search;
1021
1142
  const match = framework.routeUrl(url);
1022
1143
  if (!match) {
1023
1144
  log.warn(`FlowAction: no route for ${url}`);
1024
1145
  return;
1025
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
+ }
1026
1172
  const pagePromise = framework.dispatch(
1027
1173
  match.intent
1028
1174
  );
@@ -1037,26 +1183,80 @@ function registerFlowActionHandler(deps) {
1037
1183
  }
1038
1184
  history.beforeTransition();
1039
1185
  updateApp({
1040
- page: pagePromise.then((page) => {
1041
- if (thisNav !== navigationId) {
1042
- log.info("FlowAction commit superseded", url);
1186
+ page: pagePromise.then(
1187
+ async (page) => {
1188
+ if (thisNav !== navigationId) {
1189
+ log.info("FlowAction commit superseded", url);
1190
+ return page;
1191
+ }
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
+ }
1214
+ if (shouldReplace) {
1215
+ history.replaceState({ page }, canonicalURL);
1216
+ } else {
1217
+ history.pushState({ page }, canonicalURL);
1218
+ }
1219
+ callbacks.onNavigate(
1220
+ new URL(canonicalURL, window.location.origin).pathname
1221
+ );
1222
+ didEnterPage(page);
1043
1223
  return page;
1224
+ },
1225
+ (error) => {
1226
+ if (thisNav === navigationId) {
1227
+ const canonicalURL = url;
1228
+ if (shouldReplace) {
1229
+ history.replaceUrl(canonicalURL);
1230
+ } else {
1231
+ history.pushUrl(canonicalURL);
1232
+ }
1233
+ callbacks.onNavigate(
1234
+ new URL(canonicalURL, window.location.origin).pathname
1235
+ );
1236
+ }
1237
+ throw error;
1044
1238
  }
1045
- const canonicalURL = url;
1046
- if (shouldReplace) {
1047
- history.replaceState({ page }, canonicalURL);
1048
- } else {
1049
- history.pushState({ page }, canonicalURL);
1050
- }
1051
- callbacks.onNavigate(
1052
- new URL(canonicalURL, window.location.origin).pathname
1053
- );
1054
- didEnterPage(page);
1055
- return page;
1056
- }),
1239
+ ),
1057
1240
  isFirstPage
1058
1241
  });
1059
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);
1060
1260
  });
1061
1261
  history.onPopState(async (url, cachedState) => {
1062
1262
  log.debug(`popstate \u2192 ${url}, cached=${!!cachedState}`);
@@ -1081,6 +1281,30 @@ function registerFlowActionHandler(deps) {
1081
1281
  });
1082
1282
  return;
1083
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
+ }
1084
1308
  const pagePromise = framework.dispatch(
1085
1309
  routeMatch.intent
1086
1310
  );
@@ -1202,8 +1426,11 @@ async function startBrowserApp(config) {
1202
1426
  PrefetchedIntents,
1203
1427
  Router,
1204
1428
  buildUrl,
1429
+ createBrowserContext,
1205
1430
  createPrefetchedIntentsFromDom,
1431
+ createServerContext,
1206
1432
  defineRoutes,
1433
+ deny,
1207
1434
  deserializeServerData,
1208
1435
  generateUuid,
1209
1436
  getBaseUrl,
@@ -1216,8 +1443,10 @@ async function startBrowserApp(config) {
1216
1443
  makeExternalUrlAction,
1217
1444
  makeFlowAction,
1218
1445
  mapEach,
1446
+ next,
1219
1447
  pipe,
1220
1448
  pipeAsync,
1449
+ redirect,
1221
1450
  registerActionHandlers,
1222
1451
  registerExternalUrlHandler,
1223
1452
  registerFlowActionHandler,
@@ -1225,6 +1454,9 @@ async function startBrowserApp(config) {
1225
1454
  removeQueryParams,
1226
1455
  removeScheme,
1227
1456
  resetFilterCache,
1457
+ rewrite,
1458
+ runAfterLoadGuards,
1459
+ runBeforeLoadGuards,
1228
1460
  shouldLog,
1229
1461
  stableStringify,
1230
1462
  startBrowserApp,