effect-start 0.15.0 → 0.17.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 (46) hide show
  1. package/package.json +2 -1
  2. package/src/ContentNegotiation.test.ts +103 -0
  3. package/src/ContentNegotiation.ts +10 -3
  4. package/src/Development.test.ts +119 -0
  5. package/src/Development.ts +137 -0
  6. package/src/Entity.test.ts +592 -0
  7. package/src/Entity.ts +359 -0
  8. package/src/FileRouter.ts +2 -2
  9. package/src/Http.test.ts +315 -20
  10. package/src/Http.ts +153 -11
  11. package/src/PathPattern.ts +3 -1
  12. package/src/Route.ts +26 -10
  13. package/src/RouteBody.test.ts +98 -66
  14. package/src/RouteBody.ts +125 -35
  15. package/src/RouteHook.ts +15 -14
  16. package/src/RouteHttp.test.ts +2549 -83
  17. package/src/RouteHttp.ts +337 -113
  18. package/src/RouteHttpTracer.ts +92 -0
  19. package/src/RouteMount.test.ts +23 -10
  20. package/src/RouteMount.ts +161 -4
  21. package/src/RouteSchema.test.ts +346 -0
  22. package/src/RouteSchema.ts +386 -7
  23. package/src/RouteSse.test.ts +249 -0
  24. package/src/RouteSse.ts +195 -0
  25. package/src/RouteTree.test.ts +233 -85
  26. package/src/RouteTree.ts +98 -44
  27. package/src/StreamExtra.ts +21 -1
  28. package/src/Values.test.ts +263 -0
  29. package/src/Values.ts +68 -6
  30. package/src/bun/BunBundle.ts +0 -73
  31. package/src/bun/BunHttpServer.ts +23 -7
  32. package/src/bun/BunRoute.test.ts +162 -0
  33. package/src/bun/BunRoute.ts +144 -105
  34. package/src/hyper/HyperHtml.test.ts +119 -0
  35. package/src/hyper/HyperHtml.ts +10 -2
  36. package/src/hyper/HyperNode.ts +2 -0
  37. package/src/hyper/HyperRoute.test.tsx +197 -0
  38. package/src/hyper/HyperRoute.ts +61 -0
  39. package/src/hyper/index.ts +4 -0
  40. package/src/hyper/jsx.d.ts +15 -0
  41. package/src/index.ts +2 -0
  42. package/src/node/FileSystem.ts +8 -0
  43. package/src/testing/TestLogger.test.ts +0 -3
  44. package/src/testing/TestLogger.ts +15 -9
  45. package/src/FileSystemExtra.test.ts +0 -242
  46. package/src/FileSystemExtra.ts +0 -66
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effect-start",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "exports": {
@@ -14,6 +14,7 @@
14
14
  "./middlewares": "./src/middlewares/index.ts",
15
15
  "./experimental": "./src/experimental/index.ts",
16
16
  "./client/assets.d.ts": "./src/assets.d.ts",
17
+ "./hyper": "./src/hyper/index.ts",
17
18
  "./jsx-runtime": "./src/hyper/jsx-runtime.ts",
18
19
  "./jsx-dev-runtime": "./src/hyper/jsx-runtime.ts",
19
20
  "./package.json": "./package.json"
@@ -148,6 +148,109 @@ test.describe("ContentNegotiation.media", () => {
148
148
  .expect(result)
149
149
  .toEqual(["text/html", "application/json"])
150
150
  })
