Skip to content

Commit 589e11f

Browse files
Implemented router's URL matching logic in multiple (non-JS) languages (#118)
* implemented router' URL matching logic in multiple (non-JS) languages for server-side processing * fix go lang bug where "/user" matched "/user/:id?" * removed silly tests, added few, synced all 4 test suites * review * docs: Slightly adjust position/wording in root readme * chore: Clean up trailing whitespace --------- Co-authored-by: Ryan Christian <rchristian@ryanchristian.dev>
1 parent 82e24e7 commit 589e11f

17 files changed

+2329
-0
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,10 @@ It will not be used for any of the following:
142142
- `/movies`
143143
- `/movies/`
144144

145+
## Non-JS Servers
146+
147+
For those using non-JS servers (e.g., PHP, Python, Ruby, etc.) to serve your Preact app, you may want to use our ["polyglot-utils"](./polyglot-utils), a collection of our route matching logic ported to various other languages. Combined with a route manifest, this will allow your server to better understand which assets will be needed at runtime for a given URL, allowing you to say insert preload tags for those assets in the HTML head prior to serving the page.
148+
145149
---
146150

147151
## API Docs

polyglot-utils/.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Language-specific files to ignore
2+
__pycache__/
3+
*.pyc
4+
.venv/
5+
.ruby-version

polyglot-utils/README.md

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
# Preact ISO URL Pattern Matching - Polyglot Utils
2+
3+
Multi-language implementations of URL pattern matching utilities for building bespoke server setups that need to preload JS/CSS resources or handle early 404 responses.
4+
5+
## Use Case
6+
7+
This utility is designed for server languages that **cannot do SSR/prerendering** but still want to provide better experiences. It enables servers to:
8+
9+
- **Add preload head tags** for JS,CSS before serving HTML
10+
- **Return early 404 pages** for unmatched routes
11+
- **Generate dynamic titles** based on route parameters
12+
13+
## How can I implement preloading of JS, CSS?
14+
15+
Typical implementation flow:
16+
17+
1. **Build-time Setup:**
18+
- Write your routes as an array in a JS file
19+
- Create a build script that exports route patterns and entry files to a `.json` file
20+
- Configure your frontend build tool to output a `manifest` file mapping entry files to final fingerprinted/hashed output JS/CSS files and dependencies
21+
22+
2. **Server-time Processing:**
23+
- Load the JSON route file when a request comes in
24+
- Match the requested URL against each route pattern until you find a match
25+
- Once matched, you have the source entry `.jsx` file
26+
- Load the build manifest file to find which JS chunk contains that code and its dependency files
27+
- Generate `<link rel="preload">` tags for each dependency (JS, CSS, images, icons)
28+
- Inject those head tags into the HTML before serving
29+
30+
3. **Result:**
31+
- Browsers start downloading critical resources immediately
32+
- Faster page loads without full SSR complexity
33+
- Early 404s for invalid routes
34+
35+
### Example - preloading of JS, CSS
36+
37+
Here's how you might integrate this into a server setup. Let's say you have a client side `routes.js` as follows:
38+
39+
```js
40+
import { lazy } from 'preact-iso';
41+
42+
export const routes = [
43+
{
44+
"path": "/users/:userId/posts",
45+
"component": lazy(() => import("pages/UserPosts.jsx")),
46+
"title": "Posts by :userId"
47+
},
48+
{
49+
"path": "/products/:category/:id",
50+
"component": lazy(() => import("pages/Product.jsx")),
51+
"title": "Product :id"
52+
}
53+
];
54+
```
55+
56+
1. **Generate Routes JSON (routes.json)**
57+
58+
You can use the following standalone node.js script to create `routes.json` during build (you could convert it into a plugin for your frontend build tool):
59+
```js
60+
const routeDir = path.resolve(__dirname, 'client/src/routes');
61+
let routesFile = fs.readFileSync(path.resolve(routeDir, 'routes.js'), 'utf-8');
62+
routesFile = routesFile.replace(/lazy\s*\(\s*\(\s*\)\s*=>\s*import\s*\(\s*(.+)\s*\)\s*\)\s*(,?)/g, '$1$2');
63+
const fileName = path.resolve(__dirname, 'routes-temp.js')
64+
fs.writeFileSync(fileName, routesFile, 'utf-8');
65+
const routes = (await import(fileName)).default;
66+
fs.unlinkSync(fileName);
67+
const routeInfo = routes.map((route) => ({
68+
path: route.path,
69+
title: typeof route.title === 'string' ? route.title : null,
70+
Component: path.relative(__dirname, path.resolve(routeDir, `${route.Component}.jsx`)),
71+
default: route.default,
72+
}));
73+
// console.log(routeInfo);
74+
fs.writeFileSync(
75+
path.resolve(__dirname, 'dist/routes.json'),
76+
JSON.stringify(routeInfo, null, 2),
77+
'utf-8'
78+
);
79+
```
80+
81+
The script produces the following `routes.json` file:
82+
```json
83+
[
84+
{
85+
"path": "/users/:userId/posts",
86+
"component": "pages/UserPosts.jsx",
87+
"title": "Posts by :userId"
88+
},
89+
{
90+
"path": "/products/:category/:id",
91+
"component": "pages/Product.jsx",
92+
"title": "Product :id"
93+
}
94+
]
95+
```
96+
97+
2. **Build Manifest (manifest.json)**
98+
99+
This is the file your client build tool generates. Check your build tool's documentation for exact format. Below is an example with few important fields from a Vite manifest file:
100+
```json
101+
{
102+
"pages/UserPosts.jsx": {
103+
"file": "assets/UserPosts-abc123.js",
104+
"css": ["assets/UserPosts-def456.css"],
105+
"imports": ["chunks/shared-ghi789.js"]
106+
}
107+
}
108+
```
109+
110+
3. **Server Implementation**
111+
```python
112+
# Python example
113+
import json
114+
115+
routes = json.load(open('../dist/routes.json'))
116+
manifest = json.load(open('../dist/.vite/manifest.json'))
117+
118+
def handle_request(url_path):
119+
for route in routes:
120+
matches = preact_iso_url_pattern_match(url_path, route['path'])
121+
if matches:
122+
# Generate preload tags
123+
component = route['component']
124+
entry_info = manifest[component]
125+
126+
preload_tags = []
127+
for js_file in [entry_info['file']] + entry_info.get('imports', []):
128+
preload_tags.append(f'<link rel="modulepreload" crossorigin href="{js_file}">')
129+
130+
for css_file in entry_info.get('css', []):
131+
preload_tags.append(f'<link rel="stylesheet" crossorigin href="{css_file}">')
132+
133+
# Generate dynamic title
134+
title = route['title']
135+
for param, value in matches['params'].items():
136+
title = title.replace(f':{param}', value)
137+
138+
return {
139+
'preload_tags': preload_tags,
140+
'title': title,
141+
'params': matches['params']
142+
}
143+
144+
# No match found - return early 404
145+
return None
146+
```
147+
148+
This approach gives you the performance benefits of resource preloading without the complexity of full server-side rendering.
149+
150+
## Available Languages
151+
152+
Go, PHP, Python and Ruby.
153+
154+
Find the corresponding language's sub-directory. Each language has a README that contains usage examples and API reference.
155+
156+
## Running Tests
157+
158+
```bash
159+
# Run all tests across all languages
160+
./run_tests.sh
161+
162+
# Or run individual language tests
163+
cd go && go test -v
164+
cd python && python3 test_preact_iso_url_pattern.py
165+
cd ruby && ruby test_preact_iso_url_pattern.rb
166+
cd php && php test_preact_iso_url_pattern.php
167+
```

