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 +2 -1
- package/src/Development.test.ts +119 -0
- package/src/Development.ts +137 -0
- package/src/Entity.test.ts +1 -1
- package/src/Entity.ts +3 -6
- package/src/FileRouter.ts +2 -2
- package/src/Route.ts +4 -0
- package/src/RouteBody.test.ts +25 -45
- package/src/RouteBody.ts +11 -8
- package/src/RouteHttp.test.ts +4 -1
- package/src/RouteHttp.ts +22 -6
- package/src/RouteSse.test.ts +249 -0
- package/src/RouteSse.ts +195 -0
- package/src/Values.ts +9 -7
- package/src/bun/BunBundle.ts +0 -73
- package/src/bun/BunRoute.ts +0 -2
- package/src/hyper/HyperHtml.test.ts +119 -0
- package/src/hyper/HyperHtml.ts +10 -2
- package/src/hyper/HyperNode.ts +2 -0
- package/src/hyper/HyperRoute.test.tsx +197 -0
- package/src/hyper/HyperRoute.ts +61 -0
- package/src/hyper/index.ts +4 -0
- package/src/hyper/jsx.d.ts +15 -0
- package/src/index.ts +1 -0
- package/src/node/FileSystem.ts +8 -0
- package/src/FileSystemExtra.test.ts +0 -242
- package/src/FileSystemExtra.ts +0 -66
|
@@ -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
|
+
})
|
package/src/RouteSse.ts
ADDED
|
@@ -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,
|
package/src/bun/BunBundle.ts
CHANGED
|
@@ -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
|
*/
|
package/src/bun/BunRoute.ts
CHANGED
|
@@ -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=\"{"draft":"","pendingDraft":"","username":"User123"}\"></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=\"{"user":{"name":"John","active":true},"settings":{"theme":"dark"}}\"></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('hello')</script>")
|
|
209
|
+
})
|