effect-start 0.9.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/LICENSE +21 -0
- package/README.md +109 -0
- package/package.json +57 -0
- package/src/Bundle.ts +167 -0
- package/src/BundleFiles.ts +174 -0
- package/src/BundleHttp.test.ts +160 -0
- package/src/BundleHttp.ts +259 -0
- package/src/Commander.test.ts +1378 -0
- package/src/Commander.ts +672 -0
- package/src/Datastar.test.ts +267 -0
- package/src/Datastar.ts +68 -0
- package/src/Effect_HttpRouter.test.ts +570 -0
- package/src/EncryptedCookies.test.ts +427 -0
- package/src/EncryptedCookies.ts +451 -0
- package/src/FileHttpRouter.test.ts +207 -0
- package/src/FileHttpRouter.ts +122 -0
- package/src/FileRouter.ts +405 -0
- package/src/FileRouterCodegen.test.ts +598 -0
- package/src/FileRouterCodegen.ts +251 -0
- package/src/FileRouter_files.test.ts +64 -0
- package/src/FileRouter_path.test.ts +132 -0
- package/src/FileRouter_tree.test.ts +126 -0
- package/src/FileSystemExtra.ts +102 -0
- package/src/HttpAppExtra.ts +127 -0
- package/src/Hyper.ts +194 -0
- package/src/HyperHtml.test.ts +90 -0
- package/src/HyperHtml.ts +139 -0
- package/src/HyperNode.ts +37 -0
- package/src/JsModule.test.ts +14 -0
- package/src/JsModule.ts +116 -0
- package/src/PublicDirectory.test.ts +280 -0
- package/src/PublicDirectory.ts +108 -0
- package/src/Route.test.ts +873 -0
- package/src/Route.ts +992 -0
- package/src/Router.ts +80 -0
- package/src/SseHttpResponse.ts +55 -0
- package/src/Start.ts +133 -0
- package/src/StartApp.ts +43 -0
- package/src/StartHttp.ts +42 -0
- package/src/StreamExtra.ts +146 -0
- package/src/TestHttpClient.test.ts +54 -0
- package/src/TestHttpClient.ts +100 -0
- package/src/bun/BunBundle.test.ts +277 -0
- package/src/bun/BunBundle.ts +309 -0
- package/src/bun/BunBundle_imports.test.ts +50 -0
- package/src/bun/BunFullstackServer.ts +45 -0
- package/src/bun/BunFullstackServer_httpServer.ts +541 -0
- package/src/bun/BunImportTrackerPlugin.test.ts +77 -0
- package/src/bun/BunImportTrackerPlugin.ts +97 -0
- package/src/bun/BunTailwindPlugin.test.ts +335 -0
- package/src/bun/BunTailwindPlugin.ts +322 -0
- package/src/bun/BunVirtualFilesPlugin.ts +59 -0
- package/src/bun/index.ts +4 -0
- package/src/client/Overlay.ts +34 -0
- package/src/client/ScrollState.ts +120 -0
- package/src/client/index.ts +101 -0
- package/src/index.ts +24 -0
- package/src/jsx-datastar.d.ts +63 -0
- package/src/jsx-runtime.ts +23 -0
- package/src/jsx.d.ts +4402 -0
- package/src/testing.ts +55 -0
- package/src/x/cloudflare/CloudflareTunnel.ts +110 -0
- package/src/x/cloudflare/index.ts +1 -0
- package/src/x/datastar/Datastar.test.ts +267 -0
- package/src/x/datastar/Datastar.ts +68 -0
- package/src/x/datastar/index.ts +4 -0
- package/src/x/datastar/jsx-datastar.d.ts +63 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HttpRouter,
|
|
3
|
+
HttpServerResponse,
|
|
4
|
+
} from "@effect/platform"
|
|
5
|
+
import * as HttpApp from "@effect/platform/HttpApp"
|
|
6
|
+
import * as HttpMiddleware from "@effect/platform/HttpMiddleware"
|
|
7
|
+
import {
|
|
8
|
+
RequestError,
|
|
9
|
+
RouteNotFound,
|
|
10
|
+
} from "@effect/platform/HttpServerError"
|
|
11
|
+
import {
|
|
12
|
+
Cause,
|
|
13
|
+
Effect,
|
|
14
|
+
Option,
|
|
15
|
+
ParseResult,
|
|
16
|
+
Record,
|
|
17
|
+
} from "effect"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Groups: function, path
|
|
21
|
+
*/
|
|
22
|
+
const StackLinePattern = /^at (.*?) \((.*?)\)/
|
|
23
|
+
|
|
24
|
+
type GraciousError =
|
|
25
|
+
| RouteNotFound
|
|
26
|
+
| ParseResult.ParseError
|
|
27
|
+
| RequestError
|
|
28
|
+
| ParseResult.ParseError
|
|
29
|
+
|
|
30
|
+
export const renderError = (
|
|
31
|
+
error: unknown,
|
|
32
|
+
) =>
|
|
33
|
+
Effect.gen(function*() {
|
|
34
|
+
let unwrappedError: GraciousError | undefined
|
|
35
|
+
|
|
36
|
+
if (Cause.isCause(error)) {
|
|
37
|
+
const failure = Cause.failureOption(error).pipe(Option.getOrUndefined)
|
|
38
|
+
|
|
39
|
+
if (failure?.["_tag"]) {
|
|
40
|
+
unwrappedError = failure as GraciousError
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
yield* Effect.logError(error)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
switch (unwrappedError?._tag) {
|
|
47
|
+
case "RouteNotFound":
|
|
48
|
+
return yield* HttpServerResponse.unsafeJson({
|
|
49
|
+
error: {
|
|
50
|
+
_tag: unwrappedError._tag,
|
|
51
|
+
},
|
|
52
|
+
}, {
|
|
53
|
+
status: 404,
|
|
54
|
+
})
|
|
55
|
+
case "RequestError": {
|
|
56
|
+
const message = unwrappedError.reason === "Decode"
|
|
57
|
+
? "Request body is invalid"
|
|
58
|
+
: undefined
|
|
59
|
+
|
|
60
|
+
return yield* HttpServerResponse.unsafeJson({
|
|
61
|
+
error: {
|
|
62
|
+
_tag: unwrappedError._tag,
|
|
63
|
+
reason: unwrappedError.reason,
|
|
64
|
+
message,
|
|
65
|
+
},
|
|
66
|
+
}, {
|
|
67
|
+
status: 400,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
case "ParseError": {
|
|
71
|
+
const issues = yield* ParseResult.ArrayFormatter.formatIssue(
|
|
72
|
+
unwrappedError.issue,
|
|
73
|
+
)
|
|
74
|
+
const cleanIssues = issues.map(v => Record.remove(v, "_tag"))
|
|
75
|
+
|
|
76
|
+
return yield* HttpServerResponse.unsafeJson({
|
|
77
|
+
error: {
|
|
78
|
+
_tag: unwrappedError._tag,
|
|
79
|
+
issues: cleanIssues,
|
|
80
|
+
},
|
|
81
|
+
}, {
|
|
82
|
+
status: 400,
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return yield* HttpServerResponse.unsafeJson({
|
|
88
|
+
error: {
|
|
89
|
+
_tag: "UnexpectedError",
|
|
90
|
+
},
|
|
91
|
+
}, {
|
|
92
|
+
status: 500,
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
function extractPrettyStack(stack: string) {
|
|
97
|
+
return stack
|
|
98
|
+
.split("\n")
|
|
99
|
+
.slice(1)
|
|
100
|
+
.map((line) => {
|
|
101
|
+
const match = line.trim().match(StackLinePattern)
|
|
102
|
+
|
|
103
|
+
if (!match) return line
|
|
104
|
+
|
|
105
|
+
const [_, fn, path] = match
|
|
106
|
+
const relativePath = path.replace(process.cwd(), ".")
|
|
107
|
+
return [fn, relativePath]
|
|
108
|
+
})
|
|
109
|
+
.filter(Boolean)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function handleErrors<
|
|
113
|
+
E,
|
|
114
|
+
R,
|
|
115
|
+
>(
|
|
116
|
+
app: HttpApp.Default<E, R>,
|
|
117
|
+
): HttpApp.Default<Exclude<E, RouteNotFound>, R> {
|
|
118
|
+
return app.pipe(
|
|
119
|
+
Effect.catchAllCause(renderError),
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const withErrorHandled = HttpMiddleware.make(app => {
|
|
124
|
+
return app.pipe(
|
|
125
|
+
Effect.catchAllCause(renderError),
|
|
126
|
+
)
|
|
127
|
+
})
|
package/src/Hyper.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import * as HttpApp from "@effect/platform/HttpApp"
|
|
2
|
+
import * as HttpServerResponse from "@effect/platform/HttpServerResponse"
|
|
3
|
+
import * as Context from "effect/Context"
|
|
4
|
+
import * as Effect from "effect/Effect"
|
|
5
|
+
import * as Effectable from "effect/Effectable"
|
|
6
|
+
import * as Fiber from "effect/Fiber"
|
|
7
|
+
import * as Function from "effect/Function"
|
|
8
|
+
import * as Layer from "effect/Layer"
|
|
9
|
+
import * as Option from "effect/Option"
|
|
10
|
+
import * as Pipeable from "effect/Pipeable"
|
|
11
|
+
import { YieldWrap } from "effect/Utils"
|
|
12
|
+
import * as HyperHtml from "./HyperHtml.ts"
|
|
13
|
+
import type { JSX } from "./jsx.d.ts"
|
|
14
|
+
import { HyperHooks } from "./x/datastar/index.ts"
|
|
15
|
+
|
|
16
|
+
const TypeId = Symbol.for("~hyper/TypeId")
|
|
17
|
+
const LayoutTypeId = Symbol.for("~hyper/LayoutTypeId")
|
|
18
|
+
|
|
19
|
+
type Elements = JSX.IntrinsicElements
|
|
20
|
+
|
|
21
|
+
type Children = JSX.Children
|
|
22
|
+
|
|
23
|
+
export type {
|
|
24
|
+
Children,
|
|
25
|
+
Elements,
|
|
26
|
+
JSX,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class Hyper extends Context.Tag("Hyper")<Hyper, {
|
|
30
|
+
hooks: typeof HyperHooks | undefined
|
|
31
|
+
}>() {}
|
|
32
|
+
|
|
33
|
+
export function layer(opts: {
|
|
34
|
+
hooks: typeof HyperHooks
|
|
35
|
+
}) {
|
|
36
|
+
return Layer.sync(Hyper, () => {
|
|
37
|
+
return {
|
|
38
|
+
hooks: opts.hooks,
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Accepts Effect that returns a HyperNode
|
|
45
|
+
* to a HttpApp.
|
|
46
|
+
* TODO: Implement Hyper.page that returns Hyper.Element
|
|
47
|
+
*/
|
|
48
|
+
export function handle<E, R>(
|
|
49
|
+
handler: Effect.Effect<
|
|
50
|
+
JSX.Children | HttpServerResponse.HttpServerResponse,
|
|
51
|
+
E,
|
|
52
|
+
R
|
|
53
|
+
>,
|
|
54
|
+
): HttpApp.Default<E, R>
|
|
55
|
+
export function handle(
|
|
56
|
+
handler: () => Generator<
|
|
57
|
+
never,
|
|
58
|
+
JSX.Children | HttpServerResponse.HttpServerResponse,
|
|
59
|
+
any
|
|
60
|
+
>,
|
|
61
|
+
): HttpApp.Default<never, never>
|
|
62
|
+
export function handle<Eff extends YieldWrap<Effect.Effect<any, any, any>>>(
|
|
63
|
+
handler: () => Generator<
|
|
64
|
+
Eff,
|
|
65
|
+
JSX.Children | HttpServerResponse.HttpServerResponse,
|
|
66
|
+
any
|
|
67
|
+
>,
|
|
68
|
+
): HttpApp.Default<
|
|
69
|
+
[Eff] extends [YieldWrap<Effect.Effect<infer _A, infer E, infer _R>>] ? E
|
|
70
|
+
: never,
|
|
71
|
+
[Eff] extends [YieldWrap<Effect.Effect<infer _A, infer _E, infer R>>] ? R
|
|
72
|
+
: never
|
|
73
|
+
>
|
|
74
|
+
export function handle(
|
|
75
|
+
handler:
|
|
76
|
+
| Effect.Effect<
|
|
77
|
+
JSX.Children | HttpServerResponse.HttpServerResponse,
|
|
78
|
+
any,
|
|
79
|
+
any
|
|
80
|
+
>
|
|
81
|
+
| (() => Generator<
|
|
82
|
+
YieldWrap<Effect.Effect<any, any, any>>,
|
|
83
|
+
JSX.Children | HttpServerResponse.HttpServerResponse,
|
|
84
|
+
any
|
|
85
|
+
>),
|
|
86
|
+
): HttpApp.Default<any, any> {
|
|
87
|
+
return Effect.gen(function*() {
|
|
88
|
+
const hyper = yield* Effect.serviceOption(Hyper).pipe(
|
|
89
|
+
Effect.andThen(Option.getOrNull),
|
|
90
|
+
)
|
|
91
|
+
const effect = isGenerator(handler) ? Effect.gen(handler) : handler
|
|
92
|
+
const value = yield* effect
|
|
93
|
+
|
|
94
|
+
if (HttpServerResponse.isServerResponse(value)) {
|
|
95
|
+
return value
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const html = HyperHtml.renderToString(value, hyper?.hooks)
|
|
99
|
+
|
|
100
|
+
return yield* HttpServerResponse.html`${html}`
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isGenerator<A, E, R>(
|
|
105
|
+
handler: any,
|
|
106
|
+
): handler is () => Generator<YieldWrap<Effect.Effect<A, E, R>>, any, any> {
|
|
107
|
+
return typeof handler === "function"
|
|
108
|
+
&& handler.constructor?.name === "GeneratorFunction"
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const NoChildren: ReadonlyArray<never> = Object.freeze([])
|
|
112
|
+
|
|
113
|
+
type Primitive = string | number | boolean | null | undefined
|
|
114
|
+
|
|
115
|
+
export type HyperType = string | HyperComponent
|
|
116
|
+
|
|
117
|
+
export type HyperProps = {
|
|
118
|
+
[key: string]:
|
|
119
|
+
| Primitive
|
|
120
|
+
| ReadonlyArray<Primitive>
|
|
121
|
+
| HyperNode
|
|
122
|
+
| HyperNode[]
|
|
123
|
+
| null
|
|
124
|
+
| undefined
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type HyperComponent = (
|
|
128
|
+
props: HyperProps,
|
|
129
|
+
) => HyperNode | Primitive
|
|
130
|
+
|
|
131
|
+
export interface HyperNode {
|
|
132
|
+
type: HyperType
|
|
133
|
+
props: HyperProps
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function h(
|
|
137
|
+
type: HyperType,
|
|
138
|
+
props: HyperProps,
|
|
139
|
+
): HyperNode {
|
|
140
|
+
return {
|
|
141
|
+
type,
|
|
142
|
+
props: {
|
|
143
|
+
...props,
|
|
144
|
+
children: props.children ?? NoChildren,
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function unsafeUse<Value>(tag: Context.Tag<any, Value>) {
|
|
150
|
+
const currentFiber = Option.getOrThrow(
|
|
151
|
+
Fiber.getCurrentFiber(),
|
|
152
|
+
)
|
|
153
|
+
const context = currentFiber.currentContext
|
|
154
|
+
|
|
155
|
+
return Context.unsafeGet(context, tag)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface Layout<in out Provides, in out Requires>
|
|
159
|
+
extends Layer.Layer<Provides, never, Requires>
|
|
160
|
+
{
|
|
161
|
+
readonly [TypeId]: typeof LayoutTypeId
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function layout<Provides, Requires>(
|
|
165
|
+
handler:
|
|
166
|
+
| Effect.Effect<
|
|
167
|
+
JSX.Children | HttpServerResponse.HttpServerResponse,
|
|
168
|
+
any,
|
|
169
|
+
any
|
|
170
|
+
>
|
|
171
|
+
| (() => Generator<
|
|
172
|
+
YieldWrap<Effect.Effect<any, any, any>>,
|
|
173
|
+
JSX.Children | HttpServerResponse.HttpServerResponse,
|
|
174
|
+
any
|
|
175
|
+
>),
|
|
176
|
+
):
|
|
177
|
+
& Layout<Provides, Requires>
|
|
178
|
+
& {
|
|
179
|
+
handler: any
|
|
180
|
+
}
|
|
181
|
+
{
|
|
182
|
+
return {
|
|
183
|
+
[TypeId]: LayoutTypeId,
|
|
184
|
+
[Layer.LayerTypeId]: {
|
|
185
|
+
_ROut: Function.identity,
|
|
186
|
+
_E: Function.identity,
|
|
187
|
+
_RIn: Function.identity,
|
|
188
|
+
},
|
|
189
|
+
handler,
|
|
190
|
+
pipe() {
|
|
191
|
+
return Pipeable.pipeArguments(this, arguments)
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import * as t from "bun:test"
|
|
2
|
+
import * as HyperHtml from "./HyperHtml.ts"
|
|
3
|
+
import * as HyperNode from "./HyperNode.ts"
|
|
4
|
+
|
|
5
|
+
t.it("boolean true attributes render without value (React-like)", () => {
|
|
6
|
+
const node = HyperNode.make("div", {
|
|
7
|
+
hidden: true,
|
|
8
|
+
disabled: true,
|
|
9
|
+
"data-active": true,
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const html = HyperHtml.renderToString(node)
|
|
13
|
+
|
|
14
|
+
t
|
|
15
|
+
.expect(html)
|
|
16
|
+
.toBe("<div hidden disabled data-active></div>")
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
t.it("boolean false attributes are omitted", () => {
|
|
20
|
+
const node = HyperNode.make("div", {
|
|
21
|
+
hidden: false,
|
|
22
|
+
disabled: false,
|
|
23
|
+
"data-active": false,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const html = HyperHtml.renderToString(node)
|
|
27
|
+
|
|
28
|
+
t
|
|
29
|
+
.expect(html)
|
|
30
|
+
.toBe("<div></div>")
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
t.it("string attributes render with values", () => {
|
|
34
|
+
const node = HyperNode.make("div", {
|
|
35
|
+
id: "test",
|
|
36
|
+
class: "my-class",
|
|
37
|
+
"data-value": "hello",
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const html = HyperHtml.renderToString(node)
|
|
41
|
+
|
|
42
|
+
t
|
|
43
|
+
.expect(html)
|
|
44
|
+
.toBe("<div id=\"test\" class=\"my-class\" data-value=\"hello\"></div>")
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
t.it("number attributes render with values", () => {
|
|
48
|
+
const node = HyperNode.make("input", {
|
|
49
|
+
type: "number",
|
|
50
|
+
min: 0,
|
|
51
|
+
max: 100,
|
|
52
|
+
value: 50,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const html = HyperHtml.renderToString(node)
|
|
56
|
+
|
|
57
|
+
t
|
|
58
|
+
.expect(html)
|
|
59
|
+
.toBe("<input type=\"number\" min=\"0\" max=\"100\" value=\"50\">")
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
t.it("null and undefined attributes are omitted", () => {
|
|
63
|
+
const node = HyperNode.make("div", {
|
|
64
|
+
id: null,
|
|
65
|
+
class: undefined,
|
|
66
|
+
"data-test": "value",
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const html = HyperHtml.renderToString(node)
|
|
70
|
+
|
|
71
|
+
t
|
|
72
|
+
.expect(html)
|
|
73
|
+
.toBe("<div data-test=\"value\"></div>")
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
t.it("mixed boolean and string attributes", () => {
|
|
77
|
+
const node = HyperNode.make("input", {
|
|
78
|
+
type: "checkbox",
|
|
79
|
+
checked: true,
|
|
80
|
+
disabled: false,
|
|
81
|
+
name: "test",
|
|
82
|
+
value: "on",
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const html = HyperHtml.renderToString(node)
|
|
86
|
+
|
|
87
|
+
t
|
|
88
|
+
.expect(html)
|
|
89
|
+
.toBe("<input type=\"checkbox\" checked name=\"test\" value=\"on\">")
|
|
90
|
+
})
|
package/src/HyperHtml.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import * as HyperNode from "./HyperNode.ts"
|
|
2
|
+
import { JSX } from "./jsx"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* From: https://github.com/developit/vhtml
|
|
6
|
+
*/
|
|
7
|
+
const EMPTY_TAGS = [
|
|
8
|
+
"area",
|
|
9
|
+
"base",
|
|
10
|
+
"br",
|
|
11
|
+
"col",
|
|
12
|
+
"command",
|
|
13
|
+
"embed",
|
|
14
|
+
"hr",
|
|
15
|
+
"img",
|
|
16
|
+
"input",
|
|
17
|
+
"keygen",
|
|
18
|
+
"link",
|
|
19
|
+
"meta",
|
|
20
|
+
"param",
|
|
21
|
+
"source",
|
|
22
|
+
"track",
|
|
23
|
+
"wbr",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
// escape an attribute
|
|
27
|
+
let esc = (str: any) => String(str).replace(/[&<>"']/g, (s) => `&${map[s]};`)
|
|
28
|
+
let map = {
|
|
29
|
+
"&": "amp",
|
|
30
|
+
"<": "lt",
|
|
31
|
+
">": "gt",
|
|
32
|
+
"\"": "quot",
|
|
33
|
+
"'": "apos",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function renderToString(
|
|
37
|
+
node: JSX.Children,
|
|
38
|
+
hooks?: { onNode?: (node: HyperNode.HyperNode) => void },
|
|
39
|
+
): string {
|
|
40
|
+
const stack: any[] = [node]
|
|
41
|
+
let result = ""
|
|
42
|
+
|
|
43
|
+
while (stack.length > 0) {
|
|
44
|
+
const current = stack.pop()!
|
|
45
|
+
|
|
46
|
+
if (typeof current === "string") {
|
|
47
|
+
if (current.startsWith("<") && current.endsWith(">")) {
|
|
48
|
+
// This is a closing tag, don't escape it
|
|
49
|
+
result += current
|
|
50
|
+
} else {
|
|
51
|
+
result += esc(current)
|
|
52
|
+
}
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (typeof current === "number") {
|
|
57
|
+
result += esc(current)
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (typeof current === "boolean") {
|
|
62
|
+
// React-like behavior: booleans render nothing
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (current === null || current === undefined) {
|
|
67
|
+
// React-like behavior: null/undefined render nothing
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (Array.isArray(current)) {
|
|
72
|
+
// Handle arrays by pushing all items to stack in reverse order
|
|
73
|
+
for (let i = current.length - 1; i >= 0; i--) {
|
|
74
|
+
stack.push(current[i])
|
|
75
|
+
}
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (current && typeof current === "object" && current.type) {
|
|
80
|
+
hooks?.onNode?.(current)
|
|
81
|
+
|
|
82
|
+
if (typeof current.type === "function") {
|
|
83
|
+
const componentResult = current.type(current.props)
|
|
84
|
+
if (componentResult != null) {
|
|
85
|
+
stack.push(componentResult)
|
|
86
|
+
}
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const { type, props } = current
|
|
91
|
+
result += `<${type}`
|
|
92
|
+
|
|
93
|
+
for (const key in props) {
|
|
94
|
+
if (
|
|
95
|
+
key !== "children"
|
|
96
|
+
&& key !== "innerHTML"
|
|
97
|
+
&& key !== "dangerouslySetInnerHTML"
|
|
98
|
+
&& props[key] !== false
|
|
99
|
+
&& props[key] != null
|
|
100
|
+
) {
|
|
101
|
+
if (props[key] === true) {
|
|
102
|
+
result += ` ${esc(key)}`
|
|
103
|
+
} else {
|
|
104
|
+
const resolvedKey = key === "className" ? "class" : key
|
|
105
|
+
|
|
106
|
+
result += ` ${esc(resolvedKey)}="${esc(props[key])}"`
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
result += ">"
|
|
112
|
+
|
|
113
|
+
if (!EMPTY_TAGS.includes(type)) {
|
|
114
|
+
stack.push(`</${type}>`)
|
|
115
|
+
|
|
116
|
+
const html = props.dangerouslySetInnerHTML?.__html
|
|
117
|
+
?? props.innerHTML
|
|
118
|
+
|
|
119
|
+
if (html) {
|
|
120
|
+
result += html
|
|
121
|
+
} else {
|
|
122
|
+
const children = props.children
|
|
123
|
+
if (Array.isArray(children)) {
|
|
124
|
+
for (let i = children.length - 1; i >= 0; i--) {
|
|
125
|
+
stack.push(children[i])
|
|
126
|
+
}
|
|
127
|
+
} else if (children != null) {
|
|
128
|
+
stack.push(children)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} else if (current && typeof current === "object") {
|
|
133
|
+
// Handle objects without type property - convert to string or ignore
|
|
134
|
+
// This prevents [object Object] from appearing
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return result
|
|
139
|
+
}
|
package/src/HyperNode.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export const TypeId = Symbol.for("effect-start/HyperNode")
|
|
2
|
+
export type TypeId = typeof TypeId
|
|
3
|
+
|
|
4
|
+
const NoChildren: ReadonlyArray<never> = Object.freeze([])
|
|
5
|
+
|
|
6
|
+
type Primitive = string | number | boolean | null | undefined
|
|
7
|
+
|
|
8
|
+
export type Type = string | HyperComponent
|
|
9
|
+
|
|
10
|
+
export type Props = {
|
|
11
|
+
[key: string]:
|
|
12
|
+
| Primitive
|
|
13
|
+
| HyperNode
|
|
14
|
+
| Iterable<Primitive | HyperNode>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type HyperComponent = (
|
|
18
|
+
props: Props,
|
|
19
|
+
) => HyperNode | Primitive
|
|
20
|
+
|
|
21
|
+
export interface HyperNode {
|
|
22
|
+
type: Type
|
|
23
|
+
props: Props
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function make(
|
|
27
|
+
type: Type,
|
|
28
|
+
props: Props,
|
|
29
|
+
): HyperNode {
|
|
30
|
+
return {
|
|
31
|
+
type,
|
|
32
|
+
props: {
|
|
33
|
+
...props,
|
|
34
|
+
children: props.children ?? NoChildren,
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import * as t from "bun:test"
|
|
2
|
+
import * as JsModule from "./JsModule.ts"
|
|
3
|
+
|
|
4
|
+
t.describe("importSource", () => {
|
|
5
|
+
t.it("imports a string", async () => {
|
|
6
|
+
const mod = await JsModule.importSource<any>(`
|
|
7
|
+
export const b = "B"
|
|
8
|
+
`)
|
|
9
|
+
|
|
10
|
+
t
|
|
11
|
+
.expect(mod.b)
|
|
12
|
+
.toBe("B")
|
|
13
|
+
})
|
|
14
|
+
})
|
package/src/JsModule.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import * as Array from "effect/Array"
|
|
2
|
+
import * as Function from "effect/Function"
|
|
3
|
+
import * as Iterable from "effect/Iterable"
|
|
4
|
+
import * as Order from "effect/Order"
|
|
5
|
+
import * as Record from "effect/Record"
|
|
6
|
+
import * as NFS from "node:fs"
|
|
7
|
+
import * as NFSP from "node:fs/promises"
|
|
8
|
+
import * as NPath from "node:path"
|
|
9
|
+
import * as process from "node:process"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Imports a blob as a module.
|
|
13
|
+
* Useful for loading code from build artifacts.
|
|
14
|
+
*
|
|
15
|
+
* Temporary files are wrriten to closest node_modules/ for node resolver
|
|
16
|
+
* to pick up dependencies correctly and to avoid arbitrary file watchers
|
|
17
|
+
* from detecting them.
|
|
18
|
+
*/
|
|
19
|
+
export async function importBlob<M = unknown>(
|
|
20
|
+
blob: Blob,
|
|
21
|
+
entrypoint = "index.js",
|
|
22
|
+
): Promise<M> {
|
|
23
|
+
return await importBundle({
|
|
24
|
+
[entrypoint]: blob,
|
|
25
|
+
}, entrypoint)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Imports an entrypoint from multiple blobs.
|
|
30
|
+
* Useful for loading code from build artifacts.
|
|
31
|
+
*
|
|
32
|
+
* Temporary files are wrriten to closest node_modules/ for node resolver
|
|
33
|
+
* to pick up dependencies correctly and to avoid arbitrary file watchers
|
|
34
|
+
* from detecting them.
|
|
35
|
+
*
|
|
36
|
+
* WARNING: dynamic imports that happened after this function will fail
|
|
37
|
+
*/
|
|
38
|
+
export async function importBundle<M = unknown>(
|
|
39
|
+
blobs: {
|
|
40
|
+
[path: string]: Blob
|
|
41
|
+
},
|
|
42
|
+
entrypoint: string,
|
|
43
|
+
basePath = findNodeModules() + "/.tmp",
|
|
44
|
+
): Promise<M> {
|
|
45
|
+
const sortedBlobs = Function.pipe(
|
|
46
|
+
blobs,
|
|
47
|
+
Record.toEntries,
|
|
48
|
+
Array.sortWith(v => v[0], Order.string),
|
|
49
|
+
Array.map(v => v[1]),
|
|
50
|
+
)
|
|
51
|
+
const bundleBlob = new Blob(sortedBlobs)
|
|
52
|
+
const hashPrefix = await hashBuffer(await bundleBlob.arrayBuffer())
|
|
53
|
+
.then(v => v.slice(0, 8))
|
|
54
|
+
const dir = `${basePath}/effect-start-${hashPrefix}`
|
|
55
|
+
|
|
56
|
+
await NFSP.mkdir(dir, { recursive: true })
|
|
57
|
+
|
|
58
|
+
await Promise.all(Function.pipe(
|
|
59
|
+
blobs,
|
|
60
|
+
Record.toEntries,
|
|
61
|
+
Array.map(([path, blob]) => {
|
|
62
|
+
const fullPath = `${dir}/${path}`
|
|
63
|
+
|
|
64
|
+
return blob
|
|
65
|
+
.arrayBuffer()
|
|
66
|
+
.then(v => NFSP.writeFile(fullPath, Buffer.from(v)))
|
|
67
|
+
}),
|
|
68
|
+
))
|
|
69
|
+
|
|
70
|
+
const bundleModule = await import(`${dir}/${entrypoint}`)
|
|
71
|
+
|
|
72
|
+
await NFSP
|
|
73
|
+
.rmdir(dir, { recursive: true })
|
|
74
|
+
// if called concurrently, file sometimes may be deleted
|
|
75
|
+
// safe ignore when this happens
|
|
76
|
+
.catch(() => {})
|
|
77
|
+
|
|
78
|
+
return bundleModule
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function findNodeModules(startDir = process.cwd()) {
|
|
82
|
+
let currentDir = NPath.resolve(startDir)
|
|
83
|
+
|
|
84
|
+
while (currentDir !== NPath.parse(currentDir).root) {
|
|
85
|
+
const nodeModulesPath = NPath.join(currentDir, "node_modules")
|
|
86
|
+
if (
|
|
87
|
+
NFS.statSync(nodeModulesPath).isDirectory()
|
|
88
|
+
) {
|
|
89
|
+
return nodeModulesPath
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
currentDir = NPath.dirname(currentDir)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Loads a JS module from a string using data: import
|
|
100
|
+
*/
|
|
101
|
+
export async function importSource<M = unknown>(
|
|
102
|
+
code: string,
|
|
103
|
+
): Promise<M> {
|
|
104
|
+
const dataUrl = `data:text/javascript,${encodeURIComponent(code)}`
|
|
105
|
+
return await import(dataUrl)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function hashBuffer(buffer: BufferSource) {
|
|
109
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", buffer)
|
|
110
|
+
|
|
111
|
+
return Function.pipe(
|
|
112
|
+
new Uint8Array(hashBuffer),
|
|
113
|
+
Iterable.map(b => b.toString(16).padStart(2, "0")),
|
|
114
|
+
Iterable.reduce("", (a, b) => a + b),
|
|
115
|
+
)
|
|
116
|
+
}
|