effect-start 0.9.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 (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +109 -0
  3. package/package.json +57 -0
  4. package/src/Bundle.ts +167 -0
  5. package/src/BundleFiles.ts +174 -0
  6. package/src/BundleHttp.test.ts +160 -0
  7. package/src/BundleHttp.ts +259 -0
  8. package/src/Commander.test.ts +1378 -0
  9. package/src/Commander.ts +672 -0
  10. package/src/Datastar.test.ts +267 -0
  11. package/src/Datastar.ts +68 -0
  12. package/src/Effect_HttpRouter.test.ts +570 -0
  13. package/src/EncryptedCookies.test.ts +427 -0
  14. package/src/EncryptedCookies.ts +451 -0
  15. package/src/FileHttpRouter.test.ts +207 -0
  16. package/src/FileHttpRouter.ts +122 -0
  17. package/src/FileRouter.ts +405 -0
  18. package/src/FileRouterCodegen.test.ts +598 -0
  19. package/src/FileRouterCodegen.ts +251 -0
  20. package/src/FileRouter_files.test.ts +64 -0
  21. package/src/FileRouter_path.test.ts +132 -0
  22. package/src/FileRouter_tree.test.ts +126 -0
  23. package/src/FileSystemExtra.ts +102 -0
  24. package/src/HttpAppExtra.ts +127 -0
  25. package/src/Hyper.ts +194 -0
  26. package/src/HyperHtml.test.ts +90 -0
  27. package/src/HyperHtml.ts +139 -0
  28. package/src/HyperNode.ts +37 -0
  29. package/src/JsModule.test.ts +14 -0
  30. package/src/JsModule.ts +116 -0
  31. package/src/PublicDirectory.test.ts +280 -0
  32. package/src/PublicDirectory.ts +108 -0
  33. package/src/Route.test.ts +873 -0
  34. package/src/Route.ts +992 -0
  35. package/src/Router.ts +80 -0
  36. package/src/SseHttpResponse.ts +55 -0
  37. package/src/Start.ts +133 -0
  38. package/src/StartApp.ts +43 -0
  39. package/src/StartHttp.ts +42 -0
  40. package/src/StreamExtra.ts +146 -0
  41. package/src/TestHttpClient.test.ts +54 -0
  42. package/src/TestHttpClient.ts +100 -0
  43. package/src/bun/BunBundle.test.ts +277 -0
  44. package/src/bun/BunBundle.ts +309 -0
  45. package/src/bun/BunBundle_imports.test.ts +50 -0
  46. package/src/bun/BunFullstackServer.ts +45 -0
  47. package/src/bun/BunFullstackServer_httpServer.ts +541 -0
  48. package/src/bun/BunImportTrackerPlugin.test.ts +77 -0
  49. package/src/bun/BunImportTrackerPlugin.ts +97 -0
  50. package/src/bun/BunTailwindPlugin.test.ts +335 -0
  51. package/src/bun/BunTailwindPlugin.ts +322 -0
  52. package/src/bun/BunVirtualFilesPlugin.ts +59 -0
  53. package/src/bun/index.ts +4 -0
  54. package/src/client/Overlay.ts +34 -0
  55. package/src/client/ScrollState.ts +120 -0
  56. package/src/client/index.ts +101 -0
  57. package/src/index.ts +24 -0
  58. package/src/jsx-datastar.d.ts +63 -0
  59. package/src/jsx-runtime.ts +23 -0
  60. package/src/jsx.d.ts +4402 -0
  61. package/src/testing.ts +55 -0
  62. package/src/x/cloudflare/CloudflareTunnel.ts +110 -0
  63. package/src/x/cloudflare/index.ts +1 -0
  64. package/src/x/datastar/Datastar.test.ts +267 -0
  65. package/src/x/datastar/Datastar.ts +68 -0
  66. package/src/x/datastar/index.ts +4 -0
  67. package/src/x/datastar/jsx-datastar.d.ts +63 -0
@@ -0,0 +1,451 @@
1
+ import {
2
+ Cookies,
3
+ HttpApp,
4
+ HttpServerResponse,
5
+ } from "@effect/platform"
6
+ import {
7
+ Effect,
8
+ pipe,
9
+ } from "effect"
10
+ import * as Config from "effect/Config"
11
+ import * as Context from "effect/Context"
12
+ import * as Data from "effect/Data"
13
+ import * as Layer from "effect/Layer"
14
+
15
+ type CookieValue =
16
+ | string
17
+ | number
18
+ | boolean
19
+ | null
20
+ | undefined
21
+ | {
22
+ [key: string]:
23
+ | CookieValue
24
+ // some libraries, like XState, contain unknown in type
25
+ // that is serializable
26
+ | unknown
27
+ }
28
+ | CookieValue[]
29
+
30
+ export class EncryptedCookiesError
31
+ extends Data.TaggedError("EncryptedCookiesError")<{
32
+ cause: unknown
33
+ cookie?: Cookies.Cookie
34
+ }>
35
+ {}
36
+
37
+ export class EncryptedCookies extends Context.Tag("EncryptedCookies")<
38
+ EncryptedCookies,
39
+ {
40
+ encrypt: (
41
+ value: CookieValue,
42
+ ) => Effect.Effect<string, EncryptedCookiesError>
43
+ decrypt: (
44
+ encryptedValue: string,
45
+ ) => Effect.Effect<CookieValue, EncryptedCookiesError>
46
+ encryptCookie: (
47
+ cookie: Cookies.Cookie,
48
+ ) => Effect.Effect<Cookies.Cookie, EncryptedCookiesError>
49
+ decryptCookie: (
50
+ cookie: Cookies.Cookie,
51
+ ) => Effect.Effect<Cookies.Cookie, EncryptedCookiesError>
52
+ }
53
+ >() {}
54
+
55
+ export function layer(options: { secret: string }) {
56
+ return Layer.effect(
57
+ EncryptedCookies,
58
+ Effect.gen(function*() {
59
+ const keyMaterial = yield* deriveKeyMaterial(options.secret)
60
+
61
+ // Pre-derive both keys once
62
+ const encryptKey = yield* deriveKey(keyMaterial, ["encrypt"])
63
+ const decryptKey = yield* deriveKey(keyMaterial, ["decrypt"])
64
+
65
+ return EncryptedCookies.of({
66
+ encrypt: (value: CookieValue) =>
67
+ encryptWithDerivedKey(value, encryptKey),
68
+ decrypt: (encryptedValue: string) =>
69
+ decryptWithDerivedKey(encryptedValue, decryptKey),
70
+ encryptCookie: (cookie: Cookies.Cookie) =>
71
+ encryptCookieWithDerivedKey(cookie, encryptKey),
72
+ decryptCookie: (cookie: Cookies.Cookie) =>
73
+ decryptCookieWithDerivedKey(cookie, decryptKey),
74
+ })
75
+ }),
76
+ )
77
+ }
78
+
79
+ export function layerConfig(name = "SECRET_KEY_BASE") {
80
+ return Effect
81
+ .gen(function*() {
82
+ const secret = yield* pipe(
83
+ Config.nonEmptyString(name),
84
+ Effect.flatMap((value) => {
85
+ return (value.length < 40)
86
+ ? Effect.fail(new Error("ba"))
87
+ : Effect.succeed(value)
88
+ }),
89
+ Effect.catchAll((err) => {
90
+ return Effect.dieMessage(
91
+ "SECRET_KEY_BASE must be at least 40 characters",
92
+ )
93
+ }),
94
+ )
95
+
96
+ return layer({ secret })
97
+ })
98
+ .pipe(Layer.unwrapEffect)
99
+ }
100
+
101
+ function encodeToBase64Segments(
102
+ ciphertext: Uint8Array,
103
+ iv: Uint8Array,
104
+ authTag: Uint8Array,
105
+ ): string {
106
+ return [
107
+ base64urlEncode(ciphertext),
108
+ base64urlEncode(iv),
109
+ base64urlEncode(authTag),
110
+ ]
111
+ .join(".")
112
+ }
113
+
114
+ function base64urlEncode(data: Uint8Array): string {
115
+ const base64 = btoa(String.fromCharCode(...data))
116
+ return base64
117
+ .replace(/\+/g, "-")
118
+ .replace(/\//g, "_")
119
+ .replace(/=/g, "")
120
+ }
121
+
122
+ function decodeFromBase64Segments(
123
+ segments: string[],
124
+ ): Effect.Effect<
125
+ { ciphertext: Uint8Array; iv: Uint8Array; authTag: Uint8Array },
126
+ EncryptedCookiesError
127
+ > {
128
+ return Effect.gen(function*() {
129
+ const [ciphertextB64, ivB64, authTagB64] = segments
130
+
131
+ const ciphertext = yield* Effect.try({
132
+ try: () => base64urlDecode(ciphertextB64),
133
+ catch: (error) => new EncryptedCookiesError({ cause: error }),
134
+ })
135
+
136
+ const iv = yield* Effect.try({
137
+ try: () => base64urlDecode(ivB64),
138
+ catch: (error) => new EncryptedCookiesError({ cause: error }),
139
+ })
140
+
141
+ const authTag = yield* Effect.try({
142
+ try: () => base64urlDecode(authTagB64),
143
+ catch: (error) => new EncryptedCookiesError({ cause: error }),
144
+ })
145
+
146
+ return { ciphertext, iv, authTag }
147
+ })
148
+ }
149
+
150
+ function base64urlDecode(data: string): Uint8Array {
151
+ // Convert base64url back to standard base64
152
+ let base64 = data
153
+ .replace(/-/g, "+")
154
+ .replace(/_/g, "/")
155
+
156
+ // Add padding if needed
157
+ while (base64.length % 4) {
158
+ base64 += "="
159
+ }
160
+
161
+ return Uint8Array.from(atob(base64), c => c.charCodeAt(0))
162
+ }
163
+
164
+ /**
165
+ * Encrypts cookie value using the SECRET_KEY_BASE.
166
+ */
167
+ function encryptWithDerivedKey(
168
+ value: CookieValue,
169
+ derivedKey: CryptoKey,
170
+ ): Effect.Effect<string, EncryptedCookiesError> {
171
+ return Effect.gen(function*() {
172
+ if (value === null || value === undefined) {
173
+ return yield* Effect.fail(
174
+ new EncryptedCookiesError({
175
+ cause: "Cannot encrypt empty value",
176
+ }),
177
+ )
178
+ }
179
+
180
+ const iv = crypto.getRandomValues(new Uint8Array(12))
181
+ const data = new TextEncoder().encode(JSON.stringify(value))
182
+
183
+ const encrypted = yield* Effect.tryPromise({
184
+ try: () =>
185
+ crypto.subtle.encrypt(
186
+ { name: "AES-GCM", iv },
187
+ derivedKey,
188
+ data,
189
+ ),
190
+ catch: (error) => new EncryptedCookiesError({ cause: error }),
191
+ })
192
+
193
+ const encryptedArray = new Uint8Array(encrypted)
194
+ const authTagLength = 16
195
+ const ciphertext = encryptedArray.slice(0, -authTagLength)
196
+ const authTag = encryptedArray.slice(-authTagLength)
197
+
198
+ return encodeToBase64Segments(ciphertext, iv, authTag)
199
+ })
200
+ }
201
+
202
+ export function encrypt(
203
+ value: CookieValue,
204
+ options: { key: CryptoKey } | { secret: string },
205
+ ): Effect.Effect<string, EncryptedCookiesError> {
206
+ return Effect.gen(function*() {
207
+ if ("key" in options) {
208
+ return yield* encryptWithDerivedKey(value, options.key)
209
+ }
210
+
211
+ const keyMaterial = yield* deriveKeyMaterial(options.secret)
212
+ const derivedKey = yield* deriveKey(keyMaterial, ["encrypt"])
213
+ return yield* encryptWithDerivedKey(value, derivedKey)
214
+ })
215
+ }
216
+
217
+ function decryptWithDerivedKey(
218
+ encryptedValue: string,
219
+ derivedKey: CryptoKey,
220
+ ): Effect.Effect<CookieValue, EncryptedCookiesError> {
221
+ return Effect.gen(function*() {
222
+ if (
223
+ !encryptedValue || encryptedValue === null || encryptedValue === undefined
224
+ ) {
225
+ return yield* Effect.fail(
226
+ new EncryptedCookiesError({
227
+ cause: "Cannot decrypt null, undefined, or empty value",
228
+ }),
229
+ )
230
+ }
231
+
232
+ const segments = encryptedValue.split(".")
233
+ if (segments.length !== 3) {
234
+ return yield* Effect.fail(
235
+ new EncryptedCookiesError({
236
+ cause: "Invalid encrypted cookie format",
237
+ }),
238
+ )
239
+ }
240
+
241
+ const { ciphertext, iv, authTag } = yield* decodeFromBase64Segments(
242
+ segments,
243
+ )
244
+
245
+ const encryptedData = new Uint8Array(ciphertext.length + authTag.length)
246
+ encryptedData.set(ciphertext)
247
+ encryptedData.set(authTag, ciphertext.length)
248
+
249
+ const decrypted = yield* Effect.tryPromise({
250
+ try: () =>
251
+ crypto.subtle.decrypt(
252
+ { name: "AES-GCM", iv: iv.slice(0) },
253
+ derivedKey,
254
+ encryptedData,
255
+ ),
256
+ catch: (error) => new EncryptedCookiesError({ cause: error }),
257
+ })
258
+
259
+ const jsonString = new TextDecoder().decode(decrypted)
260
+
261
+ return yield* Effect.try({
262
+ try: () => JSON.parse(jsonString),
263
+ catch: (error) => new EncryptedCookiesError({ cause: error }),
264
+ })
265
+ })
266
+ }
267
+
268
+ function encryptCookieWithDerivedKey(
269
+ cookie: Cookies.Cookie,
270
+ derivedKey: CryptoKey,
271
+ ): Effect.Effect<Cookies.Cookie, EncryptedCookiesError> {
272
+ return Effect.gen(function*() {
273
+ const encryptedValue = yield* encryptWithDerivedKey(
274
+ cookie.value,
275
+ derivedKey,
276
+ )
277
+ .pipe(
278
+ Effect.mapError(error =>
279
+ new EncryptedCookiesError({
280
+ cause: error.cause,
281
+ cookie,
282
+ })
283
+ ),
284
+ )
285
+ return Cookies.unsafeMakeCookie(cookie.name, encryptedValue, cookie.options)
286
+ })
287
+ }
288
+ function decryptCookieWithDerivedKey(
289
+ cookie: Cookies.Cookie,
290
+ derivedKey: CryptoKey,
291
+ ): Effect.Effect<Cookies.Cookie, EncryptedCookiesError> {
292
+ return Effect.gen(function*() {
293
+ const decryptedValue = yield* decryptWithDerivedKey(
294
+ cookie.value,
295
+ derivedKey,
296
+ )
297
+ .pipe(
298
+ Effect.mapError(error =>
299
+ new EncryptedCookiesError({
300
+ cause: error.cause,
301
+ cookie,
302
+ })
303
+ ),
304
+ )
305
+ return Cookies.unsafeMakeCookie(
306
+ cookie.name,
307
+ JSON.stringify(decryptedValue),
308
+ cookie.options,
309
+ )
310
+ })
311
+ }
312
+
313
+ export function encryptCookie(
314
+ cookie: Cookies.Cookie,
315
+ options: { key: CryptoKey } | { secret: string },
316
+ ): Effect.Effect<Cookies.Cookie, EncryptedCookiesError> {
317
+ return Effect.gen(function*() {
318
+ if ("key" in options) {
319
+ return yield* encryptCookieWithDerivedKey(cookie, options.key)
320
+ }
321
+
322
+ const encryptedValue = yield* encrypt(cookie.value, {
323
+ secret: options.secret,
324
+ })
325
+ .pipe(
326
+ Effect.mapError(error =>
327
+ new EncryptedCookiesError({
328
+ cause: error.cause,
329
+ cookie,
330
+ })
331
+ ),
332
+ )
333
+ return Cookies.unsafeMakeCookie(cookie.name, encryptedValue, cookie.options)
334
+ })
335
+ }
336
+
337
+ export function decryptCookie(
338
+ cookie: Cookies.Cookie,
339
+ options: { key: CryptoKey } | { secret: string },
340
+ ): Effect.Effect<Cookies.Cookie, EncryptedCookiesError> {
341
+ return Effect.gen(function*() {
342
+ if ("key" in options) {
343
+ return yield* decryptCookieWithDerivedKey(cookie, options.key)
344
+ }
345
+
346
+ const decryptedValue = yield* decrypt(cookie.value, {
347
+ secret: options.secret,
348
+ })
349
+ .pipe(
350
+ Effect.mapError(error =>
351
+ new EncryptedCookiesError({
352
+ cause: error.cause,
353
+ cookie,
354
+ })
355
+ ),
356
+ )
357
+ return Cookies.unsafeMakeCookie(
358
+ cookie.name,
359
+ JSON.stringify(decryptedValue),
360
+ cookie.options,
361
+ )
362
+ })
363
+ }
364
+
365
+ export function decrypt(
366
+ encryptedValue: string,
367
+ options: { key: CryptoKey } | { secret: string },
368
+ ): Effect.Effect<CookieValue, EncryptedCookiesError> {
369
+ return Effect.gen(function*() {
370
+ if ("key" in options) {
371
+ return yield* decryptWithDerivedKey(encryptedValue, options.key)
372
+ }
373
+
374
+ const keyMaterial = yield* deriveKeyMaterial(options.secret)
375
+ const derivedKey = yield* deriveKey(keyMaterial, ["decrypt"])
376
+ return yield* decryptWithDerivedKey(encryptedValue, derivedKey)
377
+ })
378
+ }
379
+
380
+ function deriveKeyMaterial(
381
+ secret: string,
382
+ ): Effect.Effect<CryptoKey, EncryptedCookiesError> {
383
+ return Effect.gen(function*() {
384
+ const encoder = new TextEncoder()
385
+
386
+ const keyMaterial = yield* Effect.tryPromise({
387
+ try: () =>
388
+ crypto.subtle.importKey(
389
+ "raw",
390
+ encoder.encode(secret),
391
+ { name: "HKDF" },
392
+ false,
393
+ ["deriveKey"],
394
+ ),
395
+ catch: (error) => new EncryptedCookiesError({ cause: error }),
396
+ })
397
+
398
+ return keyMaterial
399
+ })
400
+ }
401
+
402
+ function deriveKey(
403
+ keyMaterial: CryptoKey,
404
+ usage: KeyUsage[],
405
+ ): Effect.Effect<CryptoKey, EncryptedCookiesError> {
406
+ return Effect.gen(function*() {
407
+ const encoder = new TextEncoder()
408
+
409
+ const key = yield* Effect.tryPromise({
410
+ try: () =>
411
+ crypto.subtle.deriveKey(
412
+ {
413
+ name: "HKDF",
414
+ salt: encoder.encode("cookie-encryption"),
415
+ info: encoder.encode("aes-256-gcm"),
416
+ hash: "SHA-256",
417
+ },
418
+ keyMaterial,
419
+ { name: "AES-GCM", length: 256 },
420
+ false,
421
+ usage,
422
+ ),
423
+ catch: (error) => new EncryptedCookiesError({ cause: error }),
424
+ })
425
+
426
+ return key
427
+ })
428
+ }
429
+
430
+ // TODO something si wrong with return type
431
+ export function handleError<E>(
432
+ app: HttpApp.Default<E | EncryptedCookiesError>,
433
+ ) {
434
+ return Effect.gen(function*() {
435
+ const res = yield* app.pipe(
436
+ Effect.catchTag("EncryptedCookiesError", (error) => {
437
+ return HttpServerResponse.empty()
438
+ }),
439
+ )
440
+
441
+ return res
442
+ })
443
+ }
444
+
445
+ function generateFriendlyKey(bits = 128) {
446
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
447
+ const length = Math.ceil(bits / Math.log2(chars.length))
448
+ const bytes = crypto.getRandomValues(new Uint8Array(length))
449
+
450
+ return Array.from(bytes, b => chars[b % chars.length]).join("")
451
+ }
@@ -0,0 +1,207 @@
1
+ import * as Error from "@effect/platform/Error"
2
+ import * as FileSystem from "@effect/platform/FileSystem"
3
+ import * as HttpApp from "@effect/platform/HttpApp"
4
+ import * as HttpRouter from "@effect/platform/HttpRouter"
5
+ import * as HttpServerResponse from "@effect/platform/HttpServerResponse"
6
+ import * as t from "bun:test"
7
+ import * as Data from "effect/Data"
8
+ import * as Effect from "effect/Effect"
9
+ import * as FileHttpRouter from "./FileHttpRouter.ts"
10
+ import * as Route from "./Route.ts"
11
+ import * as Router from "./Router.ts"
12
+ import * as TestHttpClient from "./TestHttpClient.ts"
13
+ import { effectFn } from "./testing.ts"
14
+
15
+ class CustomError extends Data.TaggedError("CustomError") {}
16
+
17
+ const SampleRoutes = [
18
+ {
19
+ path: "/users",
20
+ segments: [{ literal: "users" }],
21
+ load: async () => ({
22
+ default: Route
23
+ .html(Effect.succeed("Users list"))
24
+ .post(Route.html(Effect.succeed("User created"))),
25
+ }),
26
+ },
27
+ {
28
+ path: "/articles",
29
+ segments: [{ literal: "articles" }],
30
+ load: async () => ({
31
+ default: Route.html(Effect.succeed("Articles list")),
32
+ }),
33
+ },
34
+ ] as const
35
+
36
+ const SampleRouteManifest: Router.RouteManifest = {
37
+ modules: SampleRoutes,
38
+ }
39
+
40
+ const routerLayer = Router.layerPromise(async () => SampleRouteManifest)
41
+
42
+ const effect = effectFn(routerLayer)
43
+
44
+ t.it("HttpRouter Requirement and Error types infers", () =>
45
+ effect(function*() {
46
+ const router = yield* FileHttpRouter.make(SampleRoutes)
47
+
48
+ // This should fail to compile if the router type is HttpRouter<any, any>
49
+ const _typeCheck: typeof router extends HttpRouter.HttpRouter<
50
+ Error.SystemError | "PostError" | CustomError,
51
+ FileSystem.FileSystem | "PostService"
52
+ > ? true
53
+ : false = true
54
+ }))
55
+
56
+ t.it("HTTP methods", () =>
57
+ effect(function*() {
58
+ const allMethodsRoute: Router.ServerRoute = {
59
+ path: "/",
60
+ segments: [],
61
+ load: async () => ({
62
+ default: Route
63
+ .html(Effect.succeed("GET"))
64
+ .post(Route.html(Effect.succeed("POST")))
65
+ .put(Route.html(Effect.succeed("PUT")))
66
+ .patch(Route.html(Effect.succeed("PATCH")))
67
+ .del(Route.html(Effect.succeed("DELETE")))
68
+ .options(Route.html(Effect.succeed("OPTIONS")))
69
+ .head(Route.html(Effect.succeed("HEAD"))),
70
+ }),
71
+ }
72
+
73
+ const router = yield* FileHttpRouter.make([allMethodsRoute])
74
+ const routesList = Array.from(router.routes)
75
+
76
+ t
77
+ .expect(routesList)
78
+ .toEqual(
79
+ t.expect.arrayContaining([
80
+ t.expect.objectContaining({ path: "/", method: "GET" }),
81
+ t.expect.objectContaining({ path: "/", method: "POST" }),
82
+ t.expect.objectContaining({ path: "/", method: "PUT" }),
83
+ t.expect.objectContaining({ path: "/", method: "PATCH" }),
84
+ t.expect.objectContaining({ path: "/", method: "DELETE" }),
85
+ t.expect.objectContaining({ path: "/", method: "OPTIONS" }),
86
+ t.expect.objectContaining({ path: "/", method: "HEAD" }),
87
+ ]),
88
+ )
89
+ }))
90
+
91
+ t.it("router handles requests correctly", () =>
92
+ effect(function*() {
93
+ const routerContext = yield* Router.Router
94
+ const client = TestHttpClient.make(routerContext.httpRouter)
95
+
96
+ const getUsersResponse = yield* client.get("/users")
97
+
98
+ t
99
+ .expect(getUsersResponse.status)
100
+ .toBe(200)
101
+
102
+ t
103
+ .expect(yield* getUsersResponse.text)
104
+ .toBe("Users list")
105
+
106
+ const postUsersResponse = yield* client.post("/users")
107
+
108
+ t
109
+ .expect(postUsersResponse.status)
110
+ .toBe(200)
111
+
112
+ t
113
+ .expect(yield* postUsersResponse.text)
114
+ .toBe("User created")
115
+ }))
116
+
117
+ t.it("middleware falls back to original app on 404", () =>
118
+ effect(function*() {
119
+ const middleware = FileHttpRouter.middleware()
120
+ const fallbackApp = Effect.succeed(HttpServerResponse.text("fallback"))
121
+ const middlewareApp = middleware(fallbackApp)
122
+
123
+ const client = TestHttpClient.make(middlewareApp)
124
+
125
+ const existingRouteResponse = yield* client.get("/users")
126
+
127
+ t
128
+ .expect(existingRouteResponse.status)
129
+ .toBe(200)
130
+
131
+ t
132
+ .expect(yield* existingRouteResponse.text)
133
+ .toBe("Users list")
134
+
135
+ const notFoundResponse = yield* client.get("/nonexistent")
136
+
137
+ t
138
+ .expect(notFoundResponse.status)
139
+ .toBe(200)
140
+
141
+ t
142
+ .expect(yield* notFoundResponse.text)
143
+ .toBe("fallback")
144
+ }))
145
+
146
+ t.it(
147
+ "handles routes with special characters (tilde and hyphen)",
148
+ () =>
149
+ effect(function*() {
150
+ const specialCharRoutes: Router.ServerRoute[] = [
151
+ {
152
+ path: "/api-v1",
153
+ segments: [{ literal: "api-v1" }],
154
+ load: async () => ({
155
+ default: Route.text(Effect.succeed("API v1")),
156
+ }),
157
+ },
158
+ {
159
+ path: "/files~backup",
160
+ segments: [{ literal: "files~backup" }],
161
+ load: async () => ({
162
+ default: Route.text(Effect.succeed("Backup files")),
163
+ }),
164
+ },
165
+ {
166
+ path: "/test-route~temp",
167
+ segments: [{ literal: "test-route~temp" }],
168
+ load: async () => ({
169
+ default: Route.post(Route.text(Effect.succeed("Test route"))),
170
+ }),
171
+ },
172
+ ]
173
+
174
+ const router = yield* FileHttpRouter.make(specialCharRoutes)
175
+ const client = TestHttpClient.make(router)
176
+
177
+ const apiResponse = yield* client.get("/api-v1")
178
+
179
+ t
180
+ .expect(apiResponse.status)
181
+ .toBe(200)
182
+
183
+ t
184
+ .expect(yield* apiResponse.text)
185
+ .toBe("API v1")
186
+
187
+ const backupResponse = yield* client.get("/files~backup")
188
+
189
+ t
190
+ .expect(backupResponse.status)
191
+ .toBe(200)
192
+
193
+ t
194
+ .expect(yield* backupResponse.text)
195
+ .toBe("Backup files")
196
+
197
+ const testResponse = yield* client.post("/test-route~temp")
198
+
199
+ t
200
+ .expect(testResponse.status)
201
+ .toBe(200)
202
+
203
+ t
204
+ .expect(yield* testResponse.text)
205
+ .toBe("Test route")
206
+ }),
207
+ )