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.
Files changed (87) hide show
  1. package/package.json +8 -9
  2. package/src/Commander.test.ts +507 -245
  3. package/src/ContentNegotiation.test.ts +603 -0
  4. package/src/ContentNegotiation.ts +542 -0
  5. package/src/Entity.test.ts +592 -0
  6. package/src/Entity.ts +362 -0
  7. package/src/FileRouter.ts +16 -12
  8. package/src/{FileRouterCodegen.test.ts → FileRouterCodegen.todo.ts} +384 -219
  9. package/src/FileRouterCodegen.ts +6 -6
  10. package/src/FileRouterPattern.test.ts +93 -62
  11. package/src/FileRouter_files.test.ts +5 -5
  12. package/src/FileRouter_path.test.ts +121 -69
  13. package/src/FileRouter_tree.test.ts +62 -56
  14. package/src/FileSystemExtra.test.ts +46 -30
  15. package/src/Http.test.ts +319 -0
  16. package/src/Http.ts +167 -0
  17. package/src/HttpAppExtra.test.ts +39 -20
  18. package/src/HttpAppExtra.ts +0 -1
  19. package/src/HttpUtils.test.ts +35 -18
  20. package/src/HttpUtils.ts +2 -0
  21. package/src/PathPattern.test.ts +648 -0
  22. package/src/PathPattern.ts +485 -0
  23. package/src/Route.ts +266 -1069
  24. package/src/RouteBody.test.ts +234 -0
  25. package/src/RouteBody.ts +193 -0
  26. package/src/RouteHook.test.ts +40 -0
  27. package/src/RouteHook.ts +106 -0
  28. package/src/RouteHttp.test.ts +2906 -0
  29. package/src/RouteHttp.ts +427 -0
  30. package/src/RouteHttpTracer.ts +92 -0
  31. package/src/RouteMount.test.ts +481 -0
  32. package/src/RouteMount.ts +470 -0
  33. package/src/RouteSchema.test.ts +427 -0
  34. package/src/RouteSchema.ts +423 -0
  35. package/src/RouteTree.test.ts +494 -0
  36. package/src/RouteTree.ts +219 -0
  37. package/src/RouteTrie.test.ts +322 -0
  38. package/src/RouteTrie.ts +224 -0
  39. package/src/RouterPattern.test.ts +569 -548
  40. package/src/RouterPattern.ts +7 -7
  41. package/src/Start.ts +3 -3
  42. package/src/StreamExtra.ts +21 -1
  43. package/src/TuplePathPattern.ts +64 -0
  44. package/src/Values.test.ts +263 -0
  45. package/src/Values.ts +76 -0
  46. package/src/bun/BunBundle.test.ts +36 -42
  47. package/src/bun/BunBundle.ts +2 -2
  48. package/src/bun/BunBundle_imports.test.ts +4 -6
  49. package/src/bun/BunHttpServer.test.ts +183 -6
  50. package/src/bun/BunHttpServer.ts +72 -32
  51. package/src/bun/BunHttpServer_web.ts +18 -6
  52. package/src/bun/BunImportTrackerPlugin.test.ts +3 -3
  53. package/src/bun/BunRoute.test.ts +124 -442
  54. package/src/bun/BunRoute.ts +146 -286
  55. package/src/{BundleHttp.test.ts → bundler/BundleHttp.test.ts} +34 -60
  56. package/src/{BundleHttp.ts → bundler/BundleHttp.ts} +1 -2
  57. package/src/client/index.ts +1 -1
  58. package/src/{Effect_HttpRouter.test.ts → effect/HttpRouter.test.ts} +69 -90
  59. package/src/experimental/EncryptedCookies.test.ts +125 -64
  60. package/src/experimental/SseHttpResponse.ts +0 -1
  61. package/src/hyper/Hyper.ts +89 -0
  62. package/src/{HyperHtml.test.ts → hyper/HyperHtml.test.ts} +13 -13
  63. package/src/{HyperHtml.ts → hyper/HyperHtml.ts} +2 -2
  64. package/src/{jsx.d.ts → hyper/jsx.d.ts} +1 -1
  65. package/src/index.ts +3 -4
  66. package/src/middlewares/BasicAuthMiddleware.test.ts +29 -19
  67. package/src/{NodeFileSystem.ts → node/FileSystem.ts} +6 -2
  68. package/src/testing/TestHttpClient.test.ts +26 -26
  69. package/src/testing/TestLogger.test.ts +27 -14
  70. package/src/testing/TestLogger.ts +15 -9
  71. package/src/x/datastar/Datastar.test.ts +47 -48
  72. package/src/x/datastar/Datastar.ts +1 -1
  73. package/src/x/tailwind/TailwindPlugin.test.ts +56 -58
  74. package/src/x/tailwind/plugin.ts +1 -1
  75. package/src/FileHttpRouter.test.ts +0 -239
  76. package/src/FileHttpRouter.ts +0 -194
  77. package/src/Hyper.ts +0 -194
  78. package/src/Route.test.ts +0 -1370
  79. package/src/RouteRender.ts +0 -40
  80. package/src/Router.test.ts +0 -375
  81. package/src/Router.ts +0 -255
  82. package/src/bun/BunRoute_bundles.test.ts +0 -219
  83. /package/src/{Bundle.ts → bundler/Bundle.ts} +0 -0
  84. /package/src/{BundleFiles.ts → bundler/BundleFiles.ts} +0 -0
  85. /package/src/{HyperNode.ts → hyper/HyperNode.ts} +0 -0
  86. /package/src/{jsx-runtime.ts → hyper/jsx-runtime.ts} +0 -0
  87. /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
+ })
@@ -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
+ }