experimenting with Go and FS routing

A while back I've experimented with Go, implementing a file system based routing to be used with the highly experimental technology that is the router package for Lit. This original goal of this experimentation was for a desktop app concept, so a choice was made to use Wails an Electron-like solution to build desktop app through a web UI, but using the power of Go under the hood instead of the good old Node.

Folder structure

To build a file system based routing solution, we have to respect a certain hierarchy in the way we create the different files composing our app as it will reflect back to our routing system.

 1.
 2├── app.go
 3├── build
 4├── frontend
 5│   ├── dist
 6│   ├── index.html
 7│   ├── node_modules
 8│   ├── package.json
 9│   ├── src
10│   │   ├── components
11│   │   │   └── my-component.ts
12│   │   ├── config
13│   │   │   └── routes.conf.ts
14│   │   ├── my-app.ts
15│   │   ├── pages
16│   │   │   ├── editor
17│   │   │   │   ├── index.ts
18│   │   │   │   └── sub
19│   │   │   │       └── [id].ts
20│   │   │   └── index.ts
21│   │   └── vite-env.d.ts
22│   ├── vite.config.ts
23├── go.mod
24├── go.sum
25├── main.go
26└── wails.json

This is roughly what we have and how we want it to behave. Notice the folder pages, this is effectively the root of our router. Any index.ts files present in the root of a folder should contain a lit component that will be rendered when the current URL matches against a URL Pattern Object generated in our Go App. Nested routes and named parameters are supported as demonstrated with the path /editor/sub/:id mapped to /pages/editor/sub/[id].ts.

Ok let's begin.

Entry point and lit router

The app entry point will be a lit component that will simply call the outlet() method of the lit router package inside its render function.

 1import {html, LitElement} from 'lit'
 2import {customElement} from 'lit/decorators.js'
 3import {Router} from '@lit-labs/router/router.js';
 4import routes from './config/routes.conf'
 5
 6@customElement('my-app')
 7export default class MyApp extends LitElement {
 8  private router = new Router(this, routes);
 9  override render() {
10    return html`
11      ${this.router.outlet()}
12    `;
13  }
14}
15
16declare global {
17  interface HTMLElementTagNameMap {
18    'my-app': MyApp;
19  }
20}

Then let's define our routes... Hold on, Go has to do that right ?

Correct, so let's create a GetRoutes() function in App.go that we will import in our routes.conf.ts file.

 1type Route struct {}
 2
 3func (a *App) GetRoutes() (routes []Route) {
 4  wd, err := os.Getwd()
 5  check(err)
 6
 7  routesMap := make(map[string][]string)
 8  routesArr := make([]string, 0)
 9
10  routesMap, routesArr = recursiveRoute(filepath.Join(wd, "frontend", "src", "pages"), string(os.PathSeparator), routesMap, routesArr)
11
12  routes = makeRoutes(routesMap)
13  fmt.Println(routes)
14  return
15}

Notice the recursiveRoute() function, meaningful in our case to handle these type of routes /pages/sub/:id.

1func recursiveRoute(path, pathPrefix string, routesMap map[string][]string, routes []string) (map[string][]string, []string) {
2  entries, err := os.ReadDir(path)
3  check(err)
4  dirs := strings.Split(path, string(os.PathSeparator))
5}

First we read the entries in the specified path and create a dirs Array.

1if dirs[len(dirs)-1] != "pages" {
2  pathPrefix += dirs[len(dirs)-1] + string(os.PathSeparator)
3}
4routes = mapTo(entries, fs.DirEntry.Name)

Then, if we're not at the root /pages we go deeper in the folder and we begin to append in the routes array using a handy util method mapTo()

1func mapTo[Type, ReturnType any] (data []Type, f func(Type) ReturnType) (res []ReturnType) {
2  res = make([]ReturnType, 0, len(data))
3  for _, e := range data {
4    res = append(res, f(e))
5  }
6  return
7}

At this stage we want to identify if we've reached any .ts file and remove this extension from the yielded result (as we will use the name of the file to instantiate a component) and sort the possible sub routes.

1routes, subRoutes := filterWithOpt(routes, strings.HasSuffix, ".ts")
2routes = transformTo(routes, strings.TrimSuffix, ".ts")

So, again let's create those util methods :

 1func filterWithOpt[Type any] (data []Type, f func(Type, string) bool, opt string) (res []Type, res2 []Type) {
 2  res = make([]Type, 0, len(data))
 3  res2 = make([]Type, 0, len(data))
 4  for _, e := range data {
 5    if f(e, opt) {
 6      res = append(res, e)
 7    } else {
 8      res2 = append(res2, e)
 9    }
10  }
11  return
12}
13func transformTo[Type, ReturnType any] (data []Type, f func(Type, string) ReturnType, opt string) (res []ReturnType) {
14  res = make([]ReturnType, 0, len(data))
15  for _, e := range data {
16    res = append(res, f(e, opt))
17  }
18  return
19}

We then allocate the routes array to the routesMap indexed by the current path and what is left to be done is simply re-calling the recursiveRoute() function before returning a complete result.

1routesMap[pathPrefix] = routes
2var tempMap map[string][]string
3for _, route := range subRoutes {
4  tempMap, routes = recursiveRoute(filepath.Join(path, route), pathPrefix, routesMap, routes)
5  for k, v := range tempMap {
6    routesMap[k] = v
7  }
8}
9return routesMap, routes

routesMap has this shape at this point :

 1{
 2  "/": [
 3    "/index"
 4  ],
 5  "/editor/": [
 6    "/index"
 7  ],
 8  "/editor/sub/": [
 9    "/[id]"
10  ]
11}

