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
@@ -0,0 +1,162 @@
1
+ import * as test from "bun:test"
2
+ import * as Effect from "effect/Effect"
3
+ import * as Layer from "effect/Layer"
4
+ import * as Option from "effect/Option"
5
+ import * as Route from "../Route.ts"
6
+ import * as BunHttpServer from "./BunHttpServer.ts"
7
+ import * as BunRoute from "./BunRoute.ts"
8
+
9
+ const layerServer = Layer.scoped(
10
+ BunHttpServer.BunHttpServer,
11
+ BunHttpServer.make({ port: 0 }),
12
+ )
13
+
14
+ const testLayer = (routes: ReturnType<typeof Route.tree>) =>
15
+ Layer.provideMerge(
16
+ BunHttpServer.layerRoutes(routes),
17
+ layerServer,
18
+ )
19
+
20
+ test.describe(BunRoute.htmlBundle, () => {
21
+ test.test("wraps child content with layout", async () => {
22
+ const routes = Route.tree({
23
+ "/": Route.get(
24
+ BunRoute.htmlBundle(() => import("../../static/LayoutSlots.html")),
25
+ Route.html("<p>Hello World</p>"),
26
+ ),
27
+ })
28
+
29
+ const response = await Effect.runPromise(
30
+ Effect.scoped(
31
+ Effect
32
+ .gen(function*() {
33
+ const bunServer = yield* BunHttpServer.BunHttpServer
34
+ return yield* Effect.promise(() =>
35
+ fetch(`http://localhost:${bunServer.server.port}/`)
36
+ )
37
+ })
38
+ .pipe(Effect.provide(testLayer(routes))),
39
+ ),
40
+ )
41
+
42
+ test.expect(response.status).toBe(200)
43
+ const html = await response.text()
44
+ test.expect(html).toContain("<p>Hello World</p>")
45
+ test.expect(html).toContain("<body>")
46
+ test.expect(html).toContain("</body>")
47
+ })
48
+
49
+ test.test("replaces %yield% with child content", async () => {
50
+ const routes = Route.tree({
51
+ "/page": Route.get(
52
+ BunRoute.htmlBundle(() => import("../../static/LayoutSlots.html")),
53
+ Route.html("<div>Page Content</div>"),
54
+ ),
55
+ })
56
+
57
+ const response = await Effect.runPromise(
58
+ Effect.scoped(
59
+ Effect
60
+ .gen(function*() {
61
+ const bunServer = yield* BunHttpServer.BunHttpServer
62
+ return yield* Effect.promise(() =>
63
+ fetch(`http://localhost:${bunServer.server.port}/page`)
64
+ )
65
+ })
66
+ .pipe(Effect.provide(testLayer(routes))),
67
+ ),
68
+ )
69
+
70
+ const html = await response.text()
71
+ test.expect(html).toContain("<div>Page Content</div>")
72
+ test.expect(html).not.toContain("%children%")
73
+ })
74
+
75
+ test.test("works with use() for wildcard routes", async () => {
76
+ const routes = Route.tree({
77
+ "*": Route.use(
78
+ BunRoute.htmlBundle(() => import("../../static/LayoutSlots.html")),
79
+ ),
80
+ "/:path*": Route.get(Route.html("<section>Catch All</section>")),
81
+ })
82
+
83
+ const response = await Effect.runPromise(
84
+ Effect.scoped(
85
+ Effect
86
+ .gen(function*() {
87
+ const bunServer = yield* BunHttpServer.BunHttpServer
88
+ return yield* Effect.promise(() =>
89
+ fetch(`http://localhost:${bunServer.server.port}/any/path`)
90
+ )
91
+ })
92
+ .pipe(Effect.provide(testLayer(routes))),
93
+ ),
94
+ )
95
+
96
+ test.expect(response.status).toBe(200)
97
+ const html = await response.text()
98
+ test.expect(html).toContain("<section>Catch All</section>")
99
+ })
100
+
101
+ test.test("has format: html descriptor", async () => {
102
+ const routes = Route.tree({
103
+ "/": Route.get(
104
+ BunRoute.htmlBundle(() => import("../../static/LayoutSlots.html")),
105
+ Route.html("<p>content</p>"),
106
+ ),
107
+ })
108
+
109
+ const response = await Effect.runPromise(
110
+ Effect.scoped(
111
+ Effect
112
+ .gen(function*() {
113
+ const bunServer = yield* BunHttpServer.BunHttpServer
114
+ return yield* Effect.promise(() =>
115
+ fetch(`http://localhost:${bunServer.server.port}/`)
116
+ )
117
+ })
118
+ .pipe(Effect.provide(testLayer(routes))),
119
+ ),
120
+ )
121
+
122
+ test.expect(response.status).toBe(200)
123
+ const contentType = response.headers.get("content-type")
124
+ test.expect(contentType).toContain("text/html")
125
+ })
126
+ })
127
+
128
+ test.describe(BunRoute.validateBunPattern, () => {
129
+ test.test("returns none for valid patterns", () => {
130
+ test
131
+ .expect(Option.isNone(BunRoute.validateBunPattern("/users")))
132
+ .toBe(true)
133
+ test
134
+ .expect(Option.isNone(BunRoute.validateBunPattern("/users/[id]")))
135
+ .toBe(true)
136
+ test
137
+ .expect(Option.isNone(BunRoute.validateBunPattern("/[...rest]")))
138
+ .toBe(true)
139
+ })
140
+
141
+ test.test("returns error for prefixed params", () => {
142
+ const result = BunRoute.validateBunPattern("/pk_[id]")
143
+ test.expect(Option.isSome(result)).toBe(true)
144
+ })
145
+
146
+ test.test("returns error for suffixed params", () => {
147
+ const result = BunRoute.validateBunPattern("/[id]_suffix")
148
+ test.expect(Option.isSome(result)).toBe(true)
149
+ })
150
+ })
151
+
152
+ test.describe(BunRoute.isHtmlBundle, () => {
153
+ test.test("returns false for non-objects", () => {
154
+ test.expect(BunRoute.isHtmlBundle(null)).toBe(false)
155
+ test.expect(BunRoute.isHtmlBundle(undefined)).toBe(false)
156
+ test.expect(BunRoute.isHtmlBundle("string")).toBe(false)
157
+ })
158
+
159
+ test.test("returns true for object with index property", () => {
160
+ test.expect(BunRoute.isHtmlBundle({ index: "index.html" })).toBe(true)
161
+ })
162
+ })
@@ -1,11 +1,9 @@
1
- // @ts-nocheck
2
- import * as HttpServerRequest from "@effect/platform/HttpServerRequest"
3
- import * as HttpServerResponse from "@effect/platform/HttpServerResponse"
4
1
  import type * as Bun from "bun"