polyglot-utils/go/README.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Go Implementation
2+
3+
URL pattern matching utility for Go servers.
4+
5+
## Setup
6+
7+
Code tested on Go 1.24.x.
8+
9+
```sh
10+
# If using in a project, initialize go module
11+
go mod init myproject
12+
# No third party dependencies needed. Just run the tests or use the function directly
13+
```
14+
15+
## Running Tests
16+
17+
```sh
18+
go test -v
19+
```
20+
21+
## Usage
22+
23+
```go
24+
package main
25+
26+
import "fmt"
27+
28+
func main() {
29+
matches := preactIsoUrlPatternMatch("/users/test%40example.com/posts", "/users/:userId/posts", nil)
30+
if matches != nil {
31+
fmt.Printf("User ID: %s\n", matches.Params["userId"]) // Output: test@example.com
32+
}
33+
}
34+
```
35+
36+
## Function Signature
37+
38+
```go
39+
func preactIsoUrlPatternMatch(url, route string, matches *Matches) *Matches
40+
```
41+
42+
### Parameters
43+
44+
- `url` (string): The URL path to match
45+
- `route` (string): The route pattern with parameters
46+
- `matches` (*Matches): Optional pre-existing matches to extend
47+
48+
### Return Value
49+
50+
Returns a `*Matches` struct on success, or `nil` if no match:
51+
52+
```go
53+
type Matches struct {
54+
Params map[string]string
55+
Rest string
56+
}
57+
```
58+
59+
## Route Patterns
60+
61+
| Pattern | Description | Example |
62+
|---------|-------------|---------|
63+
| `/users/:id` | Named parameter | `{id: "123"}` |
64+
| `/users/:id?` | Optional parameter | `{id: ""}` |
65+
| `/files/:path+` | Required rest parameter | `{path: "docs/readme.txt"}` |
66+
| `/static/:path*` | Optional rest parameter | `{path: "css/main.css"}` |
67+
| `/static/*` | Anonymous wildcard | `{Rest: "/images/logo.png"}` |

