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.
@@ -0,0 +1,249 @@
1
+ import * as test from "bun:test"
2
+ import * as Context from "effect/Context"
3
+ import * as Effect from "effect/Effect"
4
+ import * as Layer from "effect/Layer"
5
+ import * as Runtime from "effect/Runtime"
6
+ import * as Stream from "effect/Stream"
7
+ import * as Http from "./Http.ts"
8
+ import * as Route from "./Route.ts"
9
+ import * as RouteHttp from "./RouteHttp.ts"
10
+ import * as RouteMount from "./RouteMount.ts"
11
+
12
+ test.describe("Route.sse()", () => {
13
+ test.it("infers format as text", () => {
14
+ RouteMount.get(
15
+ Route.sse(function*(ctx) {
16
+ test
17
+ .expectTypeOf(ctx)
18
+ .toExtend<{
19
+ method: "GET"
20
+ format: "text"
21
+ }>()
22
+
23
+ return Stream.make({ data: "hello" })
24
+ }),
25
+ )
26
+ })
27
+
28
+ test.it("accepts object events with data only", () => {
29
+ RouteMount.get(
30
+ Route.sse(() =>
31
+ Stream.make(
32
+ { data: "hello" },
33
+ { data: "world" },
34
+ )
35
+ ),
36
+ )
37
+ })
38
+
39
+ test.it("accepts object events with data and event", () => {
40
+ RouteMount.get(
41
+ Route.sse(() =>
42
+ Stream.make(
43
+ { data: "hello", event: "message" },
44
+ { data: "world", event: "update" },
45
+ )
46
+ ),
47
+ )
48
+ })
49
+
50
+ test.it("accepts events with retry", () => {
51
+ RouteMount.get(
52
+ Route.sse(() =>
53
+ Stream.make(
54
+ { data: "hello", retry: 3000 },
55
+ )
56
+ ),
57
+ )
58
+ })
59
+
60
+ test.it("accepts Effect returning Stream", () => {
61
+ RouteMount.get(
62
+ Route.sse(
63
+ Effect.succeed(Stream.make({ data: "hello" })),
64
+ ),
65
+ )
66
+ })
67
+
68
+ test.it("accepts generator returning Stream", () => {
69
+ RouteMount.get(
70
+ Route.sse(function*() {
71
+ const prefix = yield* Effect.succeed("msg: ")
72
+ return Stream.make({ data: `${prefix}hello` })
73
+ }),
74
+ )
75
+ })
76
+
77
+ test.it("formats data events correctly", async () => {
78
+ const handler = RouteHttp.toWebHandler(
79
+ Route.get(
80
+ Route.sse(() => Stream.make({ data: "hello" }, { data: "world" })),
81
+ ),
82
+ )
83
+ const response = await Http.fetch(handler, { path: "/events" })
84
+
85
+ test.expect(response.headers.get("content-type")).toBe("text/event-stream")
86
+ test.expect(response.headers.get("cache-control")).toBe("no-cache")
87
+ test.expect(response.headers.get("connection")).toBe("keep-alive")
88
+
89
+ const text = await response.text()
90
+ test.expect(text).toBe("data: hello\n\ndata: world\n\n")
91
+ })
92
+
93
+ test.it("formats events with event field", async () => {
94
+ const handler = RouteHttp.toWebHandler(
95
+ Route.get(
96
+ Route.sse(() => Stream.make({ data: "payload", event: "custom" })),
97
+ ),
98
+ )
99
+ const response = await Http.fetch(handler, { path: "/events" })
100
+
101
+ const text = await response.text()
102
+ test.expect(text).toBe("event: custom\ndata: payload\n\n")
103
+ })
104
+
105
+ test.it("formats events with retry", async () => {
106
+ const handler = RouteHttp.toWebHandler(
107
+ Route.get(
108
+ Route.sse(() => Stream.make({ data: "hello", retry: 5000 })),
109
+ ),
110
+ )
111
+ const response = await Http.fetch(handler, { path: "/events" })
112
+
113
+ const text = await response.text()
114
+ test.expect(text).toBe("data: hello\nretry: 5000\n\n")
115
+ })
116
+
117
+ test.it("formats multi-line data with multiple data fields", async () => {
118
+ const handler = RouteHttp.toWebHandler(
119
+ Route.get(
120
+ Route.sse(() =>
121
+ Stream.make({
122
+ event: "patch",
123
+ data: "line1\nline2\nline3",
124
+ })
125
+ ),
126
+ ),
127
+ )
128
+ const response = await Http.fetch(handler, { path: "/events" })
129
+
130
+ const text = await response.text()
131
+ test.expect(text).toBe(
132
+ "event: patch\ndata: line1\ndata: line2\ndata: line3\n\n",
133
+ )
134
+ })
135
+
136
+ test.it("accepts Stream directly", async () => {
137
+ const handler = RouteHttp.toWebHandler(
138
+ Route.get(
139
+ Route.sse(
140
+ Stream.make({ data: "direct" }),
141
+ ),
142
+ ),
143
+ )
144
+ const response = await Http.fetch(handler, { path: "/events" })
145
+
146
+ const text = await response.text()
147
+ test.expect(text).toBe("data: direct\n\n")
148
+ })
149
+
150
+ test.it("infers error type from Stream", () => {
151
+ class MyError {
152
+ readonly _tag = "MyError"
153
+ }
154
+
155
+ const stream: Stream.Stream<{ data: string }, MyError> = Stream.fail(
156
+ new MyError(),
157
+ )
158
+
159
+ const route = Route.get(
160
+ Route.sse(stream),
161
+ )
162
+
163
+ const routes = [...route]
164
+ type RouteError = (typeof routes)[0] extends
165
+ Route.Route.Route<any, any, any, infer E, any> ? E
166
+ : never
167
+
168
+ test.expectTypeOf<RouteError>().toEqualTypeOf<MyError>()
169
+ })
170
+
171
+ test.it("infers context type from Stream", () => {
172
+ class Config extends Context.Tag("Config")<Config, { url: string }>() {}
173
+
174
+ const stream = Stream.fromEffect(
175
+ Effect.map(Config, (cfg) => ({ data: cfg.url })),
176
+ )
177
+
178
+ const route = Route.get(
179
+ Route.sse(stream),
180
+ )
181
+
182
+ const routes = [...route]
183
+ type RouteContext = (typeof routes)[0] extends
184
+ Route.Route.Route<any, any, any, any, infer R> ? R
185
+ : never
186
+
187
+ test.expectTypeOf<RouteContext>().toEqualTypeOf<Config>()
188
+ })
189
+
190
+ test.it("works with context at runtime", async () => {
191
+ class Config extends Context.Tag("Config")<Config, { message: string }>() {}
192
+
193
+ const stream = Stream.fromEffect(
194
+ Effect.map(Config, (cfg) => ({ data: cfg.message })),
195
+ )
196
+
197
+ const route = Route.get(Route.sse(stream))
198
+ const layer = Layer.succeed(Config, { message: "from context" })
199
+ const runtime = Effect.runSync(Layer.toRuntime(layer).pipe(Effect.scoped))
200
+ const handler = RouteHttp.toWebHandlerRuntime(runtime)(route)
201
+
202
+ const response = await Http.fetch(handler, { path: "/events" })
203
+ const text = await response.text()
204
+
205
+ test.expect(text).toBe("data: from context\n\n")
206
+ })
207
+
208
+ test.it("formats tagged struct as event with JSON data", async () => {
209
+ const handler = RouteHttp.toWebHandler(
210
+ Route.get(
211
+ Route.sse(() =>
212
+ Stream.make(
213
+ { _tag: "UserCreated", id: 123, name: "Alice" },
214
+ { _tag: "UserUpdated", id: 123, active: true },
215
+ )
216
+ ),
217
+ ),
218
+ )
219
+ const response = await Http.fetch(handler, { path: "/events" })
220
+
221
+ const text = await response.text()
222
+ test.expect(text).toBe(
223
+ `event: UserCreated\ndata: {"_tag":"UserCreated","id":123,"name":"Alice"}\n\n`
224
+ + `event: UserUpdated\ndata: {"_tag":"UserUpdated","id":123,"active":true}\n\n`,
225
+ )
226
+ })
227
+
228
+ test.it("handles mixed tagged and regular events", async () => {
229
+ const handler = RouteHttp.toWebHandler(
230
+ Route.get(
231
+ Route.sse(() =>
232
+ Stream.make(
233
+ { data: "plain message" },
234
+ { _tag: "Notification", text: "hello" },
235
+ { data: "another", event: "custom" },
236
+ )
237
+ ),
238
+ ),
239
+ )
240
+ const response = await Http.fetch(handler, { path: "/events" })
241
+
242
+ const text = await response.text()
243
+ test.expect(text).toBe(
244
+ `data: plain message\n\n`
245
+ + `event: Notification\ndata: {"_tag":"Notification","text":"hello"}\n\n`
246
+ + `event: custom\ndata: another\n\n`,
247
+ )
248
+ })
249
+ })
@@ -0,0 +1,195 @@
1
+ import * as Duration from "effect/Duration"
2
+ import * as Effect from "effect/Effect"
3
+ import * as Schedule from "effect/Schedule"
4
+ import * as Stream from "effect/Stream"
5
+ import type * as Utils from "effect/Utils"
6
+ import * as Entity from "./Entity.ts"
7
+ import * as Route from "./Route.ts"
8
+ import * as StreamExtra from "./StreamExtra.ts"
9
+ import type * as Values from "./Values.ts"
10
+
11
+ const HEARTBEAT_INTERVAL = Duration.seconds(5)
12
+ const HEARTBEAT = ": <3\n\n"
13
+
14
+ export interface SseEvent {
15
+ data?: string | undefined
16
+ event?: string
17
+ retry?: number
18
+ }
19
+
20
+ export type SseTaggedEvent = {
21
+ readonly _tag: string
22
+ }
23
+
24
+ export type SseEventInput =
25
+ | SseEvent
26
+ | SseTaggedEvent
27
+
28
+ function isTaggedEvent(event: SseEventInput): event is SseTaggedEvent {
29
+ return Object.hasOwn(event, "_tag")
30
+ && typeof event["_tag"] === "string"
31
+ }
32
+
33
+ function formatSseEvent(event: SseEventInput): string {
34
+ if (isTaggedEvent(event)) {
35
+ const json = JSON.stringify(event)
36
+ return `event: ${event._tag}\ndata: ${json}\n\n`
37
+ }
38
+
39
+ const e = event as SseEvent
40
+ let result = ""
41
+ if (e.event) {
42
+ result += `event: ${e.event}\n`
43
+ }
44
+ if (typeof e.data === "string") {
45
+ for (const line of e.data.split("\n")) {
46
+ result += `data: ${line}\n`
47
+ }
48
+ }
49
+ if (e.retry !== undefined) {
50
+ result += `retry: ${e.retry}\n`
51
+ }
52
+ if (result === "") {
53
+ return ""
54
+ }
55
+ return result + "\n"
56
+ }
57
+
58
+ export type SseHandlerInput<B, E, R> =
59
+ | Stream.Stream<SseEventInput, E, R>
60
+ | Effect.Effect<Stream.Stream<SseEventInput, E, R>, E, R>
61
+ | ((
62
+ context: Values.Simplify<B>,
63
+ next: (
64
+ context?: Partial<B> & Record<string, unknown>,
65
+ ) => Entity.Entity<string>,
66
+ ) =>
67
+ | Stream.Stream<SseEventInput, E, R>
68
+ | Effect.Effect<Stream.Stream<SseEventInput, E, R>, E, R>
69
+ | Generator<
70
+ Utils.YieldWrap<Effect.Effect<unknown, E, R>>,
71
+ Stream.Stream<SseEventInput, E, R>,
72
+ unknown
73
+ >)
74
+
75
+ export function sse<
76
+ D extends Route.RouteDescriptor.Any,
77
+ B extends {},
78
+ I extends Route.Route.Tuple,
79
+ E = never,
80
+ R = never,
81
+ >(
82
+ handler: SseHandlerInput<
83
+ NoInfer<D & B & Route.ExtractBindings<I> & { format: "text" }>,
84
+ E,
85
+ R
86
+ >,
87
+ ) {
88
+ return function(
89
+ self: Route.RouteSet.RouteSet<D, B, I>,
90
+ ) {
91
+ const sseHandler: Route.Route.Handler<
92
+ D & B & Route.ExtractBindings<I> & { format: "text" },
93
+ Stream.Stream<string, E, R>,
94
+ E,
95
+ R
96
+ > = (ctx, _next) => {
97
+ const getStream = (): Effect.Effect<
98
+ Stream.Stream<SseEventInput, E, R>,
99
+ E,
100
+ R
101
+ > => {
102
+ if (typeof handler === "function") {
103
+ const result = (handler as Function)(ctx, _next)
104
+ if (StreamExtra.isStream(result)) {
105
+ return Effect.succeed(result as Stream.Stream<SseEventInput, E, R>)
106
+ }
107
+ if (Effect.isEffect(result)) {
108
+ return result as Effect.Effect<
109
+ Stream.Stream<SseEventInput, E, R>,
110
+ E,
111
+ R
112
+ >
113
+ }
114
+ return Effect.gen(function*() {
115
+ return yield* result
116
+ }) as Effect.Effect<Stream.Stream<SseEventInput, E, R>, E, R>
117
+ }
118
+ if (StreamExtra.isStream(handler)) {
119
+ return Effect.succeed(handler as Stream.Stream<SseEventInput, E, R>)
120
+ }
121
+ if (Effect.isEffect(handler)) {
122
+ return handler as Effect.Effect<
123
+ Stream.Stream<SseEventInput, E, R>,
124
+ E,
125
+ R
126
+ >
127
+ }
128
+ return Effect.succeed(Stream.empty)
129
+ }
130
+
131
+ return Effect.map(getStream(), (eventStream) => {
132
+ const formattedStream = Stream.map(eventStream, formatSseEvent)
133
+ const heartbeat = Stream
134
+ .repeat(
135
+ Stream.succeed(HEARTBEAT),
136
+ Schedule.spaced(HEARTBEAT_INTERVAL),
137
+ )
138
+ .pipe(Stream.drop(1))
139
+ const merged = Stream.merge(formattedStream, heartbeat, {
140
+ haltStrategy: "left",
141
+ })
142
+ return Entity.make(merged, {
143
+ headers: {
144
+ "content-type": "text/event-stream",
145
+ "cache-control": "no-cache",
146
+ "connection": "keep-alive",
147
+ },
148
+ })
149
+ })
150
+ }
151
+
152
+ const route = Route.make<
153
+ { format: "text" },
154
+ {},
155
+ Stream.Stream<string, E, R>,
156
+ E,
157
+ R
158
+ >(
159
+ sseHandler as any,
160
+ { format: "text" },
161
+ )
162
+
163
+ const items: [
164
+ ...I,
165
+ Route.Route.Route<
166
+ { format: "text" },
167
+ {},
168
+ Stream.Stream<string, E, R>,
169
+ E,
170
+ R
171
+ >,
172
+ ] = [
173
+ ...Route.items(self),
174
+ route,
175
+ ]
176
+
177
+ return Route.set<
178
+ D,
179
+ B,
180
+ [
181
+ ...I,
182
+ Route.Route.Route<
183
+ { format: "text" },
184
+ {},
185
+ Stream.Stream<string, E, R>,
186
+ E,
187
+ R
188
+ >,
189
+ ]
190
+ >(
191
+ items,
192
+ Route.descriptor(self),
193
+ )
194
+ }
195
+ }
package/src/Values.ts CHANGED
@@ -4,16 +4,18 @@ type JsonPrimitives =
4
4
  | boolean