5
2
  import * as Array from "effect/Array"
3
+ import * as Data from "effect/Data"
6
4
  import * as Effect from "effect/Effect"
7
5
  import * as Option from "effect/Option"
8
- import * as Predicate from "effect/Predicate"
6
+ import * as Entity from "../Entity.ts"
9
7
  import * as Hyper from "../hyper/Hyper.ts"
10
8
  import * as HyperHtml from "../hyper/HyperHtml.ts"
11
9
  import * as Random from "../Random.ts"
@@ -13,106 +11,155 @@ import * as Route from "../Route.ts"
13
11
  import * as RouterPattern from "../RouterPattern.ts"
14
12
  import * as BunHttpServer from "./BunHttpServer.ts"
15
13
 
16
- const BunHandlerTypeId: unique symbol = Symbol.for("effect-start/BunHandler")
17
-
18
14
  const INTERNAL_FETCH_HEADER = "x-effect-start-internal-fetch"
19
15
 
20
- export type BunHandler =
21
- & Route.RouteHandler<string, Router.RouterError, BunHttpServer.BunHttpServer>
22
- & {
23
- [BunHandlerTypeId]: typeof BunHandlerTypeId
24
- internalPathPrefix: string
25
- load: () => Promise<Bun.HTMLBundle>
26
- }
16
+ export class BunRouteError extends Data.TaggedError("BunRouteError")<{
17
+ reason: "ProxyError" | "UnsupportedPattern"
18
+ pattern: string
19
+ message: string
20
+ }> {}
21
+
22
+ export type BunDescriptors = {
23
+ bunPrefix: string
24
+ bunLoad: () => Promise<Bun.HTMLBundle>
25
+ }
27
26
 
