@typed/router 0.31.0 → 1.0.0-beta.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 (88) hide show
  1. package/README.md +111 -2
  2. package/dist/AST.d.ts +96 -0
  3. package/dist/AST.d.ts.map +1 -0
  4. package/dist/AST.js +32 -0
  5. package/dist/CurrentRoute.d.ts +18 -0
  6. package/dist/CurrentRoute.d.ts.map +1 -0
  7. package/dist/CurrentRoute.js +18 -0
  8. package/dist/Matcher.d.ts +191 -0
  9. package/dist/Matcher.d.ts.map +1 -0
  10. package/dist/Matcher.js +597 -0
  11. package/dist/Parser.d.ts +96 -0
  12. package/dist/Parser.d.ts.map +1 -0
  13. package/dist/Parser.js +1 -0
  14. package/dist/Path.d.ts +216 -0
  15. package/dist/Path.d.ts.map +1 -0
  16. package/dist/Path.js +248 -0
  17. package/dist/Route.d.ts +57 -0
  18. package/dist/Route.d.ts.map +1 -0
  19. package/dist/Route.js +151 -0
  20. package/dist/Router.d.ts +9 -0
  21. package/dist/Router.d.ts.map +1 -0
  22. package/dist/Router.js +8 -0
  23. package/dist/Uri.d.ts +115 -0
  24. package/dist/Uri.d.ts.map +1 -0
  25. package/dist/Uri.js +1 -0
  26. package/dist/index.d.ts +5 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +4 -0
  29. package/package.json +25 -69
  30. package/src/AST.ts +166 -0
  31. package/src/CurrentRoute.ts +30 -331
  32. package/src/Matcher.test.ts +476 -0
  33. package/src/Matcher.ts +1269 -328
  34. package/src/Parser.ts +282 -0
  35. package/src/Path.test.ts +318 -0
  36. package/src/Path.ts +691 -0
  37. package/src/Route.test.ts +268 -0
  38. package/src/Route.ts +316 -0
  39. package/src/Router.ts +31 -0
  40. package/src/Uri.ts +214 -0
  41. package/src/index.ts +4 -28
  42. package/tsconfig.json +6 -0
  43. package/CurrentRoute/package.json +0 -6
  44. package/LICENSE +0 -21
  45. package/MatchInput/package.json +0 -6
  46. package/Matcher/package.json +0 -6
  47. package/RouteGuard/package.json +0 -6
  48. package/RouteMatch/package.json +0 -6
  49. package/dist/cjs/CurrentRoute.js +0 -170
  50. package/dist/cjs/CurrentRoute.js.map +0 -1
  51. package/dist/cjs/MatchInput.js +0 -96
  52. package/dist/cjs/MatchInput.js.map +0 -1
  53. package/dist/cjs/Matcher.js +0 -138
  54. package/dist/cjs/Matcher.js.map +0 -1
  55. package/dist/cjs/RouteGuard.js +0 -78
  56. package/dist/cjs/RouteGuard.js.map +0 -1
  57. package/dist/cjs/RouteMatch.js +0 -49
  58. package/dist/cjs/RouteMatch.js.map +0 -1
  59. package/dist/cjs/index.js +0 -53
  60. package/dist/cjs/index.js.map +0 -1
  61. package/dist/dts/CurrentRoute.d.ts +0 -94
  62. package/dist/dts/CurrentRoute.d.ts.map +0 -1
  63. package/dist/dts/MatchInput.d.ts +0 -135
  64. package/dist/dts/MatchInput.d.ts.map +0 -1
  65. package/dist/dts/Matcher.d.ts +0 -121
  66. package/dist/dts/Matcher.d.ts.map +0 -1
  67. package/dist/dts/RouteGuard.d.ts +0 -94
  68. package/dist/dts/RouteGuard.d.ts.map +0 -1
  69. package/dist/dts/RouteMatch.d.ts +0 -50
  70. package/dist/dts/RouteMatch.d.ts.map +0 -1
  71. package/dist/dts/index.d.ts +0 -24
  72. package/dist/dts/index.d.ts.map +0 -1
  73. package/dist/esm/CurrentRoute.js +0 -152
  74. package/dist/esm/CurrentRoute.js.map +0 -1
  75. package/dist/esm/MatchInput.js +0 -79
  76. package/dist/esm/MatchInput.js.map +0 -1
  77. package/dist/esm/Matcher.js +0 -130
  78. package/dist/esm/Matcher.js.map +0 -1
  79. package/dist/esm/RouteGuard.js +0 -57
  80. package/dist/esm/RouteGuard.js.map +0 -1
  81. package/dist/esm/RouteMatch.js +0 -29
  82. package/dist/esm/RouteMatch.js.map +0 -1
  83. package/dist/esm/index.js +0 -24
  84. package/dist/esm/index.js.map +0 -1
  85. package/dist/esm/package.json +0 -4
  86. package/src/MatchInput.ts +0 -282
  87. package/src/RouteGuard.ts +0 -217
  88. package/src/RouteMatch.ts +0 -104