5
5
  | null
6
6
 
7
+ export type JsonObject = {
8
+ [key: string]:
9
+ | Json
10
+ // undefined won't be included in JSON objects but this will allow
11
+ // to use Json type in functions that return object of multiple shapes
12
+ | undefined
13
+ }
14
+
7
15
  export type Json =
8
16
  | JsonPrimitives
9
17
  | Json[]
10
- | {
11
- [key: string]:
12
- | Json
13
- // undefined won't be included in JSON objects but this will allow
14
- // to use Json type in functions that return object of multiple shapes
15
- | undefined
16
- }
18
+ | JsonObject
17
19
 
18
20
  export function isPlainObject(
19
21
  value: unknown,
@@ -9,10 +9,7 @@ import {
9
9
  Iterable,
10
10
  Layer,
11
11
  pipe,
12
- PubSub,
13
12
  Record,
14
- Stream,
15
- SynchronizedRef,
16
13
  } from "effect"
17
14
  import * as NPath from "node:path"
18
15
  import type {
@@ -20,7 +17,6 @@ import type {
20
17
  BundleManifest,
21
18
  } from "../bundler/Bundle.ts"
22
19
  import * as Bundle from "../bundler/Bundle.ts"
23
- import * as FileSystemExtra from "../FileSystemExtra.ts"
24
20
  import { BunImportTrackerPlugin } from "./index.ts"
25
21
 
26
22
  export type BuildOptions = Omit<
@@ -125,75 +121,6 @@ export function layer<T>(
125
121
  return Layer.effect(tag, build(config))
126
122
  }
127
123
 
128
- export function layerDev<T>(
129
- tag: Context.Tag<T, BundleContext>,
130
- config: BuildOptions,
131
- ) {
132
- return Layer.scoped(
133
- tag,
134
- Effect.gen(function*() {
135
- const loadRefKey = "_loadRef"
136
- const sharedBundle = yield* build(config)
137
-
138
- const loadRef = yield* SynchronizedRef.make(null)
139
- sharedBundle[loadRefKey] = loadRef
140
- sharedBundle.events = yield* PubSub.unbounded<Bundle.BundleEvent>()
141
-
142
- yield* Effect.fork(
143
- pipe(
144
- FileSystemExtra.watchSource({
145
- filter: FileSystemExtra.filterSourceFiles,
146
- }),
147
- Stream.map(v =>
148
- ({
149
- _tag: "Change",
150
- path: v.path,
151
- }) as Bundle.BundleEvent
152
- ),
153
- Stream.onError(err =>
154
- Effect.logError("Error while watching files", err)
155
- ),
156
- Stream.runForEach((v) =>
157
- pipe(
158
- Effect.gen(function*() {
159
- yield* Effect.logDebug("Updating bundle: " + tag.key)
160
-
161
- const newBundle = yield* build(config)
162
-
163
- Object.assign(sharedBundle, newBundle)
164
-
165
- // Clean old loaded bundle
166
- yield* SynchronizedRef.update(loadRef, () => null)
167
-
168
- // publish event after the built
169
- if (sharedBundle.events) {
170
- yield* PubSub.publish(sharedBundle.events, v)
171
- }
172
- }),
173
- Effect.catchAll(err =>
174
- Effect.gen(function*() {
175
- yield* Effect.logError(
176
- "Error while updating bundle",
177
- err,
178
- )
179
- if (sharedBundle.events) {
180
- yield* PubSub.publish(sharedBundle.events, {
181
- _tag: "BuildError",
182
- error: String(err),
183
- })
184
- }
185
- })
186
- ),
187
- )
188
- ),
189
- ),
190
- )
191
-
192
- return sharedBundle
193
- }),
194
- )
195
- }
196
-
197
124
  /**
198
125
  * Finds common path prefix across provided paths.
199
126
  */
@@ -162,8 +162,6 @@ export function htmlBundle(
162
162
  }
163
163
  }
164
164
 
165
-
166
-
167
165
  type BunServerFetchHandler = (
168
166
  request: Request,
169
167
  server: Bun.Server<unknown>,
@@ -88,3 +88,122 @@ test.it("mixed boolean and string attributes", () => {
88
88
  .expect(html)
89
89
  .toBe("<input type=\"checkbox\" checked name=\"test\" value=\"on\">")
90
90
  })
91
+
92
+ test.it("data-* attributes with object values are JSON stringified", () => {
93
+ const node = HyperNode.make("div", {
94
+ "data-signals": {
95
+ draft: "",
96
+ pendingDraft: "",
97
+ username: "User123",
98
+ },
99
+ })
100
+
101
+ const html = HyperHtml.renderToString(node)
102
+
103
+ test
104
+ .expect(html)
105
+ .toBe(
106
+ "<div data-signals=\"{&quot;draft&quot;:&quot;&quot;,&quot;pendingDraft&quot;:&quot;&quot;,&quot;username&quot;:&quot;User123&quot;}\"></div>",
107
+ )
108
+ })
109
+
110
+ test.it("data-* attributes with array values are JSON stringified", () => {
111
+ const node = HyperNode.make("div", {
112
+ "data-items": [1, 2, 3],
113
+ })
114
+
115
+ const html = HyperHtml.renderToString(node)
116
+
117
+ test
118
+ .expect(html)
119
+ .toBe("<div data-items=\"[1,2,3]\"></div>")
120
+ })
121
+
122
+ test.it("data-* attributes with nested object values", () => {
123
+ const node = HyperNode.make("div", {
124
+ "data-config": {
125
+ user: { name: "John", active: true },
126
+ settings: { theme: "dark" },
127
+ },
128
+ })
129
+
130
+ const html = HyperHtml.renderToString(node)
131
+
132
+ test
133
+ .expect(html)
134
+ .toBe(
135
+ "<div data-config=\"{&quot;user&quot;:{&quot;name&quot;:&quot;John&quot;,&quot;active&quot;:true},&quot;settings&quot;:{&quot;theme&quot;:&quot;dark&quot;}}\"></div>",
136
+ )
137
+ })
138
+
139
+ test.it("data-* string values are not JSON stringified", () => {
140
+ const node = HyperNode.make("div", {
141
+ "data-value": "hello world",
142
+ })
143
+
144
+ const html = HyperHtml.renderToString(node)
145
+
146
+ test
147
+ .expect(html)
148
+ .toBe("<div data-value=\"hello world\"></div>")
149
+ })
150
+
151
+ test.it("non-data attributes with object values are not JSON stringified", () => {
152
+ const node = HyperNode.make("div", {
153
+ style: "color: red",
154
+ })
155
+
156
+ const html = HyperHtml.renderToString(node)
157
+
158
+ test
159
+ .expect(html)
160
+ .toBe("<div style=\"color: red\"></div>")
161
+ })
162
+
163
+ test.it("script with function child renders as IIFE", () => {
164
+ const handler = (window: Window) => {
165
+ console.log("Hello from", window.document.title)
166
+ }
167
+
168
+ const node = HyperNode.make("script", {
169
+ children: handler,
170
+ })
171
+
172
+ const html = HyperHtml.renderToString(node)
173
+
174
+ test
175
+ .expect(html)
176
+ .toBe(`<script>(${handler.toString()})(window)</script>`)
177
+ })
178
+
179
+ test.it("script with arrow function child renders as IIFE", () => {
180
+ const node = HyperNode.make("script", {
181
+ children: (window: Window) => {
182
+ window.alert("test")
183
+ },
184
+ })
185
+
186
+ const html = HyperHtml.renderToString(node)
187
+
188
+ test
189
+ .expect(html)
190
+ .toContain("<script>(")
191
+ test
192
+ .expect(html)
193
+ .toContain(")(window)</script>")
194
+ test
195
+ .expect(html)
196
+ .toContain("window.alert")
197
+ })
198
+
199
+ test.it("script with string child renders normally", () => {
200
+ const node = HyperNode.make("script", {
201
+ children: "console.log('hello')",
202
+ })
203
+
204
+ const html = HyperHtml.renderToString(node)
205
+
206
+ test
207
+ .expect(html)
208
+ .toBe("<script>console.log(&apos;hello&apos;)</script>")
209
+ })