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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Rafał Gutkowski
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Effect Start
|
|
2
|
+
|
|
3
|
+
Build declarative full-stack apps with Effect.
|
|
4
|
+
|
|
5
|
+
This project is in its early stage. However, the code is well documented so you should be able to figure out how to use it
|
|
6
|
+
by checking out `examples/` directory.
|
|
7
|
+
|
|
8
|
+
## Development
|
|
9
|
+
|
|
10
|
+
### Configuration
|
|
11
|
+
|
|
12
|
+
`server.ts` is a main entrypoint for all environments. No more divergence between dev and prod!
|
|
13
|
+
|
|
14
|
+
It exports a layer that applies configuration and changes the behavior of the server:
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
import { Start } from "effect-start"
|
|
18
|
+
import { BunTailwindPlugin } from "effect-start/bun"
|
|
19
|
+
|
|
20
|
+
export default Start.make(
|
|
21
|
+
// enable file-based router
|
|
22
|
+
Start.router(() => import("./routes/_manifest")),
|
|
23
|
+
// bundle client-side code for the browser
|
|
24
|
+
Start.bundleClient({
|
|
25
|
+
entrypoints: [
|
|
26
|
+
"src/index.html",
|
|
27
|
+
],
|
|
28
|
+
plugins: [
|
|
29
|
+
// enable TailwindCSS for client bundle
|
|
30
|
+
BunTailwindPlugin.make(),
|
|
31
|
+
],
|
|
32
|
+
}),
|
|
33
|
+
)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### File-based Routing
|
|
37
|
+
|
|
38
|
+
Effect Start provides automatic file-based routing with support for frontend pages, backend endpoints, and stackable layouts.
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
src/routes/
|
|
42
|
+
├── _layout.tsx # Root layout
|
|
43
|
+
├── _page.tsx # Home page (/)
|
|
44
|
+
├── about/
|
|
45
|
+
│ ├── _layout.tsx # Nested layout for /about/*
|
|
46
|
+
│ └── _page.tsx # Static route (/about)
|
|
47
|
+
├── users/
|
|
48
|
+
│ ├── _page.tsx # Users list (/users)
|
|
49
|
+
│ └── $id/_page.tsx # Dynamic route (/users/:id)
|
|
50
|
+
└── $/_page.tsx # Splat/catch-all (/**)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { FileRouter } from "effect-start"
|
|
55
|
+
|
|
56
|
+
// Generate route manifest and watch for changes
|
|
57
|
+
const routerLayer = FileRouter.layer(import.meta.resolve("routes"))
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Note:** Ensure `FileRouter.layer` is provided after any bundle layer to guarantee the manifest file is properly generated before bundling.
|
|
61
|
+
|
|
62
|
+
### Tailwind CSS Support
|
|
63
|
+
|
|
64
|
+
Effect Start includes built-in support for Tailwind CSS:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { Start } from "effect-start"
|
|
68
|
+
import { BunTailwindPlugin } from "effect-start/bun"
|
|
69
|
+
|
|
70
|
+
const ClientBundle = Start.bundleClient({
|
|
71
|
+
entrypoints: [
|
|
72
|
+
"./src/index.html",
|
|
73
|
+
],
|
|
74
|
+
plugins: [
|
|
75
|
+
BunTailwindPlugin.make(),
|
|
76
|
+
],
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
export default Start.make(
|
|
80
|
+
ClientBundle,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if (import.meta.main) {
|
|
84
|
+
Start.serve(() => import("./server"))
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Then in your main CSS files add following file:
|
|
89
|
+
|
|
90
|
+
```css
|
|
91
|
+
@import "tailwindcss";
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Cloudflare Tunnel
|
|
95
|
+
|
|
96
|
+
Tunnel your local server to the Internet with `cloudflared`
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
import { Start } from "effect-start"
|
|
100
|
+
import { CloudflareTunnel } from "effect-start/x/cloudflare"
|
|
101
|
+
|
|
102
|
+
export default Start.make(
|
|
103
|
+
CloudflareTunnel.layer(),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if (import.meta.main) {
|
|
107
|
+
Start.serve(() => import("./server"))
|
|
108
|
+
}
|
|
109
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "effect-start",
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./Router": "./src/Router.ts",
|
|
9
|
+
"./Route": "./src/Route.ts",
|
|
10
|
+
"./Datastar": "./src/Datastar.ts",
|
|
11
|
+
"./EncryptedCookies": "./src/EncryptedCookies.ts",
|
|
12
|
+
"./bun": "./src/bun/index.ts",
|
|
13
|
+
"./client": "./src/client/index.ts",
|
|
14
|
+
"./FileSystemExtra": "./src/FileSystemExtra.ts",
|
|
15
|
+
"./package.json": "./package.json",
|
|
16
|
+
"./jsx-runtime": "./src/jsx-runtime.ts",
|
|
17
|
+
"./jsx-dev-runtime": "./src/jsx-runtime.ts",
|
|
18
|
+
"./hyper": "./src/hyper/index.ts",
|
|
19
|
+
"./x/*": "./src/x/*/index.ts"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"format": "bunx dprint fmt",
|
|
23
|
+
"deploy": "bunx npm publish --access public"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@effect/platform": ">=0.82",
|
|
27
|
+
"@effect/platform-bun": ">=0.65"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"typescript": "^5.8.3",
|
|
31
|
+
"effect": ">=3.16.4"
|
|
32
|
+
},
|
|
33
|
+
"peerDependenciesMeta": {
|
|
34
|
+
"@tailwindcss/node": {
|
|
35
|
+
"optional": true
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@dprint/json": "^0.21.0",
|
|
40
|
+
"@dprint/markdown": "^0.20.0",
|
|
41
|
+
"@dprint/typescript": "^0.95.12",
|
|
42
|
+
"@effect/language-service": "^0.23.3",
|
|
43
|
+
"@tailwindcss/node": ">=4.1.8",
|
|
44
|
+
"@types/bun": "^1.2.15",
|
|
45
|
+
"@types/react": "^19.1.6",
|
|
46
|
+
"@types/react-dom": "^19.1.6",
|
|
47
|
+
"dprint-cli": "^0.4.0",
|
|
48
|
+
"dprint-markup": "nounder/dprint-markup",
|
|
49
|
+
"effect": "^3.19.4",
|
|
50
|
+
"effect-memfs": "nounder/effect-memfs#a976bb0",
|
|
51
|
+
"ts-namespace-import": "nounder/ts-namespace-import#140c405"
|
|
52
|
+
},
|
|
53
|
+
"files": [
|
|
54
|
+
"src/",
|
|
55
|
+
"package.json"
|
|
56
|
+
]
|
|
57
|
+
}
|
package/src/Bundle.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Context,
|
|
3
|
+
Data,
|
|
4
|
+
Effect,
|
|
5
|
+
pipe,
|
|
6
|
+
PubSub,
|
|
7
|
+
} from "effect"
|
|
8
|
+
import * as Schema from "effect/Schema"
|
|
9
|
+
import { importBlob } from "./JsModule.ts"
|
|
10
|
+
|
|
11
|
+
export const BundleEntrypointMetaKey: unique symbol = Symbol.for(
|
|
12
|
+
"effect-start/BundleEntrypointMetaKey",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
export type BundleOutputMetaValue = {}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Generic shape describing a bundle across multiple bundlers
|
|
19
|
+
* (like bun, esbuild & vite)
|
|
20
|
+
*/
|
|
21
|
+
export const BundleManifestSchema = Schema.Struct({
|
|
22
|
+
entrypoints: Schema.Record({
|
|
23
|
+
key: Schema.String,
|
|
24
|
+
value: Schema.String,
|
|
25
|
+
}),
|
|
26
|
+
artifacts: Schema.Array(
|
|
27
|
+
Schema.Struct({
|
|
28
|
+
path: Schema.String,
|
|
29
|
+
type: Schema.String,
|
|
30
|
+
size: Schema.Number,
|
|
31
|
+
hash: pipe(
|
|
32
|
+
Schema.String,
|
|
33
|
+
Schema.optional,
|
|
34
|
+
),
|
|
35
|
+
imports: pipe(
|
|
36
|
+
Schema.Array(
|
|
37
|
+
Schema.Struct({
|
|
38
|
+
path: Schema.String,
|
|
39
|
+
kind: Schema.Literal(
|
|
40
|
+
"import-statement",
|
|
41
|
+
"require-call",
|
|
42
|
+
"require-resolve",
|
|
43
|
+
"dynamic-import",
|
|
44
|
+
"import-rule",
|
|
45
|
+
"url-token",
|
|
46
|
+
"internal",
|
|
47
|
+
"entry-point-run",
|
|
48
|
+
"entry-point-build",
|
|
49
|
+
),
|
|
50
|
+
}),
|
|
51
|
+
),
|
|
52
|
+
Schema.optional,
|
|
53
|
+
),
|
|
54
|
+
}),
|
|
55
|
+
),
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
export type BundleManifest = typeof BundleManifestSchema.Type
|
|
59
|
+
|
|
60
|
+
const BundleEventChange = Schema.TaggedStruct("Change", {
|
|
61
|
+
path: Schema.String,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const BundleEventBuildError = Schema.TaggedStruct("BuildError", {
|
|
65
|
+
error: Schema.String,
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
export const BundleEvent = Schema.Union(
|
|
69
|
+
BundleEventChange,
|
|
70
|
+
BundleEventBuildError,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
export type BundleEvent = typeof BundleEvent.Type
|
|
74
|
+
|
|
75
|
+
const IdPrefix = "effect-start/tags/"
|
|
76
|
+
|
|
77
|
+
export type BundleKey = `${string}Bundle`
|
|
78
|
+
|
|
79
|
+
export type BundleId = `${typeof IdPrefix}${BundleKey}`
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Passed to bundle effects and within bundle runtime.
|
|
83
|
+
* Used to expose artifacts via HTTP server and properly resolve
|
|
84
|
+
* imports within the bundle.
|
|
85
|
+
*/
|
|
86
|
+
export type BundleContext =
|
|
87
|
+
& BundleManifest
|
|
88
|
+
& {
|
|
89
|
+
// TODO: consider removing resolve: way of resolving URL should be
|
|
90
|
+
// the same regardless of underlying bundler since we have access
|
|
91
|
+
// to all artifacts already.
|
|
92
|
+
resolve: (url: string) => string | null
|
|
93
|
+
getArtifact: (path: string) => Blob | null
|
|
94
|
+
events?: PubSub.PubSub<BundleEvent>
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export class BundleError extends Data.TaggedError("BundleError")<{
|
|
98
|
+
message: string
|
|
99
|
+
cause?: unknown
|
|
100
|
+
}> {}
|
|
101
|
+
|
|
102
|
+
export const emptyBundleContext: BundleContext = {
|
|
103
|
+
entrypoints: {},
|
|
104
|
+
artifacts: [],
|
|
105
|
+
resolve: () => null,
|
|
106
|
+
getArtifact: () => null,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const handleBundleErrorSilently = (
|
|
110
|
+
effect: Effect.Effect<BundleContext, BundleError>,
|
|
111
|
+
): Effect.Effect<BundleContext, never> =>
|
|
112
|
+
pipe(
|
|
113
|
+
effect,
|
|
114
|
+
Effect.catchTag("BundleError", (error) =>
|
|
115
|
+
Effect.gen(function*() {
|
|
116
|
+
yield* Effect.logError("Bundle build failed", error)
|
|
117
|
+
return emptyBundleContext
|
|
118
|
+
})),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
export const Tag = <const T extends BundleKey>(name: T) => <Identifier>() =>
|
|
122
|
+
Context.Tag(`${IdPrefix}${name}` as BundleId)<
|
|
123
|
+
Identifier,
|
|
124
|
+
BundleContext
|
|
125
|
+
>()
|
|
126
|
+
|
|
127
|
+
export type Tag = Context.Tag<
|
|
128
|
+
BundleId,
|
|
129
|
+
BundleContext
|
|
130
|
+
>
|
|
131
|
+
|
|
132
|
+
export class ClientBundle extends Tag("ClientBundle")<ClientBundle>() {}
|
|
133
|
+
export class ServerBundle extends Tag("ServerBundle")<ServerBundle>() {}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Lodas a bundle as a javascript module.
|
|
137
|
+
* Bundle must have only one entrypoint.
|
|
138
|
+
*/
|
|
139
|
+
export function load<M>(
|
|
140
|
+
bundle: Effect.Effect<BundleContext, BundleError>,
|
|
141
|
+
): Effect.Effect<M, BundleError> {
|
|
142
|
+
return Effect.gen(function*() {
|
|
143
|
+
const context = yield* bundle
|
|
144
|
+
const [artifact, ...rest] = Object.values(context.entrypoints)
|
|
145
|
+
|
|
146
|
+
if (rest.length > 0) {
|
|
147
|
+
return yield* Effect.fail(
|
|
148
|
+
new BundleError({
|
|
149
|
+
message: "Multiple entrypoints are not supported in load()",
|
|
150
|
+
}),
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return yield* Effect.tryPromise({
|
|
155
|
+
try: () => {
|
|
156
|
+
const blob = context.getArtifact(artifact)
|
|
157
|
+
|
|
158
|
+
return importBlob<M>(blob!)
|
|
159
|
+
},
|
|
160
|
+
catch: (e) =>
|
|
161
|
+
new BundleError({
|
|
162
|
+
message: "Failed to load entrypoint",
|
|
163
|
+
cause: e,
|
|
164
|
+
}),
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import * as FileSystem from "@effect/platform/FileSystem"
|
|
2
|
+
import * as Array from "effect/Array"
|
|
3
|
+
import * as Effect from "effect/Effect"
|
|
4
|
+
import * as Function from "effect/Function"
|
|
5
|
+
import * as Iterable from "effect/Iterable"
|
|
6
|
+
import * as Record from "effect/Record"
|
|
7
|
+
import * as S from "effect/Schema"
|
|
8
|
+
import {
|
|
9
|
+
type BundleContext,
|
|
10
|
+
BundleError,
|
|
11
|
+
type BundleManifest,
|
|
12
|
+
BundleManifestSchema,
|
|
13
|
+
} from "./Bundle.ts"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Exports a bundle to a file system under specified directory.
|
|
17
|
+
*/
|
|
18
|
+
export const toFiles = (
|
|
19
|
+
context: BundleContext,
|
|
20
|
+
outDir: string,
|
|
21
|
+
) => {
|
|
22
|
+
return Effect.gen(function*() {
|
|
23
|
+
const fs = yield* FileSystem.FileSystem
|
|
24
|
+
const manifest: BundleManifest = {
|
|
25
|
+
entrypoints: context.entrypoints,
|
|
26
|
+
artifacts: context.artifacts,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const normalizedOutDir = outDir.replace(/\/$/, "")
|
|
30
|
+
|
|
31
|
+
const bundleArtifacts = Function.pipe(
|
|
32
|
+
manifest.artifacts,
|
|
33
|
+
Array.map((artifact) =>
|
|
34
|
+
[artifact.path, context.getArtifact(artifact.path)!] as const
|
|
35
|
+
),
|
|
36
|
+
Record.fromEntries,
|
|
37
|
+
)
|
|
38
|
+
const extraArtifacts = {
|
|
39
|
+
"manifest.json": new Blob([JSON.stringify(manifest, undefined, 2)], {
|
|
40
|
+
type: "application/json",
|
|
41
|
+
}),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const allArtifacts = {
|
|
45
|
+
...bundleArtifacts,
|
|
46
|
+
...extraArtifacts,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const existingOutDirFiles = yield* fs.readDirectory(normalizedOutDir).pipe(
|
|
50
|
+
Effect.catchAll(() => Effect.succeed(null)),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
// check if the output directory is empty. if it contains previous build,
|
|
54
|
+
// remove it. Otherwise fail.
|
|
55
|
+
if (existingOutDirFiles && existingOutDirFiles.length > 0) {
|
|
56
|
+
if (existingOutDirFiles.includes("manifest.json")) {
|
|
57
|
+
yield* Effect.logWarning(
|
|
58
|
+
"Output directory seems to contain previous build. Overwriting...",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
yield* fs.remove(normalizedOutDir, {
|
|
62
|
+
recursive: true,
|
|
63
|
+
})
|
|
64
|
+
} else {
|
|
65
|
+
return yield* Effect.fail(
|
|
66
|
+
new BundleError({
|
|
67
|
+
message: "Output directory is not empty",
|
|
68
|
+
}),
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
yield* fs.makeDirectory(normalizedOutDir, {
|
|
74
|
+
recursive: true,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// write all artifacts to files
|
|
78
|
+
yield* Effect.all(
|
|
79
|
+
Function.pipe(
|
|
80
|
+
allArtifacts,
|
|
81
|
+
Record.toEntries,
|
|
82
|
+
Array.map(([p, b]) =>
|
|
83
|
+
Function.pipe(
|
|
84
|
+
Effect.tryPromise({
|
|
85
|
+
try: () => b.arrayBuffer(),
|
|
86
|
+
catch: (e) =>
|
|
87
|
+
new BundleError({
|
|
88
|
+
message: "Failed to read an artifact as a buffer",
|
|
89
|
+
cause: e,
|
|
90
|
+
}),
|
|
91
|
+
}),
|
|
92
|
+
Effect.andThen((b) =>
|
|
93
|
+
fs.writeFile(
|
|
94
|
+
`${normalizedOutDir}/${p}`,
|
|
95
|
+
new Uint8Array(b),
|
|
96
|
+
)
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
),
|
|
100
|
+
),
|
|
101
|
+
{ concurrency: 16 },
|
|
102
|
+
)
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Loads a bundle from a directory and returns a BundleContext.
|
|
108
|
+
* Expects the directory to contain a manifest.json file and all the artifacts
|
|
109
|
+
* referenced in the manifest.
|
|
110
|
+
*/
|
|
111
|
+
export const fromFiles = (
|
|
112
|
+
directory: string,
|
|
113
|
+
): Effect.Effect<
|
|
114
|
+
BundleContext,
|
|
115
|
+
BundleError,
|
|
116
|
+
FileSystem.FileSystem
|
|
117
|
+
> => {
|
|
118
|
+
return Effect.gen(function*() {
|
|
119
|
+
const fs = yield* FileSystem.FileSystem
|
|
120
|
+
const normalizedDir = directory.replace(/\/$/, "")
|
|
121
|
+
const manifest = yield* Function.pipe(
|
|
122
|
+
fs.readFileString(`${normalizedDir}/manifest.json`),
|
|
123
|
+
Effect.andThen((v) => JSON.parse(v) as unknown),
|
|
124
|
+
Effect.andThen(S.decodeUnknownSync(BundleManifestSchema)),
|
|
125
|
+
Effect.catchAll((e) =>
|
|
126
|
+
Effect.fail(
|
|
127
|
+
new BundleError({
|
|
128
|
+
message: `Failed to read manifest.json from ${normalizedDir}`,
|
|
129
|
+
cause: e,
|
|
130
|
+
}),
|
|
131
|
+
)
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
const artifactPaths = Array.map(manifest.artifacts, (a) => a.path)
|
|
135
|
+
const artifactBlobs = yield* Function.pipe(
|
|
136
|
+
artifactPaths,
|
|
137
|
+
Iterable.map((path) => fs.readFile(`${normalizedDir}/${path}`)),
|
|
138
|
+
Effect.all,
|
|
139
|
+
Effect.catchAll((e) =>
|
|
140
|
+
new BundleError({
|
|
141
|
+
message: `Failed to read an artifact from ${normalizedDir}`,
|
|
142
|
+
cause: e,
|
|
143
|
+
})
|
|
144
|
+
),
|
|
145
|
+
Effect.andThen(Iterable.map((v, i) =>
|
|
146
|
+
new Blob([v.slice(0)], {
|
|
147
|
+
type: manifest.artifacts[i].type,
|
|
148
|
+
})
|
|
149
|
+
)),
|
|
150
|
+
)
|
|
151
|
+
const artifactsRecord = Function.pipe(
|
|
152
|
+
Iterable.zip(
|
|
153
|
+
artifactPaths,
|
|
154
|
+
artifactBlobs,
|
|
155
|
+
),
|
|
156
|
+
Record.fromEntries,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
const bundleContext: BundleContext = {
|
|
160
|
+
...manifest,
|
|
161
|
+
// TODO: support fullpath file:// urls
|
|
162
|
+
// this will require having an access to base path of a build
|
|
163
|
+
// and maybe problematic because bundlers transform urls on build
|
|
164
|
+
resolve: (url: string) => {
|
|
165
|
+
return manifest.entrypoints[url] ?? null
|
|
166
|
+
},
|
|
167
|
+
getArtifact: (path: string) => {
|
|
168
|
+
return artifactsRecord[path] ?? null
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return bundleContext
|
|
173
|
+
})
|
|
174
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import * as HttpRouter from "@effect/platform/HttpRouter"
|
|
2
|
+
import * as HttpServerResponse from "@effect/platform/HttpServerResponse"
|
|
3
|
+
import * as t from "bun:test"
|
|
4
|
+
import {
|
|
5
|
+
Bundle,
|
|
6
|
+
BundleHttp,
|
|
7
|
+
effectFn,
|
|
8
|
+
TestHttpClient,
|
|
9
|
+
} from "effect-start"
|
|
10
|
+
import * as Effect from "effect/Effect"
|
|
11
|
+
import * as Layer from "effect/Layer"
|
|
12
|
+
import IndexHtml from "../static/react-dashboard.html" with { type: "file" }
|
|
13
|
+
import * as BunBundle from "./bun/BunBundle.ts"
|
|
14
|
+
|
|
15
|
+
const effect = effectFn(
|
|
16
|
+
Layer.effect(
|
|
17
|
+
Bundle.ClientBundle,
|
|
18
|
+
BunBundle.buildClient(IndexHtml),
|
|
19
|
+
),
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
t.it("entrypoint with specific uri", () =>
|
|
23
|
+
effect(function*() {
|
|
24
|
+
const App = HttpRouter.empty.pipe(
|
|
25
|
+
HttpRouter.get(
|
|
26
|
+
"/react-dashboard",
|
|
27
|
+
BundleHttp.entrypoint("../static/react-dashboard.html"),
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
const Client = TestHttpClient.make(App)
|
|
31
|
+
|
|
32
|
+
const dashboardRes = yield* Client.get("/react-dashboard")
|
|
33
|
+
t
|
|
34
|
+
.expect(
|
|
35
|
+
dashboardRes.status,
|
|
36
|
+
)
|
|
37
|
+
.toBe(200)
|
|
38
|
+
t
|
|
39
|
+
.expect(
|
|
40
|
+
yield* dashboardRes.text,
|
|
41
|
+
)
|
|
42
|
+
.toStartWith("<!DOCTYPE html>")
|
|
43
|
+
}))
|
|
44
|
+
|
|
45
|
+
t.it("entrypoint without uri parameter", () =>
|
|
46
|
+
effect(function*() {
|
|
47
|
+
const App = HttpRouter.empty.pipe(
|
|
48
|
+
HttpRouter.get(
|
|
49
|
+
"/",
|
|
50
|
+
BundleHttp.entrypoint(),
|
|
51
|
+
),
|
|
52
|
+
HttpRouter.get(
|
|
53
|
+
"/index",
|
|
54
|
+
BundleHttp.entrypoint(),
|
|
55
|
+
),
|
|
56
|
+
HttpRouter.get(
|
|
57
|
+
"/react-dashboard",
|
|
58
|
+
BundleHttp.entrypoint(),
|
|
59
|
+
),
|
|
60
|
+
HttpRouter.get(
|
|
61
|
+
"/nonexistent",
|
|
62
|
+
BundleHttp.entrypoint(),
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
const Client = TestHttpClient.make(App)
|
|
66
|
+
|
|
67
|
+
const indexRes = yield* Client.get("/").pipe(
|
|
68
|
+
Effect.catchTag(
|
|
69
|
+
"RouteNotFound",
|
|
70
|
+
() => HttpServerResponse.empty({ status: 404 }),
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
t
|
|
74
|
+
.expect(
|
|
75
|
+
indexRes.status,
|
|
76
|
+
)
|
|
77
|
+
.toBe(404)
|
|
78
|
+
|
|
79
|
+
const indexPathRes = yield* Client.get("/index").pipe(
|
|
80
|
+
Effect.catchTag(
|
|
81
|
+
"RouteNotFound",
|
|
82
|
+
() => HttpServerResponse.empty({ status: 404 }),
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
t
|
|
86
|
+
.expect(
|
|
87
|
+
indexPathRes.status,
|
|
88
|
+
)
|
|
89
|
+
.toBe(404)
|
|
90
|
+
|
|
91
|
+
const dashboardRes = yield* Client.get("/react-dashboard")
|
|
92
|
+
t
|
|
93
|
+
.expect(
|
|
94
|
+
dashboardRes.status,
|
|
95
|
+
)
|
|
96
|
+
.toBe(200)
|
|
97
|
+
t
|
|
98
|
+
.expect(
|
|
99
|
+
yield* dashboardRes.text,
|
|
100
|
+
)
|
|
101
|
+
.toStartWith("<!DOCTYPE html>")
|
|
102
|
+
|
|
103
|
+
const nonexistentRes = yield* Client.get("/nonexistent").pipe(
|
|
104
|
+
Effect.catchTag(
|
|
105
|
+
"RouteNotFound",
|
|
106
|
+
() => HttpServerResponse.empty({ status: 404 }),
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
t
|
|
110
|
+
.expect(
|
|
111
|
+
nonexistentRes.status,
|
|
112
|
+
)
|
|
113
|
+
.toBe(404)
|
|
114
|
+
}))
|
|
115
|
+
|
|
116
|
+
t.it("withEntrypoints middleware", () =>
|
|
117
|
+
effect(function*() {
|
|
118
|
+
const fallbackApp = Effect.succeed(
|
|
119
|
+
HttpServerResponse.text("Fallback", { status: 404 }),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
const App = BundleHttp.withEntrypoints()(fallbackApp)
|
|
123
|
+
const Client = TestHttpClient.make(App)
|
|
124
|
+
|
|
125
|
+
const rootRes = yield* Client.get("/")
|
|
126
|
+
t
|
|
127
|
+
.expect(
|
|
128
|
+
rootRes.status,
|
|
129
|
+
)
|
|
130
|
+
.toBe(404)
|
|
131
|
+
t
|
|
132
|
+
.expect(
|
|
133
|
+
yield* rootRes.text,
|
|
134
|
+
)
|
|
135
|
+
.toBe("Fallback")
|
|
136
|
+
|
|
137
|
+
const dashboardRes = yield* Client.get("/react-dashboard")
|
|
138
|
+
t
|
|
139
|
+
.expect(
|
|
140
|
+
dashboardRes.status,
|
|
141
|
+
)
|
|
142
|
+
.toBe(200)
|
|
143
|
+
t
|
|
144
|
+
.expect(
|
|
145
|
+
yield* dashboardRes.text,
|
|
146
|
+
)
|
|
147
|
+
.toStartWith("<!DOCTYPE html>")
|
|
148
|
+
|
|
149
|
+
const nonexistentRes = yield* Client.get("/nonexistent")
|
|
150
|
+
t
|
|
151
|
+
.expect(
|
|
152
|
+
nonexistentRes.status,
|
|
153
|
+
)
|
|
154
|
+
.toBe(404)
|
|
155
|
+
t
|
|
156
|
+
.expect(
|
|
157
|
+
yield* nonexistentRes.text,
|
|
158
|
+
)
|
|
159
|
+
.toBe("Fallback")
|
|
160
|
+
}))
|