effect-start 0.14.0 → 0.16.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/package.json +8 -9
- package/src/Commander.test.ts +507 -245
- package/src/ContentNegotiation.test.ts +603 -0
- package/src/ContentNegotiation.ts +542 -0
- package/src/Entity.test.ts +592 -0
- package/src/Entity.ts +362 -0
- package/src/FileRouter.ts +16 -12
- package/src/{FileRouterCodegen.test.ts → FileRouterCodegen.todo.ts} +384 -219
- package/src/FileRouterCodegen.ts +6 -6
- package/src/FileRouterPattern.test.ts +93 -62
- package/src/FileRouter_files.test.ts +5 -5
- package/src/FileRouter_path.test.ts +121 -69
- package/src/FileRouter_tree.test.ts +62 -56
- package/src/FileSystemExtra.test.ts +46 -30
- package/src/Http.test.ts +319 -0
- package/src/Http.ts +167 -0
- package/src/HttpAppExtra.test.ts +39 -20
- package/src/HttpAppExtra.ts +0 -1
- package/src/HttpUtils.test.ts +35 -18
- package/src/HttpUtils.ts +2 -0
- package/src/PathPattern.test.ts +648 -0
- package/src/PathPattern.ts +485 -0
- package/src/Route.ts +266 -1069
- package/src/RouteBody.test.ts +234 -0
- package/src/RouteBody.ts +193 -0
- package/src/RouteHook.test.ts +40 -0
- package/src/RouteHook.ts +106 -0
- package/src/RouteHttp.test.ts +2906 -0
- package/src/RouteHttp.ts +427 -0
- package/src/RouteHttpTracer.ts +92 -0
- package/src/RouteMount.test.ts +481 -0
- package/src/RouteMount.ts +470 -0
- package/src/RouteSchema.test.ts +427 -0
- package/src/RouteSchema.ts +423 -0
- package/src/RouteTree.test.ts +494 -0
- package/src/RouteTree.ts +219 -0
- package/src/RouteTrie.test.ts +322 -0
- package/src/RouteTrie.ts +224 -0
- package/src/RouterPattern.test.ts +569 -548
- package/src/RouterPattern.ts +7 -7
- package/src/Start.ts +3 -3
- package/src/StreamExtra.ts +21 -1
- package/src/TuplePathPattern.ts +64 -0
- package/src/Values.test.ts +263 -0
- package/src/Values.ts +76 -0
- package/src/bun/BunBundle.test.ts +36 -42
- package/src/bun/BunBundle.ts +2 -2
- package/src/bun/BunBundle_imports.test.ts +4 -6
- package/src/bun/BunHttpServer.test.ts +183 -6
- package/src/bun/BunHttpServer.ts +72 -32
- package/src/bun/BunHttpServer_web.ts +18 -6
- package/src/bun/BunImportTrackerPlugin.test.ts +3 -3
- package/src/bun/BunRoute.test.ts +124 -442
- package/src/bun/BunRoute.ts +146 -286
- package/src/{BundleHttp.test.ts → bundler/BundleHttp.test.ts} +34 -60
- package/src/{BundleHttp.ts → bundler/BundleHttp.ts} +1 -2
- package/src/client/index.ts +1 -1
- package/src/{Effect_HttpRouter.test.ts → effect/HttpRouter.test.ts} +69 -90
- package/src/experimental/EncryptedCookies.test.ts +125 -64
- package/src/experimental/SseHttpResponse.ts +0 -1
- package/src/hyper/Hyper.ts +89 -0
- package/src/{HyperHtml.test.ts → hyper/HyperHtml.test.ts} +13 -13
- package/src/{HyperHtml.ts → hyper/HyperHtml.ts} +2 -2
- package/src/{jsx.d.ts → hyper/jsx.d.ts} +1 -1
- package/src/index.ts +3 -4
- package/src/middlewares/BasicAuthMiddleware.test.ts +29 -19
- package/src/{NodeFileSystem.ts → node/FileSystem.ts} +6 -2
- package/src/testing/TestHttpClient.test.ts +26 -26
- package/src/testing/TestLogger.test.ts +27 -14
- package/src/testing/TestLogger.ts +15 -9
- package/src/x/datastar/Datastar.test.ts +47 -48
- package/src/x/datastar/Datastar.ts +1 -1
- package/src/x/tailwind/TailwindPlugin.test.ts +56 -58
- package/src/x/tailwind/plugin.ts +1 -1
- package/src/FileHttpRouter.test.ts +0 -239
- package/src/FileHttpRouter.ts +0 -194
- package/src/Hyper.ts +0 -194
- package/src/Route.test.ts +0 -1370
- package/src/RouteRender.ts +0 -40
- package/src/Router.test.ts +0 -375
- package/src/Router.ts +0 -255
- package/src/bun/BunRoute_bundles.test.ts +0 -219
- /package/src/{Bundle.ts → bundler/Bundle.ts} +0 -0
- /package/src/{BundleFiles.ts → bundler/BundleFiles.ts} +0 -0
- /package/src/{HyperNode.ts → hyper/HyperNode.ts} +0 -0
- /package/src/{jsx-runtime.ts → hyper/jsx-runtime.ts} +0 -0
- /package/src/{NodeUtils.ts → node/Utils.ts} +0 -0
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import * as test from "bun:test"
|
|
2
|
+
import * as Route from "./Route.ts"
|
|
3
|
+
import * as RouteTree from "./RouteTree.ts"
|
|
4
|
+
|
|
5
|
+
test.describe("layer route", () => {
|
|
6
|
+
test.it("merges LayerRoute into other routes", () => {
|
|
7
|
+
const tree = RouteTree.make({
|
|
8
|
+
"*": Route.use(Route.filter({ context: { authenticated: true } })),
|
|
9
|
+
"/users": Route.get(Route.text("users")),
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
type TreeRoutes = RouteTree.Routes<typeof tree>
|
|
13
|
+
|
|
14
|
+
test
|
|
15
|
+
.expectTypeOf<TreeRoutes>()
|
|
16
|
+
.toExtend<{
|
|
17
|
+
"/users": Route.Route.Tuple
|
|
18
|
+
}>()
|
|
19
|
+
|
|
20
|
+
// "*" key should not exist in the resulting type
|
|
21
|
+
test
|
|
22
|
+
.expectTypeOf<TreeRoutes>()
|
|
23
|
+
.not
|
|
24
|
+
.toHaveProperty("*")
|
|
25
|
+
|
|
26
|
+
// layer route should be first in the tuple (method: "*")
|
|
27
|
+
test
|
|
28
|
+
.expectTypeOf<TreeRoutes["/users"][0]>()
|
|
29
|
+
.toExtend<Route.Route.With<{ method: "*" }>>()
|
|
30
|
+
|
|
31
|
+
// actual route should be second (method: "GET")
|
|
32
|
+
test
|
|
33
|
+
.expectTypeOf<TreeRoutes["/users"][1]>()
|
|
34
|
+
.toExtend<Route.Route.With<{ method: "GET" }>>()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test.it("prepends LayerRoute to all other routes when walking", () => {
|
|
38
|
+
const tree = RouteTree.make({
|
|
39
|
+
"*": Route.use(Route.filter({ context: { layer: true } })),
|
|
40
|
+
"/users": Route.get(Route.text("users")),
|
|
41
|
+
"/admin": Route.post(Route.json({ ok: true })),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test.expect(Route.descriptor(RouteTree.walk(tree))).toEqual([
|
|
45
|
+
{ path: "/admin", method: "*" },
|
|
46
|
+
{ path: "/admin", method: "POST", format: "json" },
|
|
47
|
+
{ path: "/users", method: "*" },
|
|
48
|
+
{ path: "/users", method: "GET", format: "text" },
|
|
49
|
+
])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test.it("prepends multiple LayerRoutes to all other routes", () => {
|
|
53
|
+
const tree = RouteTree.make({
|
|
54
|
+
"*": Route
|
|
55
|
+
.use(Route.filter({ context: { first: true } }))
|
|
56
|
+
.use(Route.filter({ context: { second: true } })),
|
|
57
|
+
"/users": Route.get(Route.text("users")),
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test.expect(Route.descriptor(RouteTree.walk(tree))).toEqual([
|
|
61
|
+
{ path: "/users", method: "*" },
|
|
62
|
+
{ path: "/users", method: "*" },
|
|
63
|
+
{ path: "/users", method: "GET", format: "text" },
|
|
64
|
+
])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test.it("only allows method '*' routes under '*' key", () => {
|
|
68
|
+
const _tree = RouteTree.make({
|
|
69
|
+
// @ts-expect-error - LayerRoute must have method "*"
|
|
70
|
+
"*": Route.get(Route.text("invalid")),
|
|
71
|
+
"/users": Route.get(Route.text("users")),
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test.it("lookup finds LayerRoute first", () => {
|
|
76
|
+
const tree = RouteTree.make({
|
|
77
|
+
"*": Route.use(Route.filter({ context: { layer: true } })),
|
|
78
|
+
"/users": Route.get(Route.text("users")),
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const result = RouteTree.lookup(tree, "GET", "/users")
|
|
82
|
+
test.expect(result).not.toBeNull()
|
|
83
|
+
test.expect(Route.descriptor(result!.route).method).toBe("*")
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test.it("works without LayerRoute (no '*' key)", () => {
|
|
87
|
+
const tree = RouteTree.make({
|
|
88
|
+
"/users": Route.get(Route.text("users")),
|
|
89
|
+
"/admin": Route.post(Route.json({ ok: true })),
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test
|
|
93
|
+
.expect(Route.descriptor(RouteTree.walk(tree)).map((d) => d.path))
|
|
94
|
+
.toEqual(["/admin", "/users"])
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test.describe(RouteTree.make, () => {
|
|
99
|
+
test.it("makes", () => {
|
|
100
|
+
const routes = RouteTree.make({
|
|
101
|
+
"/admin": Route
|
|
102
|
+
.use(
|
|
103
|
+
Route.filter({
|
|
104
|
+
context: {
|
|
105
|
+
isAdmin: true,
|
|
106
|
+
},
|
|
107
|
+
}),
|
|
108
|
+
)
|
|
109
|
+
.get(
|
|
110
|
+
Route.text("admin home"),
|
|
111
|
+
),
|
|
112
|
+
|
|
113
|
+
"/users": Route
|
|
114
|
+
.get(
|
|
115
|
+
Route.text("users list"),
|
|
116
|
+
),
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test
|
|
120
|
+
.expectTypeOf<RouteTree.Routes<typeof routes>>()
|
|
121
|
+
.toExtend<{
|
|
122
|
+
"/admin": unknown
|
|
123
|
+
"/users": unknown
|
|
124
|
+
}>()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test.it("flattens nested route trees with prefixed paths", () => {
|
|
128
|
+
const apiTree = RouteTree.make({
|
|
129
|
+
"/users": Route.get(Route.json({ users: [] })),
|
|
130
|
+
"/posts": Route.get(Route.json({ posts: [] })),
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const tree = RouteTree.make({
|
|
134
|
+
"/": Route.get(Route.text("home")),
|
|
135
|
+
"/api": apiTree,
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
type TreeRoutes = RouteTree.Routes<typeof tree>
|
|
139
|
+
|
|
140
|
+
test
|
|
141
|
+
.expectTypeOf<TreeRoutes["/"]>()
|
|
142
|
+
.toExtend<Route.Route.Tuple>()
|
|
143
|
+
|
|
144
|
+
test
|
|
145
|
+
.expectTypeOf<TreeRoutes["/"][0]>()
|
|
146
|
+
.toExtend<
|
|
147
|
+
Route.Route.With<
|
|
148
|
+
{ method: "GET"; format: "text" }
|
|
149
|
+
>
|
|
150
|
+
>()
|
|
151
|
+
|
|
152
|
+
test
|
|
153
|
+
.expectTypeOf<TreeRoutes["/api/users"]>()
|
|
154
|
+
.toExtend<Route.Route.Tuple>()
|
|
155
|
+
|
|
156
|
+
test
|
|
157
|
+
.expectTypeOf<TreeRoutes["/api/users"][0]>()
|
|
158
|
+
.toExtend<
|
|
159
|
+
Route.Route.With<
|
|
160
|
+
{ method: "GET"; format: "json" }
|
|
161
|
+
>
|
|
162
|
+
>()
|
|
163
|
+
|
|
164
|
+
test
|
|
165
|
+
.expectTypeOf<TreeRoutes["/api/posts"]>()
|
|
166
|
+
.toExtend<Route.Route.Tuple>()
|
|
167
|
+
|
|
168
|
+
test
|
|
169
|
+
.expectTypeOf<TreeRoutes["/api/posts"][0]>()
|
|
170
|
+
.toExtend<
|
|
171
|
+
Route.Route.With<
|
|
172
|
+
{ method: "GET"; format: "json" }
|
|
173
|
+
>
|
|
174
|
+
>()
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test.it("walks nested route trees with prefixed paths", () => {
|
|
178
|
+
const apiTree = RouteTree.make({
|
|
179
|
+
"/users": Route.get(Route.json({ users: [] })),
|
|
180
|
+
"/posts": Route.post(Route.json({ ok: true })),
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
const tree = RouteTree.make({
|
|
184
|
+
"/": Route.get(Route.text("home")),
|
|
185
|
+
"/api": apiTree,
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test.expect(Route.descriptor(RouteTree.walk(tree))).toEqual([
|
|
189
|
+
{ path: "/", method: "GET", format: "text" },
|
|
190
|
+
{ path: "/api/posts", method: "POST", format: "json" },
|
|
191
|
+
{ path: "/api/users", method: "GET", format: "json" },
|
|
192
|
+
])
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test.it("deeply nested route trees", () => {
|
|
196
|
+
const v1Tree = RouteTree.make({
|
|
197
|
+
"/health": Route.get(Route.text("ok")),
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
const apiTree = RouteTree.make({
|
|
201
|
+
"/v1": v1Tree,
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
const tree = RouteTree.make({
|
|
205
|
+
"/api": apiTree,
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test
|
|
209
|
+
.expect(Route.descriptor(RouteTree.walk(tree)).map((d) => d.path))
|
|
210
|
+
.toEqual(["/api/v1/health"])
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test.it("lookup works with nested trees", () => {
|
|
214
|
+
const apiTree = RouteTree.make({
|
|
215
|
+
"/users": Route.get(Route.json({ users: [] })),
|
|
216
|
+
"/users/:id": Route.get(Route.json({ user: null })),
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
const tree = RouteTree.make({
|
|
220
|
+
"/": Route.get(Route.text("home")),
|
|
221
|
+
"/api": apiTree,
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
const home = RouteTree.lookup(tree, "GET", "/")
|
|
225
|
+
test.expect(Route.descriptor(home!.route).path).toBe("/")
|
|
226
|
+
|
|
227
|
+
const users = RouteTree.lookup(tree, "GET", "/api/users")
|
|
228
|
+
test.expect(Route.descriptor(users!.route).path).toBe("/api/users")
|
|
229
|
+
|
|
230
|
+
const user = RouteTree.lookup(tree, "GET", "/api/users/123")
|
|
231
|
+
test.expect(Route.descriptor(user!.route).path).toBe("/api/users/:id")
|
|
232
|
+
test.expect(user!.params).toEqual({ id: "123" })
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
test.describe(RouteTree.walk, () => {
|
|
237
|
+
test.it("walks in sorted order: by depth, static before params", () => {
|
|
238
|
+
// routes defined in random order
|
|
239
|
+
const routes = RouteTree.make({
|
|
240
|
+
"/users/:userId": Route.get(Route.text("user detail")),
|
|
241
|
+
"/admin/stats": Route.get(Route.html("admin stats")),
|
|
242
|
+
"/": Route.get(Route.text("home")),
|
|
243
|
+
"/admin/users": Route.post(Route.json({ ok: true })),
|
|
244
|
+
"/users": Route.get(Route.text("users list")),
|
|
245
|
+
"/admin": Route.use(Route.filter({ context: { admin: true } })),
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
// expected order:
|
|
249
|
+
// depth 0: /
|
|
250
|
+
// depth 1: /admin, /users (alphabetical)
|
|
251
|
+
// depth 2: /admin/stats, /admin/users, /users/:userId (static first, param last)
|
|
252
|
+
test
|
|
253
|
+
.expect(Route.descriptor(RouteTree.walk(routes)).map((d) => d.path))
|
|
254
|
+
.toEqual([
|
|
255
|
+
"/",
|
|
256
|
+
"/admin",
|
|
257
|
+
"/users",
|
|
258
|
+
"/admin/stats",
|
|
259
|
+
"/admin/users",
|
|
260
|
+
"/users/:userId",
|
|
261
|
+
])
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test.it("static < :param < :param? < :param+ < :param*", () => {
|
|
265
|
+
const routes = RouteTree.make({
|
|
266
|
+
"/:path*": Route.get(Route.text("catch all")),
|
|
267
|
+
"/about": Route.get(Route.text("about")),
|
|
268
|
+
"/:path+": Route.get(Route.text("one or more")),
|
|
269
|
+
"/:page": Route.get(Route.text("single param")),
|
|
270
|
+
"/:page?": Route.get(Route.text("optional param")),
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
test
|
|
274
|
+
.expect(Route.descriptor(RouteTree.walk(routes)).map((d) => d.path))
|
|
275
|
+
.toEqual([
|
|
276
|
+
"/about",
|
|
277
|
+
"/:page",
|
|
278
|
+
"/:page?",
|
|
279
|
+
"/:path+",
|
|
280
|
+
"/:path*",
|
|
281
|
+
])
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test.it("greedy routes come after all non-greedy across depth", () => {
|
|
285
|
+
const routes = RouteTree.make({
|
|
286
|
+
"/:path*": Route.get(Route.text("catch all")),
|
|
287
|
+
"/users/:id": Route.get(Route.text("user detail")),
|
|
288
|
+
"/users": Route.get(Route.text("users")),
|
|
289
|
+
"/users/:id/posts/:postId": Route.get(Route.text("post detail")),
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
test
|
|
293
|
+
.expect(Route.descriptor(RouteTree.walk(routes)).map((d) => d.path))
|
|
294
|
+
.toEqual([
|
|
295
|
+
"/users",
|
|
296
|
+
"/users/:id",
|
|
297
|
+
"/users/:id/posts/:postId",
|
|
298
|
+
"/:path*",
|
|
299
|
+
])
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
test.it("greedy routes sorted by greedy position", () => {
|
|
303
|
+
const routes = RouteTree.make({
|
|
304
|
+
"/:path*": Route.get(Route.text("root catch all")),
|
|
305
|
+
"/api/:rest*": Route.get(Route.text("api catch all")),
|
|
306
|
+
"/api/v1/:rest*": Route.get(Route.text("api v1 catch all")),
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
test
|
|
310
|
+
.expect(Route.descriptor(RouteTree.walk(routes)).map((d) => d.path))
|
|
311
|
+
.toEqual([
|
|
312
|
+
"/api/v1/:rest*",
|
|
313
|
+
"/api/:rest*",
|
|
314
|
+
"/:path*",
|
|
315
|
+
])
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
test.it("greedy routes with same position sorted by prefix then type", () => {
|
|
319
|
+
const routes = RouteTree.make({
|
|
320
|
+
"/docs/:path*": Route.get(Route.text("docs catch all")),
|
|
321
|
+
"/api/:rest+": Route.get(Route.text("api one or more")),
|
|
322
|
+
"/api/:rest*": Route.get(Route.text("api catch all")),
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
// /api before /docs (alphabetical), then + before * for same prefix
|
|
326
|
+
test
|
|
327
|
+
.expect(Route.descriptor(RouteTree.walk(routes)).map((d) => d.path))
|
|
328
|
+
.toEqual([
|
|
329
|
+
"/api/:rest+",
|
|
330
|
+
"/api/:rest*",
|
|
331
|
+
"/docs/:path*",
|
|
332
|
+
])
|
|
333
|
+
})
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
test.describe(RouteTree.lookup, () => {
|
|
337
|
+
test.it("matches static paths", () => {
|
|
338
|
+
const tree = RouteTree.make({
|
|
339
|
+
"/users": Route.get(Route.text("users list")),
|
|
340
|
+
"/admin": Route.get(Route.text("admin")),
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
const result = RouteTree.lookup(tree, "GET", "/users")
|
|
344
|
+
test.expect(result).not.toBeNull()
|
|
345
|
+
test.expect(result!.params).toEqual({})
|
|
346
|
+
test.expect(Route.descriptor(result!.route).path).toBe("/users")
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
test.it("extracts path parameters", () => {
|
|
350
|
+
const tree = RouteTree.make({
|
|
351
|
+
"/users/:id": Route.get(Route.text("user detail")),
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
const result = RouteTree.lookup(tree, "GET", "/users/123")
|
|
355
|
+
test.expect(result).not.toBeNull()
|
|
356
|
+
test.expect(result!.params).toEqual({ id: "123" })
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
test.it("filters by HTTP method", () => {
|
|
360
|
+
const tree = RouteTree.make({
|
|
361
|
+
"/users": Route.get(Route.text("get users")),
|
|
362
|
+
"/admin": Route.post(Route.text("post admin")),
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
const getResult = RouteTree.lookup(tree, "GET", "/users")
|
|
366
|
+
test.expect(getResult).not.toBeNull()
|
|
367
|
+
|
|
368
|
+
const postOnGet = RouteTree.lookup(tree, "POST", "/users")
|
|
369
|
+
test.expect(postOnGet).toBeNull()
|
|
370
|
+
|
|
371
|
+
const postResult = RouteTree.lookup(tree, "POST", "/admin")
|
|
372
|
+
test.expect(postResult).not.toBeNull()
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
test.it("wildcard method matches any method", () => {
|
|
376
|
+
const tree = RouteTree.make({
|
|
377
|
+
"/api": Route.use(Route.filter({ context: { api: true } })),
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
const getResult = RouteTree.lookup(tree, "GET", "/api")
|
|
381
|
+
test.expect(getResult).not.toBeNull()
|
|
382
|
+
|
|
383
|
+
const postResult = RouteTree.lookup(tree, "POST", "/api")
|
|
384
|
+
test.expect(postResult).not.toBeNull()
|
|
385
|
+
|
|
386
|
+
const deleteResult = RouteTree.lookup(tree, "DELETE", "/api")
|
|
387
|
+
test.expect(deleteResult).not.toBeNull()
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
test.it("static routes take priority over param routes", () => {
|
|
391
|
+
const tree = RouteTree.make({
|
|
392
|
+
"/users/:id": Route.get(Route.text("user by id")),
|
|
393
|
+
"/users/me": Route.get(Route.text("current user")),
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
const result = RouteTree.lookup(tree, "GET", "/users/me")
|
|
397
|
+
test.expect(result).not.toBeNull()
|
|
398
|
+
test.expect(Route.descriptor(result!.route).path).toBe("/users/me")
|
|
399
|
+
test.expect(result!.params).toEqual({})
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
test.it("matches greedy params with +", () => {
|
|
403
|
+
const tree = RouteTree.make({
|
|
404
|
+
"/docs/:path+": Route.get(Route.text("docs")),
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
const result = RouteTree.lookup(tree, "GET", "/docs/api/v1/users")
|
|
408
|
+
test.expect(result).not.toBeNull()
|
|
409
|
+
test.expect(result!.params).toEqual({ path: "api/v1/users" })
|
|
410
|
+
|
|
411
|
+
const noMatch = RouteTree.lookup(tree, "GET", "/docs")
|
|
412
|
+
test.expect(noMatch).toBeNull()
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
test.it("matches greedy params with *", () => {
|
|
416
|
+
const tree = RouteTree.make({
|
|
417
|
+
"/files/:path*": Route.get(Route.text("files")),
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
const withPath = RouteTree.lookup(tree, "GET", "/files/a/b/c")
|
|
421
|
+
test.expect(withPath).not.toBeNull()
|
|
422
|
+
test.expect(withPath!.params).toEqual({ path: "a/b/c" })
|
|
423
|
+
|
|
424
|
+
const withoutPath = RouteTree.lookup(tree, "GET", "/files")
|
|
425
|
+
test.expect(withoutPath).not.toBeNull()
|
|
426
|
+
test.expect(withoutPath!.params).toEqual({})
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
test.it("returns null for no match", () => {
|
|
430
|
+
const tree = RouteTree.make({
|
|
431
|
+
"/users": Route.get(Route.text("users")),
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
const result = RouteTree.lookup(tree, "GET", "/not-found")
|
|
435
|
+
test.expect(result).toBeNull()
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
test.it("matches optional params with ?", () => {
|
|
439
|
+
const tree = RouteTree.make({
|
|
440
|
+
"/files/:name?": Route.get(Route.text("files")),
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
const withParam = RouteTree.lookup(tree, "GET", "/files/readme")
|
|
444
|
+
test.expect(withParam).not.toBeNull()
|
|
445
|
+
test.expect(withParam!.params).toEqual({ name: "readme" })
|
|
446
|
+
|
|
447
|
+
const withoutParam = RouteTree.lookup(tree, "GET", "/files")
|
|
448
|
+
test.expect(withoutParam).not.toBeNull()
|
|
449
|
+
test.expect(withoutParam!.params).toEqual({})
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
test.it("respects route priority for complex trees", () => {
|
|
453
|
+
const tree = RouteTree.make({
|
|
454
|
+
"/:path*": Route.get(Route.text("catch all")),
|
|
455
|
+
"/api/:rest+": Route.get(Route.text("api wildcard")),
|
|
456
|
+
"/api/users": Route.get(Route.text("api users")),
|
|
457
|
+
"/api/users/:id": Route.get(Route.text("api user detail")),
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
const staticMatch = RouteTree.lookup(tree, "GET", "/api/users")
|
|
461
|
+
test.expect(Route.descriptor(staticMatch!.route).path).toBe("/api/users")
|
|
462
|
+
|
|
463
|
+
const paramMatch = RouteTree.lookup(tree, "GET", "/api/users/123")
|
|
464
|
+
test.expect(Route.descriptor(paramMatch!.route).path).toBe("/api/users/:id")
|
|
465
|
+
test.expect(paramMatch!.params).toEqual({ id: "123" })
|
|
466
|
+
|
|
467
|
+
const greedyMatch = RouteTree.lookup(tree, "GET", "/api/something/else")
|
|
468
|
+
test.expect(Route.descriptor(greedyMatch!.route).path).toBe("/api/:rest+")
|
|
469
|
+
test.expect(greedyMatch!.params).toEqual({ rest: "something/else" })
|
|
470
|
+
|
|
471
|
+
const catchAll = RouteTree.lookup(tree, "GET", "/random/path")
|
|
472
|
+
test.expect(Route.descriptor(catchAll!.route).path).toBe("/:path*")
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
test.it("static routes take priority over optional param routes", () => {
|
|
476
|
+
const tree = RouteTree.make({
|
|
477
|
+
"/files/:name?": Route.get(Route.text("files optional")),
|
|
478
|
+
"/files/latest": Route.get(Route.text("files latest")),
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
const staticMatch = RouteTree.lookup(tree, "GET", "/files/latest")
|
|
482
|
+
test.expect(Route.descriptor(staticMatch!.route).path).toBe("/files/latest")
|
|
483
|
+
|
|
484
|
+
const optionalMatch = RouteTree.lookup(tree, "GET", "/files/other")
|
|
485
|
+
test.expect(Route.descriptor(optionalMatch!.route).path).toBe(
|
|
486
|
+
"/files/:name?",
|
|
487
|
+
)
|
|
488
|
+
test.expect(optionalMatch!.params).toEqual({ name: "other" })
|
|
489
|
+
|
|
490
|
+
const noParam = RouteTree.lookup(tree, "GET", "/files")
|
|
491
|
+
test.expect(Route.descriptor(noParam!.route).path).toBe("/files/:name?")
|
|
492
|
+
test.expect(noParam!.params).toEqual({})
|
|
493
|
+
})
|
|
494
|
+
})
|
package/src/RouteTree.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import * as Predicate from "effect/Predicate"
|
|
2
|
+
import * as PathPattern from "./PathPattern.ts"
|
|
3
|
+
import * as Route from "./Route.ts"
|
|
4
|
+
import * as RouteMount from "./RouteMount.ts"
|
|
5
|
+
|
|
6
|
+
const TypeId: unique symbol = Symbol.for("effect-start/RouteTree")
|
|
7
|
+
const RouteTreeRoutes: unique symbol = Symbol()
|
|
8
|
+
|
|
9
|
+
type MethodRoute = Route.Route.With<{ method: string }>
|
|
10
|
+
|
|
11
|
+
export type RouteTuple = Iterable<MethodRoute>
|
|
12
|
+
|
|
13
|
+
export type LayerRoute = Iterable<Route.Route.With<{ method: "*" }>>
|
|
14
|
+
|
|
15
|
+
type LayerKey = "*"
|
|
16
|
+
const LayerKey: LayerKey = "*"
|
|
17
|
+
|
|
18
|
+
export type InputRouteMap = {
|
|
19
|
+
[LayerKey]?: LayerRoute
|
|
20
|
+
} & {
|
|
21
|
+
[path: PathPattern.PathPattern]: RouteTuple | RouteTree
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type RouteMap = {
|
|
25
|
+
[path: PathPattern.PathPattern]: Route.Route.Tuple
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type Routes<
|
|
29
|
+
T extends RouteTree,
|
|
30
|
+
> = T[typeof RouteTreeRoutes]
|
|
31
|
+
|
|
32
|
+
export interface RouteTree<
|
|
33
|
+
Routes extends RouteMap = RouteMap,
|
|
34
|
+
> {
|
|
35
|
+
[TypeId]: typeof TypeId
|
|
36
|
+
[RouteTreeRoutes]: Routes
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function routes<
|
|
40
|
+
Routes extends RouteMap,
|
|
41
|
+
>(
|
|
42
|
+
tree: RouteTree<Routes>,
|
|
43
|
+
): Routes {
|
|
44
|
+
return tree[RouteTreeRoutes]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// segment priority: static (0) < :param (1) < :param? (2) < :param+ (3) < :param* (4)
|
|
48
|
+
function sortScore(path: string): number {
|
|
49
|
+
const segments = path.split("/")
|
|
50
|
+
const greedyIdx = segments.findIndex((s) =>
|
|
51
|
+
s.endsWith("*") || s.endsWith("+")
|
|
52
|
+
)
|
|
53
|
+
const maxPriority = Math.max(
|
|
54
|
+
...segments.map((s) =>
|
|
55
|
+
!s.startsWith(":")
|
|
56
|
+
? 0
|
|
57
|
+
: s.endsWith("*")
|
|
58
|
+
? 4
|
|
59
|
+
: s.endsWith("+")
|
|
60
|
+
? 3
|
|
61
|
+
: s.endsWith("?")
|
|
62
|
+
? 2
|
|
63
|
+
: 1
|
|
64
|
+
),
|
|
65
|
+
0,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return greedyIdx === -1
|
|
69
|
+
// non-greedy: sort by depth, then by max segment priority
|
|
70
|
+
? (segments.length << 16) + (maxPriority << 8)
|
|
71
|
+
// greedy: sort after non-greedy, by greedy position (later = first), then priority
|
|
72
|
+
: (1 << 24) + ((16 - greedyIdx) << 16) + (maxPriority << 8)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function sortRoutes(input: RouteMap): RouteMap {
|
|
76
|
+
const keys = Object.keys(input).sort((a, b) =>
|
|
77
|
+
sortScore(a) - sortScore(b) || a.localeCompare(b)
|
|
78
|
+
)
|
|
79
|
+
const sorted: RouteMap = {}
|
|
80
|
+
for (const key of keys) {
|
|
81
|
+
sorted[key as PathPattern.PathPattern] =
|
|
82
|
+
input[key as PathPattern.PathPattern]
|
|
83
|
+
}
|
|
84
|
+
return sorted
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type PrefixKeys<T, Prefix extends string> = {
|
|
88
|
+
[K in keyof T as K extends string ? `${Prefix}${K}` : never]: T[K]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
type InferItems<T> = T extends Route.RouteSet.Data<any, any, infer M> ? M
|
|
92
|
+
: []
|
|
93
|
+
|
|
94
|
+
type LayerItems<T extends InputRouteMap> = "*" extends keyof T
|
|
95
|
+
? InferItems<T["*"]>
|
|
96
|
+
: []
|
|
97
|
+
|
|
98
|
+
type FlattenRouteMap<T extends InputRouteMap> =
|
|
99
|
+
& {
|
|
100
|
+
[K in Exclude<keyof T, "*"> as T[K] extends RouteTree ? never : K]: [
|
|
101
|
+
...LayerItems<T>,
|
|
102
|
+
...InferItems<T[K]>,
|
|
103
|
+
]
|
|
104
|
+
}
|
|
105
|
+
& UnionToIntersection<FlattenNested<T, Exclude<keyof T, "*">, LayerItems<T>>>
|
|
106
|
+
|
|
107
|
+
type FlattenNested<
|
|
108
|
+
T,
|
|
109
|
+
K,
|
|
110
|
+
L extends Route.Route.Tuple,
|
|
111
|
+
> = K extends keyof T
|
|
112
|
+
? T[K] extends RouteTree<infer R>
|
|
113
|
+
? PrefixKeys<PrependLayers<R, L>, K & string>
|
|
114
|
+
: {}
|
|
115
|
+
: {}
|
|
116
|
+
|
|
117
|
+
type PrependLayers<T extends RouteMap, L extends Route.Route.Tuple> = {
|
|
118
|
+
[K in keyof T]: T[K] extends Route.Route.Tuple ? [...L, ...T[K]] : never
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
type UnionToIntersection<U> = (
|
|
122
|
+
U extends any ? (x: U) => void : never
|
|
123
|
+
) extends (x: infer I) => void ? I
|
|
124
|
+
: never
|
|
125
|
+
|
|
126
|
+
export function make<
|
|
127
|
+
const Routes extends InputRouteMap,
|
|
128
|
+
>(
|
|
129
|
+
input: Routes,
|
|
130
|
+
): RouteTree<FlattenRouteMap<Routes>> {
|
|
131
|
+
const layerRoutes = [...(input[LayerKey] ?? [])]
|
|
132
|
+
const merged: RouteMap = {}
|
|
133
|
+
|
|
134
|
+
function flatten(
|
|
135
|
+
map: InputRouteMap,
|
|
136
|
+
prefix: string,
|
|
137
|
+
layers: MethodRoute[],
|
|
138
|
+
): void {
|
|
139
|
+
for (const key of Object.keys(map)) {
|
|
140
|
+
if (key === LayerKey) continue
|
|
141
|
+
const path = key as PathPattern.PathPattern
|
|
142
|
+
const entry = map[path]
|
|
143
|
+
const fullPath = `${prefix}${path}` as PathPattern.PathPattern
|
|
144
|
+
|
|
145
|
+
if (isRouteTree(entry)) {
|
|
146
|
+
flatten(routes(entry), fullPath, layers)
|
|
147
|
+
} else {
|
|
148
|
+
merged[fullPath] = [...layers, ...(entry as RouteTuple)]
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
flatten(input, "", layerRoutes)
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
[TypeId]: TypeId,
|
|
157
|
+
[RouteTreeRoutes]: sortRoutes(merged),
|
|
158
|
+
} as RouteTree<FlattenRouteMap<Routes>>
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export type WalkDescriptor = {
|
|
162
|
+
path: PathPattern.PathPattern
|
|
163
|
+
method: string
|
|
164
|
+
} & Route.RouteDescriptor.Any
|
|
165
|
+
|
|
166
|
+
function* flattenRoutes(
|
|
167
|
+
path: PathPattern.PathPattern,
|
|
168
|
+
routes: Iterable<MethodRoute>,
|
|
169
|
+
): Generator<RouteMount.MountedRoute> {
|
|
170
|
+
for (const route of routes) {
|
|
171
|
+
const descriptor = {
|
|
172
|
+
...route[Route.RouteDescriptor],
|
|
173
|
+
path,
|
|
174
|
+
}
|
|
175
|
+
yield Route.make(
|
|
176
|
+
route.handler as any,
|
|
177
|
+
descriptor,
|
|
178
|
+
) as RouteMount.MountedRoute
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function* walk(
|
|
183
|
+
tree: RouteTree,
|
|
184
|
+
): Generator<RouteMount.MountedRoute> {
|
|
185
|
+
const _routes = routes(tree) as RouteMap
|
|
186
|
+
|
|
187
|
+
for (const path of Object.keys(_routes) as PathPattern.PathPattern[]) {
|
|
188
|
+
yield* flattenRoutes(path, _routes[path])
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function isRouteTree(
|
|
193
|
+
input: unknown,
|
|
194
|
+
): input is RouteTree {
|
|
195
|
+
return Predicate.hasProperty(input, TypeId)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface LookupResult {
|
|
199
|
+
route: RouteMount.MountedRoute
|
|
200
|
+
params: Record<string, string>
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function lookup(
|
|
204
|
+
tree: RouteTree,
|
|
205
|
+
method: string,
|
|
206
|
+
path: string,
|
|
207
|
+
): LookupResult | null {
|
|
208
|
+
for (const route of walk(tree)) {
|
|
209
|
+
const descriptor = Route.descriptor(route)
|
|
210
|
+
|
|
211
|
+
if (descriptor.method !== "*" && descriptor.method !== method) continue
|
|
212
|
+
|
|
213
|
+
const params = PathPattern.match(descriptor.path, path)
|
|
214
|
+
if (params !== null) {
|
|
215
|
+
return { route, params }
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return null
|
|
219
|
+
}
|