151
+
152
+ test.describe("wildcard in available types", () => {
153
+ test.it("text/* matches text/event-stream", () => {
154
+ const result = ContentNegotiation.media(
155
+ "text/event-stream",
156
+ ["text/*", "application/json"],
157
+ )
158
+ test
159
+ .expect(result)
160
+ .toEqual(["text/*"])
161
+ })
162
+
163
+ test.it("text/* matches text/markdown", () => {
164
+ const result = ContentNegotiation.media(
165
+ "text/markdown",
166
+ ["text/*"],
167
+ )
168
+ test
169
+ .expect(result)
170
+ .toEqual(["text/*"])
171
+ })
172
+
173
+ test.it("text/* matches text/plain", () => {
174
+ const result = ContentNegotiation.media(
175
+ "text/plain",
176
+ ["text/*"],
177
+ )
178
+ test
179
+ .expect(result)
180
+ .toEqual(["text/*"])
181
+ })
182
+
183
+ test.it("text/* does not match application/json", () => {
184
+ const result = ContentNegotiation.media(
185
+ "application/json",
186
+ ["text/*"],
187
+ )
188
+ test
189
+ .expect(result)
190
+ .toEqual([])
191
+ })
192
+
193
+ test.it("prefers exact match over wildcard available type", () => {
194
+ const result = ContentNegotiation.media(
195
+ "text/html",
196
+ ["text/*", "text/html"],
197
+ )
198
+ test
199
+ .expect(result)
200
+ .toEqual(["text/html", "text/*"])
201
+ })
202
+
203
+ test.it("text/* matches multiple text types in Accept", () => {
204
+ const result = ContentNegotiation.media(
205
+ "text/html, text/plain",
206
+ ["text/*"],
207
+ )
208
+ test
209
+ .expect(result)
210
+ .toEqual(["text/*"])
211
+ })
212
+
213
+ test.it("application/* matches application/xml", () => {
214
+ const result = ContentNegotiation.media(
215
+ "application/xml",
216
+ ["application/*", "text/html"],
217
+ )
218
+ test
219
+ .expect(result)
220
+ .toEqual(["application/*"])
221
+ })
222
+
223
+ test.it("*/* in available matches any type", () => {
224
+ const result = ContentNegotiation.media(
225
+ "image/png",
226
+ ["*/*"],
227
+ )
228
+ test
229
+ .expect(result)
230
+ .toEqual(["*/*"])
231
+ })
232
+
233
+ test.it("combines client and server wildcards", () => {
234
+ // Client wants text/*, server offers text/*
235
+ const result = ContentNegotiation.media(
236
+ "text/*",
237
+ ["text/*"],
238
+ )
239
+ test
240
+ .expect(result)
241
+ .toEqual(["text/*"])
242
+ })
243
+
244
+ test.it("quality values still apply with wildcard available", () => {
245
+ const result = ContentNegotiation.media(
246
+ "text/html;q=0.5, text/event-stream;q=0.9",
247
+ ["text/*"],
248
+ )
249
+ test
250
+ .expect(result)
251
+ .toEqual(["text/*"])
252
+ })
253
+ })
151
254
  })
152
255
 
