@stefanobalocco/jfsmrouter 2.0.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/jFSMRouter.ts ADDED
@@ -0,0 +1,442 @@
1
+ type Undefinedable<T> = T | undefined;
2
+ type Nullable<T> = T | null;
3
+
4
+ export type FunctionOnEnter = ( currentState: string, nextState: string ) => ( void | Promise<void> );
5
+ export type FunctionOnLeave = ( currentState: string, prevState: string ) => ( void | Promise<void> );
6
+ export type FunctionOnTransitionAfter = () => ( void | Promise<void> );
7
+ export type FunctionOnTransitionBefore = () => ( any | Promise<any> );
8
+ export type CheckAvailability = ( path: string, hashPath: string, params?: { [ key: string ]: string } ) => ( boolean | Promise<boolean> );
9
+ export type RouteFunction = ( path: string, hashPath: string, params?: { [ key: string ]: string } ) => ( void | Promise<void> );
10
+ type Route = {
11
+ path: string,
12
+ validState: string,
13
+ match: RegExp,
14
+ weight: number,
15
+ routeFunction: RouteFunction,
16
+ available?: CheckAvailability,
17
+ routeFunction403?: RouteFunction
18
+ };
19
+
20
+ class jFSMRouter {
21
+ private static _instance: Undefinedable<jFSMRouter>;
22
+
23
+ public static get instance(): jFSMRouter {
24
+ return ( jFSMRouter._instance ??= new jFSMRouter( window ) );
25
+ }
26
+
27
+ private _regexDuplicatePathId: RegExp = /\/(:\w+)(?:\[(?:09|AZ|AZ09)])?\/(?:.+\/)?(\1)(?:\[(?:09|AZ|AZ09)])?(?:\/|$)/g;
28
+ private _regexSearchVariables: RegExp = /(?<=^|\/):(\w+)(?:\[(09|AZ|AZ09)])?(?=\/|$)/g;
29
+ private _routes: Route[] = [];
30
+ private _routeFunction403: Undefinedable<RouteFunction>;
31
+ private _routeFunction404: Undefinedable<RouteFunction>;
32
+ private _routeFunction500: Undefinedable<RouteFunction>;
33
+ private _routing: boolean = false;
34
+ private _queue: string[] = [];
35
+ private _inTransition: boolean = false;
36
+ private _currentState: Undefinedable<string>;
37
+ private _states: { [ key: string ]: { OnEnter: FunctionOnEnter[], OnLeave: FunctionOnLeave[] } } = {};
38
+ private _transitions: { [ key: string ]: { [ key: string ]: { OnBefore: FunctionOnTransitionBefore[], OnAfter: FunctionOnTransitionAfter[] } } } = {};
39
+ private _window: Window;
40
+
41
+ private constructor( window: Window ) {
42
+ this._window = window;
43
+ this._window.addEventListener( "hashchange", this.checkHash.bind( this ) );
44
+ }
45
+
46
+ private static _CheckRouteEquivalence( path1: string, path2: string ): boolean {
47
+ const generateVariants: ( path: string ) => string[] = ( path: string ): string[ ] => {
48
+ let returnValue: string[ ] = [ path ];
49
+ if( path.includes( ':AZ09' ) ) {
50
+ returnValue.push(
51
+ ...generateVariants( path.replace( /:AZ09/, ':AZ' ) ),
52
+ ...generateVariants( path.replace( /:AZ09/, ':09' ) )
53
+ );
54
+ }
55
+ return returnValue;
56
+ };
57
+ const variants: Set<string> = new Set( generateVariants( path1 ) );
58
+ return [ ...generateVariants( path2 ) ].some( x => variants.has( x ) );
59
+ }
60
+
61
+ public stateAdd( state: string ): boolean {
62
+ let returnValue: boolean = false;
63
+ if( !this._states[ state ] ) {
64
+ this._states[ state ] = {
65
+ OnEnter: [],
66
+ OnLeave: []
67
+ };
68
+ this._transitions[ state ] = {};
69
+ if( !this._currentState ) {
70
+ this._currentState = state;
71
+ }
72
+ returnValue = true;
73
+ }
74
+ return returnValue;
75
+ }
76
+
77
+ public stateDel( state: string ): boolean {
78
+ let returnValue: boolean = false;
79
+ if( this._states[ state ] ) {
80
+ delete this._states[ state ];
81
+ if( this._transitions[ state ] ) {
82
+ delete this._transitions[ state ];
83
+ }
84
+ for( const tmpState in this._transitions ) {
85
+ if( this._transitions[ tmpState ][ state ] ) {
86
+ delete this._transitions[ tmpState ][ state ];
87
+ }
88
+ }
89
+ returnValue = true;
90
+ }
91
+ return returnValue;
92
+ }
93
+
94
+ public stateOnEnterAdd( state: string, func: FunctionOnEnter ): boolean {
95
+ let returnValue: boolean = false;
96
+ if( this._states[ state ] && !this._states[ state ].OnEnter.includes( func ) ) {
97
+ this._states[ state ].OnEnter.push( func );
98
+ returnValue = true;
99
+ }
100
+ return returnValue;
101
+ }
102
+
103
+ public stateOnEnterDel( state: string, func: FunctionOnEnter ): boolean {
104
+ let returnValue: boolean = false;
105
+ if( this._states[ state ] ) {
106
+ const position: number = this._states[ state ].OnEnter.indexOf( func );
107
+ if( -1 !== position ) {
108
+ this._states[ state ].OnEnter.splice( position, 1 );
109
+ returnValue = true;
110
+ }
111
+ }
112
+ return returnValue;
113
+ }
114
+
115
+ public stateOnLeaveAdd( state: string, func: FunctionOnLeave ): boolean {
116
+ let returnValue: boolean = false;
117
+ if( this._states[ state ] && !this._states[ state ].OnLeave.includes( func ) ) {
118
+ this._states[ state ].OnLeave.push( func );
119
+ returnValue = true;
120
+ }
121
+ return returnValue;
122
+ }
123
+
124
+ public stateOnLeaveDel( state: string, func: FunctionOnLeave ): boolean {
125
+ let returnValue: boolean = false;
126
+ if( this._states[ state ] ) {
127
+ const position: number = this._states[ state ].OnLeave.indexOf( func );
128
+ if( -1 !== position ) {
129
+ this._states[ state ].OnLeave.splice( position, 1 );
130
+ returnValue = true;
131
+ }
132
+ }
133
+ return returnValue;
134
+ }
135
+
136
+ public transitionAdd( from: string, to: string ): boolean {
137
+ let returnValue: boolean = false;
138
+ if( this._states[ from ] && this._states[ to ] && !this._transitions[ from ][ to ] ) {
139
+ this._transitions[ from ][ to ] = {
140
+ OnBefore: [],
141
+ OnAfter: []
142
+ };
143
+ returnValue = true;
144
+ }
145
+ return returnValue;
146
+ }
147
+
148
+ public transitionDel( from: string, to: string ): boolean {
149
+ let returnValue: boolean = false;
150
+ if( this._states[ from ] && this._states[ to ] && this._transitions[ from ][ to ] ) {
151
+ delete this._transitions[ from ][ to ];
152
+ returnValue = true;
153
+ }
154
+ return returnValue;
155
+ }
156
+
157
+ public transitionOnBeforeAdd( from: string, to: string, func: FunctionOnTransitionBefore ): boolean {
158
+ let returnValue: boolean = false;
159
+ if( this._states[ from ] && this._states[ to ] && this._transitions[ from ][ to ] ) {
160
+ this._transitions[ from ][ to ].OnBefore.push( func );
161
+ returnValue = true;
162
+ }
163
+ return returnValue;
164
+ }
165
+
166
+ public transitionOnBeforeDel( from: string, to: string, func: FunctionOnTransitionBefore ): boolean {
167
+ let returnValue: boolean = false;
168
+ if( this._states[ from ] && this._states[ to ] && this._transitions[ from ][ to ] ) {
169
+ const pos: number = this._transitions[ from ][ to ].OnBefore.indexOf( func );
170
+ if( -1 !== pos ) {
171
+ this._transitions[ from ][ to ].OnBefore.splice( pos, 1 );
172
+ returnValue = true;
173
+ }
174
+ }
175
+ return returnValue;
176
+ }
177
+
178
+ public transitionOnAfterAdd( from: string, to: string, func: FunctionOnTransitionAfter ): boolean {
179
+ let returnValue: boolean = false;
180
+ if( this._states[ from ] && this._states[ to ] && this._transitions[ from ][ to ] ) {
181
+ this._transitions[ from ][ to ].OnAfter.push( func );
182
+ returnValue = true;
183
+ }
184
+ return returnValue;
185
+ }
186
+
187
+ public transitionOnAfterDel( from: string, to: string, func: FunctionOnTransitionAfter ): boolean {
188
+ let returnValue: boolean = false;
189
+ if( this._states[ from ] && this._states[ to ] && this._transitions[ from ][ to ] ) {
190
+ const pos: number = this._transitions[ from ][ to ].OnAfter.indexOf( func );
191
+ if( -1 !== pos ) {
192
+ this._transitions[ from ][ to ].OnAfter.splice( pos, 1 );
193
+ returnValue = true;
194
+ }
195
+ }
196
+ return returnValue;
197
+ }
198
+
199
+ public get state(): Undefinedable<string> {
200
+ return this._currentState;
201
+ }
202
+
203
+ public async stateSet( nextState: string ): Promise<boolean> {
204
+ let returnValue: boolean = false;
205
+ if( !this._inTransition ) {
206
+ this._inTransition = true;
207
+ if( this._currentState && this._states[ nextState ] && this._transitions[ this._currentState ] && this._transitions[ this._currentState ][ nextState ] ) {
208
+ returnValue = true;
209
+ // Check if I can enter the new state: in case a function return false, abort
210
+ let cFL: number = this._transitions[ this._currentState ][ nextState ].OnBefore.length;
211
+ for( let iFL: number = 0; ( returnValue && ( iFL < cFL ) ); iFL++ ) {
212
+ if( 'function' === typeof this._transitions[ this._currentState ][ nextState ].OnBefore[ iFL ] ) {
213
+ let tmpValue = null;
214
+ if( 'AsyncFunction' === this._transitions[ this._currentState ][ nextState ].OnBefore[ iFL ].constructor.name ) {
215
+ tmpValue = await this._transitions[ this._currentState ][ nextState ].OnBefore[ iFL ]();
216
+ } else {
217
+ tmpValue = this._transitions[ this._currentState ][ nextState ].OnBefore[ iFL ]();
218
+ }
219
+ returnValue = ( false !== tmpValue );
220
+ }
221
+ }
222
+ if( returnValue ) {
223
+ cFL = this._states[ this._currentState ].OnLeave.length;
224
+ for( let iFL: number = 0; iFL < cFL; iFL++ ) {
225
+ if( 'function' === typeof this._states[ this._currentState ].OnLeave[ iFL ] ) {
226
+ if( 'AsyncFunction' === this._states[ this._currentState ].OnLeave[ iFL ].constructor.name ) {
227
+ await this._states[ this._currentState ].OnLeave[ iFL ]( this._currentState, nextState );
228
+ } else {
229
+ this._states[ this._currentState ].OnLeave[ iFL ]( this._currentState, nextState );
230
+ }
231
+ }
232
+ }
233
+ let previousState: string = this._currentState;
234
+ this._currentState = nextState;
235
+ cFL = this._transitions[ previousState ][ this._currentState ].OnAfter.length;
236
+ for( let iFL: number = 0; iFL < cFL; iFL++ ) {
237
+ if( 'function' === typeof this._transitions[ previousState ][ this._currentState ].OnAfter[ iFL ] ) {
238
+ if( 'AsyncFunction' === this._transitions[ previousState ][ this._currentState ].OnAfter[ iFL ].constructor.name ) {
239
+ await this._transitions[ previousState ][ this._currentState ].OnAfter[ iFL ]();
240
+ } else {
241
+ this._transitions[ previousState ][ this._currentState ].OnAfter[ iFL ]();
242
+ }
243
+ }
244
+ }
245
+ cFL = this._states[ this._currentState ].OnEnter.length;
246
+ for( let iFL: number = 0; iFL < cFL; iFL++ ) {
247
+ if( 'function' === typeof this._states[ this._currentState ].OnEnter[ iFL ] ) {
248
+ if( 'AsyncFunction' === this._states[ this._currentState ].OnEnter[ iFL ].constructor.name ) {
249
+ await this._states[ this._currentState ].OnEnter[ iFL ]( this._currentState, previousState );
250
+ } else {
251
+ this._states[ this._currentState ].OnEnter[ iFL ]( this._currentState, previousState );
252
+ }
253
+ }
254
+ }
255
+ }
256
+ }
257
+ this._inTransition = false;
258
+ }
259
+ return returnValue;
260
+ }
261
+
262
+ public checkTransition( nextState: string ): boolean {
263
+ return !!( !this._inTransition && this._currentState && this._states[ nextState ] && this._transitions[ this._currentState ] && this._transitions[ this._currentState ][ nextState ] );
264
+ }
265
+
266
+ public routeSpecialAdd( code: number, routeFunction: RouteFunction ) {
267
+ let returnValue: boolean = false;
268
+ switch( code ) {
269
+ case 403: {
270
+ this._routeFunction403 = routeFunction;
271
+ returnValue = true;
272
+ break;
273
+ }
274
+ case 404: {
275
+ this._routeFunction404 = routeFunction;
276
+ returnValue = true;
277
+ break;
278
+ }
279
+ case 500: {
280
+ this._routeFunction500 = routeFunction;
281
+ returnValue = true;
282
+ break;
283
+ }
284
+ default: {
285
+ throw new RangeError();
286
+ }
287
+ }
288
+ return returnValue;
289
+ }
290
+
291
+ public routeAdd( validState: string, path: string, routeFunction: RouteFunction, available?: CheckAvailability, routeFunction403?: RouteFunction ) {
292
+ let returnValue: boolean = false;
293
+ if( this._states[ validState ] ) {
294
+ if( path.match( this._regexDuplicatePathId ) ) {
295
+ throw new SyntaxError( 'Duplicate path id' );
296
+ } else {
297
+ let weight: number = 0;
298
+ const regex: RegExp = new RegExp( '^' + path.replace( this._regexSearchVariables,
299
+ function( _unused: string, name: string, type: string ): string {
300
+ let returnValue = '(?<' + name + '>[';
301
+ switch( type ) {
302
+ case '09': {
303
+ returnValue += '\\d';
304
+ break;
305
+ }
306
+ case 'AZ': {
307
+ returnValue += 'a-zA-Z';
308
+ break;
309
+ }
310
+ case 'AZ09':
311
+ default: {
312
+ returnValue += 'a-zA-Z\\d';
313
+ }
314
+ }
315
+ returnValue += ']+)';
316
+ return returnValue;
317
+ } ).replace( /\//g, '\\\/' ) + '$' );
318
+ const reducedPath = path.replace(
319
+ this._regexSearchVariables,
320
+ ( _, __, component ) => `:${ component ?? 'AZ09' }`
321
+ );
322
+ const paths: string[] = path.split( '/' );
323
+ const cFL: number = paths.length;
324
+ for( let iFL: number = 0; iFL < cFL; iFL++ ) {
325
+ if( !paths[ iFL ].startsWith( ':' ) ) {
326
+ weight += 2 ** ( cFL - iFL - 1 );
327
+ }
328
+ }
329
+ if( !this._routes.find( ( route: Route ): boolean => jFSMRouter._CheckRouteEquivalence( reducedPath, route.path ) ) ) {
330
+ this._routes.push( {
331
+ path: reducedPath,
332
+ validState: validState,
333
+ match: regex,
334
+ weight: weight,
335
+ routeFunction: routeFunction,
336
+ available: available,
337
+ routeFunction403: routeFunction403
338
+ } );
339
+ this._routes.sort(
340
+ ( a, b ) => ( ( a.weight > b.weight ) ? -1 : ( ( b.weight > a.weight ) ? 1 : 0 ) )
341
+ );
342
+ returnValue = true;
343
+ }
344
+ }
345
+ } else {
346
+ throw new SyntaxError( 'Non-existent state' );
347
+ }
348
+ return returnValue;
349
+ }
350
+
351
+ public routeDel( path: string ): boolean {
352
+ let returnValue: boolean = false;
353
+ if( !path.match( this._regexDuplicatePathId ) ) {
354
+ const reducedPath: string = path.replace(
355
+ this._regexSearchVariables,
356
+ ( _, __, component ) => `:${ component ?? 'AZ09' }`
357
+ );
358
+ const index: number = this._routes.findIndex( ( route: Route ): boolean => jFSMRouter._CheckRouteEquivalence( reducedPath, route.path ) );
359
+ if( -1 < index ) {
360
+ this._routes.splice( index, 1 );
361
+ returnValue = true;
362
+ }
363
+ } else {
364
+ throw new SyntaxError( 'Duplicate path id' );
365
+ }
366
+ return returnValue;
367
+ }
368
+
369
+ public trigger( path: string ): void {
370
+ if( '#' + path != this._window.location.hash ) {
371
+ this._window.location.hash = '#' + path;
372
+ }
373
+ }
374
+
375
+ public async route( path: string ): Promise<void> {
376
+ this._routing = true;
377
+ let routeFunction: Undefinedable<RouteFunction>;
378
+ let routePath: string = '';
379
+ let result: Nullable<RegExpExecArray> = null;
380
+ for( const route of this._routes ) {
381
+ if( ( result = route.match.exec( path ) ) ) {
382
+ routePath = route.path;
383
+ let available: boolean = true;
384
+ if( route.available ) {
385
+ if( 'function' === typeof route.available ) {
386
+ if( 'AsyncFunction' === route.available.constructor.name ) {
387
+ available = await route.available( routePath, path, ( result.groups ?? {} ) );
388
+ } else {
389
+ // @ts-ignore
390
+ available = route.available( routePath, path, ( result.groups ?? {} ) );
391
+ }
392
+ } else {
393
+ available = false;
394
+ }
395
+ }
396
+ if( available ) {
397
+ if( !route.validState || ( this._currentState === route.validState ) ) {
398
+ routeFunction = route.routeFunction;
399
+ } else if( this._routeFunction500 ) {
400
+ routeFunction = this._routeFunction500;
401
+ }
402
+ } else if( route.routeFunction403 ) {
403
+ routeFunction = route.routeFunction403;
404
+ } else if( this._routeFunction403 ) {
405
+ routeFunction = this._routeFunction403;
406
+ }
407
+ break;
408
+ }
409
+ }
410
+ if( !routeFunction && this._routeFunction404 ) {
411
+ routeFunction = this._routeFunction404;
412
+ }
413
+ if( ( 'function' !== typeof routeFunction ) && this._routeFunction500 ) {
414
+ routeFunction = this._routeFunction500;
415
+ }
416
+ if( routeFunction && ( 'function' === typeof routeFunction ) ) {
417
+ if( 'AsyncFunction' === routeFunction.constructor.name ) {
418
+ await routeFunction( routePath, path, ( result?.groups ?? {} ) );
419
+ } else {
420
+ routeFunction( routePath, path, ( result?.groups ?? {} ) );
421
+ }
422
+ }
423
+ if( this._queue.length ) {
424
+ await this.route( this._queue.shift()! );
425
+ } else {
426
+ this._routing = false;
427
+ }
428
+ }
429
+
430
+ public async checkHash(): Promise<void> {
431
+ const hash: string = ( this._window.location.hash.startsWith( '#' ) ? this._window.location.hash.substring( 1 ) : '' );
432
+ if( '' != hash ) {
433
+ if( this._routing ) {
434
+ this._queue.push( hash );
435
+ } else {
436
+ await this.route( hash );
437
+ }
438
+ }
439
+ }
440
+ }
441
+
442
+ export default jFSMRouter.instance;
package/jsdom.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ declare module 'jsdom' {
2
+ class JSDOM {
3
+ constructor( html?: string, options?: { url?: string } );
4
+ readonly window: Window & typeof globalThis;
5
+ }
6
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@stefanobalocco/jfsmrouter",
3
+ "description": "Hash router meet a finite state machine",
4
+ "exports": "./jFSMRouter.min.js",
5
+ "types": "jFSMRouter.d.ts",
6
+ "files": [
7
+ "jFSMRouter.js",
8
+ "jFSMRouter.min.js",
9
+ "jFSMRouter.d.ts",
10
+ "jFSMRouter.ts",
11
+ "jFSMRouter.test.js",
12
+ "jFSMRouter.test.ts",
13
+ "build.mjs",
14
+ "tsconfig.json",
15
+ "tsconfig.tests.json",
16
+ "jsdom.d.ts"
17
+ ],
18
+ "type": "module",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/StefanoBalocco/jFSMRouter.git"
22
+ },
23
+ "version": "2.0.0",
24
+ "keywords": [
25
+ "hash router",
26
+ "client-side router",
27
+ "spa router",
28
+ "finite state machine",
29
+ "fsm"
30
+ ],
31
+ "author": "Stefano Balocco <stefano.balocco@gmail.com>",
32
+ "license": "MIT",
33
+ "bugs": {
34
+ "url": "https://github.com/StefanoBalocco/jFSMRouter/issues"
35
+ },
36
+ "homepage": "https://github.com/StefanoBalocco/jFSMRouter",
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "scripts": {
41
+ "build": "node build.mjs library tests",
42
+ "build:library": "node build.mjs library",
43
+ "build:tests": "node build.mjs tests",
44
+ "tests": "c8 --reporter=text --reporter=lcov ava '*.test.js' --verbose"
45
+ },
46
+ "devDependencies": {
47
+ "ava": "~6",
48
+ "c8": "~10",
49
+ "jsdom": "~26",
50
+ "terser": "~5",
51
+ "typescript": "~5"
52
+ }
53
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "esnext",
4
+ "module": "esnext",
5
+ "moduleResolution": "bundler",
6
+ "declaration": true,
7
+ "removeComments": true,
8
+ "strict": true,
9
+ "noImplicitAny": true,
10
+ "strictNullChecks": true,
11
+ "strictFunctionTypes": true,
12
+ "strictPropertyInitialization": true,
13
+ "noImplicitThis": true,
14
+ "noUnusedLocals": true,
15
+ "noUnusedParameters": true,
16
+ "noImplicitReturns": true,
17
+ "allowSyntheticDefaultImports": true
18
+ },
19
+ "files": [
20
+ "jFSMRouter.ts"
21
+ ]
22
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "compilerOptions":
3
+ {
4
+ "target": "esnext",
5
+ "module": "esnext",
6
+ "moduleResolution": "bundler",
7
+ "declaration": false,
8
+ "removeComments": true,
9
+ "strict": true,
10
+ "noImplicitAny": true,
11
+ "strictNullChecks": true,
12
+ "strictFunctionTypes": true,
13
+ "strictPropertyInitialization": true,
14
+ "noImplicitThis": true,
15
+ "noUnusedLocals": true,
16
+ "noUnusedParameters": true,
17
+ "noImplicitReturns": true,
18
+ "allowSyntheticDefaultImports": true
19
+ },
20
+ "files":
21
+ [
22
+ "jsdom.d.ts",
23
+ "jFSMRouter.test.ts"
24
+ ]
25
+ }