@@ -0,0 +1,268 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Schema from "effect/Schema";
4
+ import * as Route from "./Route.js";
5
+
6
+ describe("typed/router/Route", () => {
7
+ describe("Parse", () => {
8
+ it("creates route from literal string", () => {
9
+ const route = Route.Parse("users");
10
+
11
+ expect(route.path).toEqual("/users");
12
+ expect(route.ast.type).toEqual("path");
13
+ });
14
+
15
+ it("creates route from multi-segment literal", () => {
16
+ const route = Route.Parse("api/v1/users");
17
+
18
+ expect(route.path).toEqual("/api/v1/users");
19
+ });
20
+ });
21
+
22
+ describe("slash", () => {
23
+ it("creates root route", () => {
24
+ expect(Route.Slash.path).toEqual("/");
25
+ });
26
+ });
27
+
28
+ describe("wildcard", () => {
29
+ it("creates wildcard route", () => {
30
+ expect(Route.Wildcard.path).toEqual("/*");
31
+ });
32
+ });
33
+
34
+ describe("param", () => {
35
+ it("creates parameter route", () => {
36
+ const route = Route.Param("id");
37
+
38
+ expect(route.path).toEqual("/:id");
39
+ });
40
+
41
+ it("creates parameter route with descriptive name", () => {
42
+ const route = Route.Param("userId");
43
+
44
+ expect(route.path).toEqual("/:userId");
45
+ });
46
+ });
47
+
48
+ describe("join", () => {
49
+ it("joins literal routes", () => {
50
+ const route = Route.Join(Route.Parse("api"), Route.Parse("users"));
51
+
52
+ expect(route.path).toEqual("/api/users");
53
+ });
54
+
55
+ it("joins literal with parameter", () => {
56
+ const route = Route.Join(Route.Parse("users"), Route.Param("id"));
57
+
58
+ expect(route.path).toEqual("/users/:id");
59
+ });
60
+
61
+ it("joins multiple routes", () => {
62
+ const route = Route.Join(
63
+ Route.Parse("api"),
64
+ Route.Parse("v1"),
65
+ Route.Parse("users"),
66
+ Route.Param("userId"),
67
+ Route.Parse("posts"),
68
+ Route.Param("postId"),
69
+ );
70
+
71
+ expect(route.path).toEqual("/api/v1/users/:userId/posts/:postId");
72
+ });
73
+
74
+ it("joins with wildcard", () => {
75
+ const route = Route.Join(Route.Parse("files"), Route.Wildcard);
76
+
77
+ expect(route.path).toEqual("/files/*");
78
+ });
79
+ });
80
+
81
+ describe("paramsSchema", () => {
82
+ it("decodes path params from literal route", () =>
83
+ Effect.gen(function* () {
84
+ const route = Route.Parse("users");
85
+ const decoded = yield* Schema.decodeEffect(route.paramsSchema)({});
86
+
87
+ expect(decoded).toEqual({});
88
+ }).pipe(Effect.scoped, Effect.runPromise));
89
+
90
+ it("decodes path params from param route", () =>
91
+ Effect.gen(function* () {
92
+ const route = Route.Param("id");
93
+ const decoded = yield* Schema.decodeEffect(route.paramsSchema)({ id: "123" });
94
+
95
+ expect(decoded).toEqual({ id: "123" });
96
+ }).pipe(Effect.scoped, Effect.runPromise));
97
+
98
+ it("decodes path params from joined route", () =>
99
+ Effect.gen(function* () {
100
+ const route = Route.Join(Route.Parse("users"), Route.Param("id"));
101
+ const decoded = yield* Schema.decodeEffect(route.paramsSchema)({ id: "123" });
102
+
103
+ expect(decoded).toEqual({ id: "123" });
104
+ }).pipe(Effect.scoped, Effect.runPromise));
105
+
106
+ it("decodes wildcard params", () =>
107
+ Effect.gen(function* () {
108
+ const route = Route.Join(Route.Parse("files"), Route.Wildcard);
109
+ const decoded = yield* Schema.decodeEffect(route.paramsSchema)({ "*": "path/to/file" });
110
+
111
+ expect(decoded).toEqual({ "*": "path/to/file" });
112
+ }).pipe(Effect.scoped, Effect.runPromise));
113
+
114
+ it("decodes multiple params from joined route", () =>
115
+ Effect.gen(function* () {
116
+ const route = Route.Join(
117
+ Route.Parse("users"),
118
+ Route.Param("userId"),
119
+ Route.Parse("posts"),
120
+ Route.Param("postId"),
121
+ );
122
+ const decoded = yield* Schema.decodeEffect(route.paramsSchema)({
123
+ userId: "u1",
124
+ postId: "p1",
125
+ });
126
+
127
+ expect(decoded).toEqual({ userId: "u1", postId: "p1" });
128
+ }).pipe(Effect.scoped, Effect.runPromise));
129
+ });
130
+
131
+ describe("pathSchema", () => {
132
+ it("decodes path-only params (excludes query)", () =>
133
+ Effect.gen(function* () {
134
+ const route = Route.Join(Route.Parse("users"), Route.Param("id"));
135
+ const decoded = yield* Schema.decodeEffect(route.pathSchema)({ id: "123" });
136
+
137
+ expect(decoded).toEqual({ id: "123" });
138
+ }).pipe(Effect.scoped, Effect.runPromise));
139
+ });
140
+
141
+ describe("querySchema", () => {
142
+ it("decodes empty query schema for path-only route", () =>
143
+ Effect.gen(function* () {
144
+ const route = Route.Join(Route.Parse("users"), Route.Param("id"));
145
+ const decoded = yield* Schema.decodeEffect(route.querySchema)({});
146
+
147
+ expect(decoded).toEqual({});
148
+ }).pipe(Effect.scoped, Effect.runPromise));
149
+ });
150
+
151
+ describe("pipe", () => {
152
+ it("supports pipeable interface", () => {
153
+ const route = Route.Parse("users");
154
+ const result = route.pipe((r) => r.path);
155
+
156
+ expect(result).toEqual("/users");
157
+ });
158
+ });
159
+
160
+ describe("path correctness", () => {
161
+ describe("Literal path handling", () => {
162
+ it("adds leading slash to simple literal", () => {
163
+ expect(Route.Parse("users").path).toEqual("/users");
164
+ });
165
+
166
+ it("preserves slashes in input", () => {
167
+ expect(Route.Parse("/users").path).toEqual("/users");
168
+ });
169
+
170
+ it("preserves internal slashes", () => {
171
+ expect(Route.Parse("api/v1/users").path).toEqual("/api/v1/users");
172
+ });
173
+
174
+ it("handles empty string as root", () => {
175
+ expect(Route.Parse("").path).toEqual("/");
176
+ });
177
+ });
178
+
179
+ describe("join path construction", () => {
180
+ it("joins single route", () => {
181
+ const route = Route.Join(Route.Parse("users"));
182
+ expect(route.path).toEqual("/users");
183
+ });
184
+
185
+ it("joins slash with literal", () => {
186
+ const route = Route.Join(Route.Slash, Route.Parse("users"));
187
+ expect(route.path).toEqual("//users");
188
+ });
189
+
190
+ it("joins param at start", () => {
191
+ const route = Route.Join(Route.Param("tenant"), Route.Parse("users"));
192
+ expect(route.path).toEqual("/:tenant/users");
193
+ });
194
+
195
+ it("joins wildcard at start", () => {
196
+ const route = Route.Join(Route.Wildcard, Route.Parse("match"));
197
+ expect(route.path).toEqual("/*/match");
198
+ });
199
+
200
+ it("joins three params", () => {
201
+ const route = Route.Join(Route.Param("a"), Route.Param("b"), Route.Param("c"));
202
+ expect(route.path).toEqual("/:a/:b/:c");
203
+ });
204
+
205
+ it("joins param between literals", () => {
206
+ const route = Route.Join(Route.Parse("users"), Route.Param("id"), Route.Parse("profile"));
207
+ expect(route.path).toEqual("/users/:id/profile");
208
+ });
209
+
210
+ it("joins nested api pattern", () => {
211
+ const route = Route.Join(
212
+ Route.Parse("api"),
213
+ Route.Parse("v2"),
214
+ Route.Parse("organizations"),
215
+ Route.Param("orgId"),
216
+ Route.Parse("teams"),
217
+ Route.Param("teamId"),
218
+ Route.Parse("members"),
219
+ Route.Param("memberId"),
220
+ );
221
+ expect(route.path).toEqual("/api/v2/organizations/:orgId/teams/:teamId/members/:memberId");
222
+ });
223
+
224
+ it("joins wildcard at end for catch-all", () => {
225
+ const route = Route.Join(Route.Parse("docs"), Route.Param("version"), Route.Wildcard);
226
+ expect(route.path).toEqual("/docs/:version/*");
227
+ });
228
+
229
+ it("joins multiple wildcards", () => {
230
+ const route = Route.Join(Route.Wildcard, Route.Wildcard);
231
+ expect(route.path).toEqual("/*/*");
232
+ });
233
+
234
+ it("joins alternating params and literals", () => {
235
+ const route = Route.Join(
236
+ Route.Param("a"),
237
+ Route.Parse("x"),
238
+ Route.Param("b"),
239
+ Route.Parse("y"),
240
+ Route.Param("c"),
241
+ );
242
+ expect(route.path).toEqual("/:a/x/:b/y/:c");
243
+ });
244
+ });
245
+
246
+ describe("individual route paths", () => {
247
+ it("Slash is /", () => {
248
+ expect(Route.Slash.path).toEqual("/");
249
+ });
250
+
251
+ it("Wildcard is /*", () => {
252
+ expect(Route.Wildcard.path).toEqual("/*");
253
+ });
254
+
255
+ it("Param includes colon prefix", () => {
256
+ expect(Route.Param("foo").path).toEqual("/:foo");
257
+ });
258
+
259
+ it("Param with long name", () => {
260
+ expect(Route.Param("organizationId").path).toEqual("/:organizationId");
261
+ });
262
+
263
+ it("Param with numeric suffix", () => {
264
+ expect(Route.Param("id1").path).toEqual("/:id1");
265
+ });
266
+ });
267
+ });
268
+ });
package/src/Route.ts ADDED
@@ -0,0 +1,316 @@
1
+ /* eslint-disable no-restricted-syntax */
2
+ import * as Effect from "effect/Effect";
3
+ import { type Pipeable, pipeArguments } from "effect/Pipeable";
4
+ import { singleton } from "effect/Record";
5
+ import * as Schema from "effect/Schema";
6
+ import * as Parser from "effect/SchemaParser";
7
+ import * as Transformation from "effect/SchemaTransformation";
8
+ import type { Simplify } from "effect/Types";
9
+ import * as AST from "./AST.js";
10
+ import * as Path from "./Path.js";
11
+
12
+ export interface Route<
13
+ P extends string,
14
+ S extends Schema.Codec<any, Path.Params<P>, any, any> = Schema.Codec<Path.Params<P>>,
15
+ > extends Pipeable {
16
+ readonly ast: AST.RouteAst;
17
+ readonly path: P;
18
+
19
+ readonly paramsSchema: S;
20
+ readonly pathSchema: Schema.Codec<Path.PathParams<P>>;
21
+ readonly querySchema: Schema.Codec<Path.QueryParams<P>>;
22
+ }
23
+
24
+ export declare namespace Route {
25
+ export type Any = Route<any, any>;
26
+
27
+ export type Path<T> = T extends Route<infer P, any> ? P : never;
28
+ export type Schema<T> = T extends Route<any, infer S> ? S : never;
29
+ export type Type<T> = T extends Route<any, infer S> ? S["Type"] : never;
30
+ export type Params<T> = T extends Route<infer P, infer _S> ? Path.Params<P> : never;
31
+ export type DecodingServices<T> = T extends Route<any, infer S> ? S["DecodingServices"] : never;
32
+ export type EncodingServices<T> = T extends Route<any, infer S> ? S["EncodingServices"] : never;
33
+
34
+ export type PathType<T extends Any> = T["pathSchema"]["Type"];
35
+ export type QueryType<T extends Any> = T["querySchema"]["Type"];
36
+ }
37
+
38
+ export function make<
39
+ const P extends string,
40
+ S extends Schema.Codec<any, Path.Params<P>, any, any> = Schema.Codec<Path.Params<P>>,
41
+ >(ast: AST.RouteAst): Route<P, S> {
42
+ const getParts = once(() => getPathAst(ast));
43
+ const path = once(() => Path.join(getParts()) as P);
44
+ const paramsSchema = once(() => getParamsSchema(ast) as S);
45
+ const pathSchema = once(() => getPathSchema(ast) as Schema.Codec<Path.PathParams<P>>);
46
+ const querySchema = once(() => getQuerySchema(ast) as Schema.Codec<Path.QueryParams<P>>);
47
+
48
+ return {
49
+ ast,
50
+ get path() {
51
+ return path();
52
+ },
53
+ get paramsSchema() {
54
+ return paramsSchema();
55
+ },
56
+ get pathSchema() {
57
+ return pathSchema();
58
+ },
59
+ get querySchema() {
60
+ return querySchema();
61
+ },
62
+ pipe() {
63
+ return pipeArguments(this, arguments);
64
+ },
65
+ };
66
+ }
67
+
68
+ function once<T>(fn: () => T): () => T {
69
+ let memoized: [T] | [] = [];
70
+ return (): T => {
71
+ if (memoized.length === 1) {
72
+ return memoized[0];
73
+ }
74
+ const result = fn();
75
+ memoized = [result];
76
+ return result;
77
+ };
78
+ }
79
+
80
+ function getPathAst(ast: AST.RouteAst): ReadonlyArray<AST.PathAst> {
81
+ switch (ast.type) {
82
+ case "path":
83
+ return [ast.path];
84
+ case "transform":
85
+ return getPathAst(ast.from);
86
+ case "join": {
87
+ const result: Array<AST.PathAst> = [];
88
+ for (let i = 0; i < ast.parts.length; i++) {
89
+ if (i > 0) {
90
+ result.push(AST.slash());
91
+ }
92
+ result.push(...getPathAst(ast.parts[i]));
93
+ }
94
+ return result;
95
+ }
96
+ }
97
+ }
98
+
99
+ function getParamsSchema(ast: AST.RouteAst): Schema.Top {
100
+ switch (ast.type) {
101
+ case "path": {
102
+ const { paramsSchema } = Path.getSchemas(getPathAst(ast));
103
+ return paramsSchema;
104
+ }
105
+ case "transform": {
106
+ const { paramsSchema } = Path.getSchemas(getPathAst(ast.from));
107
+ return paramsSchema.pipe(Schema.decodeTo(ast.to, ast.transformation));
108
+ }
109
+ case "join": {
110
+ const parts = ast.parts.map((part) => Path.getSchemaFields(getPathAst(part)));
111
+ const requiredFields: Array<[string, Schema.Top]> = [];
112
+ const optionalFields: Array<[Schema.Record.Key, Schema.Top]> = [];
113
+ const queryParams: Array<
114
+ [
115
+ string,
116
+ {
117
+ readonly requiredFields: Array<[string, Schema.Top]>;
118
+ readonly optionalFields: Array<[Schema.Record.Key, Schema.Top]>;
119
+ },
120
+ ]
121
+ > = [];
122
+
123
+ for (const part of parts) {
124
+ requiredFields.push(...part.requiredFields);
125
+ optionalFields.push(...part.optionalFields);
126
+ queryParams.push(...part.queryParams);
127
+ }
128
+
129
+ const pathFields = Object.fromEntries(requiredFields);
130
+ const queryFields = Object.fromEntries(
131
+ queryParams.map(([name, { optionalFields, requiredFields }]) => [
132
+ name,
133
+ Schema.StructWithRest(
134
+ Schema.Struct(Object.fromEntries(requiredFields)),
135
+ optionalFields.map(([key, value]) => Schema.Record(key, value)),
136
+ ),
137
+ ]),
138
+ );
139
+
140
+ const paramsSchema = Schema.StructWithRest(
141
+ Schema.Struct({ ...pathFields, ...queryFields }),
142
+ optionalFields.map(([key, value]) => Schema.Record(key, value)),
143
+ );
144
+
145
+ return paramsSchema;
146
+ }
147
+ }
148
+ }
149
+
150
+ function getPathSchema(ast: AST.RouteAst): Schema.Top {
151
+ if (ast.type !== "join") return Path.getSchemas(getPathAst(ast)).pathSchema;
152
+
153
+ const parts = ast.parts.map((part) => Path.getSchemaFields(getPathAst(part)));
154
+ const requiredFields: Array<[string, Schema.Top]> = [];
155
+ const optionalFields: Array<[Schema.Record.Key, Schema.Top]> = [];
156
+
157
+ for (const part of parts) {
158
+ requiredFields.push(...part.requiredFields);
159
+ optionalFields.push(...part.optionalFields);
160
+ }
161
+
162
+ const pathFields = Object.fromEntries(requiredFields);
163
+ return Schema.StructWithRest(
164
+ Schema.Struct(pathFields),
165
+ optionalFields.map(([key, value]) => Schema.Record(key, value)),
166
+ );
167
+ }
168
+
169
+ function getQuerySchema(ast: AST.RouteAst): Schema.Top {
170
+ if (ast.type !== "join") return Path.getSchemas(getPathAst(ast)).querySchema;
171
+
172
+ const parts = ast.parts.map((part) => Path.getSchemaFields(getPathAst(part)));
173
+ const queryParams: Array<
174
+ [
175
+ string,
176
+ {
177
+ readonly requiredFields: Array<[string, Schema.Top]>;
178
+ readonly optionalFields: Array<[Schema.Record.Key, Schema.Top]>;
179
+ },
180
+ ]
181
+ > = [];
182
+
183
+ for (const part of parts) {
184
+ queryParams.push(...part.queryParams);
185
+ }
186
+
187
+ const queryFields = Object.fromEntries(
188
+ queryParams.map(([name, { optionalFields, requiredFields }]) => [
189
+ name,
190
+ Schema.StructWithRest(
191
+ Schema.Struct(Object.fromEntries(requiredFields)),
192
+ optionalFields.map(([key, value]) => Schema.Record(key, value)),
193
+ ),
194
+ ]),
195
+ );
196
+
197
+ return Schema.Struct(queryFields);
198
+ }
199
+
200
+ export const Parse = <const P extends string>(path: P): Route<Path.Join<Path.ParseAsts<P>>> => {
201
+ const asts = Path.parse(path) as ReadonlyArray<AST.PathAst>;
202
+ if (asts.length === 0) return Slash as unknown as Route<Path.Join<Path.ParseAsts<P>>>;
203
+ if (asts.length === 1) return make(AST.path(asts[0]));
204
+ return Join<Array<any>>(...asts.map((ast) => make(AST.path(ast)))) as unknown as Route<
205
+ Path.Join<Path.ParseAsts<P>>
206
+ >;
207
+ };
208
+
209
+ export const Slash = make<"/">(AST.path(AST.literal("")));
210
+
211
+ export const Wildcard = make<"*">(AST.path(AST.wildcard()));
212
+
213
+ export const Param = <const P extends string>(paramName: P): Route<`/:${P}`> =>
214
+ make<`/:${P}`>(AST.path(AST.parameter(paramName)));
215
+
216
+ export const ParamWithSchema = <
217
+ const P extends string,
218
+ S extends Schema.Codec<any, string, any, any> = Schema.Codec<string>,
219
+ >(
220
+ paramName: P,
221
+ schema: S,
222
+ ): Route<
223
+ `/:${P}`,
224
+ Schema.Codec<
225
+ { readonly [K in P]: S["Type"] },
226
+ Path.Params<`/:${P}`>,
227
+ S["DecodingServices"],
228
+ S["EncodingServices"]
229
+ >
230
+ > => {
231
+ const decode = Parser.decodeEffect(schema);
232
+ const encode = Parser.encodeEffect(schema);
233
+
234
+ return make(
235
+ AST.transform(
236
+ AST.path(AST.parameter(paramName)),
237
+ Schema.Struct(singleton(paramName, schema.Type)),
238
+ Transformation.transformOrFail({
239
+ decode: (input: Record<P, S["Encoded"]>) =>
240
+ Effect.map(decode(input[paramName]), (decoded) => singleton(paramName, decoded)),
241
+ encode: (output: Record<P, S["Type"]>) =>
242
+ Effect.map(encode(output[paramName]), (encoded) => singleton(paramName, encoded)),
243
+ }),
244
+ ),
245
+ );
246
+ };
247
+
248
+ export const Number = <const P extends string>(
249
+ paramName: P,
250
+ ): Route<`/:${P}`, Schema.Codec<{ readonly [K in P]: number }, Path.Params<`/:${P}`>>> =>
251
+ ParamWithSchema(paramName, Schema.NumberFromString);
252
+
253
+ export const Int = <const P extends string>(
254
+ paramName: P,
255
+ ): Route<`/:${P}`, Schema.Codec<{ readonly [K in P]: number }, Path.Params<`/:${P}`>>> =>
256
+ ParamWithSchema(paramName, Schema.NumberFromString.pipe(Schema.decodeTo(Schema.Int)));
257
+
258
+ export type Join<Routes extends ReadonlyArray<Route<any, any>>> = [
259
+ Route<
260
+ RouteJoinPath<Routes>,
261
+ Schema.Codec<
262
+ Simplify<UnionToIntersection<Routes[number]["paramsSchema"]["Type"]>>,
263
+ Path.Params<RouteJoinPath<Routes>>,
264
+ Routes[number]["paramsSchema"]["DecodingServices"],
265
+ Routes[number]["paramsSchema"]["EncodingServices"]
266
+ >
267
+ >,
268
+ ] extends [Route<infer Path, infer Schema>]
269
+ ? Route<Path, Schema>
270
+ : never;
271
+
272
+ type AnyRoutes = ReadonlyArray<Route<any, any> | ReadonlyArray<Route<any, any>>>;
273
+ type FlattenRoutes<T extends AnyRoutes> = T extends readonly [
274
+ infer Head extends Route<any, any> | ReadonlyArray<Route<any, any>>,
275
+ ...infer Tail extends AnyRoutes,
276
+ ]
277
+ ? readonly [
278
+ ...(Head extends ReadonlyArray<Route<any, any>> ? FlattenRoutes<Head> : [Head]),
279
+ ...FlattenRoutes<Tail>,
280
+ ]
281
+ : [];
282
+
283
+ const removeSlash = (ast: AST.RouteAst): ReadonlyArray<AST.RouteAst> => {
284
+ if (ast.type === "path" && ast.path.type === "slash") return [];
285
+ return [ast];
286
+ };
287
+
288
+ export const Join = <const Routes extends AnyRoutes>(
289
+ ...routes: Routes
290
+ ): Join<FlattenRoutes<Routes>> =>
291
+ make(
292
+ AST.join(
293
+ routes.flatMap((route) => {
294
+ if (Array.isArray(route)) return route.flatMap(removeSlash);
295
+ return removeSlash((route as Route<any, any>).ast);
296
+ }),
297
+ ),
298
+ );
299
+
300
+ type UnionToIntersection<T> = (T extends any ? (x: T) => any : never) extends (x: infer R) => any
301
+ ? R
302
+ : never;
303
+ type RouteJoinPath<
304
+ Routes extends ReadonlyArray<Route<any, any>>,
305
+ R extends string = "",
306
+ > = Routes extends readonly [
307
+ infer First extends Route<any, any>,
308
+ ...infer Rest extends ReadonlyArray<Route<any, any>>,
309
+ ]
310
+ ? RouteJoinPath<Rest, `${R}/${StripSlashes<First["path"]>}`>
311
+ : R;
312
+ type StripSlashes<T extends string> = StripTrailingSlash<StripLeadingSlash<T>>;
313
+ type StripLeadingSlash<T extends string> = T extends `/${infer Rest}` ? StripLeadingSlash<Rest> : T;
314
+ type StripTrailingSlash<T extends string> = T extends `/${infer Rest}`
315
+ ? StripTrailingSlash<Rest>
316
+ : T;
package/src/Router.ts ADDED
@@ -0,0 +1,31 @@
1
+ import * as Layer from "effect/Layer";
2
+ import { fromWindow } from "@typed/navigation/fromWindow";
3
+ import {
4
+ initialMemory,
5
+ type InitialMemoryOptions,
6
+ memory,
7
+ type MemoryOptions,
8
+ } from "@typed/navigation/memory";
9
+ import type { Navigation } from "@typed/navigation/Navigation";
10
+ import { CurrentRoute } from "./CurrentRoute.js";
11
+ import { Ids } from "@typed/id";
12
+
13
+ export type Router = CurrentRoute | Navigation;
14
+
15
+ export const BrowserRouter = (window?: Window): Layer.Layer<Router> =>
16
+ CurrentRoute.Default.pipe(
17
+ Layer.provideMerge(fromWindow(window)),
18
+ Layer.provideMerge(Ids.Default),
19
+ );
20
+
21
+ export const ServerRouter = (options: MemoryOptions | InitialMemoryOptions): Layer.Layer<Router> =>
22
+ CurrentRoute.Default.pipe(
23
+ Layer.provideMerge("url" in options ? initialMemory(options) : memory(options)),
24
+ Layer.provideMerge(Ids.Default),
25
+ );
26
+
27
+ export const TestRouter = (options: (MemoryOptions | InitialMemoryOptions) & {}): Layer.Layer<Router> =>
28
+ CurrentRoute.Default.pipe(
29
+ Layer.provideMerge("url" in options ? initialMemory(options) : memory(options)),
30
+ Layer.provideMerge(Ids.Test()),
31
+ );