@mfeflow/route-factory 0.0.0-stage → 0.2.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sanjeev Sharma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,70 @@
1
- # Temporary Holding Version
1
+ # @mfeflow/route-factory
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Typed links between micro-frontends. Each remote defines its routes once; a link to any of them is checked at compile time for a missing param, a missing search param and an unknown route.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pnpm add @mfeflow/route-factory
9
+ ```
10
+
11
+ `react` (19) is an optional peer dependency, needed only for `@mfeflow/route-factory/react`. In a project made with mfeflow, `mfeflow add route-factory` does all of this.
12
+
13
+ ## Usage
14
+
15
+ ```ts
16
+ import { createLinks, defineRoutes } from '@mfeflow/route-factory';
17
+
18
+ // `/catalog` is where the shell serves the remote. `?q&page?` names search params: q is required.
19
+ export const catalogRoutes = defineRoutes('/catalog', {
20
+ home: '/',
21
+ product: '/products/:sku',
22
+ search: '/search?q&page?',
23
+ });
24
+ export const cartRoutes = defineRoutes('/cart', { home: '/' });
25
+
26
+ const links = createLinks({ catalog: catalogRoutes, cart: cartRoutes }, { base: '/app' });
27
+
28
+ links.href('catalog.product', { params: { sku: 'A1' } }); // /app/catalog/products/A1
29
+ links.href('catalog.search', { search: { q: 'red mug' }, hash: 'top' }); // /app/catalog/search?q=red+mug#top
30
+ links.href('catalog.product'); // compile error: sku is missing
31
+ links.href('catalog.checkout'); // compile error: no such route
32
+ ```
33
+
34
+ The route definitions are plain strings, so they can sit in a contracts package that other remotes read without importing the remote. `links.path` gives the same URL without `base`, for a router that has its own `basename`.
35
+
36
+ `base` can be a function, read on every call, so it may come from runtime config that loads later.
37
+
38
+ ### The shell's route table
39
+
40
+ ```ts
41
+ import { shellRoutes } from '@mfeflow/route-factory';
42
+ shellRoutes({ catalog: catalogRoutes, cart: cartRoutes });
43
+ // [{ remote: 'catalog', mount: '/catalog', pattern: '/catalog/*' }, ...]
44
+ ```
45
+
46
+ Use `pattern` in a router or in `mfe.json` `routes`, `mount` in a hand-written route table.
47
+
48
+ ### React
49
+
50
+ ```tsx
51
+ import { createMfeLink } from '@mfeflow/route-factory/react';
52
+
53
+ const MfeLink = createMfeLink(links); // or createMfeLink(links, { useNavigate }) with React Router
54
+ <MfeLink to="catalog.product" params={{ sku: 'A1' }}>
55
+ Mug
56
+ </MfeLink>;
57
+ ```
58
+
59
+ There is no dependency on a router. Without `useNavigate`, a left click on a same-origin link pushes the URL and sends `popstate`; modified clicks, middle clicks and `target="_blank"` keep the browser's behaviour.
60
+
61
+ ## Module Federation sharing
62
+
63
+ Bucket: **stateless**. Nothing here holds state shared between micro-frontends, so each remote can bundle its own copy and different versions can run side by side. No sharing config is needed.
64
+
65
+ ## Links
66
+
67
+ - [mfeflow repository](https://github.com/mfeflow/mfeflow) and its README
68
+ - [Documentation](https://mfeflow.github.io/mfeflow/)
69
+
70
+ MIT licensed.
@@ -0,0 +1,52 @@
1
+ import type { LinkArgs, LinkId, RouteSet, RouteSets, RouteTable, TemplateOf } from './types.js';
2
+ export type { LinkArgs, LinkId, LinkOptions, OptionalSearch, ParamValue, PathParams, RequiredSearch, RouteSet, RouteSets, RouteTable, SearchValue, TemplateOf, } from './types.js';
3
+ /**
4
+ * One remote's routes. `mount` is where the shell serves the remote (`'/catalog'`, or `'/'` for a
5
+ * remote at the root); each route is a path under it, with search params after a `?`:
6
+ *
7
+ * ```ts
8
+ * export const catalogRoutes = defineRoutes('/catalog', {
9
+ * home: '/',
10
+ * product: '/products/:sku',
11
+ * search: '/search?q&page?', // q is required, page is optional
12
+ * });
13
+ * ```
14
+ */
15
+ export declare function defineRoutes<const R extends RouteTable>(mount: string, routes: R): RouteSet<R>;
16
+ export interface LinkConfig {
17
+ /**
18
+ * Where the shell is served: `''` (default) at the origin's root, `'/app'` under a path, or a
19
+ * full URL for links that leave the page. A function is read on every call, so it can come from
20
+ * runtime config that loads after the links are created.
21
+ */
22
+ base?: string | (() => string);
23
+ }
24
+ export interface Links<S extends RouteSets> {
25
+ /** The URL for a route, under the shell's base: `/app/catalog/products/42?ref=home#reviews`. */
26
+ href: <Id extends LinkId<S>>(id: Id, ...args: LinkArgs<TemplateOf<S, Id>>) => string;
27
+ /** The same without the base, for a router that has its own `basename`. */
28
+ path: <Id extends LinkId<S>>(id: Id, ...args: LinkArgs<TemplateOf<S, Id>>) => string;
29
+ }
30
+ /**
31
+ * Typed links across remotes. The id is `<remote>.<route>`; a missing param, a missing required
32
+ * search param or an unknown route fails typecheck, and throws if types were bypassed.
33
+ *
34
+ * ```ts
35
+ * const links = createLinks({ catalog: catalogRoutes, cart: cartRoutes }, { base: '/app' });
36
+ * links.href('catalog.product', { params: { sku: 'A1' }, search: { ref: 'home' } });
37
+ * ```
38
+ */
39
+ export declare function createLinks<const S extends RouteSets>(sets: S, config?: LinkConfig): Links<S>;
40
+ /** One line of the shell's route table. */
41
+ export interface ShellRoute {
42
+ remote: string;
43
+ /** Where the shell serves the remote, e.g. `/catalog`. */
44
+ mount: string;
45
+ /** The same as a wildcard pattern for a router or `mfe.json`, e.g. `/catalog/*`. */
46
+ pattern: string;
47
+ }
48
+ /**
49
+ * What the shell needs to route to each remote, from the same definitions the links come from:
50
+ * add `pattern` to a router or to `mfe.json` `routes`, and `mount` to a hand-written route table.
51
+ */
52
+ export declare function shellRoutes(sets: RouteSets): ShellRoute[];
package/dist/index.js ADDED
@@ -0,0 +1,62 @@
1
+ import { fillPath } from "@mfeflow/url-factory";
2
+ const MOUNT = /^\/([A-Za-z0-9._~-]+(\/[A-Za-z0-9._~-]+)*)?$/;
3
+ function defineRoutes(mount, routes) {
4
+ if (!MOUNT.test(mount)) throw new Error(`route-factory: mount "${mount}" must start with "/" and have no trailing "/", "?" or ":"`);
5
+ for (const [name, template] of Object.entries(routes))if (!template.startsWith('/')) throw new Error(`route-factory: route "${name}" must start with "/" (got "${template}")`);
6
+ return {
7
+ mount: '/' === mount ? '' : mount,
8
+ routes
9
+ };
10
+ }
11
+ function trimEnd(base) {
12
+ return base.endsWith('/') ? base.slice(0, -1) : base;
13
+ }
14
+ function query(search, allowed, id) {
15
+ const entries = Object.entries(search ?? {}).filter(([, value])=>void 0 !== value);
16
+ for (const [key] of entries)if (!allowed.includes(key)) throw new Error(`route-factory: "${key}" is not a search param of "${id}"`);
17
+ if (0 === entries.length) return '';
18
+ const out = new URLSearchParams();
19
+ for (const [key, value] of entries)out.append(key, String(value));
20
+ return `?${out.toString()}`;
21
+ }
22
+ function split(template) {
23
+ const at = template.indexOf('?');
24
+ if (-1 === at) return {
25
+ path: template,
26
+ search: []
27
+ };
28
+ return {
29
+ path: template.slice(0, at),
30
+ search: template.slice(at + 1).split('&').filter((part)=>'' !== part).map((part)=>({
31
+ name: part.replace(/\?$/, ''),
32
+ required: !part.endsWith('?')
33
+ }))
34
+ };
35
+ }
36
+ function createLinks(sets, config = {}) {
37
+ const build = (withBase)=>(id, options)=>{
38
+ const dot = id.indexOf('.');
39
+ const set = sets[id.slice(0, dot)];
40
+ const template = set?.routes[id.slice(dot + 1)];
41
+ if (void 0 === set || void 0 === template) throw new Error(`route-factory: unknown route "${id}"`);
42
+ const { path, search } = split(template);
43
+ const base = withBase ? trimEnd('function' == typeof config.base ? config.base() : config.base ?? '') : '';
44
+ const inner = fillPath(path, options?.params, 'route-factory', id);
45
+ const joined = `${base}${set.mount}${'/' === inner && '' !== set.mount ? '' : inner}`;
46
+ for (const { name, required } of search)if (required && options?.search?.[name] === void 0) throw new Error(`route-factory: missing search param "${name}" for "${id}"`);
47
+ const hash = options?.hash ? `#${options.hash.replace(/^#/, '')}` : '';
48
+ return `${'' === joined ? '/' : joined}${query(options?.search, search.map((s)=>s.name), id)}${hash}`;
49
+ };
50
+ return {
51
+ href: build(true),
52
+ path: build(false)
53
+ };
54
+ }
55
+ function shellRoutes(sets) {
56
+ return Object.entries(sets).map(([remote, set])=>({
57
+ remote,
58
+ mount: '' === set.mount ? '/' : set.mount,
59
+ pattern: `${set.mount}/*`
60
+ }));
61
+ }
62
+ export { createLinks, defineRoutes, shellRoutes };
@@ -0,0 +1,28 @@
1
+ import type { AnchorHTMLAttributes, ReactElement } from 'react';
2
+ import type { Links } from './index.js';
3
+ import type { LinkId, LinkOptions, RouteSets, TemplateOf } from './types.js';
4
+ export type MfeLinkProps<S extends RouteSets, Id extends LinkId<S>> = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> & {
5
+ to: Id;
6
+ } & LinkOptions<TemplateOf<S, Id>>;
7
+ export interface MfeLinkConfig {
8
+ /**
9
+ * A hook that returns the router's navigate function, taking a path without the base:
10
+ * `createMfeLink(links, { useNavigate })` with React Router. Without it, a click on a link
11
+ * to this origin pushes the URL and sends `popstate`, which routers that listen to the
12
+ * history pick up (single-spa does the same); other links load the page.
13
+ */
14
+ useNavigate?: () => (path: string) => void;
15
+ }
16
+ /** Moves to `path` without a page load, and tells whoever listens to the history. */
17
+ export declare function pushPath(path: string): void;
18
+ /**
19
+ * An `<a>` that links to a route of any remote, typed from the same definitions as `createLinks`.
20
+ * Left clicks navigate without a page load; middle clicks, modified clicks and `target="_blank"`
21
+ * keep the browser's behaviour.
22
+ *
23
+ * ```tsx
24
+ * const MfeLink = createMfeLink(links);
25
+ * <MfeLink to="catalog.product" params={{ sku: 'A1' }}>Mugs</MfeLink>
26
+ * ```
27
+ */
28
+ export declare function createMfeLink<S extends RouteSets>(links: Links<S>, config?: MfeLinkConfig): <Id extends LinkId<S>>(props: MfeLinkProps<S, Id>) => ReactElement;
package/dist/react.js ADDED
@@ -0,0 +1,39 @@
1
+ import { jsx } from "react/jsx-runtime";
2
+ function pushPath(path) {
3
+ history.pushState(null, '', path);
4
+ dispatchEvent(new PopStateEvent('popstate', {
5
+ state: null
6
+ }));
7
+ }
8
+ const opensElsewhere = (event, target)=>0 !== event.button || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || void 0 !== target && '_self' !== target;
9
+ function createMfeLink(links, config = {}) {
10
+ const useRouterNavigate = config.useNavigate ?? (()=>void 0);
11
+ const href = links.href;
12
+ const path = links.path;
13
+ return function({ to, params, search, hash, onClick, children, ...rest }) {
14
+ const navigate = useRouterNavigate();
15
+ const options = {
16
+ params,
17
+ search,
18
+ hash
19
+ };
20
+ const url = href(to, options);
21
+ return /*#__PURE__*/ jsx("a", {
22
+ ...rest,
23
+ href: url,
24
+ onClick: (event)=>{
25
+ onClick?.(event);
26
+ if (event.defaultPrevented || opensElsewhere(event, rest.target)) return;
27
+ if (navigate) {
28
+ event.preventDefault();
29
+ navigate(path(to, options));
30
+ } else if (new URL(url, location.href).origin === location.origin) {
31
+ event.preventDefault();
32
+ pushPath(url);
33
+ }
34
+ },
35
+ children: children
36
+ });
37
+ };
38
+ }
39
+ export { createMfeLink, pushPath };
@@ -0,0 +1,41 @@
1
+ import type { PathParams } from '@mfeflow/url-factory';
2
+ export type { PathParams };
3
+ type PathOf<T extends string> = T extends `${infer Path}?${string}` ? Path : T;
4
+ type QueryOf<T extends string> = T extends `${string}?${infer Query}` ? Query : never;
5
+ type QueryKeys<Q extends string> = Q extends `${infer Head}&${infer Rest}` ? Head | QueryKeys<Rest> : Q;
6
+ /** Search params a template names after `?`: `'/search?q&page?'` has required `q` and optional `page`. */
7
+ export type RequiredSearch<T extends string> = Exclude<QueryKeys<QueryOf<T>>, `${string}?`>;
8
+ export type OptionalSearch<T extends string> = Extract<QueryKeys<QueryOf<T>>, `${string}?`> extends `${infer Key}?` ? Key : never;
9
+ export type ParamValue = string | number;
10
+ export type SearchValue = string | number | boolean;
11
+ type Obj<K extends string, V> = {
12
+ readonly [P in K]: V;
13
+ };
14
+ /** What a link to a route needs: its path params, its required search params, and optional extras. */
15
+ export type LinkOptions<T extends string> = (PathParams<PathOf<T>> extends never ? {
16
+ params?: undefined;
17
+ } : {
18
+ params: Obj<PathParams<PathOf<T>>, ParamValue>;
19
+ }) & (RequiredSearch<T> extends never ? {
20
+ search?: Partial<Obj<OptionalSearch<T>, SearchValue | undefined>>;
21
+ } : {
22
+ search: Obj<RequiredSearch<T>, SearchValue> & Partial<Obj<OptionalSearch<T>, SearchValue | undefined>>;
23
+ }) & {
24
+ hash?: string;
25
+ };
26
+ type NeedsOptions<T extends string> = [PathParams<PathOf<T>> | RequiredSearch<T>] extends [never] ? false : true;
27
+ /** `[options?]` for a route with nothing required, `[options]` otherwise. */
28
+ export type LinkArgs<T extends string> = NeedsOptions<T> extends true ? [options: LinkOptions<T>] : [options?: LinkOptions<T>];
29
+ /** A route is a path template, optionally followed by `?` and its search params. */
30
+ export type RouteTable = Readonly<Record<string, string>>;
31
+ /** One remote's routes: where the shell mounts it and the paths inside. */
32
+ export interface RouteSet<R extends RouteTable = RouteTable> {
33
+ readonly mount: string;
34
+ readonly routes: R;
35
+ }
36
+ export type RouteSets = Readonly<Record<string, RouteSet>>;
37
+ /** `catalog.product`: the remote's name, a dot, the route's name. */
38
+ export type LinkId<S extends RouteSets> = {
39
+ [Remote in keyof S & string]: `${Remote}.${keyof S[Remote]['routes'] & string}`;
40
+ }[keyof S & string];
41
+ export type TemplateOf<S extends RouteSets, Id extends string> = Id extends `${infer Remote}.${infer Name}` ? Remote extends keyof S ? Name extends keyof S[Remote]['routes'] ? S[Remote]['routes'][Name] & string : never : never : never;
package/dist/types.js ADDED
File without changes
package/package.json CHANGED
@@ -1,6 +1,78 @@
1
1
  {
2
2
  "name": "@mfeflow/route-factory",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Typed links between micro-frontends: each remote defines its routes once, and a link to any of them is checked for missing params, missing search params and unknown routes at compile time.",
5
+ "keywords": [
6
+ "micro-frontend",
7
+ "mfe",
8
+ "module-federation",
9
+ "routing",
10
+ "links",
11
+ "typescript",
12
+ "react"
13
+ ],
14
+ "homepage": "https://mfeflow.github.io/mfeflow/",
15
+ "bugs": "https://github.com/mfeflow/mfeflow/issues",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/mfeflow/mfeflow.git",
19
+ "directory": "packages/route-factory"
20
+ },
21
+ "license": "MIT",
22
+ "author": "Sanjeev Sharma <speedsharma.ai@gmail.com>",
23
+ "type": "module",
24
+ "sideEffects": false,
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "import": "./dist/index.js",
29
+ "default": "./dist/index.js"
30
+ },
31
+ "./react": {
32
+ "types": "./dist/react.d.ts",
33
+ "import": "./dist/react.js",
34
+ "default": "./dist/react.js"
35
+ }
36
+ },
37
+ "files": [
38
+ "dist"
39
+ ],
40
+ "engines": {
41
+ "node": ">=22.12"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public",
45
+ "provenance": true
46
+ },
47
+ "dependencies": {
48
+ "@mfeflow/url-factory": "0.2.0"
49
+ },
50
+ "peerDependencies": {
51
+ "react": "^19.0.0"
52
+ },
53
+ "peerDependenciesMeta": {
54
+ "react": {
55
+ "optional": true
56
+ }
57
+ },
58
+ "devDependencies": {
59
+ "@rsbuild/plugin-react": "2.1.1",
60
+ "@rslib/core": "1.0.5",
61
+ "@testing-library/dom": "10.4.2",
62
+ "@testing-library/react": "16.3.3",
63
+ "@types/node": "26.6.6",
64
+ "@types/react": "19.3.0",
65
+ "@types/react-dom": "19.3.0",
66
+ "jsdom": "30.1.3",
67
+ "react": "19.3.1",
68
+ "react-dom": "19.3.1",
69
+ "typescript": "7.0.2"
70
+ },
71
+ "mfeflow": {
72
+ "bucket": "stateless"
73
+ },
74
+ "scripts": {
75
+ "build": "rslib build",
76
+ "typecheck": "tsc --noEmit"
77
+ }
6
78
  }