But the lit-router package expects us to provide a route in this format

1interface BaseRouteConfig {
2  name?: string | undefined;
3  render?: (params: {
4      [key: string]: string | undefined;
5  }) => unknown;
6  enter?: (params: {
7      [key: string]: string | undefined;
8  }) => Promise<boolean> | boolean;
9}

We first initialized an empty struct Route, let's make it more relevant so that it'll provide an easy and effective api to work with on the JS side.

1type Route struct {
2  Component string // HTML Tag name
3  Path      string // URL to reached to display the component
4  Filename  string // URL of the component's file needed for its dynamic import
5  Args      bool // Custom Element's attribute (reflects the param routes)
6}

With this established we can start writing some TS in the routes.conf.ts to map our Go Route struct to an expected BaseRouteConfig typescript type.

 1import type { RouteConfig } from "@lit-labs/router/routes";
 2import {unsafeHTML} from 'lit/directives/unsafe-html.js'
 3import { GetRoutes } from '../../wailsjs/go/main/App'
 4
 5const makeRoutes = async (): Promise<RouteConfig[]> => {
 6  return (await GetRoutes()).map(route => 
 7    Object.create({
 8      path: route.Path,
 9      render: () =>    
10        unsafeHTML(
11          `<${route.Component} ${makeArgs(route)} >
12          </${route.Component}>`
13        ),
14      enter: async () => await import(`../pages${route.Filename}`)
15    }) as RouteConfig 
16  ) as RouteConfig[]
17}
18export default await makeRoutes()

The logic is there in place, we can finish up the Go side of the code by implementing the makeRoutes() function from earlier.

 1func makeRoutes(m map[string][]string) (routes []Route) {
 2  routes = make([]Route, 0)
 3  for dir, files := range m {
 4    for i := 0; i <= len(files)-1; i++ {
 5      file := files[i]
 6      file = strings.TrimPrefix(file, string(os.PathSeparator))
 7      cmp := file
 8      args := false
 9      path := dir
10      if strings.HasSuffix(dir, string(os.PathSeparator)) && len(dir) > 1 {
11        path = strings.TrimSuffix(dir, string(os.PathSeparator))
12      }
13      if len(strings.Split(dir, string(os.PathSeparator))) > 2 {
14        dirs := strings.Split(dir, string(os.PathSeparator))
15        cmp = dirs[len(dirs)-2]
16        // map /some/[id].ts to /some/:id 
17        if strings.Contains(file, "[") {
18          formatedFileName := ReplaceDynamicPattern(file)
19          path += string(os.PathSeparator) + formatedFileName
20          args = true
21	}
22      }
23      // Route object creation
24      r := Route{
25        cmp + "-page",
26        strings.ReplaceAll(path, string(os.PathSeparator), "/"),
27        strings.ReplaceAll(dir+file, string(os.PathSeparator), "/"),
28        args,
29      }
30      routes = append(routes, r)
31    }
32  }
33  return
34}
35func ReplaceDynamicPattern(input string) string {
36  re := regexp.MustCompile(`\[(\w+)\]`)
37  return re.ReplaceAllString(input, ":$1")
38}

And voila, the Go part is done. Now let's go back to typescript to implement the makeArgs() function we left. We are gonna use the URL Pattern API to dynamically assign our Lit's component reactive prop bound to an attribute based on the URL's param.

Accourding to the doc for the URL Pattern API :

1const pattern = new URLPattern("http{s}?://*.example.com/books/:id");
2let match = pattern.exec("https://store.example.com/books/123");
3console.log(match.pathname); // { input: "/books/123", groups: { "id": "123" } }

This is exactly what we want.

 1const makeArgs = (route): string => { 
 2  let ret: string | null = ''
 3  if(route.Args) {
 4    const {origin, href} = location;
 5    const pattern = new URLPattern(`${origin}${route.Path}`);
 6    for(const [k,v] of Object.entries(pattern.exec(href).pathname.groups)) {
 7      ret += `${k}=${v} `; // It just works
 8    }
 9  }
10  return ret;
11}

Putting it all together

Okay, now let's have some fun and create our page components

 1// /pages/index.ts
 2import {html, LitElement} from 'lit'
 3import {customElement, property} from 'lit/decorators.js'
 4
 5@customElement('index-page')
 6export default class IndexPage extends LitElement {
 7  @property({type: Number}) count = 3
 8  render() {
 9    return html`
10    <section>
11      <button @click=${() => this.count++}>click to   increase count and to go a different sub page
12      </button>
13      <h1>hello home page world count : ${this.count}</h1>
14      <a href="/editor">editor page</a>
15      <a href=${"/editor/sub/"+this.count}>editor sub ${this.count}</a>
16    </section>
17    `
18  }
19}
20
21declare global {
22  interface HTMLElementTagNameMap {
23    'index-page': IndexPage
24  }
25}
26
27// pages/editor/sub/[id].ts
28import {html, LitElement} from 'lit'
29import {customElement, property} from 'lit/decorators.js'
30
31@customElement('sub-page')
32export default class SubPage extends LitElement {
33  @property({type:String}) id
34  override render() {
35    return html`
36    <section>
37      <h1>prop received from url /editor/sub/:id = ${this.id}</h1>
38      <a href="/">home page</a>
39    </section>
40    `
41  }
42}
43
44declare global {
45  interface HTMLElementTagNameMap {
46    'sub-page': SubPage
47  }
48}

Resulting in

==> Link of the repo <==