polyglot-utils/go/go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module myproject
2+
3+
go 1.13
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Run program: go run preact-iso-url-pattern.go
2+
3+
package main
4+
5+
import (
6+
// "fmt"
7+
"net/url"
8+
"regexp"
9+
"strings"
10+
)
11+
12+
type Matches struct {
13+
Params map[string]string `json:"params"`
14+
Rest string `json:"rest,omitempty"`
15+
}
16+
17+
func preactIsoUrlPatternMatch(urlStr, route string, matches *Matches) *Matches {
18+
if matches == nil {
19+
matches = &Matches{
20+
Params: make(map[string]string),
21+
}
22+
}
23+
urlParts := filterEmpty(strings.Split(urlStr, "/"))
24+
routeParts := filterEmpty(strings.Split(route, "/"))
25+
26+
for i := 0; i < max(len(urlParts), len(routeParts)); i++ {
27+
var m, param, flag string
28+
if i < len(routeParts) {
29+
re := regexp.MustCompile(`^(:?)(.*?)([+*?]?)$`)
30+
matches := re.FindStringSubmatch(routeParts[i])
31+
if len(matches) > 3 {
32+
m, param, flag = matches[1], matches[2], matches[3]
33+
}
34+
}
35+
36+
var val string
37+
if i < len(urlParts) {
38+
val = urlParts[i]
39+
}
40+
41+
// segment match:
42+
if m == "" && param != "" && param == val {
43+
continue
44+
}
45+
46+
// /foo/* match
47+
if m == "" && val != "" && flag == "*" {
48+
matches.Rest = "/" + strings.Join(urlParts[i:], "/")
49+
break
50+
}
51+
52+
// segment mismatch / missing required field:
53+
if m == "" || (val == "" && flag != "?" && flag != "*") {
54+
return nil
55+
}
56+
57+
rest := flag == "+" || flag == "*"
58+
59+
// rest (+/*) match:
60+
if rest {
61+
decodedParts := make([]string, len(urlParts[i:]))
62+
for j, part := range urlParts[i:] {
63+
decoded, err := url.QueryUnescape(part)
64+
if err != nil {
65+
decoded = part // fallback to original if decode fails
66+
}
67+
decodedParts[j] = decoded
68+
}
69+
val = strings.Join(decodedParts, "/")
70+
} else if val != "" {
71+
// normal/optional field: decode val (like JavaScript does)
72+
decoded, err := url.QueryUnescape(val)
73+
if err != nil {
74+
decoded = urlParts[i]
75+
}
76+
val = decoded
77+
}
78+
79+
matches.Params[param] = val
80+
81+
if rest {
82+
break
83+
}
84+
}
85+
86+
return matches
87+
}
88+
89+
func filterEmpty(s []string) []string {
90+
var result []string
91+
for _, str := range s {
92+
if str != "" {
93+
result = append(result, str)
94+
}
95+
}
96+
return result
97+
}
98+
99+
func max(a, b int) int {
100+
if a > b {
101+
return a
102+
}
103+
return b
104+
}
105+
106+
// Example usage:
107+
// func main() {
108+
// params := &Matches{Params: make(map[string]string)}
109+
// fmt.Println(preactIsoUrlPatternMatch("/foo/bar%20baz", "/foo/:param", params))
110+
//
111+
// params := &Matches{Params: make(map[string]string)}
112+
// fmt.Println(preactIsoUrlPatternMatch("/foo/bar/baz", "/foo/*"))
113+
//
114+
// params := &Matches{Params: make(map[string]string)}
115+
// fmt.Println(preactIsoUrlPatternMatch("/foo", "/foo/:param?"))
116+
//
117+
// params := &Matches{Params: make(map[string]string)}
118+
// fmt.Println(preactIsoUrlPatternMatch("/foo/bar", "/bar/:param"))
119+
//
120+
// params := &Matches{Params: make(map[string]string)}
121+
// fmt.Println(preactIsoUrlPatternMatch("/users/test%40example.com/posts", "/users/:userId/posts"))
122+
// }

0 commit comments

Comments
 (0)