effect-start 0.9.0 → 0.10.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 (44) hide show
  1. package/package.json +12 -13
  2. package/src/BundleHttp.test.ts +1 -1
  3. package/src/Commander.test.ts +15 -15
  4. package/src/Commander.ts +58 -88
  5. package/src/EncryptedCookies.test.ts +4 -4
  6. package/src/FileHttpRouter.test.ts +81 -12
  7. package/src/FileHttpRouter.ts +115 -26
  8. package/src/FileRouter.ts +60 -162
  9. package/src/FileRouterCodegen.test.ts +250 -64
  10. package/src/FileRouterCodegen.ts +13 -56
  11. package/src/FileRouterPattern.test.ts +116 -0
  12. package/src/FileRouterPattern.ts +59 -0
  13. package/src/FileRouter_path.test.ts +63 -102
  14. package/src/FileSystemExtra.test.ts +226 -0
  15. package/src/FileSystemExtra.ts +24 -60
  16. package/src/HttpUtils.test.ts +68 -0
  17. package/src/HttpUtils.ts +15 -0
  18. package/src/HyperHtml.ts +24 -5
  19. package/src/JsModule.test.ts +1 -1
  20. package/src/NodeFileSystem.ts +764 -0
  21. package/src/Random.ts +59 -0
  22. package/src/Route.test.ts +471 -0
  23. package/src/Route.ts +298 -153
  24. package/src/RouteRender.ts +38 -0
  25. package/src/Router.ts +11 -33
  26. package/src/RouterPattern.test.ts +629 -0
  27. package/src/RouterPattern.ts +391 -0
  28. package/src/Start.ts +14 -52
  29. package/src/bun/BunBundle.test.ts +0 -3
  30. package/src/bun/BunHttpServer.ts +246 -0
  31. package/src/bun/BunHttpServer_web.ts +384 -0
  32. package/src/bun/BunRoute.test.ts +341 -0
  33. package/src/bun/BunRoute.ts +326 -0
  34. package/src/bun/BunRoute_bundles.test.ts +218 -0
  35. package/src/bun/BunRuntime.ts +33 -0
  36. package/src/bun/BunTailwindPlugin.test.ts +1 -1
  37. package/src/bun/_empty.html +1 -0
  38. package/src/bun/index.ts +2 -1
  39. package/src/testing.ts +12 -3
  40. package/src/Datastar.test.ts +0 -267
  41. package/src/Datastar.ts +0 -68
  42. package/src/bun/BunFullstackServer.ts +0 -45
  43. package/src/bun/BunFullstackServer_httpServer.ts +0 -541
  44. package/src/jsx-datastar.d.ts +0 -63
