effect-start 0.9.0 → 0.10.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 +12 -13
- package/src/BundleHttp.test.ts +1 -1
- package/src/Commander.test.ts +15 -15
- package/src/Commander.ts +58 -88
- package/src/EncryptedCookies.test.ts +4 -4
- package/src/FileHttpRouter.test.ts +81 -12
- package/src/FileHttpRouter.ts +115 -26
- package/src/FileRouter.ts +60 -162
- package/src/FileRouterCodegen.test.ts +250 -64
- package/src/FileRouterCodegen.ts +13 -56
- package/src/FileRouterPattern.test.ts +116 -0
- package/src/FileRouterPattern.ts +59 -0
- package/src/FileRouter_path.test.ts +63 -102
- package/src/FileSystemExtra.test.ts +226 -0
- package/src/FileSystemExtra.ts +24 -60
- package/src/HttpUtils.test.ts +68 -0
- package/src/HttpUtils.ts +15 -0
- package/src/HyperHtml.ts +24 -5
- package/src/JsModule.test.ts +1 -1
- package/src/NodeFileSystem.ts +764 -0
- package/src/Random.ts +59 -0
- package/src/Route.test.ts +471 -0
- package/src/Route.ts +298 -153
- package/src/RouteRender.ts +38 -0
- package/src/Router.ts +11 -33
- package/src/RouterPattern.test.ts +629 -0
- package/src/RouterPattern.ts +391 -0
- package/src/Start.ts +14 -52
- package/src/bun/BunBundle.test.ts +0 -3
- package/src/bun/BunHttpServer.ts +246 -0
- package/src/bun/BunHttpServer_web.ts +384 -0
- package/src/bun/BunRoute.test.ts +341 -0
- package/src/bun/BunRoute.ts +326 -0
- package/src/bun/BunRoute_bundles.test.ts +218 -0
- package/src/bun/BunRuntime.ts +33 -0
- package/src/bun/BunTailwindPlugin.test.ts +1 -1
- package/src/bun/_empty.html +1 -0
- package/src/bun/index.ts +2 -1
- package/src/testing.ts +12 -3
- package/src/Datastar.test.ts +0 -267
- package/src/Datastar.ts +0 -68
- package/src/bun/BunFullstackServer.ts +0 -45
- package/src/bun/BunFullstackServer_httpServer.ts +0 -541
- package/src/jsx-datastar.d.ts +0 -63
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import type { HTMLBundle } from "bun"
|
|
2
|
+
import * as t from "bun:test"
|
|
3
|
+
import * as Effect from "effect/Effect"
|
|
4
|
+
import * as Route from "../Route.ts"
|
|
5
|
+
import type * as Router from "../Router.ts"
|
|
6
|
+
import * as BunRoute from "./BunRoute.ts"
|
|
7
|
+
|
|
8
|
+
t.describe(`${BunRoute.loadBundle.name}`, () => {
|
|
9
|
+
t.test("creates BunRoute from HTMLBundle", () => {
|
|
10
|
+
const mockBundle = { index: "index.html" } as HTMLBundle
|
|
11
|
+
const bunRoute = BunRoute.loadBundle(() => Promise.resolve(mockBundle))
|
|
12
|
+
|
|
13
|
+
t.expect(BunRoute.isBunRoute(bunRoute)).toBe(true)
|
|
14
|
+
t.expect(Route.isRouteSet(bunRoute)).toBe(true)
|
|
15
|
+
t.expect(bunRoute.set).toHaveLength(1)
|
|
16
|
+
t.expect(bunRoute.method as string).toBe("GET")
|
|
17
|
+
t.expect(bunRoute.media as string).toBe("text/html")
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
t.test("unwraps default export", async () => {
|
|
21
|
+
const mockBundle = { index: "index.html" } as HTMLBundle
|
|
22
|
+
const bunRoute = BunRoute.loadBundle(() =>
|
|
23
|
+
Promise.resolve({ default: mockBundle })
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
const loaded = await bunRoute.load()
|
|
27
|
+
t.expect(loaded).toBe(mockBundle)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
t.test("returns bundle directly when no default", async () => {
|
|
31
|
+
const mockBundle = { index: "index.html" } as HTMLBundle
|
|
32
|
+
const bunRoute = BunRoute.loadBundle(() => Promise.resolve(mockBundle))
|
|
33
|
+
|
|
34
|
+
const loaded = await bunRoute.load()
|
|
35
|
+
t.expect(loaded).toBe(mockBundle)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
t.describe(`${BunRoute.isBunRoute.name}`, () => {
|
|
40
|
+
t.test("returns true for BunRoute", () => {
|
|
41
|
+
const mockBundle = { index: "index.html" } as HTMLBundle
|
|
42
|
+
const bunRoute = BunRoute.loadBundle(() => Promise.resolve(mockBundle))
|
|
43
|
+
|
|
44
|
+
t.expect(BunRoute.isBunRoute(bunRoute)).toBe(true)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
t.test("returns false for regular Route", () => {
|
|
48
|
+
const route = Route.text(Effect.succeed("hello"))
|
|
49
|
+
|
|
50
|
+
t.expect(BunRoute.isBunRoute(route)).toBe(false)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
t.test("returns false for non-route values", () => {
|
|
54
|
+
t.expect(BunRoute.isBunRoute(null)).toBe(false)
|
|
55
|
+
t.expect(BunRoute.isBunRoute(undefined)).toBe(false)
|
|
56
|
+
t.expect(BunRoute.isBunRoute({})).toBe(false)
|
|
57
|
+
t.expect(BunRoute.isBunRoute("string")).toBe(false)
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
t.describe(`${BunRoute.routesFromRouter.name}`, () => {
|
|
62
|
+
t.test("converts text route to fetch handler", async () => {
|
|
63
|
+
const fetch = await makeFetch(
|
|
64
|
+
makeRouter([
|
|
65
|
+
{ path: "/hello", routes: Route.text(Effect.succeed("Hello World")) },
|
|
66
|
+
]),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
const response = await fetch("/hello")
|
|
70
|
+
|
|
71
|
+
t.expect(response.status).toBe(200)
|
|
72
|
+
t.expect(await response.text()).toBe("Hello World")
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
t.test("converts json route to fetch handler", async () => {
|
|
76
|
+
const fetch = await makeFetch(
|
|
77
|
+
makeRouter([
|
|
78
|
+
{
|
|
79
|
+
path: "/api/data",
|
|
80
|
+
routes: Route.json(Effect.succeed({ message: "ok", count: 42 })),
|
|
81
|
+
},
|
|
82
|
+
]),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
const response = await fetch("/api/data")
|
|
86
|
+
|
|
87
|
+
t.expect(response.status).toBe(200)
|
|
88
|
+
t.expect(await response.json()).toEqual({ message: "ok", count: 42 })
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
t.test("converts html route to fetch handler", async () => {
|
|
92
|
+
const fetch = await makeFetch(
|
|
93
|
+
makeRouter([
|
|
94
|
+
{ path: "/page", routes: Route.html(Effect.succeed("<h1>Title</h1>")) },
|
|
95
|
+
]),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
const response = await fetch("/page")
|
|
99
|
+
|
|
100
|
+
t.expect(response.status).toBe(200)
|
|
101
|
+
t.expect(await response.text()).toBe("<h1>Title</h1>")
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
t.test("handles method-specific routes", async () => {
|
|
105
|
+
const fetch = await makeFetch(
|
|
106
|
+
makeRouter([
|
|
107
|
+
{
|
|
108
|
+
path: "/users",
|
|
109
|
+
routes: Route.get(Route.json(Effect.succeed({ users: [] }))).post(
|
|
110
|
+
Route.json(Effect.succeed({ created: true })),
|
|
111
|
+
),
|
|
112
|
+
},
|
|
113
|
+
]),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
const getResponse = await fetch("/users")
|
|
117
|
+
t.expect(await getResponse.json()).toEqual({ users: [] })
|
|
118
|
+
|
|
119
|
+
const postResponse = await fetch("/users", { method: "POST" })
|
|
120
|
+
t.expect(await postResponse.json()).toEqual({ created: true })
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
t.test("converts path syntax to Bun format", async () => {
|
|
124
|
+
const routes = await Effect.runPromise(
|
|
125
|
+
BunRoute.routesFromRouter(
|
|
126
|
+
makeRouter([
|
|
127
|
+
{ path: "/users/[id]", routes: Route.text(Effect.succeed("user")) },
|
|
128
|
+
{
|
|
129
|
+
path: "/docs/[...path]",
|
|
130
|
+
routes: Route.text(Effect.succeed("docs")),
|
|
131
|
+
},
|
|
132
|
+
]),
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
t.expect(routes["/users/:id"]).toBeDefined()
|
|
137
|
+
t.expect(routes["/docs/*"]).toBeDefined()
|
|
138
|
+
t.expect(routes["/users/[id]"]).toBeUndefined()
|
|
139
|
+
t.expect(routes["/docs/[...path]"]).toBeUndefined()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
t.test("creates proxy and internal routes for BunRoute", async () => {
|
|
143
|
+
const mockBundle = { index: "index.html" } as HTMLBundle
|
|
144
|
+
const bunRoute = BunRoute.loadBundle(() => Promise.resolve(mockBundle))
|
|
145
|
+
|
|
146
|
+
const routes = await Effect.runPromise(
|
|
147
|
+
BunRoute.routesFromRouter(
|
|
148
|
+
makeRouter([{ path: "/app", routes: bunRoute }]),
|
|
149
|
+
),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
const internalPath = Object.keys(routes).find((k) =>
|
|
153
|
+
k.includes("~BunRoute-")
|
|
154
|
+
)
|
|
155
|
+
t.expect(internalPath).toBeDefined()
|
|
156
|
+
t.expect(routes[internalPath!]).toBe(mockBundle)
|
|
157
|
+
t.expect(typeof routes["/app"]).toBe("function")
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
t.test("handles mixed BunRoute and regular routes", async () => {
|
|
161
|
+
const mockBundle = { index: "index.html" } as HTMLBundle
|
|
162
|
+
const bunRoute = BunRoute.loadBundle(() => Promise.resolve(mockBundle))
|
|
163
|
+
|
|
164
|
+
const routes = await Effect.runPromise(
|
|
165
|
+
BunRoute.routesFromRouter({
|
|
166
|
+
routes: [
|
|
167
|
+
{
|
|
168
|
+
path: "/app",
|
|
169
|
+
load: () => Promise.resolve({ default: bunRoute }),
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
path: "/api/health",
|
|
173
|
+
load: () =>
|
|
174
|
+
Promise.resolve({
|
|
175
|
+
default: Route.json(Effect.succeed({ ok: true })),
|
|
176
|
+
}),
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
}),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
const internalPath = Object.keys(routes).find((k) =>
|
|
183
|
+
k.includes("~BunRoute-")
|
|
184
|
+
)
|
|
185
|
+
t.expect(internalPath).toBeDefined()
|
|
186
|
+
t.expect(routes[internalPath!]).toBe(mockBundle)
|
|
187
|
+
t.expect(typeof routes["/app"]).toBe("function")
|
|
188
|
+
t.expect(routes["/api/health"]).toBeDefined()
|
|
189
|
+
t.expect(typeof routes["/api/health"]).toBe("object")
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
t.test("groups multiple methods under same path", async () => {
|
|
193
|
+
const fetch = await makeFetch(
|
|
194
|
+
makeRouter([
|
|
195
|
+
{
|
|
196
|
+
path: "/resource",
|
|
197
|
+
routes: Route
|
|
198
|
+
.get(Route.text(Effect.succeed("get")))
|
|
199
|
+
.post(Route.text(Effect.succeed("post")))
|
|
200
|
+
.del(Route.text(Effect.succeed("delete"))),
|
|
201
|
+
},
|
|
202
|
+
]),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
const getRes = await fetch("/resource")
|
|
206
|
+
const postRes = await fetch("/resource", { method: "POST" })
|
|
207
|
+
const delRes = await fetch("/resource", { method: "DELETE" })
|
|
208
|
+
|
|
209
|
+
t.expect(await getRes.text()).toBe("get")
|
|
210
|
+
t.expect(await postRes.text()).toBe("post")
|
|
211
|
+
t.expect(await delRes.text()).toBe("delete")
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
t.describe("fetch handler Response", () => {
|
|
216
|
+
t.test("returns Response instance", async () => {
|
|
217
|
+
const fetch = await makeFetch(
|
|
218
|
+
makeRouter([{
|
|
219
|
+
path: "/test",
|
|
220
|
+
routes: Route.text(Effect.succeed("test")),
|
|
221
|
+
}]),
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
const response = await fetch("/test")
|
|
225
|
+
|
|
226
|
+
t.expect(response).toBeInstanceOf(Response)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
t.test("text response has correct content-type", async () => {
|
|
230
|
+
const fetch = await makeFetch(
|
|
231
|
+
makeRouter([{
|
|
232
|
+
path: "/text",
|
|
233
|
+
routes: Route.text(Effect.succeed("hello")),
|
|
234
|
+
}]),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
const response = await fetch("/text")
|
|
238
|
+
|
|
239
|
+
t.expect(response.headers.get("content-type")).toContain("text/plain")
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
t.test("json response has correct content-type", async () => {
|
|
243
|
+
const fetch = await makeFetch(
|
|
244
|
+
makeRouter([{
|
|
245
|
+
path: "/json",
|
|
246
|
+
routes: Route.json(Effect.succeed({ data: 1 })),
|
|
247
|
+
}]),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
const response = await fetch("/json")
|
|
251
|
+
|
|
252
|
+
t.expect(response.headers.get("content-type")).toContain("application/json")
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
t.test("html response has correct content-type", async () => {
|
|
256
|
+
const fetch = await makeFetch(
|
|
257
|
+
makeRouter([{
|
|
258
|
+
path: "/html",
|
|
259
|
+
routes: Route.html(Effect.succeed("<p>hi</p>")),
|
|
260
|
+
}]),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
const response = await fetch("/html")
|
|
264
|
+
|
|
265
|
+
t.expect(response.headers.get("content-type")).toContain("text/html")
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
t.test("response body is readable", async () => {
|
|
269
|
+
const fetch = await makeFetch(
|
|
270
|
+
makeRouter([{
|
|
271
|
+
path: "/body",
|
|
272
|
+
routes: Route.text(Effect.succeed("readable body")),
|
|
273
|
+
}]),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
const response = await fetch("/body")
|
|
277
|
+
|
|
278
|
+
t.expect(response.bodyUsed).toBe(false)
|
|
279
|
+
const text = await response.text()
|
|
280
|
+
t.expect(text).toBe("readable body")
|
|
281
|
+
t.expect(response.bodyUsed).toBe(true)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
t.test("response ok is true for 200 status", async () => {
|
|
285
|
+
const fetch = await makeFetch(
|
|
286
|
+
makeRouter([{ path: "/ok", routes: Route.text(Effect.succeed("ok")) }]),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
const response = await fetch("/ok")
|
|
290
|
+
|
|
291
|
+
t.expect(response.ok).toBe(true)
|
|
292
|
+
t.expect(response.status).toBe(200)
|
|
293
|
+
})
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
const makeRouter = (
|
|
297
|
+
routesList: Array<{
|
|
298
|
+
path: `/${string}`
|
|
299
|
+
routes: Route.RouteSet.Default
|
|
300
|
+
}>,
|
|
301
|
+
): Router.RouterContext => ({
|
|
302
|
+
routes: routesList.map((m) => ({
|
|
303
|
+
path: m.path,
|
|
304
|
+
load: () => Promise.resolve({ default: m.routes }),
|
|
305
|
+
})),
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
type FetchFn = (path: string, init?: { method?: string }) => Promise<Response>
|
|
309
|
+
|
|
310
|
+
type HandlerFn = (
|
|
311
|
+
req: Request,
|
|
312
|
+
server: unknown,
|
|
313
|
+
) => Response | Promise<Response>
|
|
314
|
+
|
|
315
|
+
async function makeFetch(router: Router.RouterContext): Promise<FetchFn> {
|
|
316
|
+
const routes = await Effect.runPromise(BunRoute.routesFromRouter(router))
|
|
317
|
+
const mockServer = {} as import("bun").Server<unknown>
|
|
318
|
+
|
|
319
|
+
return async (path, init) => {
|
|
320
|
+
const method = init?.method ?? "GET"
|
|
321
|
+
const handler = routes[path]
|
|
322
|
+
|
|
323
|
+
if (!handler) {
|
|
324
|
+
throw new Error(`No handler for path: ${path}`)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (typeof handler === "function") {
|
|
328
|
+
return handler(new Request(`http://localhost${path}`, init), mockServer)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const methodHandler = (handler as Record<string, HandlerFn>)[method]
|
|
332
|
+
if (!methodHandler) {
|
|
333
|
+
throw new Error(`No handler for ${method} ${path}`)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return methodHandler(
|
|
337
|
+
new Request(`http://localhost${path}`, init),
|
|
338
|
+
mockServer,
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import * as HttpApp from "@effect/platform/HttpApp"
|
|
2
|
+
import * as HttpServerRequest from "@effect/platform/HttpServerRequest"
|
|
3
|
+
import * as HttpServerResponse from "@effect/platform/HttpServerResponse"
|
|
4
|
+
import type * as Bun from "bun"
|
|
5
|
+
import * as Effect from "effect/Effect"
|
|
6
|
+
|
|
7
|
+
import * as Function from "effect/Function"
|
|
8
|
+
import * as Predicate from "effect/Predicate"
|
|
9
|
+
import type * as Runtime from "effect/Runtime"
|
|
10
|
+
import * as HttpUtils from "../HttpUtils.ts"
|
|
11
|
+
import * as Random from "../Random.ts"
|
|
12
|
+
import * as Route from "../Route.ts"
|
|
13
|
+
import * as Router from "../Router.ts"
|
|
14
|
+
import * as RouteRender from "../RouteRender.ts"
|
|
15
|
+
import * as RouterPattern from "../RouterPattern.ts"
|
|
16
|
+
|
|
17
|
+
const TypeId: unique symbol = Symbol.for("effect-start/BunRoute")
|
|
18
|
+
|
|
19
|
+
export type BunRoute =
|
|
20
|
+
& Route.Route
|
|
21
|
+
& {
|
|
22
|
+
[TypeId]: typeof TypeId
|
|
23
|
+
load: () => Promise<Bun.HTMLBundle>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function loadBundle(
|
|
27
|
+
load: () => Promise<Bun.HTMLBundle | { default: Bun.HTMLBundle }>,
|
|
28
|
+
): BunRoute {
|
|
29
|
+
const route = Route.make({
|
|
30
|
+
method: "GET",
|
|
31
|
+
media: "text/html",
|
|
32
|
+
handler: () => HttpServerResponse.text("Empty BunRoute"),
|
|
33
|
+
schemas: {},
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const bunRoute: BunRoute = Object.assign(
|
|
37
|
+
Object.create(route),
|
|
38
|
+
{
|
|
39
|
+
[TypeId]: TypeId,
|
|
40
|
+
load: () => load().then(mod => "default" in mod ? mod.default : mod),
|
|
41
|
+
},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
bunRoute.set = [bunRoute]
|
|
45
|
+
|
|
46
|
+
return bunRoute
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isBunRoute(input: unknown): input is BunRoute {
|
|
50
|
+
return Predicate.hasProperty(input, TypeId)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function findMatchingLayerRoutes(
|
|
54
|
+
route: Route.Route.Default,
|
|
55
|
+
layers: Route.RouteLayer[],
|
|
56
|
+
): Route.Route.Default[] {
|
|
57
|
+
const matchingRoutes: Route.Route.Default[] = []
|
|
58
|
+
for (const layer of layers) {
|
|
59
|
+
for (const layerRoute of layer.set) {
|
|
60
|
+
if (Route.matches(layerRoute, route)) {
|
|
61
|
+
matchingRoutes.push(layerRoute)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return matchingRoutes
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function wrapWithLayerRoute(
|
|
69
|
+
innerRoute: Route.Route.Default,
|
|
70
|
+
layerRoute: Route.Route.Default,
|
|
71
|
+
): Route.Route.Default {
|
|
72
|
+
const handler: Route.RouteHandler = (context) => {
|
|
73
|
+
const innerNext = () => innerRoute.handler(context)
|
|
74
|
+
|
|
75
|
+
const contextWithNext: Route.RouteContext = {
|
|
76
|
+
...context,
|
|
77
|
+
next: innerNext,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return layerRoute.handler(contextWithNext)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return Route.make({
|
|
84
|
+
method: layerRoute.method,
|
|
85
|
+
media: layerRoute.media,
|
|
86
|
+
handler,
|
|
87
|
+
schemas: {},
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Finds BunRoutes in the Router and returns
|
|
93
|
+
* a mapping of paths to their bundles that can be passed
|
|
94
|
+
* to Bun's `serve` function.
|
|
95
|
+
*/
|
|
96
|
+
export function bundlesFromRouter(
|
|
97
|
+
router: Router.RouterContext,
|
|
98
|
+
): Effect.Effect<Record<string, Bun.HTMLBundle>> {
|
|
99
|
+
return Function.pipe(
|
|
100
|
+
Effect.forEach(
|
|
101
|
+
router.routes,
|
|
102
|
+
(mod) =>
|
|
103
|
+
Effect.promise(() =>
|
|
104
|
+
mod.load().then((m) => ({ path: mod.path, exported: m.default }))
|
|
105
|
+
),
|
|
106
|
+
),
|
|
107
|
+
Effect.map((modules) =>
|
|
108
|
+
modules.flatMap(({ path, exported }) => {
|
|
109
|
+
if (Route.isRouteSet(exported)) {
|
|
110
|
+
return [...exported.set]
|
|
111
|
+
.filter(isBunRoute)
|
|
112
|
+
.map((route) =>
|
|
113
|
+
[
|
|
114
|
+
path,
|
|
115
|
+
route,
|
|
116
|
+
] as const
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return []
|
|
121
|
+
})
|
|
122
|
+
),
|
|
123
|
+
Effect.flatMap((bunRoutes) =>
|
|
124
|
+
Effect.forEach(
|
|
125
|
+
bunRoutes,
|
|
126
|
+
([path, route]) =>
|
|
127
|
+
Effect.promise(() =>
|
|
128
|
+
route.load().then((bundle) => {
|
|
129
|
+
const httpPath = RouterPattern.toHttpPath(path)
|
|
130
|
+
|
|
131
|
+
return [httpPath, bundle] as const
|
|
132
|
+
})
|
|
133
|
+
),
|
|
134
|
+
{ concurrency: "unbounded" },
|
|
135
|
+
)
|
|
136
|
+
),
|
|
137
|
+
Effect.map((entries) =>
|
|
138
|
+
Object.fromEntries(entries) as Record<string, Bun.HTMLBundle>
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
type BunServerFetchHandler = (
|
|
144
|
+
request: Request,
|
|
145
|
+
server: Bun.Server<unknown>,
|
|
146
|
+
) => Response | Promise<Response>
|
|
147
|
+
|
|
148
|
+
type BunServerRouteHandler =
|
|
149
|
+
| Bun.HTMLBundle
|
|
150
|
+
| BunServerFetchHandler
|
|
151
|
+
| Partial<Record<Bun.Serve.HTTPMethod, BunServerFetchHandler>>
|
|
152
|
+
|
|
153
|
+
export type BunRoutes = Record<string, BunServerRouteHandler>
|
|
154
|
+
|
|
155
|
+
type MethodHandlers = Partial<
|
|
156
|
+
Record<Bun.Serve.HTTPMethod, BunServerFetchHandler>
|
|
157
|
+
>
|
|
158
|
+
|
|
159
|
+
function isMethodHandlers(value: unknown): value is MethodHandlers {
|
|
160
|
+
return typeof value === "object" && value !== null && !("index" in value)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Converts a Router into Bun-compatible routes passed to {@link Bun.serve}.
|
|
165
|
+
*
|
|
166
|
+
* For BunRoutes (HtmlBundle), creates two routes:
|
|
167
|
+
* - An internal route at `${path}~BunRoute-${nonce}:${path}` holding the actual HtmlBundle
|
|
168
|
+
* - A proxy route at the original path that forwards requests to the internal route
|
|
169
|
+
*
|
|
170
|
+
* This allows middleware to be attached to the proxy route while Bun handles
|
|
171
|
+
* the HtmlBundle natively on the internal route.
|
|
172
|
+
*/
|
|
173
|
+
export function routesFromRouter(
|
|
174
|
+
router: Router.RouterContext,
|
|
175
|
+
runtime?: Runtime.Runtime<never>,
|
|
176
|
+
): Effect.Effect<BunRoutes> {
|
|
177
|
+
return Effect.gen(function*() {
|
|
178
|
+
const rt = runtime ?? (yield* Effect.runtime<never>())
|
|
179
|
+
const nonce = Random.token(6)
|
|
180
|
+
|
|
181
|
+
const loadedRoutes = yield* Effect.forEach(
|
|
182
|
+
router.routes,
|
|
183
|
+
(mod) =>
|
|
184
|
+
Effect.gen(function*() {
|
|
185
|
+
const routeModule = yield* Effect.promise(() => mod.load())
|
|
186
|
+
|
|
187
|
+
const layerModules = mod.layers
|
|
188
|
+
? yield* Effect.forEach(
|
|
189
|
+
mod.layers,
|
|
190
|
+
(layerLoad) => Effect.promise(() => layerLoad()),
|
|
191
|
+
)
|
|
192
|
+
: []
|
|
193
|
+
|
|
194
|
+
const layers = layerModules
|
|
195
|
+
.map((m: any) => m.default)
|
|
196
|
+
.filter(Route.isRouteLayer)
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
path: mod.path,
|
|
200
|
+
exported: routeModule.default,
|
|
201
|
+
layers,
|
|
202
|
+
}
|
|
203
|
+
}),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
const result: BunRoutes = {}
|
|
207
|
+
|
|
208
|
+
for (const { path, exported, layers } of loadedRoutes) {
|
|
209
|
+
const httpPaths = RouterPattern.toBun(path)
|
|
210
|
+
|
|
211
|
+
const byMethod = new Map<Route.RouteMethod, Route.Route.Default[]>()
|
|
212
|
+
for (const route of exported.set) {
|
|
213
|
+
if (isBunRoute(route)) {
|
|
214
|
+
const bundle = yield* Effect.promise(() => route.load())
|
|
215
|
+
const internalPath = `${path}~BunRoute-${nonce}`
|
|
216
|
+
|
|
217
|
+
result[internalPath] = bundle
|
|
218
|
+
|
|
219
|
+
const proxyHandler: BunServerFetchHandler = (request) => {
|
|
220
|
+
const url = new URL(internalPath, request.url)
|
|
221
|
+
return fetch(new Request(url, request))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
for (const httpPath of httpPaths) {
|
|
225
|
+
if (!(httpPath in result)) {
|
|
226
|
+
result[httpPath] = proxyHandler
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
const existing = byMethod.get(route.method) ?? []
|
|
231
|
+
existing.push(route)
|
|
232
|
+
byMethod.set(route.method, existing)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
for (const [method, routes] of byMethod) {
|
|
237
|
+
const httpApp = Effect.gen(function*() {
|
|
238
|
+
const request = yield* HttpServerRequest.HttpServerRequest
|
|
239
|
+
const accept = request.headers.accept ?? ""
|
|
240
|
+
|
|
241
|
+
let selectedRoute: Route.Route.Default | undefined
|
|
242
|
+
|
|
243
|
+
if (accept.includes("application/json")) {
|
|
244
|
+
selectedRoute = routes.find((r) => r.media === "application/json")
|
|
245
|
+
}
|
|
246
|
+
if (!selectedRoute && accept.includes("text/plain")) {
|
|
247
|
+
selectedRoute = routes.find((r) => r.media === "text/plain")
|
|
248
|
+
}
|
|
249
|
+
if (
|
|
250
|
+
!selectedRoute
|
|
251
|
+
&& (accept.includes("text/html")
|
|
252
|
+
|| accept.includes("*/*")
|
|
253
|
+
|| !accept)
|
|
254
|
+
) {
|
|
255
|
+
selectedRoute = routes.find((r) => r.media === "text/html")
|
|
256
|
+
}
|
|
257
|
+
if (!selectedRoute) {
|
|
258
|
+
selectedRoute = routes[0]
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (!selectedRoute) {
|
|
262
|
+
return HttpServerResponse.empty({ status: 406 })
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const matchingLayerRoutes = findMatchingLayerRoutes(
|
|
266
|
+
selectedRoute,
|
|
267
|
+
layers,
|
|
268
|
+
)
|
|
269
|
+
let wrappedRoute = selectedRoute
|
|
270
|
+
for (const layerRoute of matchingLayerRoutes.reverse()) {
|
|
271
|
+
wrappedRoute = wrapWithLayerRoute(wrappedRoute, layerRoute)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const context: Route.RouteContext = {
|
|
275
|
+
request,
|
|
276
|
+
get url() {
|
|
277
|
+
return HttpUtils.makeUrlFromRequest(request)
|
|
278
|
+
},
|
|
279
|
+
slots: {},
|
|
280
|
+
next: () => Effect.void,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return yield* RouteRender.render(wrappedRoute, context)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
const allMiddleware = layers
|
|
287
|
+
.map((layer) => layer.httpMiddleware)
|
|
288
|
+
.filter((m): m is Route.HttpMiddlewareFunction => m !== undefined)
|
|
289
|
+
|
|
290
|
+
let finalHandler = httpApp
|
|
291
|
+
for (const middleware of allMiddleware) {
|
|
292
|
+
finalHandler = middleware(finalHandler)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const webHandler = HttpApp.toWebHandlerRuntime(rt)(finalHandler)
|
|
296
|
+
const handler: BunServerFetchHandler = (request) => webHandler(request)
|
|
297
|
+
|
|
298
|
+
for (const httpPath of httpPaths) {
|
|
299
|
+
if (method === "*") {
|
|
300
|
+
if (!(httpPath in result)) {
|
|
301
|
+
result[httpPath] = handler
|
|
302
|
+
}
|
|
303
|
+
} else {
|
|
304
|
+
const existing = result[httpPath]
|
|
305
|
+
if (isMethodHandlers(existing)) {
|
|
306
|
+
existing[method] = handler
|
|
307
|
+
} else if (!(httpPath in result)) {
|
|
308
|
+
result[httpPath] = { [method]: handler }
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return result
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export const isHTMLBundle = (handle: any) => {
|
|
320
|
+
return (
|
|
321
|
+
typeof handle === "object"
|
|
322
|
+
&& handle !== null
|
|
323
|
+
&& (handle.toString() === "[object HTMLBundle]"
|
|
324
|
+
|| typeof handle.index === "string")
|
|
325
|
+
)
|
|
326
|
+
}
|