@iyulab/router 0.8.0 → 0.9.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.1] - 2026-04-08
4
+
5
+ ### Fixed
6
+ - Redirect cycle detection no longer triggers false positives on initial navigation — `visit()` check is now skipped for the first `go()` call and only applied within redirect chains (`isRedirect: true`)
7
+
8
+ ## [0.9.0] - 2026-04-08
9
+
10
+ ### Added
11
+ - `NavigateOptions` — new optional second parameter for `go()` with `isRedirect`, `replace`, and `state` fields
12
+ - `replace` option — navigate without pushing a new browser history entry (`replaceState`)
13
+ - `state` option — attach custom state object to `history.pushState` / `replaceState`
14
+ - `AccessDeniedError` — new error class (HTTP 403) thrown when an `enter` guard returns `false`; renders an error page with `ACCESS_DENIED` code
15
+ - Redirect cycle detection — logs an error and halts routing if the same URL is visited more than once within a single navigation chain
16
+ - `UErrorPage` now accepts an optional `RouteError` in its constructor for direct instantiation
17
+
18
+ ### Changed
19
+ - **Breaking:** `enter` returning `false` now throws `AccessDeniedError` and renders a 403 error page instead of silently aborting navigation
20
+ - Redirect navigations (`isRedirect: true`) use `replaceState` — intermediate redirect URLs are no longer pushed onto the browser history stack
21
+ - Global `enter` guard is skipped on redirect hops — runs only once per user-initiated navigation
22
+ - Route-level `enter` hooks are deduplicated within a redirect chain — each route's `enter` executes at most once per navigation cycle
23
+ - `document.title` is now updated in a `finally` block — title is set regardless of whether routing succeeds or fails
24
+
3
25
  ## [0.8.0] - 2026-04-08
4
26
 
5
27
  ### Added
package/dist/index.d.ts CHANGED
@@ -3,6 +3,13 @@ import { LitElement } from 'lit';
3
3
  import { PropertyValues } from 'lit';
4
4
  import { TemplateResult } from 'lit-html';
5
5
 
6
+ /**
7
+ * enter 가드가 false를 반환하여 접근이 거부되었을 때 발생하는 에러
8
+ */
9
+ export declare class AccessDeniedError extends RouteError {
10
+ constructor(path: string);
11
+ }
12
+
6
13
  /**
7
14
  * 공통 라우트 속성
8
15
  */
@@ -135,6 +142,32 @@ declare interface IndexRouteConfig extends BaseRouteConfig {
135
142
  index: true;
136
143
  }
137
144
 