@@ -0,0 +1,391 @@
1
+ import type * as Route from "./Route.ts"
2
+
3
+ export type ParamDelimiter = "_" | "-" | "." | "," | ";" | "!" | "@" | "~"
4
+ export type ParamPrefix = `${string}${ParamDelimiter}` | ""
5
+ export type ParamSuffix = `${ParamDelimiter}${string}` | ""
6
+
7
+ export type LiteralSegment<
8
+ Value extends string = string,
9
+ > = {
10
+ _tag: "LiteralSegment"
11
+ value: Value
12
+ }
13
+
14
+ export type ParamSegment<
15
+ Name extends string = string,
16
+ Optional extends boolean = boolean,
17
+ Prefix extends ParamPrefix = "",
18
+ Suffix extends ParamSuffix = "",
19
+ > = {
20
+ _tag: "ParamSegment"
21
+ name: Name
22
+ optional?: Optional
23
+ prefix?: Prefix
24
+ suffix?: Suffix
25
+ }
26
+
27
+ export type RestSegment<
28
+ Name extends string = string,
29
+ Optional extends boolean = boolean,
30
+ > = {
31
+ _tag: "RestSegment"
32
+ name: Name
33
+ optional?: Optional
34
+ }
35
+
36
+ export type Segment =
37
+ | LiteralSegment
38
+ | ParamSegment<
39
+ string,
40
+ boolean,
41
+ ParamPrefix,
42
+ ParamSuffix
43
+ >
44
+ | RestSegment
45
+
46
+ /**
47
+ * Parses a route path string into a tuple of Segment types at compile time.
48
+ *
49
+ * @example
50
+ * ```ts
51
+ * type Usage = Segments<"/users/[id]/posts/[...rest]">
52
+ * type Expected = [
53
+ * LiteralSegment<"users">,
54
+ * ParamSegment<"id", false>,
55
+ * LiteralSegment<"posts">,
56
+ * RestSegment<"rest", false>
57
+ * ]
58
+ * ```
59
+ *
60
+ * Supports:
61
+ * - Literals: `users` → `LiteralSegment<"users">`
62
+ * - Params: `[id]` → `ParamSegment<"id", false>`
63
+ * - Params (optional): `[[id]]` → `ParamSegment<"id", true>`
64
+ * - Rest: `[...rest]` → `RestSegment<"rest", false>`
65
+ * - Rest (optional): `[[...rest]]` → `RestSegment<"rest", true>`
66
+ * - {Pre,Suf}fixed params: `prefix_[id]_suffix` → `ParamSegment<"id", false, "prefix_", "_suffix">`
67
+ * - Malformed segments: `pk_[id]foo` → `undefined` (suffix must start with delimiter)
68
+ *
69
+ * @limit Paths with more than 48 segments in TypeScript 5.9.3 will fail with
70
+ * `TS2589: Type instantiation is excessively deep and possibly infinite`.
71
+ */
72
+ export type Segments<Path extends string> = Path extends `/${infer PathRest}`
73
+ ? Segments<PathRest>
74
+ : Path extends `${infer Head}/${infer Tail}`
75
+ ? [ExtractSegment<Head>, ...Segments<Tail>]
76
+ : Path extends "" ? []
77
+ : [ExtractSegment<Path>]
78
+
79
+ const PARAM_PATTERN =
80
+ /^(?<prefix>.*[^a-zA-Z0-9])?\[(?<name>[^\]]+)\](?<suffix>[^a-zA-Z0-9].*)?$/
81
+
82
+ export function parseSegment(segment: string): Segment | null {
83
+ if (
84
+ segment.startsWith("[[...")
85
+ && segment.endsWith("]]")
86
+ ) {
87
+ return {
88
+ _tag: "RestSegment",
89
+ name: segment.slice(5, -2),
90
+ optional: true,
91
+ }
92
+ }
93
+
94
+ if (
95
+ segment.startsWith("[...")
96
+ && segment.endsWith("]")
97
+ ) {
98
+ return {
99
+ _tag: "RestSegment",
100
+ name: segment.slice(4, -1),
101
+ }
102
+ }
103
+
104
+ if (
105
+ segment.startsWith("[[")
106
+ && segment.endsWith("]]")
107
+ ) {
108
+ return {
109
+ _tag: "ParamSegment",
110
+ name: segment.slice(2, -2),
111
+ optional: true,
112
+ }
113
+ }
114
+
115
+ const match = segment.match(PARAM_PATTERN)
116
+ if (match?.groups) {
117
+ const { prefix, name, suffix } = match.groups
118
+
119
+ return {
120
+ _tag: "ParamSegment",
121
+ name,
122
+ prefix: (prefix as ParamPrefix) || undefined,
123
+ suffix: (suffix as ParamSuffix) || undefined,
124
+ }
125
+ }
126
+
127
+ if (/^[\p{L}\p{N}._~-]+$/u.test(segment)) {
128
+ return { _tag: "LiteralSegment", value: segment }
129
+ }
130
+
131
+ return null
132
+ }
133
+
134
+ export function parse(pattern: string): Segment[] {
135
+ const segments = pattern.split("/").filter(Boolean).map(parseSegment)
136
+
137
+ if (segments.some((seg) => seg === null)) {
138
+ throw new Error(
139
+ `Invalid path segment in "${pattern}": contains invalid characters or format`,
140
+ )
141
+ }
142
+
143
+ return segments as Segment[]
144
+ }
145
+
146
+ export function formatSegment(seg: Segment): string {
147
+ switch (seg._tag) {
148
+ case "LiteralSegment":
149
+ return seg.value
150
+ case "ParamSegment": {
151
+ const param = seg.optional ? `[[${seg.name}]]` : `[${seg.name}]`
152
+ return (seg.prefix ?? "") + param + (seg.suffix ?? "")
153
+ }
154
+ case "RestSegment":
155
+ return seg.optional ? `[[...${seg.name}]]` : `[...${seg.name}]`
156
+ }
157
+ }
158
+
159
+ export function format(segments: Segment[]): `/${string}` {
160
+ const joined = segments.map(formatSegment).join("/")
161
+ return (joined ? `/${joined}` : "/") as `/${string}`
162
+ }
163
+
164
+ function buildPaths(
165
+ segments: Segment[],
166
+ mapper: (seg: Segment) => string,
167
+ restWildcard: string,
168
+ ): string[] {
169
+ const optionalRestIndex = segments.findIndex(
170
+ (s) => s._tag === "RestSegment" && s.optional,
171
+ )
172
+
173
+ if (optionalRestIndex !== -1) {
174
+ const before = segments.slice(0, optionalRestIndex)
175
+ const beforeJoined = before.map(mapper).join("/")
176
+ const basePath = beforeJoined ? "/" + beforeJoined : "/"
177
+ const withWildcard = basePath === "/"
178
+ ? restWildcard
179
+ : basePath + restWildcard
180
+ return [basePath, withWildcard]
181
+ }
182
+
183
+ const joined = segments.map(mapper).join("/")
184
+ return [joined ? "/" + joined : "/"]
185
+ }
186
+
187
+ function colonParamSegment(segment: Segment): string {
188
+ switch (segment._tag) {
189
+ case "LiteralSegment":
190
+ return segment.value
191
+ case "ParamSegment": {
192
+ const param = `:${segment.name}${segment.optional ? "?" : ""}`
193
+ return (segment.prefix ?? "") + param + (segment.suffix ?? "")
194
+ }
195
+ case "RestSegment":
196
+ return "*"
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Converts to colon-style path pattern (used by Hono, Bun, itty-router).
202
+ *
203
+ * - `[param]` → `:param`
204
+ * - `[[param]]` → `:param?`
205
+ * - `[...param]` → `*`
206
+ * - `[[...param]]` → `/`, `/*`
207
+ * - `pk_[id]` → `pk_:id`
208
+ */
209
+ export function toColon(path: Route.RoutePattern): string[] {
210
+ return buildPaths(parse(path), colonParamSegment, "/*")
211
+ }
212
+
213
+ export const toHono = toColon
214
+
215
+ /**
216
+ * Converts to Express path pattern.
217
+ *
218
+ * - `[param]` → `:param`
219
+ * - `[[param]]` → `{/:param}`
220
+ * - `[...param]` → `/*param`
221
+ * - `[[...param]]` → `/`, `/*param`
222
+ * - `pk_[id]` → `pk_:id`
223
+ */
224
+ export function toExpress(path: Route.RoutePattern): string[] {
225
+ const segments = parse(path)
226
+ const optionalRestIndex = segments.findIndex(
227
+ (s) => s._tag === "RestSegment" && s.optional,
228
+ )
229
+
230
+ const mapper = (segment: Segment): string => {
231
+ switch (segment._tag) {
232
+ case "LiteralSegment":
233
+ return segment.value
234
+ case "ParamSegment": {
235
+ const param = `:${segment.name}`
236
+ return (segment.prefix ?? "") + param + (segment.suffix ?? "")
237
+ }
238
+ case "RestSegment":
239
+ return `*${segment.name}`
240
+ }
241
+ }
242
+
243
+ if (optionalRestIndex !== -1) {
244
+ const before = segments.slice(0, optionalRestIndex)
245
+ const rest = segments[optionalRestIndex]
246
+ if (rest._tag !== "RestSegment") throw new Error("unreachable")
247
+ const restName = rest.name
248
+ const beforeJoined = before.map(mapper).join("/")
249
+ const basePath = beforeJoined ? "/" + beforeJoined : "/"
250
+ const withWildcard = basePath === "/"
251
+ ? `/*${restName}`
252
+ : basePath + `/*${restName}`
253
+ return [basePath, withWildcard]
254
+ }
255
+
256
+ let result = ""
257
+ for (let i = 0; i < segments.length; i++) {
258
+ const segment = segments[i]
259
+ const isFirst = i === 0
260
+ switch (segment._tag) {
261
+ case "LiteralSegment":
262
+ result += "/" + segment.value
263
+ break
264
+ case "ParamSegment":
265
+ if (segment.optional && !segment.prefix && !segment.suffix) {
266
+ result += isFirst
267
+ ? "/{/:$name}".replace("$name", segment.name)
268
+ : `{/:${segment.name}}`
269
+ } else {
270
+ const param = `:${segment.name}`
271
+ result += "/"
272
+ + (segment.prefix ?? "")
273
+ + param
274
+ + (segment.suffix ?? "")
275
+ }
276
+ break
277
+ case "RestSegment":
278
+ result += `/*${segment.name}`
279
+ break
280
+ }
281
+ }
282
+ return [result || "/"]
283
+ }
284
+
285
+ /**
286
+ * Converts to Effect HttpRouter/find-my-way path pattern.
287
+ *
288
+ * - `[param]` → `:param`
289
+ * - `[[param]]` → `:param?` (must be final segment)
290
+ * - `[...param]` → `*`
291
+ * - `[[...param]]` → `/`, `/*`
292
+ * - `pk_[id]` → `pk_:id`
293
+ */
294
+ export function toEffect(path: Route.RoutePattern): string[] {
295
+ return buildPaths(parse(path), colonParamSegment, "/*")
296
+ }
297
+
298
+ /**
299
+ * Converts to URLPattern path pattern.
300
+ *
301
+ * - `[param]` → `:param`
302
+ * - `[[param]]` → `:param?`
303
+ * - `[...param]` → `:param+`
304
+ * - `[[...param]]` → `:param*`
305
+ * - `pk_[id]` → `pk_:id`
306
+ */
307
+ export function toURLPattern(path: Route.RoutePattern): string[] {
308
+ const segments = parse(path)
309
+ const joined = segments
310
+ .map((segment) => {
311
+ switch (segment._tag) {
312
+ case "LiteralSegment":
313
+ return segment.value
314
+ case "ParamSegment": {
315
+ const param = `:${segment.name}${segment.optional ? "?" : ""}`
316
+ return (segment.prefix ?? "") + param + (segment.suffix ?? "")
317
+ }
318
+ case "RestSegment":
319
+ return `:${segment.name}${segment.optional ? "*" : "+"}`
320
+ }
321
+ })
322
+ .join("/")
323
+ return [joined ? "/" + joined : "/"]
324
+ }
325
+
326
+ /**
327
+ * Converts to Remix path pattern.
328
+ *
329
+ * - `[param]` → `$param`
330
+ * - `[[param]]` → `($param)`
331
+ * - `[...param]` → `$`
332
+ * - `[[...param]]` → `/`, `$`
333
+ * - `pk_[id]` → (not supported, emits `pk_$id`)
334
+ */
335
+ export function toRemix(path: Route.RoutePattern): string[] {
336
+ const segments = parse(path)
337
+ const optionalRestIndex = segments.findIndex(
338
+ (s) => s._tag === "RestSegment" && s.optional,
339
+ )
340
+
341
+ const mapper = (segment: Segment): string => {
342
+ switch (segment._tag) {
343
+ case "LiteralSegment":
344
+ return segment.value
345
+ case "ParamSegment": {
346
+ const param = segment.optional
347
+ ? `($${segment.name})`
348
+ : `$${segment.name}`
349
+ return (segment.prefix ?? "") + param + (segment.suffix ?? "")
350
+ }
351
+ case "RestSegment":
352
+ return "$"
353
+ }
354
+ }
355
+
356
+ if (optionalRestIndex !== -1) {
357
+ const before = segments.slice(0, optionalRestIndex)
358
+ const beforeJoined = before.map(mapper).join("/")
359
+ const basePath = beforeJoined ? "/" + beforeJoined : "/"
360
+ const withWildcard = basePath === "/" ? "$" : basePath + "/$"
361
+ return [basePath, withWildcard]
362
+ }
363
+
364
+ const joined = segments.map(mapper).join("/")
365
+ return [joined ? "/" + joined : "/"]
366
+ }
367
+
368
+ export const toBun = toColon
369
+
370
+ /**
371
+ * @deprecated Use toEffectHttpRouterPath instead
372
+ */
373
+ export function toHttpPath(path: Route.RoutePattern): string {
374
+ return toEffect(path)[0]
375
+ }
376
+
377
+ type ExtractSegment<S extends string> = S extends `[[...${infer Name}]]`
378
+ ? RestSegment<Name, true>
379
+ : S extends `[...${infer Name}]` ? RestSegment<Name, false>
380
+ : S extends `[[${infer Name}]]` ? ParamSegment<Name, true, "", "">
381
+ : S extends
382
+ `${infer Pre
383
+ extends `${string}${ParamDelimiter}`}[${infer Name}]${infer Suf}`
384
+ ? Suf extends `${infer Delim extends ParamDelimiter}${infer SufRest}`
385
+ ? ParamSegment<Name, false, Pre, `${Delim}${SufRest}`>
386
+ : Suf extends "" ? ParamSegment<Name, false, Pre, "">
387
+ : undefined
388
+ : S extends `[${infer Name}]${infer Suf extends `${ParamDelimiter}${string}`}`
389
+ ? ParamSegment<Name, false, "", Suf>
390
+ : S extends `[${infer Name}]` ? ParamSegment<Name, false, "", "">
391
+ : LiteralSegment<S>
package/src/Start.ts CHANGED
@@ -1,48 +1,24 @@
1
- import * as BunContext from "@effect/platform-bun/BunContext"
2
- import * as BunHttpServer from "@effect/platform-bun/BunHttpServer"
3
- import * as BunRuntime from "@effect/platform-bun/BunRuntime"
4
1
  import * as FetchHttpClient from "@effect/platform/FetchHttpClient"
2
+ import * as FileSystem from "@effect/platform/FileSystem"
5
3
  import * as HttpClient from "@effect/platform/HttpClient"
6
4
  import * as HttpRouter from "@effect/platform/HttpRouter"
7
5
  import * as HttpServer from "@effect/platform/HttpServer"
6
+ import * as Config from "effect/Config"
8
7
  import * as Effect from "effect/Effect"
9
8
  import * as Function from "effect/Function"
10
9
  import * as Layer from "effect/Layer"
10
+ import * as Option from "effect/Option"
11
11
  import * as BunBundle from "./bun/BunBundle.ts"
12
+ import * as BunHttpServer from "./bun/BunHttpServer.ts"
13
+ import * as BunRoute from "./bun/BunRoute.ts"
14
+ import * as BunRuntime from "./bun/BunRuntime.ts"
12
15
  import * as Bundle from "./Bundle.ts"
13
16
  import * as BundleHttp from "./BundleHttp.ts"
14
- import * as FileRouter from "./FileRouter.ts"
15
17
  import * as HttpAppExtra from "./HttpAppExtra.ts"
18
+ import * as NodeFileSystem from "./NodeFileSystem.ts"
16
19
  import * as Router from "./Router.ts"
17
20
  import * as StartApp from "./StartApp.ts"
18
21
 
19
- // TODO: we probably want to remove this API to avoid
20
- // multiple entrypoints for routers and bundles.
21
- // We could handle endpoints routing in {@link layer}
22
- // or {@link serve}.
23
- // Serve probably makes more sense because it's an entrypoint
24
- // for serving an HTTP server
25
- export function router(options: {
26
- load: () => Promise<Router.RouteManifest>
27
- path: string
28
- }) {
29
- return Layer.provideMerge(
30
- // add it to BundleHttp
31
- Layer.effectDiscard(
32
- Effect.gen(function*() {
33
- const httpRouter = yield* HttpRouter.Default
34
- const startRouter = yield* Router.Router
35
-
36
- yield* httpRouter.concat(startRouter.httpRouter)
37
- }),
38
- ),
39
- Layer.merge(
40
- Router.layerPromise(options.load),
41
- FileRouter.layer(options),
42
- ),
43
- )
44
- }
45
-
46
22
  export function bundleClient(config: BunBundle.BuildOptions | string) {
47
23
  const clientLayer = Layer.effect(
48
24
  Bundle.ClientBundle,
@@ -88,9 +64,10 @@ export function serve<ROut, E>(
88
64
  ROut,
89
65
  E,
90
66
  | HttpServer.HttpServer
91
- | HttpRouter.Default
92
67
  | HttpClient.HttpClient
93
- | BunContext.BunContext
68
+ | HttpRouter.Default
69
+ | FileSystem.FileSystem
70
+ | BunHttpServer.BunServer
94
71
  >
95
72
  }>,
96
73
  ) {
@@ -102,29 +79,14 @@ export function serve<ROut, E>(
102
79
  )
103
80
 
104
81
  return Function.pipe(
105
- Layer.unwrapEffect(Effect.gen(function*() {
106
- const middlewareService = yield* StartApp.StartApp
107
- const middleware = yield* middlewareService.middleware
108
-
109
- const finalMiddleware = Function.flow(
110
- HttpAppExtra.handleErrors,
111
- middleware,
112
- )
113
-
114
- return Function.pipe(
115
- HttpRouter
116
- .Default
117
- .serve(finalMiddleware),
118
- HttpServer.withLogAddress,
119
- )
120
- })),
82
+ BunHttpServer.layerRoutes(),
83
+ HttpServer.withLogAddress,
121
84
  Layer.provide(appLayer),
122
85
  Layer.provide([
123
86
  FetchHttpClient.layer,
124
87
  HttpRouter.Default.Live,
125
- BunHttpServer.layer({
126
- port: 3000,
127
- }),
88
+ BunHttpServer.layerServer(),
89
+ NodeFileSystem.layer,
128
90
  StartApp.layer(),
129
91
  ]),
130
92
  Layer.launch,
@@ -1,7 +1,4 @@
1
- import * as BunContext from "@effect/platform-bun/BunContext"
2
- import * as BunHttpServer from "@effect/platform-bun/BunHttpServer"
3
1
  import * as HttpRouter from "@effect/platform/HttpRouter"
4
- import * as HttpServer from "@effect/platform/HttpServer"
5
2
  import * as t from "bun:test"
6
3
  import * as Effect from "effect/Effect"
7
4
  import * as Layer from "effect/Layer"