153
256
  test.describe("ContentNegotiation.language", () => {
@@ -113,17 +113,24 @@ function specifyMediaType(
113
113
  let s = 0
114
114
 
115
115
  if (spec.type === type) {
116
- s |= 4
116
+ s |= 4 // exact match: highest specificity
117
+ } else if (type === "*") {
118
+ s |= 0 // server offers wildcard (e.g. */*): matches any client type
117
119
  } else if (spec.type !== "*") {
120
+ // client is NOT requesting wildcard: no match
118
121
  return null
119
122
  }
120
123
 
124
+ // client requests wildcard (e.g. Accept: */*)
121
125
  if (spec.subtype === subtype) {
122
- s |= 2
126
+ s |= 2 // // exact match: highest specificity
127
+ } else if (subtype === "*") {
128
+ s |= 1 // server offers wildcard (e.g. text/*)
123
129
  } else if (spec.subtype !== "*") {
124
- return null
130
+ return null // client is NOT requesting wildcard
125
131
  }
126
132
 
133
+ // client requests wildcard (e.g. Accept: text/*): matches any server subtype
127
134
  const specParams = Object.keys(spec.params)
128
135
  if (specParams.length > 0) {
129
136
  if (
@@ -0,0 +1,119 @@
1
+ import * as FileSystem from "@effect/platform/FileSystem"
2
+ import * as test from "bun:test"
3
+ import { MemoryFileSystem } from "effect-memfs"
4
+ import * as Chunk from "effect/Chunk"
5
+ import * as Effect from "effect/Effect"
6
+ import * as Fiber from "effect/Fiber"
7
+ import * as Layer from "effect/Layer"
8
+ import * as Stream from "effect/Stream"
9
+ import * as Development from "./Development.ts"
10
+
11
+ test.beforeEach(() => {
12
+ Development._resetForTesting()
13
+ })
14
+
15
+ test.describe("watch", () => {
16
+ test.it("creates pubsub and publishes file events", () =>
17
+ Effect
18
+ .gen(function*() {
19
+ const fs = yield* FileSystem.FileSystem
20
+ const watchDir = "/dev-watch"
21
+
22
+ const dev = yield* Development.watch({ path: watchDir })
23
+
24
+ const subFiber = yield* Effect.fork(
25
+ Stream.runCollect(
26
+ Stream.take(Stream.fromPubSub(dev.events), 1),
27
+ ),
28
+ )
29
+
30
+ yield* Effect.sleep(1)
31
+ yield* fs.writeFileString(`${watchDir}/test.ts`, "const x = 1")
32
+
33
+ const events = yield* Fiber.join(subFiber)
34
+
35
+ test
36
+ .expect(Chunk.size(events))
37
+ .toBe(1)
38
+ const first = Chunk.unsafeGet(events, 0)
39
+ test
40
+ .expect("path" in first && first.path)
41
+ .toContain("test.ts")
42
+ })
43
+ .pipe(
44
+ Effect.scoped,
45
+ Effect.provide(
46
+ MemoryFileSystem.layerWith({ "/dev-watch/.gitkeep": "" }),
47
+ ),
48
+ Effect.runPromise,
49
+ ))
50
+ })
51
+
52
+ test.describe("layerWatch", () => {
53
+ test.it("provides Development service", () =>
54
+ Effect
55
+ .gen(function*() {
56
+ const dev = yield* Development.Development
57
+
58
+ test
59
+ .expect(dev.events)
60
+ .toBeDefined()
61
+ })
62
+ .pipe(
63
+ Effect.scoped,
64
+ Effect.provide(Development.layerWatch({ path: "/layer-test" })),
65
+ Effect.provide(
66
+ MemoryFileSystem.layerWith({ "/layer-test/.gitkeep": "" }),
67
+ ),
68
+ Effect.runPromise,
69
+ ))
70
+ })
71
+
72
+ test.describe("stream", () => {
73
+ test.it("returns stream from pubsub when Development is available", () =>
74
+ Effect
75
+ .gen(function*() {
76
+ const fs = yield* FileSystem.FileSystem
77
+ const watchDir = "/events-test"
78
+
79
+ const collectFiber = yield* Effect.fork(
80
+ Stream.runCollect(Stream.take(Development.stream(), 1)),
81
+ )
82
+
83
+ yield* Effect.sleep(1)
84
+ yield* fs.writeFileString(`${watchDir}/file.ts`, "content")
85
+
86
+ const collected = yield* Fiber.join(collectFiber)
87
+
88
+ test
89
+ .expect(Chunk.size(collected))
90
+ .toBe(1)
91
+ const first = Chunk.unsafeGet(collected, 0)
92
+ test
93
+ .expect("path" in first && first.path)
94
+ .toContain("file.ts")
95
+ })
96
+ .pipe(
97
+ Effect.scoped,
98
+ Effect.provide(Development.layerWatch({ path: "/events-test" })),
99
+ Effect.provide(
100
+ MemoryFileSystem.layerWith({ "/events-test/.gitkeep": "" }),
101
+ ),
102
+ Effect.runPromise,
103
+ ))
104
+
105
+ test.it("returns empty stream when Development is not available", () =>
106
+ Effect
107
+ .gen(function*() {
108
+ const collected = yield* Stream.runCollect(Development.stream())
109
+
110
+ test
111
+ .expect(Chunk.size(collected))
112
+ .toBe(0)
113
+ })
114
+ .pipe(
115
+ Effect.scoped,
116
+ Effect.provide(Layer.empty),
117
+ Effect.runPromise,
118
+ ))
119
+ })
@@ -0,0 +1,137 @@
1
+ import {
2
+ Context,
3
+ Effect,
4
+ Function,
5
+ Layer,
6
+ Option,
7
+ pipe,
8
+ PubSub,
9
+ Stream,
10
+ } from "effect"
11
+ import { globalValue } from "effect/GlobalValue"
12
+ import {
13
+ Error,
14
+ FileSystem,
15
+ } from "./node/FileSystem.ts"
16
+
17
+ export type DevelopmentEvent =
18
+ | FileSystem.WatchEvent
19
+ | {
20
+ readonly _tag: "Reload"
21
+ }
22
+
23
+ const devState = globalValue(
24
+ Symbol.for("effect-start/Development"),
25
+ () => ({
26
+ count: 0,
27
+ pubsub: null as PubSub.PubSub<DevelopmentEvent> | null,
28
+ }),
29
+ )
30
+
31
+ /** @internal */
32
+ export const _resetForTesting = () => {
33
+ devState.count = 0
34
+ devState.pubsub = null
35
+ }
36
+
37
+ export type DevelopmentService = {
38
+ events: PubSub.PubSub<DevelopmentEvent>
39
+ }
40
+
41
+ export class Development extends Context.Tag("effect-start/Development")<
42
+ Development,
43
+ DevelopmentService
44
+ >() {}
45
+
46
+ const SOURCE_FILENAME = /\.(tsx?|jsx?|html?|css|json)$/
47
+
48
+ export const filterSourceFiles = (event: FileSystem.WatchEvent): boolean => {
49
+ return SOURCE_FILENAME.test(event.path)
50
+ }
51
+
52
+ export const filterDirectory = (event: FileSystem.WatchEvent): boolean => {
53
+ return event.path.endsWith("/")
54
+ }
55
+
56
+ export const watchSource = (
57
+ opts?: {
58
+ path?: string
59
+ recursive?: boolean
60
+ filter?: (event: FileSystem.WatchEvent) => boolean
61
+ },
62
+ ): Stream.Stream<
63
+ FileSystem.WatchEvent,
64
+ Error.PlatformError,
65
+ FileSystem.FileSystem
66
+ > => {
67
+ const baseDir = opts?.path ?? process.cwd()
68
+ const customFilter = opts?.filter
69
+
70
+ return Function.pipe(
71
+ Stream.unwrap(
72
+ Effect.map(
73
+ FileSystem.FileSystem,
74
+ fs => fs.watch(baseDir, { recursive: opts?.recursive ?? true }),
75
+ ),
76
+ ),
77
+ customFilter ? Stream.filter(customFilter) : Function.identity,
78
+ Stream.rechunk(1),
79
+ Stream.throttle({
80
+ units: 1,
81
+ cost: () => 1,
82
+ duration: "400 millis",
83
+ strategy: "enforce",
84
+ }),
85
+ )
86
+ }
87
+
88
+ export const watch = (
89
+ opts?: {
90
+ path?: string
91
+ recursive?: boolean
92
+ filter?: (event: FileSystem.WatchEvent) => boolean
93
+ },
94
+ ) =>
95
+ Effect.gen(function*() {
96
+ devState.count++
97
+
98
+ if (devState.count === 1) {
99
+ const pubsub = yield* PubSub.unbounded<DevelopmentEvent>()
100
+ devState.pubsub = pubsub
101
+
102
+ yield* pipe(
103
+ watchSource({
104
+ path: opts?.path,
105
+ recursive: opts?.recursive,
106
+ filter: opts?.filter ?? filterSourceFiles,
107
+ }),
108
+ Stream.runForEach((event) => PubSub.publish(pubsub, event)),
109
+ Effect.fork,
110
+ )
111
+ } else {
112
+ yield* PubSub.publish(devState.pubsub!, { _tag: "Reload" })
113
+ }
114
+
115
+ return { events: devState.pubsub! } satisfies DevelopmentService
116
+ })
117
+
118
+ export const layerWatch = (
119
+ opts?: {
120
+ path?: string
121
+ recursive?: boolean
122
+ filter?: (event: FileSystem.WatchEvent) => boolean
123
+ },
124
+ ) => Layer.scoped(Development, watch(opts))
125
+
126
+ export const stream = (): Stream.Stream<DevelopmentEvent> =>
127
+ Stream.unwrap(
128
+ pipe(
129
+ Effect.serviceOption(Development),
130
+ Effect.map(
131
+ Option.match({
132
+ onNone: () => Stream.empty,
133
+ onSome: (dev) => Stream.fromPubSub(dev.events),
134
+ }),
135
+ ),
136
+ ),
137
+ )