effect-start 0.16.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effect-start",
3
- "version": "0.16.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"
@@ -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
+ )
@@ -366,7 +366,7 @@ test.describe("bytes", () => {
366
366
 
367
367
  test
368
368
  .expect(text)
369
- .toBe('{"key":"value"}')
369
+ .toBe("{\"key\":\"value\"}")
370
370
  })
371
371
  })
372
372
 
package/src/Entity.ts CHANGED
@@ -39,11 +39,9 @@ export interface Entity<
39
39
  */
40
40
  readonly url: string | undefined
41
41
  readonly status: number | undefined
42
- readonly text: T extends string
43
- ? Effect.Effect<T, ParseResult.ParseError | E>
42
+ readonly text: T extends string ? Effect.Effect<T, ParseResult.ParseError | E>
44
43
  : Effect.Effect<string, ParseResult.ParseError | E>
45
- readonly json: [T] extends [Effect.Effect<infer A, any, any>]
46
- ? Effect.Effect<
44
+ readonly json: [T] extends [Effect.Effect<infer A, any, any>] ? Effect.Effect<
47
45
  A extends string | Uint8Array | ArrayBuffer ? unknown : A,
48
46
  ParseResult.ParseError | E
49
47
  >
@@ -51,8 +49,7 @@ export interface Entity<
51
49
  ? Effect.Effect<unknown, ParseResult.ParseError | E>
52
50
  : [T] extends [string | Uint8Array | ArrayBuffer]
53
51
  ? Effect.Effect<unknown, ParseResult.ParseError | E>
54
- : [T] extends [Values.Json]
55
- ? Effect.Effect<T, ParseResult.ParseError | E>
52
+ : [T] extends [Values.Json] ? Effect.Effect<T, ParseResult.ParseError | E>
56
53
  : Effect.Effect<unknown, ParseResult.ParseError | E>
57
54
  readonly bytes: Effect.Effect<Uint8Array, ParseResult.ParseError | E>
58
55
  readonly stream: T extends Stream.Stream<infer A, infer E1, any>
package/src/FileRouter.ts CHANGED
@@ -10,9 +10,9 @@ import * as Record from "effect/Record"
10
10
  import * as Stream from "effect/Stream"
11
11
  import * as NPath from "node:path"
12
12
  import * as NUrl from "node:url"
13
+ import * as Development from "./Development.ts"
13
14
  import * as FileRouterCodegen from "./FileRouterCodegen.ts"
14
15
  import * as FileRouterPattern from "./FileRouterPattern.ts"
15
- import * as FileSystemExtra from "./FileSystemExtra.ts"
16
16
 