145
+ /**
146
+ * go() 메서드에 전달할 네비게이션 옵션
147
+ */
148
+ export declare interface NavigateOptions {
149
+ /**
150
+ * 리다이렉트로 인한 네비게이션 여부.
151
+ * - `true`이면 히스토리에 새 항목을 추가하지 않고 현재 항목을 교체합니다(replaceState).
152
+ * - 뒤로가기 버튼이 리다이렉트 경유지를 건너뛰게 됩니다.
153
+ * - 리다이렉트 사이클 감지에 사용됩니다.
154
+ * @default false
155
+ */
156
+ isRedirect?: boolean;
157
+ /**
158
+ * 히스토리에 새 항목을 추가하지 않고 현재 항목을 교체합니다(replaceState).
159
+ * - `isRedirect`와 달리 리다이렉트 체인 추적에는 영향을 주지 않습니다.
160
+ * @default false
161
+ */
162
+ replace?: boolean;
163
+ /**
164
+ * pushState / replaceState 호출 시 함께 저장할 커스텀 상태 객체.
165
+ * - `history.state`로 다시 읽을 수 있습니다.
166
+ * @example { from: '/login', referrer: 'email-link' }
167
+ */
168
+ state?: Record<string, unknown>;
169
+ }
170
+
138
171
  declare interface NonIndexRouteConfig extends BaseRouteConfig {
139
172
  /**
140
173
  * 인덱스 라우트가 아님을 나타냅니다.
@@ -326,6 +359,7 @@ export declare class Router {
326
359
  private readonly _routes;
327
360
  private readonly _fallback?;
328
361
  private readonly _enter?;
362
+ private readonly _tracker;
329
363
  /** 현재 라우팅 요청 ID */
330
364
  private _requestID?;
331
365
  /** 현재 라우팅 정보 */
@@ -342,8 +376,9 @@ export declare class Router {
342
376
  /**
343
377
  * 지정한 경로의 클라이언트 라우팅을 수행합니다. 상대경로일 경우 basepath와 조합되어 이동합니다.
344
378
  * @param href 이동할 경로
379
+ * @param options 네비게이션 옵션
345
380
  */
346
- go(href: string): Promise<undefined>;
381
+ go(href: string, options?: NavigateOptions): Promise<undefined>;
347
382
  /** 브라우저 히스토리 이벤트가 발생시 라우팅 처리 */
348
383
  private handleWindowPopstate;
349
384
  /** 클릭 이벤트에서 라우터로 처리할 앵커를 찾아 클라이언트 라우팅 수행 */
package/dist/index.js CHANGED
@@ -24,6 +24,14 @@ var NotFoundError = class extends RouteError {
24
24
  }
25
25
  };
26
26
  /**
27
+ * enter 가드가 false를 반환하여 접근이 거부되었을 때 발생하는 에러
28
+ */
29
+ var AccessDeniedError = class extends RouteError {
30
+ constructor(path) {
31
+ super(403, `Access denied: ${path}`);
32
+ }
33
+ };
34
+ /**
27
35
  * u-outlet 요소를 찾을 수 없을 때 발생하는 에러
28
36
  */
29
37
  var OutletMissingError = class extends RouteError {
@@ -105,8 +113,12 @@ var RouteErrorEvent = class extends RouteEvent {
105
113
  };
106
114
  //#endregion
107
115
  //#region src/components/UErrorPage.ts
108
- var _ref;
116
+ var _ref, _ref2;
109
117
  var UErrorPage = class UErrorPage extends LitElement {
118
+ constructor(error) {
119
+ super();
120
+ this.error = error;
121
+ }
110
122
  render() {
111
123
  const error = this.error || this.getDefaultError();
112
124
  return html`
@@ -121,14 +133,13 @@ var UErrorPage = class UErrorPage extends LitElement {
121
133
  }
122
134
  /** 에러 코드에 따른 기본 아이콘 반환 */
123
135
  getErrorIcon(code) {
124
- const codeStr = String(code);
125
- const numericCode = typeof code === "string" ? parseInt(code) : code;
126
- switch (codeStr) {
136
+ switch (String(code)) {
127
137
  case "OUTLET_MISSING": return "📦";
128
138
  case "CONTENT_LOAD_FAILED": return "📡";
129
139
  case "CONTENT_RENDER_FAILED": return "🎨";
140
+ case "ACCESS_DENIED": return "🚫";
130
141
  }
131
- switch (numericCode) {
142
+ switch (typeof code === "string" ? parseInt(code) : code) {
132
143
  case 404: return "🔍";
133
144
  case 403: return "🚫";
134
145
  case 401: return "🔐";
@@ -194,8 +205,8 @@ var UErrorPage = class UErrorPage extends LitElement {
194
205
  `;
195
206
  }
196
207
  };
197
- __decorate([property({ type: Object }), __decorateMetadata("design:type", typeof (_ref = typeof RouteError !== "undefined" && RouteError) === "function" ? _ref : Object)], UErrorPage.prototype, "error", void 0);
198
- UErrorPage = __decorate([customElement("u-error-page")], UErrorPage);
208
+ __decorate([property({ type: Object }), __decorateMetadata("design:type", typeof (_ref2 = typeof RouteError !== "undefined" && RouteError) === "function" ? _ref2 : Object)], UErrorPage.prototype, "error", void 0);
209
+ UErrorPage = __decorate([customElement("u-error-page"), __decorateMetadata("design:paramtypes", [typeof (_ref = typeof RouteError !== "undefined" && RouteError) === "function" ? _ref : Object])], UErrorPage);
199
210
  //#endregion
200
211
  //#region src/internals/crypto-helpers.ts
201
212
  /**
@@ -323,15 +334,55 @@ function getRoutes(routes, pathname) {
323
334
  return [];
324
335
  }
325
336
  //#endregion
337
+ //#region src/internals/RouteTracker.ts
338
+ /**
339
+ * 라우팅 체인 상태를 추적합니다.
340
+ * - 방문한 href를 기록하여 리다이렉트 사이클을 감지합니다.
341
+ * - 이미 실행된 route enter를 기록하여 중복 실행을 방지합니다.
342
+ */
343
+ var RouteTracker = class {
344
+ constructor() {
345
+ this._history = /* @__PURE__ */ new Set();
346
+ this._processed = /* @__PURE__ */ new Set();
347
+ }
348
+ /** 새 네비게이션 시작 시 상태를 초기화합니다. */
349
+ reset() {
350
+ this._history.clear();
351
+ this._processed.clear();
352
+ }
353
+ /**
354
+ * href 방문을 기록하고 사이클 여부를 반환합니다.
355
+ * @returns 사이클이 감지되면 true
356
+ */
357
+ visit(href) {
358
+ if (this._history.has(href)) {
359
+ console.error("Router: Redirect cycle detected:", [...this._history, href].join(" → "));
360
+ return true;
361
+ }
362
+ this._history.add(href);
363
+ return false;
364
+ }
365
+ /**
366
+ * route enter를 아직 실행하지 않았다면 키를 등록하고 true를 반환합니다.
367
+ * 이미 실행된 route라면 false를 반환합니다 (중첩 라우트 redirect 체인에서 중복 방지).
368
+ */
369
+ enter(route) {
370
+ const key = route.id ?? (route.path instanceof URLPattern ? route.path.pathname : String(route.path));
371
+ if (this._processed.has(key)) return false;
372
+ this._processed.add(key);
373
+ return true;
374
+ }
375
+ };
376
+ //#endregion
326
377
  //#region src/Router.ts
327
378
  /**
328
379
  * `lit-element`, `react`를 지원하는 SPA 클라이언트 라우터 객체입니다.
329
380
  */
330
381
  var Router = class {
331
382
  constructor(config) {
383
+ this._tracker = new RouteTracker();
332
384
  this.handleWindowPopstate = async (_) => {
333
- const href = window.location.href;
334
- await this.go(href);
385
+ await this.go(window.location.href);
335
386
  };
336
387
  this.handleDocumentClick = async (e) => {
337
388
  try {
@@ -383,101 +434,100 @@ var Router = class {
383
434
  /**
384
435
  * 지정한 경로의 클라이언트 라우팅을 수행합니다. 상대경로일 경우 basepath와 조합되어 이동합니다.
385
436
  * @param href 이동할 경로
437
+ * @param options 네비게이션 옵션
386
438
  */
387
- async go(href) {
439
+ async go(href, options) {
440
+ if (!options?.isRedirect) this._tracker.reset();
388
441
  const requestID = getRandomID();
389
442
  this._requestID = requestID;
390
443
  const context = parseUrl(href, this._basepath);
391
- if (context.href !== window.location.href) window.history.pushState({ basepath: context.basepath }, "", context.href);
392
- else window.history.replaceState({ basepath: context.basepath }, "", context.href);
393
- const progressCallback = (value) => {
394
- if (this._requestID !== requestID) return;
395
- const progress = Math.max(0, Math.min(100, Math.round(value)));
396
- window.dispatchEvent(new RouteProgressEvent(context, progress));
444
+ if (options?.isRedirect && this._tracker.visit(context.href)) return;
445
+ const useReplace = options?.isRedirect || options?.replace || context.href === window.location.href;
446
+ const historyState = {
447
+ basepath: context.basepath,
448
+ ...options?.state
397
449
  };
398
- context.progress = progressCallback;
450
+ if (useReplace) window.history.replaceState(historyState, "", context.href);
451
+ else window.history.pushState(historyState, "", context.href);
399
452
  let outlet = void 0;
453
+ let title = void 0;
400
454
  try {
401
- if (this._enter) {
455
+ outlet = findOutletOrThrow(this._rootElement);
456
+ const routes = getRoutes(this._routes, context.pathname);
457
+ if (routes.length === 0) throw new NotFoundError(context.href);
458
+ const lastRoute = routes[routes.length - 1];
459
+ if (lastRoute.path instanceof URLPattern) context.params = lastRoute.path.exec({ pathname: context.pathname })?.pathname.groups || {};
460
+ context.progress = (value) => {
461
+ if (this._requestID !== requestID) return;
462
+ const progress = Math.max(0, Math.min(100, Math.round(value)));
463
+ window.dispatchEvent(new RouteProgressEvent(context, progress));
464
+ };
465
+ if (this._enter && !options?.isRedirect) {
402
466
  const result = await this._enter(context);
403
467
  if (this._requestID !== requestID) return;
404
- if (typeof result === "string") return void this.go(result);
405
- if (result === false) return;
468
+ if (result === false) throw new AccessDeniedError(context.pathname);
469
+ if (typeof result === "string") return void this.go(result, { isRedirect: true });
406
470
  }
407
- if (this._requestID !== requestID) return;
408
- window.dispatchEvent(new RouteBeginEvent(context));
409
- const routes = getRoutes(this._routes, context.pathname);
410
- const lastRoute = routes[routes.length - 1];
411
- if (lastRoute && lastRoute.path instanceof URLPattern) context.params = lastRoute.path.exec({ pathname: context.pathname })?.pathname.groups || {};
412
- const mergedMeta = {};
413
- for (const route of routes) if (route.metadata) Object.assign(mergedMeta, route.metadata);
414
- context.metadata = mergedMeta;
415
471
  this._context = context;
416
- outlet = findOutletOrThrow(this._rootElement);
417
- let title = void 0;
418
- let content = null;
419
- if (routes.length === 0) throw new NotFoundError(context.href);
472
+ window.dispatchEvent(new RouteBeginEvent(context));
420
473
  for (const route of routes) {
421
474
  if (this._requestID !== requestID) return;
422
- if (route.enter) {
475
+ context.metadata = {
476
+ ...context.metadata,
477
+ ...route.metadata
478
+ };
479
+ if (route.enter && this._tracker.enter(route)) {
423
480
  const result = await route.enter(context);
424
481
  if (this._requestID !== requestID) return;
425
- if (typeof result === "string") return void this.go(result);
426
- if (result === false) return;
482
+ if (result === false) throw new AccessDeniedError(context.pathname);
483
+ if (typeof result === "string") return void this.go(result, { isRedirect: true });
427
484
  }
428
485
  if (!route.render) continue;
486
+ let content;
429
487
  try {
430
488
  content = await route.render(context);
431
489
  if (content === false || content === void 0 || content === null) throw new Error("Failed to load content for the route.");
432
- } catch (LoadError) {
433
- throw new ContentLoadError(LoadError);
490
+ } catch (e) {
491
+ throw new ContentLoadError(e);
434
492
  }
435
493
  try {
436
494
  outlet.render(content, {
437
495
  id: route.id,
438
496
  force: route.force
439
497
  });
440
- } catch (renderError) {
441
- throw new ContentRenderError(renderError);
498
+ } catch (e) {
499
+ throw new ContentRenderError(e);
442
500
  }
443
501
  outlet = findOutlet(outlet, true) || outlet;
444
502
  title = route.title || title;
445
503
  }
446
- document.title = title || document.title;
447
504
  window.dispatchEvent(new RouteDoneEvent(context));
448
505
  } catch (error) {
449
506
  const routeError = error instanceof RouteError ? error : new RouteError(error?.status || error?.code || "UNKNOWN_ERROR", error?.message || "An unexpected error occurred", error);
450
507
  window.dispatchEvent(new RouteErrorEvent(context, routeError));
451
- console.error("Routing error:", routeError.original);
508
+ console.error("Routing error:", routeError.original || routeError);
452
509
  try {
453
- if (this._fallback && this._fallback.render && outlet) {
454
- const fallbackContent = await this._fallback.render({
455
- ...context,
456
- error: routeError
457
- });
458
- outlet.render(fallbackContent, {
459
- id: "#fallback",
460
- force: true
461
- });
462
- document.title = this._fallback.title || document.title;
463
- } else {
464
- const errorContent = new UErrorPage();
465
- errorContent.error = error;
466
- if (outlet) outlet.render(errorContent, {
467
- id: "#error",
468
- force: true
469
- });
470
- else {
471
- document.body.innerHTML = "";
472
- document.body.appendChild(errorContent);
473
- }
510
+ const content = this._fallback?.render ? await this._fallback.render({
511
+ ...context,
512
+ error: routeError
513
+ }) : new UErrorPage(routeError);
514
+ if (outlet) outlet.render(content, {
515
+ id: getRandomID(),
516
+ force: true
517
+ });
518
+ else {
519
+ document.body.innerHTML = "";
520
+ document.body.appendChild(content instanceof Node ? content : new UErrorPage(routeError));
474
521
  }
522
+ title = this._fallback?.title || routeError.message || "Error";
475
523
  } catch (pageError) {
476
524
  console.error("Failed to render error component:", pageError);
477
525
  console.error("Original error:", routeError.original || routeError);
478
526
  }
527
+ } finally {
528
+ document.title = title || document.title;
479
529
  }
480
530
  }
481
531
  };
482
532
  //#endregion
483
- export { ContentLoadError, ContentRenderError, NotFoundError, OutletMissingError, RouteBeginEvent, RouteDoneEvent, RouteError, RouteErrorEvent, RouteProgressEvent, Router, ULink, UOutlet };
533
+ export { AccessDeniedError, ContentLoadError, ContentRenderError, NotFoundError, OutletMissingError, RouteBeginEvent, RouteDoneEvent, RouteError, RouteErrorEvent, RouteProgressEvent, Router, ULink, UOutlet };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iyulab/router",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "A modern client-side router for web applications with support for Lit and React components",
5
5
  "keywords": [
6
6
  "lit",
@@ -5,7 +5,7 @@ license: MIT
5
5
  compatibility: Browser environments only (requires URLPattern and History API)
6
6
  metadata:
7
7
  author: iyulab
8
- version: "0.8.0"
8
+ version: "0.9.1"
9
9
  ---
10
10
 
11
11
  # @iyulab/router