@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/LICENSE.md +22 -0
- package/README.md +182 -0
- package/build.mjs +102 -0
- package/jFSMRouter.d.ts +53 -0
- package/jFSMRouter.js +398 -0
- package/jFSMRouter.min.js +1 -0
- package/jFSMRouter.test.js +408 -0
- package/jFSMRouter.test.ts +505 -0
- package/jFSMRouter.ts +442 -0
- package/jsdom.d.ts +6 -0
- package/package.json +53 -0
- package/tsconfig.json +22 -0
- package/tsconfig.tests.json +25 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Copyright (c) 2019-2025, Stefano Balocco <stefano.balocco@gmail.com>
|
|
2
|
+
All rights reserved.
|
|
3
|
+
Redistribution and use in source and binary forms, with or without
|
|
4
|
+
modification, are permitted provided that the following conditions are met:
|
|
5
|
+
* Redistributions of source code must retain the above copyright notice, this
|
|
6
|
+
list of conditions and the following disclaimer.
|
|
7
|
+
* Redistributions in binary form must reproduce the above copyright notice,
|
|
8
|
+
this list of conditions and the following disclaimer in the documentation
|
|
9
|
+
and/or other materials provided with the distribution.
|
|
10
|
+
* Neither the name of Stefano Balocco nor the names of its contributors may
|
|
11
|
+
be used to endorse or promote products derived from this software without
|
|
12
|
+
specific prior written permission.
|
|
13
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
14
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
15
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
16
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
17
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
18
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
19
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
20
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
21
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
22
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
package/README.md
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# jFSMRouter
|
|
2
|
+
JavaScript Finite State Machine Router
|
|
3
|
+
|
|
4
|
+
`jFSMRouter` (JavaScript Finite State Machine Router) is a JavaScript class that implements a **Finite State Machine** (FSM) integrated with a **hash-based router** for browsers. It allows centralized management of states, transitions, and routes.
|
|
5
|
+
|
|
6
|
+
## 1. Singleton Instance
|
|
7
|
+
|
|
8
|
+
The class uses the Singleton pattern: only one global instance exists, which you can access with:
|
|
9
|
+
|
|
10
|
+
```js
|
|
11
|
+
import router from 'https://cdn.jsdelivr.net/gh/StefanoBalocco/jFSMRouter@2.0.0/jFSMRouter.min.js';
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## 2. States
|
|
15
|
+
|
|
16
|
+
States represent the logical steps of your application.
|
|
17
|
+
|
|
18
|
+
### 2.1 Add a State
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
router.stateAdd('home'); // Adds the "home" state
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- Returns `true` if the state is successfully created.
|
|
25
|
+
- The first state added becomes the initial current state.
|
|
26
|
+
|
|
27
|
+
### 2.2 Remove a State
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
router.stateDel('home'); // Removes the "home" state
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### 2.3 Handle Entry and Exit Hooks
|
|
34
|
+
|
|
35
|
+
- **OnEnter**: functions called when entering a state.
|
|
36
|
+
- **OnLeave**: functions called when leaving a state.
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
function onEnter(prev, next) { console.log(`Entering ${next}`); }
|
|
40
|
+
function onLeave(curr, next) { console.log(`Leaving ${curr}`); }
|
|
41
|
+
|
|
42
|
+
router.stateOnEnterAdd('home', onEnter);
|
|
43
|
+
router.stateOnLeaveAdd('home', onLeave);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
To remove hooks:
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
router.stateOnEnterDel('home', onEnter);
|
|
50
|
+
router.stateOnLeaveDel('home', onLeave);
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## 3. Transitions
|
|
54
|
+
|
|
55
|
+
Transitions define permissions and hooks between two states.
|
|
56
|
+
|
|
57
|
+
### 3.1 Add a Transition
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
router.transitionAdd('home', 'about');
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### 3.2 Remove a Transition
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
router.transitionDel('home', 'about');
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### 3.3 Transition Hooks
|
|
70
|
+
|
|
71
|
+
- **OnBefore**: called before transitioning, can block it by returning `false`.
|
|
72
|
+
- **OnAfter**: called after the state change.
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
function before() { return confirm('Go to the About page?'); }
|
|
76
|
+
async function after() { console.log('Transition completed'); }
|
|
77
|
+
|
|
78
|
+
router.transitionOnBeforeAdd('home', 'about', before);
|
|
79
|
+
router.transitionOnAfterAdd('home', 'about', after);
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
To remove hooks:
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
router.transitionOnBeforeDel('home', 'about', before);
|
|
86
|
+
router.transitionOnAfterDel('home', 'about', after);
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## 4. Hash-Based Routing
|
|
90
|
+
|
|
91
|
+
Each route is associated with a valid state.
|
|
92
|
+
|
|
93
|
+
### 4.1 Add a Route
|
|
94
|
+
|
|
95
|
+
```js
|
|
96
|
+
// path: '/user/:id[09]'
|
|
97
|
+
router.routeAdd(
|
|
98
|
+
'home', // required state
|
|
99
|
+
'/user/:id[09]', // path with variables
|
|
100
|
+
(pathDef, actual, vars) => { console.log(vars.id); },
|
|
101
|
+
() => true, // optional availability function
|
|
102
|
+
(pathDef, actual, vars) => { console.warn('Access denied'); } // 403
|
|
103
|
+
);
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
- **path** may include variables like `:name[AZ09]`, `:num[09]`, `:str[AZ]`.
|
|
107
|
+
- **routeFunction**: callback called if all checks pass.
|
|
108
|
+
- **available**: sync or async function to allow/deny the route.
|
|
109
|
+
- **routeFunction403**: callback called in case of access denial (403).
|
|
110
|
+
|
|
111
|
+
### 4.2 Remove a Route
|
|
112
|
+
|
|
113
|
+
```js
|
|
114
|
+
router.routeDel('/user/:id[09]');
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### 4.3 Special Routes
|
|
118
|
+
|
|
119
|
+
```js
|
|
120
|
+
router.routeSpecialAdd(404, () => { /* page not found */ });
|
|
121
|
+
router.routeSpecialAdd(403, () => { /* access denied */ });
|
|
122
|
+
router.routeSpecialAdd(500, () => { /* internal error */ });
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### 4.4 Manual trigger
|
|
126
|
+
|
|
127
|
+
To force navigation:
|
|
128
|
+
|
|
129
|
+
```js
|
|
130
|
+
router.trigger('user/123'); // sets the hash and triggers routing
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## 5. Internal Mechanism
|
|
134
|
+
|
|
135
|
+
- The `hashchange` listener calls `checkHash()`.
|
|
136
|
+
- More specific paths (higher weight) take priority.
|
|
137
|
+
- FSM handles the proper hook sequence: OnBefore → OnLeave → OnAfter → OnEnter.
|
|
138
|
+
|
|
139
|
+
## 6. Complete Example
|
|
140
|
+
|
|
141
|
+
```html
|
|
142
|
+
<!DOCTYPE html>
|
|
143
|
+
<html>
|
|
144
|
+
<head><title>jFSMRouter Demo</title></head>
|
|
145
|
+
<body>
|
|
146
|
+
<script type="module">
|
|
147
|
+
import router from 'https://example.org/jFSMRouter.js';
|
|
148
|
+
|
|
149
|
+
// Define states
|
|
150
|
+
router.stateAdd('home');
|
|
151
|
+
router.stateAdd('user');
|
|
152
|
+
|
|
153
|
+
// State hooks
|
|
154
|
+
router.stateOnEnterAdd('home', () => console.log('Entered Home'));
|
|
155
|
+
router.stateOnEnterAdd('user', (_, prev) => console.log(`User ${prev}→user`));
|
|
156
|
+
|
|
157
|
+
// Transitions
|
|
158
|
+
router.transitionAdd('home', 'user');
|
|
159
|
+
|
|
160
|
+
// Routing
|
|
161
|
+
router.routeSpecialAdd(404, () => document.body.innerHTML = '<h1>404 Not Found</h1>');
|
|
162
|
+
router.routeAdd(
|
|
163
|
+
'home', '/home', () => alert('Welcome!')
|
|
164
|
+
);
|
|
165
|
+
router.routeAdd(
|
|
166
|
+
'user', '/user/:id[09]', (pd, act, { id }) =>
|
|
167
|
+
document.body.innerHTML = `<h1>User ${id}</h1>`
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// Initial startup (if hash already present)
|
|
171
|
+
router.checkHash();
|
|
172
|
+
</script>
|
|
173
|
+
</body>
|
|
174
|
+
</html>
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
**Notes**:
|
|
180
|
+
- Uses ES Module syntax for import.
|
|
181
|
+
- Hook handling supports both sync and async functions.
|
|
182
|
+
- Avoid duplicate variable IDs in a single path (throws `Duplicate path id` exception).
|
package/build.mjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { minify } from 'terser';
|
|
5
|
+
import ts from 'typescript';
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname( fileURLToPath( import.meta.url ) );
|
|
8
|
+
|
|
9
|
+
// ── Utilities ─────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
function compileTsc( configPath ) {
|
|
12
|
+
const absConfig = path.resolve( __dirname, configPath );
|
|
13
|
+
const configFile = ts.readConfigFile( absConfig, ts.sys.readFile );
|
|
14
|
+
if( configFile.error ) {
|
|
15
|
+
throw new Error( ts.formatDiagnosticsWithColorAndContext( [ configFile.error ], {
|
|
16
|
+
getCurrentDirectory: ts.sys.getCurrentDirectory,
|
|
17
|
+
getCanonicalFileName: f => f,
|
|
18
|
+
getNewLine: () => '\n'
|
|
19
|
+
} ) );
|
|
20
|
+
}
|
|
21
|
+
const parsed = ts.parseJsonConfigFileContent(
|
|
22
|
+
configFile.config,
|
|
23
|
+
ts.sys,
|
|
24
|
+
path.dirname( absConfig )
|
|
25
|
+
);
|
|
26
|
+
const program = ts.createProgram( parsed.fileNames, parsed.options );
|
|
27
|
+
const emitResult = program.emit();
|
|
28
|
+
const diagnostics = ts.getPreEmitDiagnostics( program ).concat( emitResult.diagnostics );
|
|
29
|
+
if( 0 < diagnostics.length ) {
|
|
30
|
+
const message = ts.formatDiagnosticsWithColorAndContext( diagnostics, {
|
|
31
|
+
getCurrentDirectory: ts.sys.getCurrentDirectory,
|
|
32
|
+
getCanonicalFileName: f => f,
|
|
33
|
+
getNewLine: () => '\n'
|
|
34
|
+
} );
|
|
35
|
+
throw new Error( message );
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Library ───────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
async function buildLibrary() {
|
|
42
|
+
console.log( 'Compiling TypeScript...' );
|
|
43
|
+
compileTsc( 'tsconfig.json' );
|
|
44
|
+
console.log( 'Minifying with terser...' );
|
|
45
|
+
const code = await fs.readFile( 'jFSMRouter.js', 'utf8' );
|
|
46
|
+
const result = await minify( code, {
|
|
47
|
+
module: true,
|
|
48
|
+
toplevel: true,
|
|
49
|
+
compress: true,
|
|
50
|
+
mangle: {
|
|
51
|
+
properties: {
|
|
52
|
+
regex: /^_/
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} );
|
|
56
|
+
if( undefined === result.code ) {
|
|
57
|
+
throw new Error( 'Terser did not produce output.' );
|
|
58
|
+
}
|
|
59
|
+
await fs.writeFile( 'jFSMRouter.min.js', result.code );
|
|
60
|
+
console.log( '✓ Library built.' );
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
async function buildTests() {
|
|
66
|
+
console.log( 'Compiling tests...' );
|
|
67
|
+
compileTsc( 'tsconfig.tests.json' );
|
|
68
|
+
console.log( '✓ Tests compiled.' );
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Dispatch ──────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
const targets = {
|
|
74
|
+
library: buildLibrary,
|
|
75
|
+
tests: buildTests
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const args = process.argv.slice( 2 );
|
|
79
|
+
|
|
80
|
+
if( 0 === args.length ) {
|
|
81
|
+
console.log( 'Usage: node build.mjs <target> [<target> ...]' );
|
|
82
|
+
console.log( 'Available targets: ' + Object.keys( targets ).join( ', ' ) );
|
|
83
|
+
process.exit( 0 );
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const unknown = args.filter( a => !( a in targets ) );
|
|
87
|
+
if( 0 < unknown.length ) {
|
|
88
|
+
console.error( 'Unknown target(s): ' + unknown.join( ', ' ) );
|
|
89
|
+
process.exit( 1 );
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function main() {
|
|
93
|
+
const cL1 = args.length;
|
|
94
|
+
for( let iL1 = 0; iL1 < cL1; iL1++ ) {
|
|
95
|
+
await targets[ args[ iL1 ] ]();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
main().catch( err => {
|
|
100
|
+
console.error( err );
|
|
101
|
+
process.exit( 1 );
|
|
102
|
+
} );
|
package/jFSMRouter.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
type Undefinedable<T> = T | undefined;
|
|
2
|
+
export type FunctionOnEnter = (currentState: string, nextState: string) => (void | Promise<void>);
|
|
3
|
+
export type FunctionOnLeave = (currentState: string, prevState: string) => (void | Promise<void>);
|
|
4
|
+
export type FunctionOnTransitionAfter = () => (void | Promise<void>);
|
|
5
|
+
export type FunctionOnTransitionBefore = () => (any | Promise<any>);
|
|
6
|
+
export type CheckAvailability = (path: string, hashPath: string, params?: {
|
|
7
|
+
[key: string]: string;
|
|
8
|
+
}) => (boolean | Promise<boolean>);
|
|
9
|
+
export type RouteFunction = (path: string, hashPath: string, params?: {
|
|
10
|
+
[key: string]: string;
|
|
11
|
+
}) => (void | Promise<void>);
|
|
12
|
+
declare class jFSMRouter {
|
|
13
|
+
private static _instance;
|
|
14
|
+
static get instance(): jFSMRouter;
|
|
15
|
+
private _regexDuplicatePathId;
|
|
16
|
+
private _regexSearchVariables;
|
|
17
|
+
private _routes;
|
|
18
|
+
private _routeFunction403;
|
|
19
|
+
private _routeFunction404;
|
|
20
|
+
private _routeFunction500;
|
|
21
|
+
private _routing;
|
|
22
|
+
private _queue;
|
|
23
|
+
private _inTransition;
|
|
24
|
+
private _currentState;
|
|
25
|
+
private _states;
|
|
26
|
+
private _transitions;
|
|
27
|
+
private _window;
|
|
28
|
+
private constructor();
|
|
29
|
+
private static _CheckRouteEquivalence;
|
|
30
|
+
stateAdd(state: string): boolean;
|
|
31
|
+
stateDel(state: string): boolean;
|
|
32
|
+
stateOnEnterAdd(state: string, func: FunctionOnEnter): boolean;
|
|
33
|
+
stateOnEnterDel(state: string, func: FunctionOnEnter): boolean;
|
|
34
|
+
stateOnLeaveAdd(state: string, func: FunctionOnLeave): boolean;
|
|
35
|
+
stateOnLeaveDel(state: string, func: FunctionOnLeave): boolean;
|
|
36
|
+
transitionAdd(from: string, to: string): boolean;
|
|
37
|
+
transitionDel(from: string, to: string): boolean;
|
|
38
|
+
transitionOnBeforeAdd(from: string, to: string, func: FunctionOnTransitionBefore): boolean;
|
|
39
|
+
transitionOnBeforeDel(from: string, to: string, func: FunctionOnTransitionBefore): boolean;
|
|
40
|
+
transitionOnAfterAdd(from: string, to: string, func: FunctionOnTransitionAfter): boolean;
|
|
41
|
+
transitionOnAfterDel(from: string, to: string, func: FunctionOnTransitionAfter): boolean;
|
|
42
|
+
get state(): Undefinedable<string>;
|
|
43
|
+
stateSet(nextState: string): Promise<boolean>;
|
|
44
|
+
checkTransition(nextState: string): boolean;
|
|
45
|
+
routeSpecialAdd(code: number, routeFunction: RouteFunction): boolean;
|
|
46
|
+
routeAdd(validState: string, path: string, routeFunction: RouteFunction, available?: CheckAvailability, routeFunction403?: RouteFunction): boolean;
|
|
47
|
+
routeDel(path: string): boolean;
|
|
48
|
+
trigger(path: string): void;
|
|
49
|
+
route(path: string): Promise<void>;
|
|
50
|
+
checkHash(): Promise<void>;
|
|
51
|
+
}
|
|
52
|
+
declare const _default: jFSMRouter;
|
|
53
|
+
export default _default;
|