17
17
  export type RouteModule = {
18
18
  default: RouteSet.RouteSet.Default
@@ -161,7 +161,7 @@ export function layer(options: {
161
161
  yield* FileRouterCodegen.update(routesPath, manifestFilename)
162
162
 
163
163
  const stream = Function.pipe(
164
- FileSystemExtra.watchSource({
164
+ Development.watchSource({
165
165
  path: routesPath,
166
166
  filter: (e) => !e.path.includes("node_modules"),
167
167
  }),
package/src/Route.ts CHANGED
@@ -323,6 +323,10 @@ export {
323
323
  render,
324
324
  } from "./RouteBody.ts"
325
325
 
326
+ export {
327
+ sse,
328
+ } from "./RouteSse.ts"
329
+
326
330
  export class Routes extends Context.Tag("effect-start/Routes")<
327
331
  Routes,
328
332
  RouteTree.RouteTree
@@ -1,5 +1,6 @@
1
1
  import * as test from "bun:test"
2
2
  import * as Effect from "effect/Effect"
3
+ import * as ParseResult from "effect/ParseResult"
3
4
  import * as Stream from "effect/Stream"
4
5
  import * as Entity from "./Entity.ts"
5
6
  import * as Route from "./Route.ts"
@@ -107,51 +108,36 @@ test.describe(`${RouteBody.handle.name}()`, () => {
107
108
  })
108
109
 
109
110
  test.it("handles plain value", async () => {
110
- const handler = RouteBody.handle("hello")
111
-
112
- test
113
- .expectTypeOf(handler)
114
- .returns
115
- .toEqualTypeOf<
116
- Effect.Effect<Entity.Entity<string>, never, never>
117
- >()
118
-
111
+ const handler = RouteBody.handle<{}, string, never, never>("hello")
119
112
  const result = await Effect.runPromise(handler(ctx, next))
120
113
  test.expect(result.body).toBe("hello")
121
114
  test.expect(result.status).toBe(200)
122
115
  })
123
116
 
124
117
  test.it("handles Effect directly", async () => {
125
- const handler = RouteBody.handle(Effect.succeed("from effect"))
126
-
127
- test
128
- .expectTypeOf(handler)
129
- .returns
130
- .toEqualTypeOf<
131
- Effect.Effect<Entity.Entity<string>, never, never>
132
- >()
133
-
118
+ const handler = RouteBody.handle<{}, string, never, never>(
119
+ Effect.succeed("from effect"),
120
+ )
134
121
  const result = await Effect.runPromise(handler(ctx, next))
135
-
136
- test
137
- .expect(result.body)
138
- .toBe("from effect")
122
+ test.expect(result.body).toBe("from effect")
139
123
  })
140
124
 
141
125
  test.it("handles Effect with error", async () => {
142
- const handler = RouteBody.handle(Effect.fail(new Error("oops")))
126
+ const handler = RouteBody.handle<{}, never, Error, never>(
127
+ Effect.fail(new Error("oops")),
128
+ )
143
129
 
144
130
  test
145
131
  .expectTypeOf(handler)
146
132
  .returns
147
- .toEqualTypeOf<
133
+ .toExtend<
148
134
  Effect.Effect<Entity.Entity<never>, Error, never>
149
135
  >()
150
136
  })
151
137
 
152
138
  test.it("handles function", async () => {
153
- const handler = RouteBody.handle(
154
- (ctx: { id: number }) => Effect.succeed(ctx.id),
139
+ const handler = RouteBody.handle<{ id: number }, number, never, never>(
140
+ (ctx) => Effect.succeed(ctx.id),
155
141
  )
156
142
 
157
143
  test
@@ -173,20 +159,18 @@ test.describe(`${RouteBody.handle.name}()`, () => {
173
159
  >()
174
160
 
175
161
  const numNext = () => Entity.effect(Effect.succeed(Entity.make(23)))
176
- const result = await Effect.runPromise(
177
- handler({ id: 42 }, numNext),
178
- )
162
+ const result = await Effect.runPromise(handler({ id: 42 }, numNext))
179
163
 
180
- test
181
- .expect(result.body)
182
- .toBe(42)
164
+ test.expect(result.body).toBe(42)
183
165
  })
184
166
 
185
167
  test.it("handles generator", async () => {
186
- const handler = RouteBody.handle(function*(ctx: { id: number }) {
187
- const n = yield* Effect.succeed(ctx.id)
188
- return n * 2
189
- })
168
+ const handler = RouteBody.handle<{ id: number }, number, never, never>(
169
+ function*(ctx) {
170
+ const n = yield* Effect.succeed(ctx.id)
171
+ return n * 2
172
+ },
173
+ )
190
174
 
191
175
  test
192
176
  .expectTypeOf(handler)
@@ -208,24 +192,20 @@ test.describe(`${RouteBody.handle.name}()`, () => {
208
192
  >()
209
193
 
210
194
  const numNext = () => Entity.effect(Effect.succeed(Entity.make(23)))
211
- const result = await Effect.runPromise(
212
- handler({ id: 21 }, numNext),
213
- )
195
+ const result = await Effect.runPromise(handler({ id: 21 }, numNext))
214
196
 
215
- test
216
- .expect(result.body)
217
- .toBe(42)
197
+ test.expect(result.body).toBe(42)
218
198
  })
219
199
 
220
200
  test.it("generator can call next", async () => {
221
- const handler = RouteBody.handle(
222
- function*(_ctx: {}, next: () => Entity.Entity<string>) {
201
+ const handler = RouteBody.handle<{}, string, ParseResult.ParseError, never>(
202
+ function*(_ctx, next) {
223
203
  const fromNext = yield* next().text
224
204
  return `got: ${fromNext}`
225
205
  },
226
206
  )
227
207
 
228
- const result = await Effect.runPromise(handler(ctx, next))
208
+ const result = await Effect.runPromise(Effect.orDie(handler(ctx, next)))
229
209
 
230
210
  test
231
211
  .expect(result.body)
package/src/RouteBody.ts CHANGED
@@ -115,14 +115,17 @@ export function build<
115
115
  E,
116
116
  R
117
117
  > = (ctx, next) =>
118
- Effect.map(baseHandler(ctx as any, next as any), (entity) =>
119
- entity.headers["content-type"]
120
- ? entity
121
- : Entity.make(entity.body, {
122
- status: entity.status,
123
- url: entity.url,
124
- headers: { ...entity.headers, "content-type": contentType },
125
- }))
118
+ Effect.map(
119
+ baseHandler(ctx as any, next as any),
120
+ (entity) =>
121
+ entity.headers["content-type"]
122
+ ? entity
123
+ : Entity.make(entity.body, {
124
+ status: entity.status,
125
+ url: entity.url,
126
+ headers: { ...entity.headers, "content-type": contentType },
127
+ }),
128
+ )
126
129
 
127
130
  const route = Route.make<{ format: F }, {}, A, E, R>(
128
131
  wrappedHandler as any,
@@ -2571,7 +2571,10 @@ test.describe("Route.render (format=*)", () => {
2571
2571
  )
2572
2572
 
2573
2573
  const responses = await Promise.all([
2574
- Http.fetch(handler, { path: "/", headers: { Accept: "text/event-stream" } }),
2574
+ Http.fetch(handler, {
2575
+ path: "/",
2576
+ headers: { Accept: "text/event-stream" },
2577
+ }),
2575
2578
  Http.fetch(handler, { path: "/", headers: { Accept: "image/png" } }),
2576
2579
  Http.fetch(handler, { path: "/", headers: { Accept: "*/*" } }),
2577
2580
  Http.fetch(handler, { path: "/" }),
package/src/RouteHttp.ts CHANGED
@@ -211,7 +211,9 @@ export const toWebHandlerRuntime = <R>(
211
211
 
212
212
  if (methodRoutes.length === 0 && wildcards.length === 0) {
213
213
  return Promise.resolve(
214
- Response.json({ status: 405, message: "method not allowed" }, { status: 405 }),
214
+ Response.json({ status: 405, message: "method not allowed" }, {
215
+ status: 405,
216
+ }),
215
217
  )
216
218
  }
217
219
 
@@ -232,7 +234,9 @@ export const toWebHandlerRuntime = <R>(
232
234
  && !hasWildcardFormatRoutes
233
235
  ) {
234
236
  return Promise.resolve(
235
- Response.json({ status: 406, message: "not acceptable" }, { status: 406 }),
237
+ Response.json({ status: 406, message: "not acceptable" }, {
238
+ status: 406,
239
+ }),
236
240
  )
237
241
  }
238
242
 
@@ -311,7 +315,9 @@ export const toWebHandlerRuntime = <R>(
311
315
  : Entity.make(result, { status: 200 })
312
316
 
313
317
  if (entity.status === 404 && entity.body === undefined) {
314
- return Response.json({ status: 406, message: "not acceptable" }, { status: 406 })
318
+ return Response.json({ status: 406, message: "not acceptable" }, {
319
+ status: 406,
320
+ })
315
321
  }
316
322
 
317
323
  return yield* toResponse(entity, selectedFormat, runtime)
@@ -374,7 +380,9 @@ export const toWebHandlerRuntime = <R>(
374
380
  Effect.gen(function*() {
375
381
  yield* Effect.logError(cause)
376
382
  const status = getStatusFromCause(cause)
377
- return Response.json({ status, message: Cause.pretty(cause) }, { status })
383
+ return Response.json({ status, message: Cause.pretty(cause) }, {
384
+ status,
385
+ })
378
386
  })
379
387
  ),
380
388
  ),
@@ -392,10 +400,18 @@ export const toWebHandlerRuntime = <R>(
392
400
  if (exit._tag === "Success") {
393
401
  resolve(exit.value)
394
402
  } else if (isClientAbort(exit.cause)) {
395
- resolve(Response.json({ status: 499, message: "client closed request" }, { status: 499 }))
403
+ resolve(
404
+ Response.json({ status: 499, message: "client closed request" }, {
405
+ status: 499,
406
+ }),
407
+ )
396
408
  } else {
397
409
  const status = getStatusFromCause(exit.cause)
398
- resolve(Response.json({ status, message: Cause.pretty(exit.cause) }, { status }))
410
+ resolve(
411
+ Response.json({ status, message: Cause.pretty(exit.cause) }, {
412
+ status,
413
+ }),
414
+ )
399
415
  }
400
416
  })
401
417
  })