@zenithbuild/router 0.5.0-beta.2.5 → 0.6.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/README.md CHANGED
@@ -5,6 +5,12 @@
5
5
 
6
6
  File-based SPA router for Zenith framework with **deterministic, compile-time route resolution**.
7
7
 
8
+ ## Canonical Docs
9
+
10
+ - Routing contract: `../zenith-docs/documentation/contracts/routing.md`
11
+ - Navigation contract: `../zenith-docs/documentation/contracts/navigation.md`
12
+ - Router contract: `../zenith-docs/documentation/contracts/router-contract.md`
13
+
8
14
  ## Features
9
15
 
10
16
  - 📁 **File-based routing** — Pages in `pages/` directory become routes automatically
package/dist/events.js CHANGED
@@ -51,3 +51,98 @@ export function _clearSubscribers() {
51
51
  export function _getSubscriberCount() {
52
52
  return _subscribers.size;
53
53
  }
54
+
55
+ // --- Route Protection Events & Policy ---
56
+
57
+ const ROUTE_POLICY_KEY = '__zenith_route_protection_policy';
58
+ const ROUTE_EVENT_LISTENERS_KEY = '__zenith_route_event_listeners';
59
+ const ROUTE_EVENT_NAMES = [
60
+ 'guard:start',
61
+ 'guard:end',
62
+ 'route-check:start',
63
+ 'route-check:end',
64
+ 'route-check:error',
65
+ 'route:deny',
66
+ 'route:redirect'
67
+ ];
68
+
69
+ function getRouteProtectionScope() {
70
+ return typeof globalThis === 'object' && globalThis ? globalThis : {};
71
+ }
72
+
73
+ function ensureRouteProtectionState() {
74
+ const scope = getRouteProtectionScope();
75
+
76
+ let policy = scope[ROUTE_POLICY_KEY];
77
+ if (!policy || typeof policy !== 'object') {
78
+ policy = {};
79
+ scope[ROUTE_POLICY_KEY] = policy;
80
+ }
81
+
82
+ let listeners = scope[ROUTE_EVENT_LISTENERS_KEY];
83
+ if (!listeners || typeof listeners !== 'object') {
84
+ listeners = Object.create(null);
85
+ scope[ROUTE_EVENT_LISTENERS_KEY] = listeners;
86
+ }
87
+
88
+ for (const eventName of ROUTE_EVENT_NAMES) {
89
+ if (!(listeners[eventName] instanceof Set)) {
90
+ listeners[eventName] = new Set();
91
+ }
92
+ }
93
+
94
+ return { policy, listeners };
95
+ }
96
+
97
+ /**
98
+ * Configure default behaviors for route protection.
99
+ * @param {import('../index').RouteProtectionPolicy} policy
100
+ */
101
+ export function setRouteProtectionPolicy(policy) {
102
+ const state = ensureRouteProtectionState();
103
+ state.policy = Object.assign({}, state.policy, policy);
104
+ getRouteProtectionScope()[ROUTE_POLICY_KEY] = state.policy;
105
+ }
106
+
107
+ export function _getRouteProtectionPolicy() {
108
+ return ensureRouteProtectionState().policy;
109
+ }
110
+
111
+ /**
112
+ * Listen to route protection lifecycle events.
113
+ * @param {string} eventName
114
+ * @param {Function} handler
115
+ */
116
+ export function on(eventName, handler) {
117
+ const listeners = ensureRouteProtectionState().listeners;
118
+ if (listeners[eventName] instanceof Set) {
119
+ listeners[eventName].add(handler);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Remove a route protection lifecycle event listener.
125
+ * @param {string} eventName
126
+ * @param {Function} handler
127
+ */
128
+ export function off(eventName, handler) {
129
+ const listeners = ensureRouteProtectionState().listeners;
130
+ if (listeners[eventName] instanceof Set) {
131
+ listeners[eventName].delete(handler);
132
+ }
133
+ }
134
+
135
+ export function _dispatchRouteEvent(eventName, payload) {
136
+ const listeners = ensureRouteProtectionState().listeners[eventName];
137
+ if (!(listeners instanceof Set)) {
138
+ return;
139
+ }
140
+
141
+ for (const handler of listeners) {
142
+ try {
143
+ handler(payload);
144
+ } catch (e) {
145
+ console.error(`[Zenith Router] Error in ${eventName} listener:`, e);
146
+ }
147
+ }
148
+ }
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // index.js — Zenith Router V0 Public API
3
3
  // ---------------------------------------------------------------------------
4
- // Seven exports. No more.
4
+ // Structural navigation exports + route protection policy/event hooks.
5
5
  // ---------------------------------------------------------------------------
6
6
 
7
7
  export { createRouter } from './router.js';
8
8
  export { navigate, back, forward, getCurrentPath } from './navigate.js';
9
- export { onRouteChange } from './events.js';
9
+ export { onRouteChange, on, off, setRouteProtectionPolicy, _getRouteProtectionPolicy, _dispatchRouteEvent } from './events.js';
10
10
  export { matchRoute } from './match.js';
package/index.d.ts ADDED
@@ -0,0 +1,55 @@
1
+ export type RouteResult =
2
+ | { kind: "allow" }
3
+ | { kind: "redirect"; location: string; status?: number }
4
+ | { kind: "deny"; status: 401 | 403 | 500; message?: string }
5
+ | { kind: "data"; data: any };
6
+
7
+ export type GuardResult = Extract<RouteResult, { kind: "allow" | "redirect" | "deny" }>;
8
+ export type LoadResult = Extract<RouteResult, { kind: "data" | "redirect" | "deny" }>;
9
+
10
+ export interface RouteContext {
11
+ params: Record<string, string>;
12
+ url: URL;
13
+ headers: Record<string, string>;
14
+ cookies: Record<string, string>;
15
+ request: Request;
16
+ method: string;
17
+ route: { id: string; pattern: string; file: string };
18
+ env: Record<string, string>;
19
+ auth: {
20
+ getSession(ctx: RouteContext): Promise<any>;
21
+ requireSession(ctx: RouteContext): Promise<void>;
22
+ };
23
+ allow(): { kind: "allow" };
24
+ redirect(location: string, status?: number): { kind: "redirect"; location: string; status: number };
25
+ deny(status: 401 | 403 | 500, message?: string): { kind: "deny"; status: 401 | 403 | 500; message?: string };
26
+ data(payload: any): { kind: "data"; data: any };
27
+ }
28
+
29
+ export declare function createRouter(config: { routes: any[]; container: HTMLElement }): { start: () => Promise<void>; destroy: () => void; };
30
+ export declare function navigate(path: string): Promise<void>;
31
+ export declare function back(): void;
32
+ export declare function forward(): void;
33
+ export declare function getCurrentPath(): string;
34
+ export declare function onRouteChange(listener: (event: any) => void): () => void;
35
+ export declare function matchRoute(routes: any[], path: string): any;
36
+
37
+ export interface RouteProtectionPolicy {
38
+ onDeny?: "stay" | "redirect" | "render403" | ((ctx: any) => void);
39
+ defaultLoginPath?: string;
40
+ deny401RedirectToLogin?: boolean;
41
+ forbiddenPath?: string;
42
+ }
43
+
44
+ export type RouteEventName =
45
+ | "guard:start"
46
+ | "guard:end"
47
+ | "route-check:start"
48
+ | "route-check:end"
49
+ | "route-check:error"
50
+ | "route:deny"
51
+ | "route:redirect";
52
+
53
+ export declare function setRouteProtectionPolicy(policy: RouteProtectionPolicy): void;
54
+ export declare function on(eventName: RouteEventName, handler: (payload: any) => void): void;
55
+ export declare function off(eventName: RouteEventName, handler: (payload: any) => void): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenithbuild/router",
3
- "version": "0.5.0-beta.2.5",
3
+ "version": "0.6.0",
4
4
  "description": "File-based SPA router for Zenith framework with deterministic, compile-time route resolution",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -59,4 +59,4 @@
59
59
  "@types/node": "latest",
60
60
  "prettier": "^3.7.4"
61
61
  }
62
- }
62
+ }
package/template.js CHANGED
@@ -56,6 +56,47 @@ export function renderRouterModule(opts) {
56
56
  " return Array.isArray(routes) && routes.includes(route);",
57
57
  '}',
58
58
  '',
59
+ 'const __ZENITH_ROUTE_POLICY_KEY = "__zenith_route_protection_policy";',
60
+ 'const __ZENITH_ROUTE_EVENT_KEY = "__zenith_route_event_listeners";',
61
+ 'const __ZENITH_ROUTE_EVENT_NAMES = ["guard:start", "guard:end", "route-check:start", "route-check:end", "route-check:error", "route:deny", "route:redirect"];',
62
+ '',
63
+ 'function __zenithEnsureRouteProtectionState() {',
64
+ ' const scope = typeof globalThis === "object" && globalThis ? globalThis : window;',
65
+ ' let policy = scope[__ZENITH_ROUTE_POLICY_KEY];',
66
+ ' if (!policy || typeof policy !== "object") {',
67
+ ' policy = {};',
68
+ ' scope[__ZENITH_ROUTE_POLICY_KEY] = policy;',
69
+ ' }',
70
+ ' let listeners = scope[__ZENITH_ROUTE_EVENT_KEY];',
71
+ ' if (!listeners || typeof listeners !== "object") {',
72
+ ' listeners = Object.create(null);',
73
+ ' scope[__ZENITH_ROUTE_EVENT_KEY] = listeners;',
74
+ ' }',
75
+ ' for (let i = 0; i < __ZENITH_ROUTE_EVENT_NAMES.length; i++) {',
76
+ ' const name = __ZENITH_ROUTE_EVENT_NAMES[i];',
77
+ ' if (!(listeners[name] instanceof Set)) {',
78
+ ' listeners[name] = new Set();',
79
+ ' }',
80
+ ' }',
81
+ ' return { policy, listeners };',
82
+ '}',
83
+ '',
84
+ 'function __zenithGetRouteProtectionPolicy() {',
85
+ ' return __zenithEnsureRouteProtectionState().policy;',
86
+ '}',
87
+ '',
88
+ 'function __zenithDispatchRouteEvent(eventName, payload) {',
89
+ ' const listeners = __zenithEnsureRouteProtectionState().listeners[eventName];',
90
+ ' if (!(listeners instanceof Set)) return;',
91
+ ' for (const handler of listeners) {',
92
+ ' try {',
93
+ ' handler(payload);',
94
+ ' } catch (error) {',
95
+ ' console.error("[Zenith Router] route event handler failed", error);',
96
+ ' }',
97
+ ' }',
98
+ '}',
99
+ '',
59
100
  'function segmentWeight(segment) {',
60
101
  ' if (!segment) return 0;',
61
102
  " if (segment.startsWith('*')) return 1;",
@@ -234,13 +275,157 @@ export function renderRouterModule(opts) {
234
275
  " if (nextPath === window.location.pathname && url.search === window.location.search && url.hash === window.location.hash) return;",
235
276
  ' const resolved = resolveRoute(nextPath);',
236
277
  ' if (!resolved) return;',
237
- ' if (requiresServerReload(resolved.route)) return;',
238
278
  '',
239
- ' try {',
240
- ' window.location.assign(url.href);',
241
- ' } catch (e) {',
242
- " console.error('[Zenith Router] click navigation failed, falling back to browser default', e);",
279
+ ' function evaluateServerGuard(path, targetUrl, onSuccess) {',
280
+ ' if (!requiresServerReload(resolved.route)) {',
281
+ ' onSuccess();',
282
+ ' return;',
283
+ ' }',
284
+ ' __zenithDispatchRouteEvent("route-check:start", { to: targetUrl, from: new URL(window.location.href), routeId: resolved.route });',
285
+ ' fetch("/__zenith/route-check?path=" + encodeURIComponent(path), { headers: { "x-zenith-route-check": "1" }, credentials: "include" }).then(function(res) {',
286
+ ' return res.json().then(function(data) {',
287
+ ' if (!res.ok) throw new Error("route-check failed");',
288
+ ' return data;',
289
+ ' });',
290
+ ' }).then(function(checkResult) {',
291
+ ' if (checkResult && checkResult.result) {',
292
+ ' const result = checkResult.result;',
293
+ ' __zenithDispatchRouteEvent("route-check:end", { to: targetUrl, from: new URL(window.location.href), routeId: resolved.route, result });',
294
+ ' const policy = __zenithGetRouteProtectionPolicy();',
295
+ ' if (result.kind === "redirect") {',
296
+ ' __zenithDispatchRouteEvent("route:redirect", { to: targetUrl, from: new URL(window.location.href), routeId: resolved.route, result });',
297
+ ' window.location.assign(result.location || "/");',
298
+ ' return;',
299
+ ' }',
300
+ ' if (result.kind === "deny") {',
301
+ ' __zenithDispatchRouteEvent("route:deny", { to: targetUrl, from: new URL(window.location.href), routeId: resolved.route, result });',
302
+ ' console.warn("[Zenith] Route denied:", result.message);',
303
+ ' if (result.status === 401 && policy.deny401RedirectToLogin !== false) {',
304
+ ' const loginPath = policy.defaultLoginPath || "/login";',
305
+ ' window.location.assign(loginPath + "?next=" + encodeURIComponent(targetUrl.pathname + targetUrl.search + targetUrl.hash));',
306
+ ' return;',
307
+ ' }',
308
+ ' if (result.status === 403 && (policy.onDeny === "navigate" || policy.onDeny === "render403" || policy.forbiddenPath)) {',
309
+ ' window.location.assign(policy.forbiddenPath || "/403");',
310
+ ' return;',
311
+ ' }',
312
+ ' if (policy.onDeny === "redirect") {',
313
+ ' window.location.assign(policy.defaultLoginPath || "/login");',
314
+ ' return;',
315
+ ' }',
316
+ ' if (typeof policy.onDeny === "function") {',
317
+ ' policy.onDeny(result);',
318
+ ' return;',
319
+ ' }',
320
+ ' // Restore history state if this was a popstate (to prevent URL bar flash)',
321
+ ' if (window._zenith_is_popstate_nav) {',
322
+ ' history.pushState(null, "", window.location.href);',
323
+ ' }',
324
+ ' return; // No-flash block',
325
+ ' }',
326
+ ' onSuccess();',
327
+ ' } else {',
328
+ ' window.location.assign(targetUrl.href);',
329
+ ' }',
330
+ ' }).catch(function(e) {',
331
+ ' __zenithDispatchRouteEvent("route-check:error", { to: targetUrl, from: new URL(window.location.href), routeId: resolved.route, error: e });',
332
+ ' console.error("[Zenith Router] fallback route-check failed", e);',
333
+ ' window.location.assign(targetUrl.href);',
334
+ ' });',
335
+ ' }',
336
+ '',
337
+ ' evaluateServerGuard(nextPath, url, function() {',
338
+ ' try {',
339
+ ' event.preventDefault();',
340
+ ' navigate(nextPath, url).catch(function (error) {',
341
+ " console.error('[Zenith Router] click navigation failed', error);",
342
+ ' window.location.assign(url.href);',
343
+ ' });',
344
+ ' } catch (e) {',
345
+ " console.error('[Zenith Router] click navigation generated sync error', e);",
346
+ ' window.location.assign(url.href);',
347
+ ' }',
348
+ ' });',
349
+ ' });',
350
+ '',
351
+ " window.addEventListener('popstate', function (event) {",
352
+ ' const url = new URL(window.location.href);',
353
+ ' const nextPath = url.pathname;',
354
+ ' const resolved = resolveRoute(nextPath);',
355
+ ' if (!resolved) return;',
356
+ '',
357
+ ' window._zenith_is_popstate_nav = true;',
358
+ ' // Re-use evaluateServerGuard but attach different success handler since popstate already changes URL',
359
+ ' function popstateEvaluateGuard(path, targetUrl, onSuccess) {',
360
+ ' if (!requiresServerReload(resolved.route)) {',
361
+ ' onSuccess();',
362
+ ' return;',
363
+ ' }',
364
+ ' __zenithDispatchRouteEvent("route-check:start", { to: targetUrl, from: new URL(window.location.href), routeId: resolved.route });',
365
+ ' fetch("/__zenith/route-check?path=" + encodeURIComponent(path), { headers: { "x-zenith-route-check": "1" }, credentials: "include" }).then(function(res) {',
366
+ ' return res.json().then(function(data) {',
367
+ ' if (!res.ok) throw new Error("route-check failed");',
368
+ ' return data;',
369
+ ' });',
370
+ ' }).then(function(checkResult) {',
371
+ ' if (checkResult && checkResult.result) {',
372
+ ' const result = checkResult.result;',
373
+ ' __zenithDispatchRouteEvent("route-check:end", { to: targetUrl, from: new URL(window.location.href), routeId: resolved.route, result });',
374
+ ' const policy = __zenithGetRouteProtectionPolicy();',
375
+ ' if (result.kind === "redirect") {',
376
+ ' __zenithDispatchRouteEvent("route:redirect", { to: targetUrl, from: new URL(window.location.href), routeId: resolved.route, result });',
377
+ ' window._zenith_is_popstate_nav = false;',
378
+ ' window.location.assign(result.location || "/");',
379
+ ' return;',
380
+ ' }',
381
+ ' if (result.kind === "deny") {',
382
+ ' __zenithDispatchRouteEvent("route:deny", { to: targetUrl, from: new URL(window.location.href), routeId: resolved.route, result });',
383
+ ' console.warn("[Zenith] Route denied:", result.message);',
384
+ ' if (result.status === 401 && policy.deny401RedirectToLogin !== false) {',
385
+ ' const loginPath = policy.defaultLoginPath || "/login";',
386
+ ' window._zenith_is_popstate_nav = false;',
387
+ ' window.location.assign(loginPath + "?next=" + encodeURIComponent(targetUrl.pathname + targetUrl.search + targetUrl.hash));',
388
+ ' return;',
389
+ ' }',
390
+ ' if (result.status === 403 && (policy.onDeny === "navigate" || policy.onDeny === "render403" || policy.forbiddenPath)) {',
391
+ ' window._zenith_is_popstate_nav = false;',
392
+ ' window.location.assign(policy.forbiddenPath || "/403");',
393
+ ' return;',
394
+ ' }',
395
+ ' if (policy.onDeny === "redirect") {',
396
+ ' window._zenith_is_popstate_nav = false;',
397
+ ' window.location.assign(policy.defaultLoginPath || "/login");',
398
+ ' return;',
399
+ ' }',
400
+ ' if (typeof policy.onDeny === "function") {',
401
+ ' policy.onDeny(result);',
402
+ ' window._zenith_is_popstate_nav = false;',
403
+ ' return;',
404
+ ' }',
405
+ ' window._zenith_is_popstate_nav = false;',
406
+ ' history.back(); // Revert the popstate in history to align url bar with DOM',
407
+ ' return; // No-flash block',
408
+ ' }',
409
+ ' onSuccess();',
410
+ ' } else {',
411
+ ' window._zenith_is_popstate_nav = false;',
412
+ ' window.location.assign(targetUrl.href);',
413
+ ' }',
414
+ ' }).catch(function(e) {',
415
+ ' __zenithDispatchRouteEvent("route-check:error", { to: targetUrl, from: new URL(window.location.href), routeId: resolved.route, error: e });',
416
+ ' console.error("[Zenith Router] fallback route-check failed", e);',
417
+ ' window._zenith_is_popstate_nav = false;',
418
+ ' window.location.assign(targetUrl.href);',
419
+ ' });',
243
420
  ' }',
421
+ '',
422
+ ' popstateEvaluateGuard(nextPath, url, function() {',
423
+ ' navigate(nextPath, url).catch(function (error) {',
424
+ " console.error('[Zenith Router] popstate navigation failed', error);",
425
+ ' window.location.assign(url.href);',
426
+ ' });',
427
+ ' window._zenith_is_popstate_nav = false;',
428
+ ' });',
244
429
  ' });',
245
430
  '',
246
431
  '',