@typed/ui 0.13.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.
- package/README.md +96 -2
- package/dist/HttpRouter.d.ts +13 -0
- package/dist/HttpRouter.d.ts.map +1 -0
- package/dist/HttpRouter.js +99 -0
- package/dist/Link.d.ts +36 -0
- package/dist/Link.d.ts.map +1 -0
- package/dist/Link.js +45 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/package.json +25 -79
- package/src/HttpRouter.test.ts +294 -0
- package/src/HttpRouter.ts +168 -0
- package/src/Link.test.ts +85 -0
- package/src/Link.ts +97 -90
- package/src/index.ts +2 -40
- package/tsconfig.json +6 -0
- package/LICENSE +0 -21
- package/Link/package.json +0 -6
- package/Props/package.json +0 -6
- package/dist/cjs/Link.js +0 -76
- package/dist/cjs/Link.js.map +0 -1
- package/dist/cjs/Props.js +0 -26
- package/dist/cjs/Props.js.map +0 -1
- package/dist/cjs/dom-properties.js +0 -6
- package/dist/cjs/dom-properties.js.map +0 -1
- package/dist/cjs/hyperscript.js +0 -484
- package/dist/cjs/hyperscript.js.map +0 -1
- package/dist/cjs/index.js +0 -39
- package/dist/cjs/index.js.map +0 -1
- package/dist/cjs/internal/addEventListener.js +0 -19
- package/dist/cjs/internal/addEventListener.js.map +0 -1
- package/dist/cjs/useClickAway.js +0 -43
- package/dist/cjs/useClickAway.js.map +0 -1
- package/dist/cjs/usePagination.js +0 -68
- package/dist/cjs/usePagination.js.map +0 -1
- package/dist/dts/Link.d.ts +0 -26
- package/dist/dts/Link.d.ts.map +0 -1
- package/dist/dts/Props.d.ts +0 -67
- package/dist/dts/Props.d.ts.map +0 -1
- package/dist/dts/dom-properties.d.ts +0 -928
- package/dist/dts/dom-properties.d.ts.map +0 -1
- package/dist/dts/hyperscript.d.ts +0 -461
- package/dist/dts/hyperscript.d.ts.map +0 -1
- package/dist/dts/index.d.ts +0 -16
- package/dist/dts/index.d.ts.map +0 -1
- package/dist/dts/internal/addEventListener.d.ts +0 -6
- package/dist/dts/internal/addEventListener.d.ts.map +0 -1
- package/dist/dts/useClickAway.d.ts +0 -19
- package/dist/dts/useClickAway.d.ts.map +0 -1
- package/dist/dts/usePagination.d.ts +0 -41
- package/dist/dts/usePagination.d.ts.map +0 -1
- package/dist/esm/Link.js +0 -52
- package/dist/esm/Link.js.map +0 -1
- package/dist/esm/Props.js +0 -19
- package/dist/esm/Props.js.map +0 -1
- package/dist/esm/dom-properties.js +0 -5
- package/dist/esm/dom-properties.js.map +0 -1
- package/dist/esm/hyperscript.js +0 -470
- package/dist/esm/hyperscript.js.map +0 -1
- package/dist/esm/index.js +0 -37
- package/dist/esm/index.js.map +0 -1
- package/dist/esm/internal/addEventListener.js +0 -11
- package/dist/esm/internal/addEventListener.js.map +0 -1
- package/dist/esm/package.json +0 -4
- package/dist/esm/useClickAway.js +0 -36
- package/dist/esm/useClickAway.js.map +0 -1
- package/dist/esm/usePagination.js +0 -59
- package/dist/esm/usePagination.js.map +0 -1
- package/dom-properties/package.json +0 -6
- package/hyperscript/package.json +0 -6
- package/src/Props.ts +0 -104
- package/src/dom-properties.ts +0 -1132
- package/src/hyperscript.ts +0 -649
- package/src/internal/addEventListener.ts +0 -22
- package/src/useClickAway.ts +0 -66
- package/src/usePagination.ts +0 -122
- package/useClickAway/package.json +0 -6
- package/usePagination/package.json +0 -6
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { NodeHttpServer } from "@effect/platform-node";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { describe, it } from "vitest";
|
|
4
|
+
import { Effect, Layer } from "effect";
|
|
5
|
+
import { Fx } from "@typed/fx";
|
|
6
|
+
import { Navigation } from "@typed/navigation";
|
|
7
|
+
import { CurrentRoute } from "@typed/router/CurrentRoute";
|
|
8
|
+
import * as Matcher from "@typed/router/Matcher";
|
|
9
|
+
import * as Route from "@typed/router/Route";
|
|
10
|
+
import { html, StaticHtmlRenderTemplate } from "@typed/template";
|
|
11
|
+
import { handleHttpServerError, ssrForHttp } from "./HttpRouter.js";
|
|
12
|
+
import { HttpClient, HttpRouter } from "effect/unstable/http";
|
|
13
|
+
|
|
14
|
+
describe("typed/ui/HttpRouter", () => {
|
|
15
|
+
it("renders simple html template", () => {
|
|
16
|
+
const matcher = Matcher.empty.match(
|
|
17
|
+
Route.Parse("home"),
|
|
18
|
+
html`
|
|
19
|
+
<div>Hello, world!</div>
|
|
20
|
+
`,
|
|
21
|
+
);
|
|
22
|
+
const Live = HttpRouter.use(ssrForHttp(matcher)).pipe(
|
|
23
|
+
Layer.provide(StaticHtmlRenderTemplate),
|
|
24
|
+
HttpRouter.serve,
|
|
25
|
+
Layer.provideMerge([NodeHttpServer.layerTest]),
|
|
26
|
+
);
|
|
27
|
+
return Effect.gen(function* () {
|
|
28
|
+
const response = yield* HttpClient.get("/home").pipe(Effect.flatMap((r) => r.text));
|
|
29
|
+
assert.strictEqual(response, "<div>Hello, world!</div>");
|
|
30
|
+
}).pipe(Effect.provide(Live), Effect.scoped, Effect.runPromise);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("renders html template with route params", () => {
|
|
34
|
+
const users = Route.Join(Route.Parse("users"), Route.Param("id"));
|
|
35
|
+
const matcher = Matcher.empty.match(
|
|
36
|
+
users,
|
|
37
|
+
(params) => html`<div>User ${params.pipe(Fx.map((p) => p.id))}</div>`,
|
|
38
|
+
);
|
|
39
|
+
const Live = HttpRouter.use(ssrForHttp(matcher)).pipe(
|
|
40
|
+
Layer.provide(StaticHtmlRenderTemplate),
|
|
41
|
+
HttpRouter.serve,
|
|
42
|
+
Layer.provideMerge(NodeHttpServer.layerTest),
|
|
43
|
+
);
|
|
44
|
+
return Effect.gen(function* () {
|
|
45
|
+
const response = yield* HttpClient.get("/users/123").pipe(Effect.flatMap((r) => r.text));
|
|
46
|
+
assert.strictEqual(response, "<div>User 123</div>");
|
|
47
|
+
}).pipe(Effect.provide(Live), Effect.scoped, Effect.runPromise);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("renders html template with search params", () => {
|
|
51
|
+
const route = Route.Parse("search");
|
|
52
|
+
const matcher = Matcher.empty.match(
|
|
53
|
+
route,
|
|
54
|
+
html`
|
|
55
|
+
<div>Search results</div>
|
|
56
|
+
`,
|
|
57
|
+
);
|
|
58
|
+
const Live = HttpRouter.use(ssrForHttp(matcher)).pipe(
|
|
59
|
+
Layer.provide(StaticHtmlRenderTemplate),
|
|
60
|
+
HttpRouter.serve,
|
|
61
|
+
Layer.provideMerge(NodeHttpServer.layerTest),
|
|
62
|
+
);
|
|
63
|
+
return Effect.gen(function* () {
|
|
64
|
+
const response = yield* HttpClient.get("/search?q=test").pipe(Effect.flatMap((r) => r.text));
|
|
65
|
+
assert.strictEqual(response, "<div>Search results</div>");
|
|
66
|
+
}).pipe(Effect.provide(Live), Effect.scoped, Effect.runPromise);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("handles multiple routes", () => {
|
|
70
|
+
const home = Route.Parse("home");
|
|
71
|
+
const about = Route.Parse("about");
|
|
72
|
+
const matcher = Matcher.empty
|
|
73
|
+
.match(
|
|
74
|
+
home,
|
|
75
|
+
html`
|
|
76
|
+
<div>Home</div>
|
|
77
|
+
`,
|
|
78
|
+
)
|
|
79
|
+
.match(
|
|
80
|
+
about,
|
|
81
|
+
html`
|
|
82
|
+
<div>About</div>
|
|
83
|
+
`,
|
|
84
|
+
);
|
|
85
|
+
const Live = HttpRouter.use(ssrForHttp(matcher)).pipe(
|
|
86
|
+
Layer.provide(StaticHtmlRenderTemplate),
|
|
87
|
+
HttpRouter.serve,
|
|
88
|
+
Layer.provideMerge(NodeHttpServer.layerTest),
|
|
89
|
+
);
|
|
90
|
+
return Effect.gen(function* () {
|
|
91
|
+
const homeResponse = yield* HttpClient.get("/home").pipe(Effect.flatMap((r) => r.text));
|
|
92
|
+
assert.strictEqual(homeResponse, "<div>Home</div>");
|
|
93
|
+
const aboutResponse = yield* HttpClient.get("/about").pipe(Effect.flatMap((r) => r.text));
|
|
94
|
+
assert.strictEqual(aboutResponse, "<div>About</div>");
|
|
95
|
+
}).pipe(Effect.provide(Live), Effect.scoped, Effect.runPromise);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("returns 404 for unmatched routes", () => {
|
|
99
|
+
const matcher = Matcher.empty.match(
|
|
100
|
+
Route.Parse("home"),
|
|
101
|
+
html`
|
|
102
|
+
<div>Home</div>
|
|
103
|
+
`,
|
|
104
|
+
);
|
|
105
|
+
const Live = HttpRouter.use(
|
|
106
|
+
Effect.fn(function* (router) {
|
|
107
|
+
yield* ssrForHttp(router, matcher);
|
|
108
|
+
yield* handleHttpServerError(router);
|
|
109
|
+
}),
|
|
110
|
+
).pipe(
|
|
111
|
+
Layer.provide(StaticHtmlRenderTemplate),
|
|
112
|
+
HttpRouter.serve,
|
|
113
|
+
Layer.provideMerge(NodeHttpServer.layerTest),
|
|
114
|
+
);
|
|
115
|
+
return Effect.gen(function* () {
|
|
116
|
+
const response = yield* HttpClient.get("/notfound");
|
|
117
|
+
assert.strictEqual(response.status, 404);
|
|
118
|
+
}).pipe(Effect.provide(Live), Effect.scoped, Effect.runPromise);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("renders dynamic content from Effect", () => {
|
|
122
|
+
const matcher = Matcher.empty.match(
|
|
123
|
+
Route.Parse("dynamic"),
|
|
124
|
+
html`<div>Value: ${Effect.succeed("42")}</div>`,
|
|
125
|
+
);
|
|
126
|
+
const Live = HttpRouter.use(ssrForHttp(matcher)).pipe(
|
|
127
|
+
HttpRouter.serve,
|
|
128
|
+
Layer.provideMerge(NodeHttpServer.layerTest),
|
|
129
|
+
Layer.provide(StaticHtmlRenderTemplate),
|
|
130
|
+
);
|
|
131
|
+
return Effect.gen(function* () {
|
|
132
|
+
const response = yield* HttpClient.get("/dynamic").pipe(Effect.flatMap((r) => r.text));
|
|
133
|
+
assert.strictEqual(response, "<div>Value: 42</div>");
|
|
134
|
+
}).pipe(Effect.provide(Live), Effect.scoped, Effect.runPromise);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("sets correct content-type header", () => {
|
|
138
|
+
const matcher = Matcher.empty.match(
|
|
139
|
+
Route.Parse("home"),
|
|
140
|
+
html`
|
|
141
|
+
<div>Hello</div>
|
|
142
|
+
`,
|
|
143
|
+
);
|
|
144
|
+
const Live = HttpRouter.use(ssrForHttp(matcher)).pipe(
|
|
145
|
+
Layer.provide(StaticHtmlRenderTemplate),
|
|
146
|
+
HttpRouter.serve,
|
|
147
|
+
Layer.provideMerge(NodeHttpServer.layerTest),
|
|
148
|
+
);
|
|
149
|
+
return Effect.gen(function* () {
|
|
150
|
+
const response = yield* HttpClient.get("/home");
|
|
151
|
+
const contentType = response.headers["content-type"];
|
|
152
|
+
assert.strictEqual(contentType, "text/html; charset=utf-8");
|
|
153
|
+
}).pipe(Effect.provide(Live), Effect.scoped, Effect.runPromise);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("handles nested routes", () => {
|
|
157
|
+
const users = Route.Join(Route.Parse("api"), Route.Parse("users"));
|
|
158
|
+
const user = Route.Join(users, Route.Param("id"));
|
|
159
|
+
const matcher = Matcher.empty
|
|
160
|
+
.match(
|
|
161
|
+
users,
|
|
162
|
+
html`
|
|
163
|
+
<div>Users list</div>
|
|
164
|
+
`,
|
|
165
|
+
)
|
|
166
|
+
.match(user, (params) => html`<div>User ${params.pipe(Fx.map((p) => p.id))}</div>`);
|
|
167
|
+
const Live = HttpRouter.use(ssrForHttp(matcher)).pipe(
|
|
168
|
+
Layer.provide(StaticHtmlRenderTemplate),
|
|
169
|
+
HttpRouter.serve,
|
|
170
|
+
Layer.provideMerge(NodeHttpServer.layerTest),
|
|
171
|
+
);
|
|
172
|
+
return Effect.gen(function* () {
|
|
173
|
+
const listResponse = yield* HttpClient.get("/api/users").pipe(Effect.flatMap((r) => r.text));
|
|
174
|
+
assert.strictEqual(listResponse, "<div>Users list</div>");
|
|
175
|
+
const userResponse = yield* HttpClient.get("/api/users/456").pipe(
|
|
176
|
+
Effect.flatMap((r) => r.text),
|
|
177
|
+
);
|
|
178
|
+
assert.strictEqual(userResponse, "<div>User 456</div>");
|
|
179
|
+
}).pipe(Effect.provide(Live), Effect.scoped, Effect.runPromise);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("provides Navigation with correct base and origin", () => {
|
|
183
|
+
const matcher = Matcher.empty.match(
|
|
184
|
+
Route.Parse("test"),
|
|
185
|
+
Fx.gen(function* () {
|
|
186
|
+
const origin = yield* Navigation.origin;
|
|
187
|
+
const base = yield* Navigation.base;
|
|
188
|
+
const currentEntry = yield* Navigation.currentEntry;
|
|
189
|
+
return html`<div data-origin="${origin}" data-base="${base}" data-url="${currentEntry.url.href}"></div>`;
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
const Live = HttpRouter.use(ssrForHttp(matcher)).pipe(
|
|
193
|
+
Layer.provide(StaticHtmlRenderTemplate),
|
|
194
|
+
HttpRouter.serve,
|
|
195
|
+
Layer.provideMerge(NodeHttpServer.layerTest),
|
|
196
|
+
);
|
|
197
|
+
return Effect.gen(function* () {
|
|
198
|
+
const response = yield* HttpClient.get("/test").pipe(Effect.flatMap((r) => r.text));
|
|
199
|
+
assert.ok(response.includes('data-origin="http://localhost"'));
|
|
200
|
+
assert.ok(response.includes('data-base="/"'));
|
|
201
|
+
assert.ok(response.includes('data-url="http://localhost/test"'));
|
|
202
|
+
}).pipe(Effect.provide(Live), Effect.scoped, Effect.runPromise);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("provides CurrentRoute with correct route path", () => {
|
|
206
|
+
const matcher = Matcher.empty.match(
|
|
207
|
+
Route.Parse("users"),
|
|
208
|
+
Fx.gen(function* () {
|
|
209
|
+
const currentRoute = yield* CurrentRoute;
|
|
210
|
+
return html`<div data-path="${currentRoute.route.path}"></div>`;
|
|
211
|
+
}),
|
|
212
|
+
);
|
|
213
|
+
const Live = HttpRouter.use(ssrForHttp(matcher)).pipe(
|
|
214
|
+
Layer.provide(StaticHtmlRenderTemplate),
|
|
215
|
+
HttpRouter.serve,
|
|
216
|
+
Layer.provideMerge(NodeHttpServer.layerTest),
|
|
217
|
+
);
|
|
218
|
+
return Effect.gen(function* () {
|
|
219
|
+
const response = yield* HttpClient.get("/users").pipe(Effect.flatMap((r) => r.text));
|
|
220
|
+
assert.ok(response.includes('data-path="/users"'));
|
|
221
|
+
}).pipe(Effect.provide(Live), Effect.scoped, Effect.runPromise);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("provides CurrentRoute with no parent for root routes", () => {
|
|
225
|
+
const matcher = Matcher.empty.match(
|
|
226
|
+
Route.Parse("home"),
|
|
227
|
+
Fx.gen(function* () {
|
|
228
|
+
const currentRoute = yield* CurrentRoute;
|
|
229
|
+
const hasParent = currentRoute.parent !== undefined;
|
|
230
|
+
return html`<div data-has-parent="${hasParent}"></div>`;
|
|
231
|
+
}),
|
|
232
|
+
);
|
|
233
|
+
const Live = HttpRouter.use(ssrForHttp(matcher)).pipe(
|
|
234
|
+
Layer.provide(StaticHtmlRenderTemplate),
|
|
235
|
+
HttpRouter.serve,
|
|
236
|
+
Layer.provideMerge(NodeHttpServer.layerTest),
|
|
237
|
+
);
|
|
238
|
+
return Effect.gen(function* () {
|
|
239
|
+
const response = yield* HttpClient.get("/home").pipe(Effect.flatMap((r) => r.text));
|
|
240
|
+
assert.ok(response.includes('data-has-parent="false"'));
|
|
241
|
+
}).pipe(Effect.provide(Live), Effect.scoped, Effect.runPromise);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("provides CurrentRoute with parent for nested routes", () => {
|
|
245
|
+
const api = Route.Parse("api");
|
|
246
|
+
const users = Route.Parse("users");
|
|
247
|
+
const matcher = Matcher.empty
|
|
248
|
+
.match(
|
|
249
|
+
Route.Slash,
|
|
250
|
+
html`
|
|
251
|
+
<div>API</div>
|
|
252
|
+
`,
|
|
253
|
+
)
|
|
254
|
+
.match(
|
|
255
|
+
users,
|
|
256
|
+
Fx.gen(function* () {
|
|
257
|
+
const currentRoute = yield* CurrentRoute;
|
|
258
|
+
const hasParent = currentRoute.parent !== undefined;
|
|
259
|
+
const parentPath = currentRoute.parent?.route.path ?? "none";
|
|
260
|
+
return html`<div data-has-parent="${hasParent}" data-parent-path="${parentPath}"></div>`;
|
|
261
|
+
}),
|
|
262
|
+
);
|
|
263
|
+
const Live = HttpRouter.use(ssrForHttp(matcher)).pipe(
|
|
264
|
+
Layer.provide(StaticHtmlRenderTemplate),
|
|
265
|
+
HttpRouter.serve,
|
|
266
|
+
Layer.provideMerge(NodeHttpServer.layerTest),
|
|
267
|
+
Layer.provide(CurrentRoute.extend(api)),
|
|
268
|
+
);
|
|
269
|
+
return Effect.gen(function* () {
|
|
270
|
+
const response = yield* HttpClient.get("/api/users").pipe(Effect.flatMap((r) => r.text));
|
|
271
|
+
assert.ok(response.includes('data-has-parent="true"'));
|
|
272
|
+
assert.ok(response.includes('data-parent-path="/api"'));
|
|
273
|
+
}).pipe(Effect.provide(Live), Effect.scoped, Effect.runPromise);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("provides Navigation with correct currentEntry for different paths", () => {
|
|
277
|
+
const matcher = Matcher.empty.match(
|
|
278
|
+
Route.Parse("about"),
|
|
279
|
+
Fx.gen(function* () {
|
|
280
|
+
const currentEntry = yield* Navigation.currentEntry;
|
|
281
|
+
return html`<div data-pathname="${currentEntry.url.pathname}"></div>`;
|
|
282
|
+
}),
|
|
283
|
+
);
|
|
284
|
+
const Live = HttpRouter.use(ssrForHttp(matcher)).pipe(
|
|
285
|
+
Layer.provide(StaticHtmlRenderTemplate),
|
|
286
|
+
HttpRouter.serve,
|
|
287
|
+
Layer.provideMerge(NodeHttpServer.layerTest),
|
|
288
|
+
);
|
|
289
|
+
return Effect.gen(function* () {
|
|
290
|
+
const response = yield* HttpClient.get("/about").pipe(Effect.flatMap((r) => r.text));
|
|
291
|
+
assert.ok(response.includes('data-pathname="/about"'));
|
|
292
|
+
}).pipe(Effect.provide(Live), Effect.scoped, Effect.runPromise);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Exit from "effect/Exit";
|
|
3
|
+
import { dual } from "effect/Function";
|
|
4
|
+
import * as Layer from "effect/Layer";
|
|
5
|
+
import * as Option from "effect/Option";
|
|
6
|
+
import * as Scope from "effect/Scope";
|
|
7
|
+
import * as ServiceMap from "effect/ServiceMap";
|
|
8
|
+
import { type HttpRouter, type Route, RouteContext } from "effect/unstable/http/HttpRouter";
|
|
9
|
+
import * as HttpServerError from "effect/unstable/http/HttpServerError";
|
|
10
|
+
import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
|
|
11
|
+
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
|
|
12
|
+
import { RefSubject } from "@typed/fx";
|
|
13
|
+
import {
|
|
14
|
+
type CurrentRouteTree,
|
|
15
|
+
type CompiledEntry,
|
|
16
|
+
compile,
|
|
17
|
+
CurrentRoute,
|
|
18
|
+
makeCatchManager,
|
|
19
|
+
makeLayerManager,
|
|
20
|
+
makeLayoutManager,
|
|
21
|
+
type Matcher,
|
|
22
|
+
Join,
|
|
23
|
+
Parse,
|
|
24
|
+
type Router,
|
|
25
|
+
} from "@typed/router";
|
|
26
|
+
import { initialMemory } from "@typed/navigation";
|
|
27
|
+
import { renderToHtmlString, type RenderEvent } from "@typed/template";
|
|
28
|
+
|
|
29
|
+
type ProvidedForSsr = Scope.Scope | Router;
|
|
30
|
+
|
|
31
|
+
export const ssrForHttp: {
|
|
32
|
+
<E, R>(
|
|
33
|
+
input: Matcher<RenderEvent, E, R>,
|
|
34
|
+
): (router: HttpRouter) => Effect.Effect<void, never, Exclude<R, ProvidedForSsr>>;
|
|
35
|
+
<E, R>(
|
|
36
|
+
router: HttpRouter,
|
|
37
|
+
input: Matcher<RenderEvent, E, R>,
|
|
38
|
+
): Effect.Effect<void, never, Exclude<R, ProvidedForSsr>>;
|
|
39
|
+
} = dual(2, <E, R>(router: HttpRouter, input: Matcher<RenderEvent, E, R>) => {
|
|
40
|
+
return Effect.gen(function* () {
|
|
41
|
+
const matcher = Option.match(yield* Effect.serviceOption(CurrentRoute), {
|
|
42
|
+
onNone: () => input,
|
|
43
|
+
onSome: (parent: CurrentRouteTree) => input.prefix(parent.route),
|
|
44
|
+
});
|
|
45
|
+
const entries = compile(matcher.cases);
|
|
46
|
+
const currentServices = yield* Effect.services<R>();
|
|
47
|
+
|
|
48
|
+
yield* router.addAll(entries.map((e: CompiledEntry) => toRoute(e, currentServices)));
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export function handleHttpServerError(router: HttpRouter) {
|
|
53
|
+
return router.addGlobalMiddleware(
|
|
54
|
+
Effect.catch((error: unknown) =>
|
|
55
|
+
HttpServerError.isHttpServerError(error)
|
|
56
|
+
? Effect.succeed(HttpServerResponse.text(error.message, { status: getStatus(error) }))
|
|
57
|
+
: Effect.fail(error),
|
|
58
|
+
),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getStatus(error: HttpServerError.HttpServerError): number {
|
|
63
|
+
switch (error.reason._tag) {
|
|
64
|
+
case "RouteNotFound":
|
|
65
|
+
return 404;
|
|
66
|
+
case "RequestParseError":
|
|
67
|
+
return 400;
|
|
68
|
+
case "InternalError":
|
|
69
|
+
case "ResponseError":
|
|
70
|
+
return 500;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function toRoute(
|
|
75
|
+
entry: CompiledEntry,
|
|
76
|
+
currentServices: ServiceMap.ServiceMap<never>,
|
|
77
|
+
): Route<any, any> {
|
|
78
|
+
return {
|
|
79
|
+
"~effect/http/HttpRouter/Route": "~effect/http/HttpRouter/Route",
|
|
80
|
+
method: "GET",
|
|
81
|
+
path: entry.route.path,
|
|
82
|
+
handler: Effect.gen(function* () {
|
|
83
|
+
const fiberId = yield* Effect.fiberId;
|
|
84
|
+
const rootScope = yield* Effect.scope;
|
|
85
|
+
const routeContext = yield* RouteContext;
|
|
86
|
+
const request = yield* HttpServerRequest.HttpServerRequest;
|
|
87
|
+
const searchParams = yield* HttpServerRequest.ParsedSearchParams;
|
|
88
|
+
const provided = Layer.mergeAll(
|
|
89
|
+
initialMemory({ url: request.url }),
|
|
90
|
+
Layer.succeed(
|
|
91
|
+
CurrentRoute,
|
|
92
|
+
yield* Effect.serviceOption(CurrentRoute).pipe(
|
|
93
|
+
Effect.map(
|
|
94
|
+
Option.match({
|
|
95
|
+
onNone: (): CurrentRouteTree => ({
|
|
96
|
+
route: Parse(request.url),
|
|
97
|
+
parent: undefined,
|
|
98
|
+
}),
|
|
99
|
+
onSome: (parent: CurrentRouteTree): CurrentRouteTree => ({
|
|
100
|
+
route: Join(parent.route, Parse(request.url)),
|
|
101
|
+
parent,
|
|
102
|
+
}),
|
|
103
|
+
}),
|
|
104
|
+
),
|
|
105
|
+
),
|
|
106
|
+
),
|
|
107
|
+
);
|
|
108
|
+
const input = { ...routeContext.params, ...searchParams };
|
|
109
|
+
|
|
110
|
+
const params = yield* Effect.mapError(
|
|
111
|
+
entry.decode(input),
|
|
112
|
+
(cause) =>
|
|
113
|
+
new HttpServerError.HttpServerError({
|
|
114
|
+
reason: new HttpServerError.RequestParseError({ request, cause }),
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const memoMap = yield* Layer.makeMemoMap;
|
|
119
|
+
const layerManager = makeLayerManager(memoMap, rootScope, fiberId);
|
|
120
|
+
const layoutManager = makeLayoutManager(rootScope, fiberId);
|
|
121
|
+
const catchManager = makeCatchManager(rootScope, fiberId);
|
|
122
|
+
const prepared = yield* layerManager.prepare(entry.layers.concat(provided));
|
|
123
|
+
|
|
124
|
+
const guardExit = yield* entry
|
|
125
|
+
.guard(params)
|
|
126
|
+
.pipe(Effect.provideServices(prepared.services), Effect.exit);
|
|
127
|
+
|
|
128
|
+
if (Exit.isFailure(guardExit) || Option.isNone(guardExit.value)) {
|
|
129
|
+
yield* prepared.rollback;
|
|
130
|
+
return yield* new HttpServerError.HttpServerError({
|
|
131
|
+
reason: new HttpServerError.RouteNotFound({ request }),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const matchedParams = guardExit.value.value;
|
|
136
|
+
yield* prepared.commit;
|
|
137
|
+
|
|
138
|
+
const scope = yield* Scope.fork(rootScope);
|
|
139
|
+
const paramsRef = yield* RefSubject.make(matchedParams).pipe(Scope.provide(scope));
|
|
140
|
+
|
|
141
|
+
const preparedServices = prepared.services as ServiceMap.ServiceMap<any>;
|
|
142
|
+
const handlerServices = ServiceMap.merge(
|
|
143
|
+
ServiceMap.merge(currentServices, preparedServices),
|
|
144
|
+
ServiceMap.make(Scope.Scope, scope),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const handlerFx = entry.handler(paramsRef);
|
|
148
|
+
|
|
149
|
+
const withLayouts = yield* layoutManager.apply(
|
|
150
|
+
entry.layouts,
|
|
151
|
+
matchedParams,
|
|
152
|
+
handlerFx,
|
|
153
|
+
preparedServices,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const withCatches = yield* catchManager.apply(entry.catches, withLayouts, preparedServices);
|
|
157
|
+
|
|
158
|
+
const html = yield* renderToHtmlString(withCatches).pipe(
|
|
159
|
+
Effect.provideServices(handlerServices),
|
|
160
|
+
);
|
|
161
|
+
return HttpServerResponse.text(html, {
|
|
162
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
163
|
+
});
|
|
164
|
+
}),
|
|
165
|
+
uninterruptible: false,
|
|
166
|
+
prefix: undefined,
|
|
167
|
+
};
|
|
168
|
+
}
|
package/src/Link.test.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { describe, it } from "vitest";
|
|
3
|
+
import { Effect } from "effect";
|
|
4
|
+
import * as Layer from "effect/Layer";
|
|
5
|
+
import { Fx } from "@typed/fx";
|
|
6
|
+
import { CurrentPath } from "@typed/navigation";
|
|
7
|
+
import { ServerRouter } from "@typed/router/Router";
|
|
8
|
+
import { DomRenderTemplate, render } from "@typed/template";
|
|
9
|
+
import { Link } from "./Link.js";
|
|
10
|
+
import { Window } from "happy-dom";
|
|
11
|
+
|
|
12
|
+
describe("typed/ui/Link", () => {
|
|
13
|
+
it("renders <a> with href and content", () =>
|
|
14
|
+
Effect.gen(function* () {
|
|
15
|
+
const [window, layer] = createHappyDomLayer();
|
|
16
|
+
|
|
17
|
+
const [root] = yield* render(
|
|
18
|
+
Link({ href: "/about", content: "Go to about" }),
|
|
19
|
+
window.document.body,
|
|
20
|
+
).pipe(Fx.provide(layer), Fx.take(1), Fx.collectAll);
|
|
21
|
+
|
|
22
|
+
assert(root instanceof window.HTMLElement);
|
|
23
|
+
const anchor = root as HTMLAnchorElement;
|
|
24
|
+
assert(anchor.tagName === "A");
|
|
25
|
+
assert(anchor.getAttribute("href") === "/about");
|
|
26
|
+
assert(anchor.textContent === "Go to about");
|
|
27
|
+
}).pipe(Effect.scoped, Effect.runPromise));
|
|
28
|
+
|
|
29
|
+
it("intercepts same-origin click and navigates", () => {
|
|
30
|
+
const [window, layer] = createHappyDomLayer({ url: "http://localhost/" });
|
|
31
|
+
return Effect.gen(function* () {
|
|
32
|
+
const [root] = yield* render(
|
|
33
|
+
Link({ href: "/about", content: "Go" }),
|
|
34
|
+
window.document.body,
|
|
35
|
+
).pipe(Fx.take(1), Fx.collectAll);
|
|
36
|
+
|
|
37
|
+
assert(root instanceof window.HTMLElement);
|
|
38
|
+
const anchor = root as HTMLAnchorElement;
|
|
39
|
+
assert(anchor.tagName === "A");
|
|
40
|
+
|
|
41
|
+
const pathBefore = yield* CurrentPath;
|
|
42
|
+
anchor.click();
|
|
43
|
+
yield* Effect.sleep(50);
|
|
44
|
+
const pathAfter = yield* CurrentPath;
|
|
45
|
+
|
|
46
|
+
assert(pathBefore === "/" || pathBefore === "");
|
|
47
|
+
assert(pathAfter === "/about");
|
|
48
|
+
}).pipe(Effect.provide(layer), Effect.scoped, Effect.runPromise);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("does not intercept when modifier key is pressed", () => {
|
|
52
|
+
const [window, layer] = createHappyDomLayer();
|
|
53
|
+
return Effect.gen(function* () {
|
|
54
|
+
const [root] = yield* render(
|
|
55
|
+
Link({ href: "/other", content: "Go" }),
|
|
56
|
+
window.document.body,
|
|
57
|
+
).pipe(Fx.take(1), Fx.collectAll);
|
|
58
|
+
|
|
59
|
+
assert(root instanceof window.HTMLElement);
|
|
60
|
+
const anchor = root as HTMLAnchorElement;
|
|
61
|
+
assert(anchor.tagName === "A");
|
|
62
|
+
|
|
63
|
+
const ev = new window.MouseEvent("click", {
|
|
64
|
+
bubbles: true,
|
|
65
|
+
cancelable: true,
|
|
66
|
+
ctrlKey: true,
|
|
67
|
+
});
|
|
68
|
+
anchor.dispatchEvent(ev);
|
|
69
|
+
yield* Effect.sleep(50);
|
|
70
|
+
const pathAfter = yield* CurrentPath;
|
|
71
|
+
|
|
72
|
+
assert(pathAfter === "/" || pathAfter === "");
|
|
73
|
+
}).pipe(Effect.provide(layer), Effect.scoped, Effect.runPromise);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
function createHappyDomLayer(...params: ConstructorParameters<typeof Window>) {
|
|
78
|
+
const window = new Window(...params) as unknown as globalThis.Window & typeof globalThis;
|
|
79
|
+
const domLayer = DomRenderTemplate.using(window.document);
|
|
80
|
+
const opts = params[0] as { url?: string } | undefined;
|
|
81
|
+
const url = opts?.url ?? "http://localhost/";
|
|
82
|
+
const routerLayer = ServerRouter({ url });
|
|
83
|
+
const layer = domLayer.pipe(Layer.merge(routerLayer));
|
|
84
|
+
return [window, layer] as const;
|
|
85
|
+
}
|