@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 +22 -0
- package/dist/index.d.ts +36 -1
- package/dist/index.js +113 -63
- package/package.json +1 -1
- package/skills/iyulab-router/SKILL.md +1 -1
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
392
|
-
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
405
|
-
if (result ===
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
426
|
-
if (result ===
|
|
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 (
|
|
433
|
-
throw new ContentLoadError(
|
|
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 (
|
|
441
|
-
throw new ContentRenderError(
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
document.
|
|
463
|
-
|
|
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