├── mod.ts ├── .gitignore ├── src ├── index.ts ├── util │ ├── combine_transitions.ts │ ├── create_transition.ts │ ├── match_route_pattern.ts │ └── prefix_transition.ts ├── types.ts └── create_router.ts ├── examples └── console │ ├── api.ts │ ├── index.ts │ └── transitions.ts ├── README.md ├── LICENSE └── package.json /mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./src/index.ts"; 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /cjs/ 2 | /esm/ 3 | /node_modules/ 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { createRouter } from "./create_router.ts"; 2 | 3 | export { combineTransitions } from "./util/combine_transitions.ts"; 4 | export { createTransition } from "./util/create_transition.ts"; 5 | export { prefixTransition } from "./util/prefix_transition.ts"; 6 | export { matchRoutePattern } from "./util/match_route_pattern.ts"; 7 | 8 | export * from "./types.ts"; 9 | -------------------------------------------------------------------------------- /examples/console/api.ts: -------------------------------------------------------------------------------- 1 | import { QueryParameters } from "../../mod.ts"; 2 | 3 | export function fetchIndexData(): Promise { 4 | return new Promise((resolve) => { 5 | setTimeout(() => resolve({ title: "Welcome" }), 100); 6 | }); 7 | } 8 | 9 | export function fetchAboutData( 10 | queryParameters: QueryParameters 11 | ): Promise { 12 | return new Promise((resolve) => { 13 | setTimeout(() => resolve({ title: queryParameters.title || "About" }), 100); 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /src/util/combine_transitions.ts: -------------------------------------------------------------------------------- 1 | import { Screen, Transition, Location } from "../types.ts"; 2 | 3 | export function combineTransitions(...transitions: Transition[]): Transition { 4 | return async function combinedTransition( 5 | location: Location 6 | ): Promise { 7 | for (const transition of transitions) { 8 | const page = await transition(location); 9 | 10 | if (page) { 11 | return page; 12 | } 13 | } 14 | 15 | return null; 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vstack-router 2 | 3 | > Universal router 4 | 5 | - `router` convert `location` changes to `screen` changes using single 6 | `transition` function. 7 | - `location` is an object that represent some `history` action. 8 | - `history` is a universal abstraction on session history. 9 | - `screen` is an object that represent UI of some `location`. 10 | - `transition` is a function that trying to eventually satisfy a location. 11 | - `transition` could be combination of other `transition` functions. 12 | - `transition` calls are synchronised via queue, but `router` skips tasks 13 | in the middle of the queue, because they are unreachable. 14 | -------------------------------------------------------------------------------- /src/util/create_transition.ts: -------------------------------------------------------------------------------- 1 | import { matchRoutePattern } from "./match_route_pattern.ts"; 2 | 3 | import { Screen, Transition, TransitionHandler, Location } from "../types.ts"; 4 | 5 | export function createTransition( 6 | pattern: string, 7 | handler: TransitionHandler 8 | ): Transition { 9 | return async function transition(location: Location): Promise { 10 | const queryParameters = matchRoutePattern( 11 | pattern, 12 | location.pathname + location.search 13 | ); 14 | 15 | if (!queryParameters) { 16 | return null; 17 | } 18 | 19 | return await handler(queryParameters); 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type Screen = Object; 2 | 3 | export interface Location { 4 | pathname: string; 5 | search: string; 6 | } 7 | 8 | export type QueryParameters = { [key: string]: string }; 9 | 10 | export type Transition = (location: Location) => Promise; 11 | 12 | export type TransitionHandler = ( 13 | queryParameters: QueryParameters 14 | ) => Promise; 15 | 16 | export interface History { 17 | listen(listener: (location: Location) => void): () => void; 18 | } 19 | 20 | export interface Router { 21 | listen(listener: (screen: Screen, location: Location) => void): () => void; 22 | waitQueue(): Promise; 23 | } 24 | -------------------------------------------------------------------------------- /src/util/match_route_pattern.ts: -------------------------------------------------------------------------------- 1 | export function matchRoutePattern( 2 | pattern: string, 3 | fullUrl: string 4 | ): { [key: string]: string } | null { 5 | const [url, query] = fullUrl.split("?"); 6 | 7 | const names: string[] = []; 8 | const urlParamsRegexpString = pattern 9 | .replace(/:([^/]+)/g, (name) => { 10 | names.push(name.replace(/^:/, "")); 11 | return "([^/]+)"; 12 | }) 13 | .replace(/\//g, "\\/"); 14 | 15 | const urlParamsRegexp = new RegExp(`^${urlParamsRegexpString}$`); 16 | const urlParamsMatches = urlParamsRegexp.exec(url); 17 | 18 | if (!urlParamsMatches) { 19 | return null; 20 | } 21 | 22 | const queryParams = Array.from(new URLSearchParams(query || "").entries()); 23 | const urlParams = urlParamsMatches 24 | .slice(1) 25 | .map((value, index) => [names[index], value] as [string, string]); 26 | 27 | const paramsObject = [...queryParams, ...urlParams].reduce( 28 | (acc, [key, value]) => { 29 | acc[key] = value; 30 | return acc; 31 | }, 32 | {} as { [key: string]: string } 33 | ); 34 | 35 | return paramsObject; 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2020 Vyacheslav Slinko 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /src/util/prefix_transition.ts: -------------------------------------------------------------------------------- 1 | import { Screen, Transition, Location } from "../types.ts"; 2 | 3 | export function prefixTransition( 4 | prefix: string | RegExp, 5 | transition: Transition 6 | ): Transition { 7 | if (prefix instanceof RegExp && prefix.toString()[1] !== "^") { 8 | throw new Error("Prefix regexp should start with ^"); 9 | } 10 | 11 | const hasPrefix = (pathname: string) => { 12 | if (prefix instanceof RegExp) { 13 | return prefix.test(pathname); 14 | } else { 15 | return pathname.startsWith(prefix); 16 | } 17 | }; 18 | 19 | const removePrefix = (pathname: string) => { 20 | if (prefix instanceof RegExp) { 21 | return pathname.replace(prefix, ""); 22 | } else { 23 | return pathname.slice(prefix.length); 24 | } 25 | }; 26 | 27 | return async function prefixedTransition( 28 | location: Location 29 | ): Promise { 30 | const { pathname, ...rest } = location; 31 | 32 | if (!hasPrefix(pathname)) { 33 | return null; 34 | } 35 | 36 | return transition({ 37 | ...rest, 38 | pathname: removePrefix(pathname), 39 | }); 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /examples/console/index.ts: -------------------------------------------------------------------------------- 1 | import { createMemoryHistory } from "https://cdn.pika.dev/history@^4.10.1"; 2 | 3 | import { 4 | createRouter, 5 | combineTransitions, 6 | prefixTransition, 7 | Router, 8 | } from "../../mod.ts"; 9 | 10 | import { 11 | indexTransition, 12 | aboutTransition, 13 | failTransition, 14 | notFoundTransition, 15 | createErrorTransition, 16 | } from "./transitions.ts"; 17 | 18 | const history = createMemoryHistory({}); 19 | 20 | const router: Router = createRouter( 21 | history, 22 | createErrorTransition( 23 | combineTransitions( 24 | indexTransition, 25 | prefixTransition("/company", aboutTransition), 26 | failTransition, 27 | notFoundTransition 28 | ) 29 | ) 30 | ); 31 | 32 | router.listen((screen, location) => { 33 | console.log("location", location); 34 | console.log("screen", screen); 35 | console.log(); 36 | }); 37 | 38 | history.push("/qwerty", {}); 39 | await router.waitQueue(); 40 | 41 | history.push("/company/about?title=Test", {}); 42 | await router.waitQueue(); 43 | 44 | history.push("/404", {}); 45 | await router.waitQueue(); 46 | 47 | history.push("/fail", {}); 48 | await router.waitQueue(); 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vstack-router", 3 | "version": "0.5.2", 4 | "description": "Universal router", 5 | "keywords": [ 6 | "vstack", 7 | "router" 8 | ], 9 | "homepage": "https://github.com/vslinko/vstack-router", 10 | "bugs": { 11 | "url": "https://github.com/vslinko/vstack-router/issues", 12 | "email": "vslinko@yahoo.com" 13 | }, 14 | "license": "MIT", 15 | "author": { 16 | "name": "Vyacheslav Slinko", 17 | "email": "vslinko@yahoo.com", 18 | "url": "https://twitter.com/vslinko" 19 | }, 20 | "contributors": [], 21 | "main": "cjs/index.js", 22 | "module": "esm/index.ts", 23 | "scripts": { 24 | "prepublish": "rm -rf cjs esm && babel src -x .ts --out-dir cjs --plugins @babel/plugin-transform-modules-commonjs && babel src -x .ts --out-dir esm" 25 | }, 26 | "devDependencies": { 27 | "@babel/cli": "^7.8.4", 28 | "@babel/core": "^7.9.6", 29 | "@babel/plugin-transform-modules-commonjs": "^7.9.6", 30 | "@babel/preset-typescript": "^7.9.0", 31 | "babel-plugin-transform-rename-import": "^2.3.0" 32 | }, 33 | "babel": { 34 | "presets": [ 35 | "@babel/preset-typescript" 36 | ], 37 | "plugins": [ 38 | [ 39 | "babel-plugin-transform-rename-import", 40 | { 41 | "original": "^(.+?)\\.ts$", 42 | "replacement": "$1.js" 43 | } 44 | ] 45 | ] 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /examples/console/transitions.ts: -------------------------------------------------------------------------------- 1 | import * as api from "./api.ts"; 2 | import { 3 | Screen, 4 | Location, 5 | QueryParameters, 6 | Transition, 7 | createTransition, 8 | } from "../../mod.ts"; 9 | 10 | export const indexTransition: Transition = createTransition( 11 | "/", 12 | async (queryParameters: QueryParameters): Promise => { 13 | const props = await api.fetchIndexData(); 14 | return { page: "IndexPage", props }; 15 | } 16 | ); 17 | 18 | export const aboutTransition: Transition = createTransition( 19 | "/about", 20 | async (queryParameters: QueryParameters): Promise => { 21 | const props = await api.fetchAboutData(queryParameters); 22 | return { page: "AboutPage", props }; 23 | } 24 | ); 25 | 26 | export async function notFoundTransition( 27 | location: Location 28 | ): Promise { 29 | return { page: "NotFoundPage", props: { location } }; 30 | } 31 | 32 | export const failTransition: Transition = createTransition( 33 | "/fail", 34 | async (queryParameters: QueryParameters): Promise => { 35 | throw new Error("Fail"); 36 | } 37 | ); 38 | 39 | export function createErrorTransition(transition: Transition): Transition { 40 | return async function errorTransition( 41 | location: Location 42 | ): Promise { 43 | try { 44 | return await transition(location); 45 | } catch (error) { 46 | return { 47 | page: "ErrorPage", 48 | props: { error }, 49 | }; 50 | } 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /src/create_router.ts: -------------------------------------------------------------------------------- 1 | import { Screen, Location, History, Transition, Router } from "./types.ts"; 2 | 3 | export function createRouter(history: History, transition: Transition): Router { 4 | const listeners: Set<( 5 | screen: Screen, 6 | location: Location 7 | ) => void> = new Set(); 8 | const waiting: Set<() => void> = new Set(); 9 | let nextLocation: Location | undefined; 10 | let inProcess = false; 11 | 12 | function listen( 13 | listener: (screen: Screen, location: Location) => void 14 | ): () => void { 15 | listeners.add(listener); 16 | 17 | return () => { 18 | listeners.delete(listener); 19 | }; 20 | } 21 | 22 | async function navigateTo(location: Location): Promise { 23 | const screen = await transition(location); 24 | 25 | if (screen) { 26 | for (const listener of listeners) { 27 | listener(screen, location); 28 | } 29 | } 30 | } 31 | 32 | async function processQueue() { 33 | if (inProcess) { 34 | return; 35 | } 36 | 37 | if (!nextLocation) { 38 | for (const resolve of waiting) { 39 | resolve(); 40 | } 41 | waiting.clear(); 42 | return; 43 | } 44 | 45 | const location = nextLocation; 46 | nextLocation = undefined; 47 | inProcess = true; 48 | 49 | try { 50 | await navigateTo(location); 51 | } catch (error) { 52 | console.error(error.stack); 53 | } finally { 54 | inProcess = false; 55 | processQueue(); 56 | } 57 | } 58 | 59 | function waitQueue(): Promise { 60 | return new Promise((resolve) => { 61 | if (inProcess) { 62 | waiting.add(resolve); 63 | } else { 64 | resolve(); 65 | } 66 | }); 67 | } 68 | 69 | history.listen((location) => { 70 | nextLocation = location; 71 | processQueue(); 72 | }); 73 | 74 | return { 75 | listen, 76 | waitQueue, 77 | }; 78 | } 79 | --------------------------------------------------------------------------------