@real-router/core 0.22.0 → 0.23.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 +1 -3
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/metafile-cjs.json +1 -1
- package/dist/esm/index.d.mts +1 -1
- package/dist/esm/index.mjs +1 -1
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/metafile-esm.json +1 -1
- package/package.json +7 -5
- package/src/Router.ts +1174 -0
- package/src/RouterError.ts +324 -0
- package/src/constants.ts +112 -0
- package/src/createRouter.ts +32 -0
- package/src/fsm/index.ts +5 -0
- package/src/fsm/routerFSM.ts +129 -0
- package/src/getNavigator.ts +15 -0
- package/src/helpers.ts +194 -0
- package/src/index.ts +46 -0
- package/src/namespaces/CloneNamespace/CloneNamespace.ts +120 -0
- package/src/namespaces/CloneNamespace/index.ts +3 -0
- package/src/namespaces/CloneNamespace/types.ts +46 -0
- package/src/namespaces/DependenciesNamespace/DependenciesNamespace.ts +250 -0
- package/src/namespaces/DependenciesNamespace/index.ts +3 -0
- package/src/namespaces/DependenciesNamespace/validators.ts +105 -0
- package/src/namespaces/EventBusNamespace/EventBusNamespace.ts +272 -0
- package/src/namespaces/EventBusNamespace/index.ts +5 -0
- package/src/namespaces/EventBusNamespace/types.ts +11 -0
- package/src/namespaces/MiddlewareNamespace/MiddlewareNamespace.ts +206 -0
- package/src/namespaces/MiddlewareNamespace/index.ts +5 -0
- package/src/namespaces/MiddlewareNamespace/types.ts +28 -0
- package/src/namespaces/MiddlewareNamespace/validators.ts +96 -0
- package/src/namespaces/NavigationNamespace/NavigationNamespace.ts +308 -0
- package/src/namespaces/NavigationNamespace/index.ts +5 -0
- package/src/namespaces/NavigationNamespace/transition/executeLifecycleHooks.ts +84 -0
- package/src/namespaces/NavigationNamespace/transition/executeMiddleware.ts +56 -0
- package/src/namespaces/NavigationNamespace/transition/index.ts +107 -0
- package/src/namespaces/NavigationNamespace/transition/makeError.ts +37 -0
- package/src/namespaces/NavigationNamespace/transition/mergeStates.ts +54 -0
- package/src/namespaces/NavigationNamespace/transition/processLifecycleResult.ts +81 -0
- package/src/namespaces/NavigationNamespace/transition/wrapSyncError.ts +82 -0
- package/src/namespaces/NavigationNamespace/types.ts +129 -0
- package/src/namespaces/NavigationNamespace/validators.ts +87 -0
- package/src/namespaces/OptionsNamespace/OptionsNamespace.ts +50 -0
- package/src/namespaces/OptionsNamespace/constants.ts +41 -0
- package/src/namespaces/OptionsNamespace/helpers.ts +51 -0
- package/src/namespaces/OptionsNamespace/index.ts +11 -0
- package/src/namespaces/OptionsNamespace/validators.ts +252 -0
- package/src/namespaces/PluginsNamespace/PluginsNamespace.ts +325 -0
- package/src/namespaces/PluginsNamespace/constants.ts +35 -0
- package/src/namespaces/PluginsNamespace/index.ts +7 -0
- package/src/namespaces/PluginsNamespace/types.ts +32 -0
- package/src/namespaces/PluginsNamespace/validators.ts +79 -0
- package/src/namespaces/RouteLifecycleNamespace/RouteLifecycleNamespace.ts +389 -0
- package/src/namespaces/RouteLifecycleNamespace/index.ts +5 -0
- package/src/namespaces/RouteLifecycleNamespace/types.ts +17 -0
- package/src/namespaces/RouteLifecycleNamespace/validators.ts +65 -0
- package/src/namespaces/RouterLifecycleNamespace/RouterLifecycleNamespace.ts +140 -0
- package/src/namespaces/RouterLifecycleNamespace/constants.ts +25 -0
- package/src/namespaces/RouterLifecycleNamespace/index.ts +5 -0
- package/src/namespaces/RouterLifecycleNamespace/types.ts +23 -0
- package/src/namespaces/RoutesNamespace/RoutesNamespace.ts +1482 -0
- package/src/namespaces/RoutesNamespace/constants.ts +14 -0
- package/src/namespaces/RoutesNamespace/helpers.ts +532 -0
- package/src/namespaces/RoutesNamespace/index.ts +9 -0
- package/src/namespaces/RoutesNamespace/stateBuilder.ts +70 -0
- package/src/namespaces/RoutesNamespace/types.ts +82 -0
- package/src/namespaces/RoutesNamespace/validators.ts +331 -0
- package/src/namespaces/StateNamespace/StateNamespace.ts +317 -0
- package/src/namespaces/StateNamespace/helpers.ts +43 -0
- package/src/namespaces/StateNamespace/index.ts +5 -0
- package/src/namespaces/StateNamespace/types.ts +15 -0
- package/src/namespaces/index.ts +42 -0
- package/src/transitionPath.ts +441 -0
- package/src/typeGuards.ts +74 -0
- package/src/types.ts +194 -0
- package/src/wiring/RouterWiringBuilder.ts +235 -0
- package/src/wiring/index.ts +7 -0
- package/src/wiring/types.ts +53 -0
- package/src/wiring/wireRouter.ts +29 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// packages/core/src/namespaces/OptionsNamespace/validators.ts
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Static validation functions for OptionsNamespace.
|
|
5
|
+
* Called by Router facade before instance methods.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { isObjKey, getTypeDescription } from "type-guards";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
defaultOptions,
|
|
12
|
+
VALID_OPTION_VALUES,
|
|
13
|
+
VALID_QUERY_PARAMS,
|
|
14
|
+
} from "./constants";
|
|
15
|
+
import { LIMIT_BOUNDS } from "../../constants";
|
|
16
|
+
|
|
17
|
+
import type { LimitsConfig, Options } from "@real-router/types";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Validates that value is a plain object without getters.
|
|
21
|
+
*/
|
|
22
|
+
export function validatePlainObject(
|
|
23
|
+
value: unknown,
|
|
24
|
+
optionName: string,
|
|
25
|
+
methodName: string,
|
|
26
|
+
): asserts value is Record<string, unknown> {
|
|
27
|
+
if (!value || typeof value !== "object" || value.constructor !== Object) {
|
|
28
|
+
throw new TypeError(
|
|
29
|
+
`[router.${methodName}] Invalid type for "${optionName}": ` +
|
|
30
|
+
`expected plain object, got ${getTypeDescription(value)}`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Getters can throw, return different values, or have side effects
|
|
35
|
+
for (const key in value) {
|
|
36
|
+
if (Object.getOwnPropertyDescriptor(value, key)?.get) {
|
|
37
|
+
throw new TypeError(
|
|
38
|
+
`[router.${methodName}] Getters not allowed in "${optionName}": "${key}"`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Validates queryParams keys and values against allowed enums.
|
|
46
|
+
*/
|
|
47
|
+
export function validateQueryParams(
|
|
48
|
+
value: Record<string, unknown>,
|
|
49
|
+
methodName: string,
|
|
50
|
+
): void {
|
|
51
|
+
for (const key in value) {
|
|
52
|
+
if (!isObjKey(key, VALID_QUERY_PARAMS)) {
|
|
53
|
+
const validKeys = Object.keys(VALID_QUERY_PARAMS)
|
|
54
|
+
.map((k) => `"${k}"`)
|
|
55
|
+
.join(", ");
|
|
56
|
+
|
|
57
|
+
throw new TypeError(
|
|
58
|
+
`[router.${methodName}] Unknown queryParams key: "${key}". ` +
|
|
59
|
+
`Valid keys: ${validKeys}`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const paramValue = value[key];
|
|
64
|
+
const validValues = VALID_QUERY_PARAMS[key];
|
|
65
|
+
const isValid = (validValues as readonly string[]).includes(
|
|
66
|
+
paramValue as string,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
if (!isValid) {
|
|
70
|
+
const allowedValues = validValues.map((v) => `"${v}"`).join(", ");
|
|
71
|
+
|
|
72
|
+
throw new TypeError(
|
|
73
|
+
`[router.${methodName}] Invalid value for queryParams.${key}: ` +
|
|
74
|
+
`expected one of ${allowedValues}, got "${String(paramValue)}"`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Validates string enum options against allowed values.
|
|
82
|
+
*/
|
|
83
|
+
export function validateEnumOption(
|
|
84
|
+
optionName: keyof typeof VALID_OPTION_VALUES,
|
|
85
|
+
value: unknown,
|
|
86
|
+
methodName: string,
|
|
87
|
+
): void {
|
|
88
|
+
const validValues = VALID_OPTION_VALUES[optionName];
|
|
89
|
+
const isValid = (validValues as readonly string[]).includes(value as string);
|
|
90
|
+
|
|
91
|
+
if (!isValid) {
|
|
92
|
+
const allowedValues = validValues.map((v) => `"${v}"`).join(", ");
|
|
93
|
+
|
|
94
|
+
throw new TypeError(
|
|
95
|
+
`[router.${methodName}] Invalid value for "${optionName}": ` +
|
|
96
|
+
`expected one of ${allowedValues}, got "${String(value)}"`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Validates a single option value against expected type and constraints.
|
|
103
|
+
* Skips validation for unknown options - validateOptionExists handles that.
|
|
104
|
+
*/
|
|
105
|
+
export function validateOptionValue(
|
|
106
|
+
optionName: keyof Options,
|
|
107
|
+
value: unknown,
|
|
108
|
+
methodName: string,
|
|
109
|
+
): void {
|
|
110
|
+
// Allow callback functions for dynamic default route/params options
|
|
111
|
+
// MUST be first check — before object branch (L140) which would reject
|
|
112
|
+
// functions via validatePlainObject for defaultParams (default = {})
|
|
113
|
+
if (
|
|
114
|
+
typeof value === "function" &&
|
|
115
|
+
(optionName === "defaultRoute" || optionName === "defaultParams")
|
|
116
|
+
) {
|
|
117
|
+
return; // Valid — callback resolved at runtime
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const expectedValue = defaultOptions[optionName];
|
|
121
|
+
|
|
122
|
+
// For object options - ensure plain objects only (not null, arrays, Date, etc)
|
|
123
|
+
if (expectedValue && typeof expectedValue === "object") {
|
|
124
|
+
validatePlainObject(value, optionName, methodName);
|
|
125
|
+
|
|
126
|
+
if (optionName === "queryParams") {
|
|
127
|
+
validateQueryParams(value, methodName);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// For primitives - typeof check first
|
|
134
|
+
if (typeof value !== typeof expectedValue) {
|
|
135
|
+
throw new TypeError(
|
|
136
|
+
`[router.${methodName}] Invalid type for "${optionName}": ` +
|
|
137
|
+
`expected ${typeof expectedValue}, got ${typeof value}`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// For string enum options - validate against allowed values
|
|
142
|
+
if (optionName in VALID_OPTION_VALUES) {
|
|
143
|
+
validateEnumOption(
|
|
144
|
+
optionName as keyof typeof VALID_OPTION_VALUES,
|
|
145
|
+
value,
|
|
146
|
+
methodName,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Validates optional fields not in defaultOptions.
|
|
153
|
+
* Note: logger is handled before validateOptions in Router constructor.
|
|
154
|
+
*/
|
|
155
|
+
function validateOptionalField(
|
|
156
|
+
key: string,
|
|
157
|
+
value: unknown,
|
|
158
|
+
methodName: string,
|
|
159
|
+
): boolean {
|
|
160
|
+
if (key === "limits") {
|
|
161
|
+
if (value !== undefined) {
|
|
162
|
+
validateLimits(value, methodName);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
throw new TypeError(`[router.${methodName}] Unknown option: "${key}"`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Validates a partial options object.
|
|
173
|
+
* Called by facade before constructor/withOptions.
|
|
174
|
+
*/
|
|
175
|
+
export function validateOptions(
|
|
176
|
+
options: unknown,
|
|
177
|
+
methodName: string,
|
|
178
|
+
): asserts options is Partial<Options> {
|
|
179
|
+
if (
|
|
180
|
+
!options ||
|
|
181
|
+
typeof options !== "object" ||
|
|
182
|
+
options.constructor !== Object
|
|
183
|
+
) {
|
|
184
|
+
throw new TypeError(
|
|
185
|
+
`[router.${methodName}] Invalid options: expected plain object, got ${getTypeDescription(options)}`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const [key, value] of Object.entries(options)) {
|
|
190
|
+
// Skip optional fields that aren't in defaultOptions (limits, logger, etc.)
|
|
191
|
+
if (!isObjKey(key, defaultOptions)) {
|
|
192
|
+
validateOptionalField(key, value, methodName);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Skip undefined values for conditional configuration
|
|
197
|
+
if (value === undefined) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
validateOptionValue(key, value, methodName);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Validates that a limit value is within bounds.
|
|
207
|
+
*/
|
|
208
|
+
export function validateLimitValue(
|
|
209
|
+
limitName: keyof LimitsConfig,
|
|
210
|
+
value: unknown,
|
|
211
|
+
methodName: string,
|
|
212
|
+
): void {
|
|
213
|
+
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
214
|
+
throw new TypeError(
|
|
215
|
+
`[router.${methodName}]: limit "${limitName}" must be an integer, got ${String(value)}`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const bounds = LIMIT_BOUNDS[limitName];
|
|
220
|
+
|
|
221
|
+
if (value < bounds.min || value > bounds.max) {
|
|
222
|
+
throw new RangeError(
|
|
223
|
+
`[router.${methodName}]: limit "${limitName}" must be between ${bounds.min} and ${bounds.max}, got ${value}`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Validates a partial limits object.
|
|
230
|
+
*/
|
|
231
|
+
export function validateLimits(
|
|
232
|
+
limits: unknown,
|
|
233
|
+
methodName: string,
|
|
234
|
+
): asserts limits is Partial<LimitsConfig> {
|
|
235
|
+
if (!limits || typeof limits !== "object" || limits.constructor !== Object) {
|
|
236
|
+
throw new TypeError(
|
|
237
|
+
`[router.${methodName}]: invalid limits: expected plain object, got ${typeof limits}`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
for (const [key, value] of Object.entries(limits)) {
|
|
242
|
+
if (!Object.hasOwn(LIMIT_BOUNDS, key)) {
|
|
243
|
+
throw new TypeError(`[router.${methodName}]: unknown limit: "${key}"`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (value === undefined) {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
validateLimitValue(key as keyof LimitsConfig, value, methodName);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// packages/core/src/namespaces/PluginsNamespace/PluginsNamespace.ts
|
|
2
|
+
|
|
3
|
+
import { logger } from "@real-router/logger";
|
|
4
|
+
|
|
5
|
+
import { EVENTS_MAP, EVENT_METHOD_NAMES, LOGGER_CONTEXT } from "./constants";
|
|
6
|
+
import {
|
|
7
|
+
validatePlugin,
|
|
8
|
+
validatePluginLimit,
|
|
9
|
+
validateUsePluginArgs,
|
|
10
|
+
} from "./validators";
|
|
11
|
+
import { DEFAULT_LIMITS } from "../../constants";
|
|
12
|
+
import { computeThresholds } from "../../helpers";
|
|
13
|
+
|
|
14
|
+
import type { PluginsDependencies } from "./types";
|
|
15
|
+
import type { Router } from "../../Router";
|
|
16
|
+
import type { Limits, PluginFactory } from "../../types";
|
|
17
|
+
import type {
|
|
18
|
+
DefaultDependencies,
|
|
19
|
+
Plugin,
|
|
20
|
+
Unsubscribe,
|
|
21
|
+
} from "@real-router/types";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Independent namespace for managing plugins.
|
|
25
|
+
*
|
|
26
|
+
* Static methods handle validation (called by facade).
|
|
27
|
+
* Instance methods handle storage and business logic.
|
|
28
|
+
*/
|
|
29
|
+
export class PluginsNamespace<
|
|
30
|
+
Dependencies extends DefaultDependencies = DefaultDependencies,
|
|
31
|
+
> {
|
|
32
|
+
readonly #plugins = new Set<PluginFactory<Dependencies>>();
|
|
33
|
+
readonly #unsubscribes = new Set<Unsubscribe>();
|
|
34
|
+
|
|
35
|
+
#router!: Router<Dependencies>;
|
|
36
|
+
#deps!: PluginsDependencies<Dependencies>;
|
|
37
|
+
#limits: Limits = DEFAULT_LIMITS;
|
|
38
|
+
|
|
39
|
+
// =========================================================================
|
|
40
|
+
// Static validation methods (called by facade before instance methods)
|
|
41
|
+
// Proxy to functions in validators.ts for separation of concerns
|
|
42
|
+
// =========================================================================
|
|
43
|
+
|
|
44
|
+
static validateUsePluginArgs<D extends DefaultDependencies>(
|
|
45
|
+
plugins: unknown[],
|
|
46
|
+
): asserts plugins is PluginFactory<D>[] {
|
|
47
|
+
validateUsePluginArgs<D>(plugins);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
static validatePlugin(plugin: Plugin): void {
|
|
51
|
+
validatePlugin(plugin);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
static validatePluginLimit(
|
|
55
|
+
currentCount: number,
|
|
56
|
+
newCount: number,
|
|
57
|
+
maxPlugins?: number,
|
|
58
|
+
): void {
|
|
59
|
+
validatePluginLimit(currentCount, newCount, maxPlugins);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
static validateNoDuplicatePlugins<D extends DefaultDependencies>(
|
|
63
|
+
newFactories: PluginFactory<D>[],
|
|
64
|
+
hasPlugin: (factory: PluginFactory<D>) => boolean,
|
|
65
|
+
): void {
|
|
66
|
+
for (const factory of newFactories) {
|
|
67
|
+
if (hasPlugin(factory)) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`[router.usePlugin] Plugin factory already registered. ` +
|
|
70
|
+
`To re-register, first unsubscribe the existing plugin.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// =========================================================================
|
|
77
|
+
// Dependency injection
|
|
78
|
+
// =========================================================================
|
|
79
|
+
|
|
80
|
+
setRouter(router: Router<Dependencies>): void {
|
|
81
|
+
this.#router = router;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
setDependencies(deps: PluginsDependencies<Dependencies>): void {
|
|
85
|
+
this.#deps = deps;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
setLimits(limits: Limits): void {
|
|
89
|
+
this.#limits = limits;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// =========================================================================
|
|
93
|
+
// Instance methods (trust input - already validated by facade)
|
|
94
|
+
// =========================================================================
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Returns the number of registered plugins.
|
|
98
|
+
* Used by facade for limit validation.
|
|
99
|
+
*/
|
|
100
|
+
count(): number {
|
|
101
|
+
return this.#plugins.size;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Registers one or more plugin factories.
|
|
106
|
+
* Returns unsubscribe function to remove all added plugins.
|
|
107
|
+
* Input already validated by facade (limit, duplicates).
|
|
108
|
+
*
|
|
109
|
+
* @param factories - Already validated by facade
|
|
110
|
+
*/
|
|
111
|
+
use(...factories: PluginFactory<Dependencies>[]): Unsubscribe {
|
|
112
|
+
// Emit warnings for count thresholds (not validation, just warnings)
|
|
113
|
+
this.#checkCountThresholds(factories.length);
|
|
114
|
+
|
|
115
|
+
// Fast path for single plugin (common case)
|
|
116
|
+
if (factories.length === 1) {
|
|
117
|
+
const factory = factories[0];
|
|
118
|
+
const cleanup = this.#startPlugin(factory);
|
|
119
|
+
|
|
120
|
+
this.#plugins.add(factory);
|
|
121
|
+
|
|
122
|
+
let unsubscribed = false;
|
|
123
|
+
|
|
124
|
+
const unsubscribe: Unsubscribe = () => {
|
|
125
|
+
if (unsubscribed) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
unsubscribed = true;
|
|
130
|
+
this.#plugins.delete(factory);
|
|
131
|
+
this.#unsubscribes.delete(unsubscribe);
|
|
132
|
+
try {
|
|
133
|
+
cleanup();
|
|
134
|
+
} catch (error) {
|
|
135
|
+
logger.error(LOGGER_CONTEXT, "Error during cleanup:", error);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
this.#unsubscribes.add(unsubscribe);
|
|
140
|
+
|
|
141
|
+
return unsubscribe;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Deduplicate batch with warning (validation already done by facade)
|
|
145
|
+
const seenInBatch = this.#deduplicateBatch(factories);
|
|
146
|
+
|
|
147
|
+
// Track successfully initialized plugins for cleanup
|
|
148
|
+
const initializedPlugins: {
|
|
149
|
+
factory: PluginFactory<Dependencies>;
|
|
150
|
+
cleanup: Unsubscribe;
|
|
151
|
+
}[] = [];
|
|
152
|
+
|
|
153
|
+
// Initialize deduplicated plugins sequentially
|
|
154
|
+
try {
|
|
155
|
+
for (const plugin of seenInBatch) {
|
|
156
|
+
const cleanup = this.#startPlugin(plugin);
|
|
157
|
+
|
|
158
|
+
initializedPlugins.push({ factory: plugin, cleanup });
|
|
159
|
+
}
|
|
160
|
+
} catch (error) {
|
|
161
|
+
// Rollback on failure - cleanup all initialized plugins
|
|
162
|
+
for (const { cleanup } of initializedPlugins) {
|
|
163
|
+
try {
|
|
164
|
+
cleanup();
|
|
165
|
+
} catch (cleanupError) {
|
|
166
|
+
logger.error(LOGGER_CONTEXT, "Cleanup error:", cleanupError);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Commit phase - add to registry
|
|
174
|
+
for (const { factory } of initializedPlugins) {
|
|
175
|
+
this.#plugins.add(factory);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Return unsubscribe function
|
|
179
|
+
let unsubscribed = false;
|
|
180
|
+
|
|
181
|
+
const unsubscribe: Unsubscribe = () => {
|
|
182
|
+
if (unsubscribed) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
unsubscribed = true;
|
|
187
|
+
this.#unsubscribes.delete(unsubscribe);
|
|
188
|
+
|
|
189
|
+
for (const { factory } of initializedPlugins) {
|
|
190
|
+
this.#plugins.delete(factory);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
for (const { cleanup } of initializedPlugins) {
|
|
194
|
+
try {
|
|
195
|
+
cleanup();
|
|
196
|
+
} catch (error) {
|
|
197
|
+
logger.error(LOGGER_CONTEXT, "Error during cleanup:", error);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
this.#unsubscribes.add(unsubscribe);
|
|
203
|
+
|
|
204
|
+
return unsubscribe;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Returns registered plugin factories.
|
|
209
|
+
*/
|
|
210
|
+
getAll(): PluginFactory<Dependencies>[] {
|
|
211
|
+
return [...this.#plugins];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Checks if a plugin factory is registered.
|
|
216
|
+
* Used internally by validation to avoid array allocation.
|
|
217
|
+
*/
|
|
218
|
+
has(factory: PluginFactory<Dependencies>): boolean {
|
|
219
|
+
return this.#plugins.has(factory);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
disposeAll(): void {
|
|
223
|
+
for (const unsubscribe of this.#unsubscribes) {
|
|
224
|
+
unsubscribe();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
this.#plugins.clear();
|
|
228
|
+
this.#unsubscribes.clear();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// =========================================================================
|
|
232
|
+
// Private methods
|
|
233
|
+
// =========================================================================
|
|
234
|
+
|
|
235
|
+
#checkCountThresholds(newCount: number): void {
|
|
236
|
+
const maxPlugins = this.#limits.maxPlugins;
|
|
237
|
+
|
|
238
|
+
if (maxPlugins === 0) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const totalCount = newCount + this.#plugins.size;
|
|
243
|
+
|
|
244
|
+
const { warn, error } = computeThresholds(maxPlugins);
|
|
245
|
+
|
|
246
|
+
if (totalCount >= error) {
|
|
247
|
+
logger.error(LOGGER_CONTEXT, `${totalCount} plugins registered!`);
|
|
248
|
+
} else if (totalCount >= warn) {
|
|
249
|
+
logger.warn(LOGGER_CONTEXT, `${totalCount} plugins registered`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Deduplicates batch with warning for duplicates within batch.
|
|
255
|
+
* Validation (existing duplicates) is done by facade.
|
|
256
|
+
*/
|
|
257
|
+
#deduplicateBatch(
|
|
258
|
+
plugins: PluginFactory<Dependencies>[],
|
|
259
|
+
): Set<PluginFactory<Dependencies>> {
|
|
260
|
+
const seenInBatch = new Set<PluginFactory<Dependencies>>();
|
|
261
|
+
|
|
262
|
+
for (const plugin of plugins) {
|
|
263
|
+
if (seenInBatch.has(plugin)) {
|
|
264
|
+
logger.warn(
|
|
265
|
+
LOGGER_CONTEXT,
|
|
266
|
+
"Duplicate factory in batch, will be registered once",
|
|
267
|
+
);
|
|
268
|
+
} else {
|
|
269
|
+
seenInBatch.add(plugin);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return seenInBatch;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
#startPlugin(pluginFactory: PluginFactory<Dependencies>): Unsubscribe {
|
|
277
|
+
// Bind getDependency to preserve 'this' context when called from factory
|
|
278
|
+
// Plugin factories receive full router as part of their public API
|
|
279
|
+
const appliedPlugin = pluginFactory(this.#router, this.#deps.getDependency);
|
|
280
|
+
|
|
281
|
+
PluginsNamespace.validatePlugin(appliedPlugin);
|
|
282
|
+
|
|
283
|
+
Object.freeze(appliedPlugin);
|
|
284
|
+
|
|
285
|
+
// Collect all unsubscribe functions
|
|
286
|
+
const removeEventListeners: Unsubscribe[] = [];
|
|
287
|
+
|
|
288
|
+
// Subscribe plugin methods to corresponding router events
|
|
289
|
+
for (const methodName of EVENT_METHOD_NAMES) {
|
|
290
|
+
if (methodName in appliedPlugin) {
|
|
291
|
+
if (typeof appliedPlugin[methodName] === "function") {
|
|
292
|
+
removeEventListeners.push(
|
|
293
|
+
this.#deps.addEventListener(
|
|
294
|
+
EVENTS_MAP[methodName],
|
|
295
|
+
appliedPlugin[methodName],
|
|
296
|
+
),
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
if (methodName === "onStart" && this.#deps.canNavigate()) {
|
|
300
|
+
logger.warn(
|
|
301
|
+
LOGGER_CONTEXT,
|
|
302
|
+
"Router already started, onStart will not be called",
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
} else {
|
|
306
|
+
logger.warn(
|
|
307
|
+
LOGGER_CONTEXT,
|
|
308
|
+
`Property '${methodName}' is not a function, skipping`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Return composite cleanup function
|
|
315
|
+
return () => {
|
|
316
|
+
for (const removeListener of removeEventListeners) {
|
|
317
|
+
removeListener();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (typeof appliedPlugin.teardown === "function") {
|
|
321
|
+
appliedPlugin.teardown();
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// packages/core/src/namespaces/PluginsNamespace/constants.ts
|
|
2
|
+
|
|
3
|
+
import { isObjKey } from "type-guards";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
events as EVENTS_CONST,
|
|
7
|
+
plugins as PLUGINS_CONST,
|
|
8
|
+
} from "../../constants";
|
|
9
|
+
|
|
10
|
+
import type { EventName } from "@real-router/types";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Maps plugin method names to router event names.
|
|
14
|
+
*/
|
|
15
|
+
export const EVENTS_MAP = {
|
|
16
|
+
[PLUGINS_CONST.ROUTER_START]: EVENTS_CONST.ROUTER_START,
|
|
17
|
+
[PLUGINS_CONST.ROUTER_STOP]: EVENTS_CONST.ROUTER_STOP,
|
|
18
|
+
[PLUGINS_CONST.TRANSITION_SUCCESS]: EVENTS_CONST.TRANSITION_SUCCESS,
|
|
19
|
+
[PLUGINS_CONST.TRANSITION_START]: EVENTS_CONST.TRANSITION_START,
|
|
20
|
+
[PLUGINS_CONST.TRANSITION_ERROR]: EVENTS_CONST.TRANSITION_ERROR,
|
|
21
|
+
[PLUGINS_CONST.TRANSITION_CANCEL]: EVENTS_CONST.TRANSITION_CANCEL,
|
|
22
|
+
} as const satisfies Record<
|
|
23
|
+
(typeof PLUGINS_CONST)[keyof typeof PLUGINS_CONST],
|
|
24
|
+
EventName
|
|
25
|
+
>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Plugin method names that correspond to router events.
|
|
29
|
+
*/
|
|
30
|
+
export const EVENT_METHOD_NAMES = Object.keys(EVENTS_MAP).filter(
|
|
31
|
+
(eventName): eventName is keyof typeof EVENTS_MAP =>
|
|
32
|
+
isObjKey<typeof EVENTS_MAP>(eventName, EVENTS_MAP),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
export const LOGGER_CONTEXT = "router.usePlugin";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// packages/core/src/namespaces/PluginsNamespace/types.ts
|
|
2
|
+
|
|
3
|
+
import type { EventMethodMap } from "../../types";
|
|
4
|
+
import type {
|
|
5
|
+
DefaultDependencies,
|
|
6
|
+
EventName,
|
|
7
|
+
Plugin,
|
|
8
|
+
Unsubscribe,
|
|
9
|
+
} from "@real-router/types";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Dependencies injected into PluginsNamespace.
|
|
13
|
+
*
|
|
14
|
+
* Note: Plugin factories still receive the router object directly
|
|
15
|
+
* as they need access to various router methods. This interface
|
|
16
|
+
* only covers the internal namespace operations.
|
|
17
|
+
*/
|
|
18
|
+
export interface PluginsDependencies<
|
|
19
|
+
Dependencies extends DefaultDependencies = DefaultDependencies,
|
|
20
|
+
> {
|
|
21
|
+
/** Add event listener for plugin subscription */
|
|
22
|
+
addEventListener: <E extends EventName>(
|
|
23
|
+
eventName: E,
|
|
24
|
+
cb: Plugin[EventMethodMap[E]],
|
|
25
|
+
) => Unsubscribe;
|
|
26
|
+
|
|
27
|
+
/** Check if navigation is possible (for warning about late onStart) */
|
|
28
|
+
canNavigate: () => boolean;
|
|
29
|
+
|
|
30
|
+
/** Get dependency value for plugin factory */
|
|
31
|
+
getDependency: <K extends keyof Dependencies>(key: K) => Dependencies[K];
|
|
32
|
+
}
|