28
- export function isBunHandler(input: unknown): input is BunHandler {
29
- return typeof input === "function"
30
- && Predicate.hasProperty(input, BunHandlerTypeId)
27
+ export function descriptors(
28
+ route: Route.Route.Route,
29
+ ): BunDescriptors | undefined {
30
+ const descriptor = Route.descriptor(route) as Partial<BunDescriptors>
31
+ if (
32
+ typeof descriptor.bunPrefix === "string"
33
+ && typeof descriptor.bunLoad === "function"
34
+ ) {
35
+ return descriptor as BunDescriptors
36
+ }
37
+ return undefined
31
38
  }
32
39
 
33
- export function bundle(
40
+ export function htmlBundle(
34
41
  load: () => Promise<Bun.HTMLBundle | { default: Bun.HTMLBundle }>,
35
- ): BunHandler {
36
- const internalPathPrefix = `/.BunRoute-${Random.token(6)}`
37
-
38
- const handler = (context: Route.RouteContext, next: Route.RouteNext) =>
39
- Effect.gen(function*() {
40
- const request = yield* HttpServerRequest.HttpServerRequest
41
- const originalRequest = request.source as Request
42
-
43
- if (
44
- originalRequest.headers.get(INTERNAL_FETCH_HEADER) === "true"
45
- ) {
46
- return yield* Effect.fail(
47
- new Router.RouterError({
48
- reason: "ProxyError",
49
- pattern: context.url.pathname,
50
- message:
51
- "Request to internal Bun server was caught by BunRoute handler. This should not happen. Please report a bug.",
52
- }),
53
- )
54
- }
55
-
56
- const bunServer = yield* BunHttpServer.BunHttpServer
57
-
58
- const internalPath = `${internalPathPrefix}${context.url.pathname}`
59
- const internalUrl = new URL(internalPath, bunServer.server.url)
60
-
61
- const headers = new Headers(originalRequest.headers)
62
- headers.set(INTERNAL_FETCH_HEADER, "true")
63
-
64
- const proxyRequest = new Request(internalUrl, {
65
- method: originalRequest.method,
66
- headers,
67
- })
42
+ ) {
43
+ const bunPrefix = `/.BunRoute-${Random.token(6)}`
44
+ const bunLoad = () => load().then(mod => "default" in mod ? mod.default : mod)
45
+ const descriptors = { bunPrefix, bunLoad, format: "html" as const }
46
+
47
+ return function<
48
+ D extends Route.RouteDescriptor.Any,
49
+ B extends {},
50
+ I extends Route.Route.Tuple,
51
+ >(
52
+ self: Route.RouteSet.RouteSet<D, B, I>,
53
+ ): Route.RouteSet.RouteSet<
54
+ D,
55
+ B,
56
+ [
57
+ ...I,
58
+ Route.Route.Route<
59
+ BunDescriptors & { format: "html" },
60
+ { request: Request },
61
+ string,
62
+ BunRouteError,
63
+ BunHttpServer.BunHttpServer
64
+ >,
65
+ ]
66
+ > {
67
+ const handler: Route.Route.Handler<
68
+ BunDescriptors & { format: "html" } & { request: Request },
69
+ string,
70
+ BunRouteError,
71
+ BunHttpServer.BunHttpServer
72
+ > = (context, next) =>
73
+ Effect.gen(function*() {
74
+ const originalRequest = context.request
75
+
76
+ if (
77
+ originalRequest.headers.get(INTERNAL_FETCH_HEADER) === "true"
78
+ ) {
79
+ const url = new URL(originalRequest.url)
80
+ return yield* Effect.fail(
81
+ new BunRouteError({
82
+ reason: "ProxyError",
83
+ pattern: url.pathname,
84
+ message:
85
+ "Request to internal Bun server was caught by BunRoute handler. This should not happen. Please report a bug.",
86
+ }),
87
+ )
88
+ }
68
89
 
69
- const response = yield* Effect.tryPromise({
70
- try: () => fetch(proxyRequest),
71
- catch: (error) =>
72
- new Router.RouterError({
73
- reason: "ProxyError",
74
- pattern: internalPath,
75
- message: `Failed to fetch internal HTML bundle: ${String(error)}`,
76
- }),
77
- })
90
+ const bunServer = yield* BunHttpServer.BunHttpServer
91
+ const url = new URL(originalRequest.url)
92
+
93
+ const internalPath = `${bunPrefix}${url.pathname}`
94
+ const internalUrl = new URL(internalPath, bunServer.server.url)
95
+
96
+ const headers = new Headers(originalRequest.headers)
97
+ headers.set(INTERNAL_FETCH_HEADER, "true")
98
+
99
+ const proxyRequest = new Request(internalUrl, {
100
+ method: originalRequest.method,
101
+ headers,
102
+ })
103
+
104
+ const response = yield* Effect.tryPromise({
105
+ try: () => fetch(proxyRequest),
106
+ catch: (error) =>
107
+ new BunRouteError({
108
+ reason: "ProxyError",
109
+ pattern: internalPath,
110
+ message: `Failed to fetch internal HTML bundle: ${String(error)}`,
111
+ }),
112
+ })
113
+
114
+ let html = yield* Effect.tryPromise({
115
+ try: () => response.text(),
116
+ catch: (error) =>
117
+ new BunRouteError({
118
+ reason: "ProxyError",
119
+ pattern: internalPath,
120
+ message: String(error),
121
+ }),
122
+ })
123
+
124
+ const childEntity = yield* Entity.resolve(next(context))
125
+ const children = childEntity?.body ?? childEntity
126
+
127
+ let childrenHtml = ""
128
+ if (children != null) {
129
+ if ((children as unknown) instanceof Response) {
130
+ childrenHtml = yield* Effect.promise(() =>
131
+ (children as unknown as Response).text()
132
+ )
133
+ } else if (Hyper.isGenericJsxObject(children)) {
134
+ childrenHtml = HyperHtml.renderToString(children)
135
+ } else {
136
+ childrenHtml = String(children)
137
+ }
138
+ }
139
+
140
+ html = html.replace(/%children%/g, childrenHtml)
78
141
 
79
- let html = yield* Effect.tryPromise({
80
- try: () => response.text(),
81
- catch: (error) =>
82
- new Router.RouterError({
83
- reason: "ProxyError",
84
- pattern: internalPath,
85
- message: String(error),
86
- }),
142
+ return Entity.make(html, {
143
+ status: response.status,
144
+ headers: {
145
+ "content-type": response.headers.get("content-type"),
146
+ },
147
+ })
87
148
  })
88
149
 
89
- const children = yield* next()
90
- let childrenHtml = ""
91
- if (children != null) {
92
- if (HttpServerResponse.isServerResponse(children)) {
93
- const webResponse = HttpServerResponse.toWeb(children)
94
- childrenHtml = yield* Effect.promise(() => webResponse.text())
95
- } else if (Hyper.isGenericJsxObject(children)) {
96
- childrenHtml = HyperHtml.renderToString(children)
97
- } else {
98
- childrenHtml = String(children)
99
- }
100
- }
101
-
102
- html = html.replace(/%yield%/g, childrenHtml)
103
- html = html.replace(
104
- /%slots\.(\w+)%/g,
105
- (_, name) => context.slots[name] ?? "",
106
- )
107
-
108
- return html
109
- })
110
-
111
- return Object.assign(handler, {
112
- [BunHandlerTypeId]: BunHandlerTypeId,
113
- internalPathPrefix,
114
- load: () => load().then(mod => "default" in mod ? mod.default : mod),
115
- }) as BunHandler
150
+ const route = Route.make<
151
+ BunDescriptors & { format: "html" },
152
+ { request: Request },
153
+ string,
154
+ BunRouteError,
155
+ BunHttpServer.BunHttpServer
156
+ >(handler, descriptors)
157
+
158
+ return Route.set(
159
+ [...Route.items(self), route] as any,
160
+ Route.descriptor(self),
161
+ )
162
+ }
116
163
  }
117
164
 
118
165
  type BunServerFetchHandler = (
@@ -127,14 +174,6 @@ type BunServerRouteHandler =
127
174
 
128
175
  export type BunRoutes = Record<string, BunServerRouteHandler>
129
176
 
130
- type MethodHandlers = Partial<
131
- Record<Bun.Serve.HTTPMethod, BunServerFetchHandler>
132
- >
133
-
134
- function isMethodHandlers(value: unknown): value is MethodHandlers {
135
- return typeof value === "object" && value !== null && !("index" in value)
136
- }
137
-
138
177
  /**
139
178
  * Validates that a route pattern can be implemented with Bun.serve routes.
140
179
  *
@@ -156,7 +195,7 @@ function isMethodHandlers(value: unknown): value is MethodHandlers {
156
195
 
157
196
  export function validateBunPattern(
158
197
  pattern: string,
159
- ): Option.Option<Router.RouterError> {
198
+ ): Option.Option<BunRouteError> {
160
199
  const segments = RouterPattern.parse(pattern)
161
200
 
162
201
  const unsupported = Array.findFirst(segments, (seg) => {
@@ -169,7 +208,7 @@ export function validateBunPattern(
169
208
 
170
209
  if (Option.isSome(unsupported)) {
171
210
  return Option.some(
172
- new Router.RouterError({
211
+ new BunRouteError({
173
212
  reason: "UnsupportedPattern",
174
213
  pattern,
175
214
  message:
@@ -182,7 +221,7 @@ export function validateBunPattern(
182
221
  return Option.none()
183
222
  }
184
223
 
185
- export const isHTMLBundle = (handle: any) => {
224
+ export const isHtmlBundle = (handle: any) => {
186
225
  return (
187
226
  typeof handle === "object"
188
227
  && handle !== null
@@ -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
+ })
@@ -120,8 +120,13 @@ export function renderToString(
120
120
  result += ` ${esc(key)}`
121
121
  } else {
122
122
  const resolvedKey = key === "className" ? "class" : key
123
+ const value = props[key]
123
124
 
124
- result += ` ${esc(resolvedKey)}="${esc(props[key])}"`
125
+ if (key.startsWith("data-") && typeof value === "object") {
126
+ result += ` ${esc(resolvedKey)}="${esc(JSON.stringify(value))}"`
127
+ } else {
128
+ result += ` ${esc(resolvedKey)}="${esc(value)}"`
129
+ }
125
130
  }
126
131
  }
127
132
  }
@@ -139,7 +144,10 @@ export function renderToString(
139
144
  result += html
140
145
  } else {
141
146
  const children = props.children
142
- if (Array.isArray(children)) {
147
+
148
+ if (type === "script" && typeof children === "function") {
149
+ result += `(${children.toString()})(window)`
150
+ } else if (Array.isArray(children)) {
143
151
  for (let i = children.length - 1; i >= 0; i--) {
144
152
  stack.push(children[i])
145
153
  }
@@ -12,6 +12,8 @@ export type Props = {
12
12
  | Primitive
13
13
  | HyperNode
14
14
  | Iterable<Primitive | HyperNode>
15
+ | Record<string, unknown>
16
+ | ((window: Window) => void)
15
17
  }
16
18
 
17
19
  export type HyperComponent = (