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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +109 -0
  3. package/package.json +57 -0
  4. package/src/Bundle.ts +167 -0
  5. package/src/BundleFiles.ts +174 -0
  6. package/src/BundleHttp.test.ts +160 -0
  7. package/src/BundleHttp.ts +259 -0
  8. package/src/Commander.test.ts +1378 -0
  9. package/src/Commander.ts +672 -0
  10. package/src/Datastar.test.ts +267 -0
  11. package/src/Datastar.ts +68 -0
  12. package/src/Effect_HttpRouter.test.ts +570 -0
  13. package/src/EncryptedCookies.test.ts +427 -0
  14. package/src/EncryptedCookies.ts +451 -0
  15. package/src/FileHttpRouter.test.ts +207 -0
  16. package/src/FileHttpRouter.ts +122 -0
  17. package/src/FileRouter.ts +405 -0
  18. package/src/FileRouterCodegen.test.ts +598 -0
  19. package/src/FileRouterCodegen.ts +251 -0
  20. package/src/FileRouter_files.test.ts +64 -0
  21. package/src/FileRouter_path.test.ts +132 -0
  22. package/src/FileRouter_tree.test.ts +126 -0
  23. package/src/FileSystemExtra.ts +102 -0
  24. package/src/HttpAppExtra.ts +127 -0
  25. package/src/Hyper.ts +194 -0
  26. package/src/HyperHtml.test.ts +90 -0
  27. package/src/HyperHtml.ts +139 -0
  28. package/src/HyperNode.ts +37 -0
  29. package/src/JsModule.test.ts +14 -0
  30. package/src/JsModule.ts +116 -0
  31. package/src/PublicDirectory.test.ts +280 -0
  32. package/src/PublicDirectory.ts +108 -0
  33. package/src/Route.test.ts +873 -0
  34. package/src/Route.ts +992 -0
  35. package/src/Router.ts +80 -0
  36. package/src/SseHttpResponse.ts +55 -0
  37. package/src/Start.ts +133 -0
  38. package/src/StartApp.ts +43 -0
  39. package/src/StartHttp.ts +42 -0
  40. package/src/StreamExtra.ts +146 -0
  41. package/src/TestHttpClient.test.ts +54 -0
  42. package/src/TestHttpClient.ts +100 -0
  43. package/src/bun/BunBundle.test.ts +277 -0
  44. package/src/bun/BunBundle.ts +309 -0
  45. package/src/bun/BunBundle_imports.test.ts +50 -0
  46. package/src/bun/BunFullstackServer.ts +45 -0
  47. package/src/bun/BunFullstackServer_httpServer.ts +541 -0
  48. package/src/bun/BunImportTrackerPlugin.test.ts +77 -0
  49. package/src/bun/BunImportTrackerPlugin.ts +97 -0
  50. package/src/bun/BunTailwindPlugin.test.ts +335 -0
  51. package/src/bun/BunTailwindPlugin.ts +322 -0
  52. package/src/bun/BunVirtualFilesPlugin.ts +59 -0
  53. package/src/bun/index.ts +4 -0
  54. package/src/client/Overlay.ts +34 -0
  55. package/src/client/ScrollState.ts +120 -0
  56. package/src/client/index.ts +101 -0
  57. package/src/index.ts +24 -0
  58. package/src/jsx-datastar.d.ts +63 -0
  59. package/src/jsx-runtime.ts +23 -0
  60. package/src/jsx.d.ts +4402 -0
  61. package/src/testing.ts +55 -0
  62. package/src/x/cloudflare/CloudflareTunnel.ts +110 -0
  63. package/src/x/cloudflare/index.ts +1 -0
  64. package/src/x/datastar/Datastar.test.ts +267 -0
  65. package/src/x/datastar/Datastar.ts +68 -0
  66. package/src/x/datastar/index.ts +4 -0
  67. package/src/x/datastar/jsx-datastar.d.ts +63 -0
@@ -0,0 +1,122 @@
1
+ // @ts-nocheck
2
+ import * as HttpApp from "@effect/platform/HttpApp"
3
+ import * as HttpMiddleware from "@effect/platform/HttpMiddleware"
4
+ import * as HttpRouter from "@effect/platform/HttpRouter"
5
+ import * as HttpServerRequest from "@effect/platform/HttpServerRequest"
6
+ import * as Effect from "effect/Effect"
7
+ import * as Function from "effect/Function"
8
+ import * as Router from "./Router.ts"
9
+
10
+ /**
11
+ * Combines Effect error channel from a record of effects.
12
+ */
13
+ type RecordEffectError<A> = A extends Record<string, any> ? Exclude<
14
+ {
15
+ [K in keyof A]: A[K] extends Effect.Effect<any, infer E, any> ? E
16
+ : never
17
+ }[keyof A],
18
+ undefined
19
+ >
20
+ : never
21
+
22
+ /**
23
+ * Combines Effect requirement channel from a record of effects.
24
+ */
25
+ type RecordEffectRequirements<A> = A extends Record<string, any> ? Exclude<
26
+ {
27
+ [K in keyof A]: A[K] extends Effect.Effect<any, any, infer R> ? R
28
+ : never
29
+ }[keyof A],
30
+ undefined
31
+ >
32
+ : never
33
+
34
+ /**
35
+ * Infers the HttpRouter type from an array of ServerRoutes
36
+ */
37
+ export type HttpRouterFromServerRoutes<
38
+ Routes extends ReadonlyArray<Router.ServerRoute>,
39
+ > = HttpRouter.HttpRouter<
40
+ (Routes extends ReadonlyArray<infer Route>
41
+ ? Route extends Router.ServerRoute
42
+ ? RecordEffectError<Awaited<ReturnType<Route["load"]>>>
43
+ : never
44
+ : never),
45
+ Exclude<
46
+ Routes extends ReadonlyArray<infer Route>
47
+ ? Route extends Router.ServerRoute
48
+ ? RecordEffectRequirements<Awaited<ReturnType<Route["load"]>>>
49
+ : never
50
+ : never,
51
+ // exclude HttpServerRequest since HttpRouter already has it
52
+ HttpServerRequest.HttpServerRequest
53
+ >
54
+ >
55
+
56
+ /**
57
+ * Converts file-based route path format to HttpRouter path format.
58
+ * Examples:
59
+ * /movies/[id] -> /movies/:id
60
+ * /docs/[[...slug]] -> /docs/*
61
+ * /api/[...path] -> /api/*
62
+ */
63
+ function convertPathFormat(path: string): string {
64
+ return path
65
+ // Convert required params: [id] -> :id
66
+ .replace(/\[([^\]\.]+)\]/g, ":$1")
67
+ // Convert optional rest params: [[...slug]] -> *
68
+ .replace(/\[\[\.\.\.([^\]]+)\]\]/g, "*")
69
+ // Convert required rest params: [...path] -> *
70
+ .replace(/\[\.\.\.([^\]]+)\]/g, "*")
71
+ }
72
+
73
+ /**
74
+ * Makes a HttpRouter from file-based routes.
75
+ */
76
+ export function make<Routes extends Router.ServerRoutes>(
77
+ routes: Routes,
78
+ ): Effect.Effect<HttpRouterFromServerRoutes<Routes>> {
79
+ return Effect.gen(function*() {
80
+ const modules = yield* Effect.forEach(
81
+ routes,
82
+ (route) =>
83
+ Function.pipe(
84
+ Effect.tryPromise(() => route.load()),
85
+ Effect.orDie,
86
+ Effect.map((module) => ({ path: route.path, module })),
87
+ ),
88
+ )
89
+
90
+ let router: HttpRouter.HttpRouter<any, any> = HttpRouter.empty
91
+
92
+ for (const { path, module } of modules) {
93
+ const routeSet = module.default
94
+ const httpRouterPath = convertPathFormat(path)
95
+
96
+ for (const route of routeSet.set) {
97
+ router = HttpRouter.route(route.method)(
98
+ httpRouterPath,
99
+ route.handler as any,
100
+ )(
101
+ router,
102
+ )
103
+ }
104
+ }
105
+
106
+ return router as HttpRouterFromServerRoutes<Routes>
107
+ })
108
+ }
109
+
110
+ export function middleware() {
111
+ return HttpMiddleware.make((app) =>
112
+ Effect.gen(function*() {
113
+ const routerContext = yield* Router.Router
114
+ const router = routerContext.httpRouter
115
+ const res = yield* router.pipe(
116
+ Effect.catchTag("RouteNotFound", () => app),
117
+ )
118
+
119
+ return res
120
+ })
121
+ )
122
+ }
@@ -0,0 +1,405 @@
1
+ import type { PlatformError } from "@effect/platform/Error"
2
+ import * as FileSystem from "@effect/platform/FileSystem"
3
+ import * as Array from "effect/Array"
4
+ import * as Effect from "effect/Effect"
5
+ import * as Function from "effect/Function"
6
+ import * as Layer from "effect/Layer"
7
+ import * as Record from "effect/Record"
8
+ import * as Stream from "effect/Stream"
9
+ import * as NPath from "node:path"
10
+ import * as NUrl from "node:url"
11
+ import * as FileRouterCodegen from "./FileRouterCodegen.ts"
12
+ import * as FileSystemExtra from "./FileSystemExtra.ts"
13
+ import { ServerModule } from "./Router.ts"
14
+
15
+ type LiteralSegment = {
16
+ literal: string
17
+ }
18
+
19
+ type GroupSegment = {
20
+ group: string
21
+ }
22
+
23
+ type ParamSegment = {
24
+ param: string
25
+ optional?: true
26
+ }
27
+
28
+ type RestSegment = {
29
+ rest: string
30
+ optional?: true
31
+ }
32
+
33
+ type HandleSegment = {
34
+ handle: "route" | "layer"
35
+ }
36
+
37
+ export type Extension = "tsx" | "jsx" | "ts" | "js"
38
+
39
+ export type Segment =
40
+ | LiteralSegment
41
+ | GroupSegment
42
+ | ParamSegment
43
+ | RestSegment
44
+ | HandleSegment
45
+
46
+ export function isSegmentEqual(a: Segment, b: Segment): boolean {
47
+ if ("literal" in a && "literal" in b) return a.literal === b.literal
48
+ if ("group" in a && "group" in b) return a.group === b.group
49
+ if ("param" in a && "param" in b) return a.param === b.param
50
+ if ("rest" in a && "rest" in b) return a.rest === b.rest
51
+ if ("handle" in a && "handle" in b) return a.handle === b.handle
52
+ return false
53
+ }
54
+
55
+ export type RouteModule = {
56
+ path: `/${string}`
57
+ segments: readonly Segment[]
58
+ load: () => Promise<ServerModule>
59
+ layers?: ReadonlyArray<() => Promise<unknown>>
60
+ }
61
+
62
+ export type RouteManifest = {
63
+ Modules: readonly RouteModule[]
64
+ }
65
+
66
+ export type RouteHandle = {
67
+ handle: "route" | "layer"
68
+ modulePath: string // eg. `about/route.tsx`, `users/[userId]/route.tsx`, `(admin)/users/route.tsx`
69
+ routePath: `/${string}` // eg. `/about`, `/users/[userId]`, `/users` (groups stripped)
70
+ segments: Segment[]
71
+ }
72
+
73
+ /**
74
+ * Routes are sorted by depth, layers are first,
75
+ * rest parameters are put at the end for each segment.
76
+ * - layer.tsx
77
+ * - users/route.tsx
78
+ * - users/[userId]/route.tsx
79
+ * - [[...rest]]/route.tsx
80
+ */
81
+ export type OrderedRouteHandles = RouteHandle[]
82
+
83
+ const ROUTE_PATH_REGEX = /^\/?(.*\/?)((route|layer))\.(jsx?|tsx?)$/
84
+
85
+ type RoutePathMatch = [
86
+ path: string,
87
+ kind: string,
88
+ kind: string,
89
+ ext: string,
90
+ ]
91
+
92
+ export function segmentPath(path: string): Segment[] {
93
+ const trimmedPath = path.replace(/(^\/)|(\/$)/g, "") // trim leading/trailing slashes
94
+
95
+ if (trimmedPath === "") {
96
+ return [] // Handles "" and "/"
97
+ }
98
+
99
+ const segmentStrings = trimmedPath
100
+ .split("/")
101
+ .filter(s => s !== "") // Remove empty segments from multiple slashes, e.g. "foo//bar"
102
+
103
+ if (segmentStrings.length === 0) {
104
+ return []
105
+ }
106
+
107
+ const segments: (Segment | null)[] = segmentStrings.map(
108
+ (s): Segment | null => {
109
+ // Check if it's a handle (route.ts, layer.tsx, etc.)
110
+ const [, handle] = s.match(/^(route|layer)\.(tsx?|jsx?)$/)
111
+ ?? []
112
+
113
+ if (handle) {
114
+ // @ts-expect-error regexp group ain't typed
115
+ return { handle }
116
+ }
117
+
118
+ // (group) - Groups
119
+ const groupMatch = s.match(/^\((\w+)\)$/)
120
+ if (groupMatch) {
121
+ return { group: groupMatch[1] }
122
+ }
123
+
124
+ // [[...rest]] - Optional rest parameter
125
+ const optionalRestMatch = s.match(/^\[\[\.\.\.(\w+)\]\]$/)
126
+ if (optionalRestMatch) {
127
+ return {
128
+ rest: optionalRestMatch[1],
129
+ optional: true,
130
+ }
131
+ }
132
+
133
+ // [...rest] - Required rest parameter
134
+ const requiredRestMatch = s.match(/^\[\.\.\.(\w+)\]$/)
135
+ if (requiredRestMatch) {
136
+ return { rest: requiredRestMatch[1] }
137
+ }
138
+
139
+ // [param] - Dynamic parameter
140
+ const paramMatch = s.match(/^\[(\w+)\]$/)
141
+ if (paramMatch) {
142
+ return { param: paramMatch[1] }
143
+ }
144
+
145
+ // Literal segment
146
+ if (/^[A-Za-z0-9._~-]+$/.test(s)) {
147
+ return { literal: s }
148
+ }
149
+
150
+ return null
151
+ },
152
+ )
153
+
154
+ if (segments.some((seg) => seg === null)) {
155
+ throw new Error(
156
+ `Invalid path segment in "${path}": contains invalid characters or format`,
157
+ )
158
+ }
159
+
160
+ return segments as Segment[]
161
+ }
162
+
163
+ function segmentToText(seg: Segment): string {
164
+ if ("literal" in seg) return seg.literal
165
+ if ("group" in seg) return `(${seg.group})`
166
+ if ("param" in seg) return `[${seg.param}]`
167
+ if ("rest" in seg) {
168
+ return seg.optional ? `[[...${seg.rest}]]` : `[...${seg.rest}]`
169
+ }
170
+ if ("handle" in seg) return seg.handle
171
+ return ""
172
+ }
173
+
174
+ export function parseRoute(
175
+ path: string,
176
+ ): RouteHandle {
177
+ const segs = segmentPath(path)
178
+
179
+ const handle = segs.at(-1)
180
+
181
+ if (!handle || !("handle" in handle)) {
182
+ throw new Error(
183
+ `Invalid route path "${path}": must end with a valid handle (route or layer)`,
184
+ )
185
+ }
186
+
187
+ // Validate Route constraints: rest segments must be the last segment before the handle
188
+ const pathSegments = segs.slice(0, -1) // All segments except the handle
189
+ const restIndex = pathSegments.findIndex(seg => "rest" in seg)
190
+
191
+ if (restIndex !== -1) {
192
+ // If there's a rest, it must be the last path segment
193
+ if (restIndex !== pathSegments.length - 1) {
194
+ throw new Error(
195
+ `Invalid route path "${path}": rest segment ([...rest] or [[...rest]]) must be the last path segment before the handle`,
196
+ )
197
+ }
198
+
199
+ // Validate that all segments before the rest are literal, param, or group
200
+ for (let i = 0; i < restIndex; i++) {
201
+ const seg = pathSegments[i]
202
+ if (!("literal" in seg) && !("param" in seg) && !("group" in seg)) {
203
+ throw new Error(
204
+ `Invalid route path "${path}": segments before rest must be literal, param, or group segments`,
205
+ )
206
+ }
207
+ }
208
+ } else {
209
+ // No rest: validate that all path segments are literal, param, or group
210
+ for (const seg of pathSegments) {
211
+ if (!("literal" in seg) && !("param" in seg) && !("group" in seg)) {
212
+ throw new Error(
213
+ `Invalid route path "${path}": path segments must be literal, param, or group segments`,
214
+ )
215
+ }
216
+ }
217
+ }
218
+
219
+ // Construct routePath from path segments (excluding handle and groups)
220
+ // Groups like (admin) are stripped from the URL path
221
+ const routePathSegments = pathSegments
222
+ .filter(seg => !("group" in seg))
223
+ .map(segmentToText)
224
+
225
+ const routePath = (routePathSegments.length > 0
226
+ ? `/${routePathSegments.join("/")}`
227
+ : "/") as `/${string}`
228
+
229
+ return {
230
+ handle: handle.handle,
231
+ modulePath: path,
232
+ routePath,
233
+ segments: segs,
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Generates a file that references all routes.
239
+ */
240
+ export function layer(options: {
241
+ load: () => Promise<unknown>
242
+ path: string
243
+ }) {
244
+ let manifestPath = options.path
245
+
246
+ // handle use of import.meta.resolve
247
+ if (manifestPath.startsWith("file://")) {
248
+ manifestPath = NUrl.fileURLToPath(manifestPath)
249
+ }
250
+
251
+ const routesPath = NPath.dirname(manifestPath)
252
+ const manifestFilename = NPath.basename(manifestPath)
253
+ const resolvedManifestPath = NPath.resolve(routesPath, manifestFilename)
254
+
255
+ return Layer.scopedDiscard(
256
+ Effect.gen(function*() {
257
+ yield* FileRouterCodegen.update(routesPath, manifestFilename)
258
+
259
+ const stream = Function.pipe(
260
+ FileSystemExtra.watchSource({
261
+ path: routesPath,
262
+ filter: (e) => !e.path.includes("node_modules"),
263
+ }),
264
+ Stream.onError((e) => Effect.logError(e)),
265
+ )
266
+
267
+ yield* Function.pipe(
268
+ stream,
269
+ // filter out edits to gen file
270
+ Stream.filter(e => e.path !== resolvedManifestPath),
271
+ Stream.runForEach(() =>
272
+ FileRouterCodegen.update(routesPath, manifestFilename)
273
+ ),
274
+ Effect.fork,
275
+ )
276
+ }),
277
+ )
278
+ }
279
+
280
+ export function walkRoutesDirectory(
281
+ dir: string,
282
+ ): Effect.Effect<
283
+ OrderedRouteHandles,
284
+ PlatformError,
285
+ FileSystem.FileSystem
286
+ > {
287
+ return Effect.gen(function*() {
288
+ const fs = yield* FileSystem.FileSystem
289
+ const files = yield* fs.readDirectory(dir, { recursive: true })
290
+
291
+ return getRouteHandlesFromPaths(files)
292
+ })
293
+ }
294
+
295
+ /**
296
+ * Given a list of paths, return a list of route handles.
297
+ */
298
+ export function getRouteHandlesFromPaths(
299
+ paths: string[],
300
+ ): OrderedRouteHandles {
301
+ const handles = paths
302
+ .map(f => f.match(ROUTE_PATH_REGEX) as RoutePathMatch)
303
+ .filter(Boolean)
304
+ .map(v => {
305
+ const path = v[0]
306
+ try {
307
+ return parseRoute(path)
308
+ } catch {
309
+ return null
310
+ }
311
+ })
312
+ .filter((route): route is RouteHandle => route !== null)
313
+ .toSorted((a, b) => {
314
+ const aDepth = a.segments.length
315
+ const bDepth = b.segments.length
316
+ const aHasRest = a.segments.some(seg => "rest" in seg)
317
+ const bHasRest = b.segments.some(seg => "rest" in seg)
318
+
319
+ return (
320
+ // rest is a dominant factor (routes with rest come last)
321
+ (+aHasRest - +bHasRest) * 1000
322
+ // depth is reversed for rest
323
+ + (aDepth - bDepth) * (1 - 2 * +aHasRest)
324
+ // lexicographic comparison as tiebreaker
325
+ + a.modulePath.localeCompare(b.modulePath) * 0.001
326
+ )
327
+ })
328
+
329
+ // Detect conflicting routes at the same path
330
+ const routesByPath = new Map<string, RouteHandle[]>()
331
+ for (const handle of handles) {
332
+ const existing = routesByPath.get(handle.routePath) || []
333
+ existing.push(handle)
334
+ routesByPath.set(handle.routePath, existing)
335
+ }
336
+
337
+ for (const [path, pathHandles] of routesByPath) {
338
+ const routeHandles = pathHandles.filter(h => h.handle === "route")
339
+
340
+ if (routeHandles.length > 1) {
341
+ const modulePaths = routeHandles.map(h => h.modulePath).join(", ")
342
+ throw new Error(
343
+ `Conflicting routes detected at path ${path}: ${modulePaths}`,
344
+ )
345
+ }
346
+ }
347
+
348
+ return handles
349
+ }
350
+
351
+ type RouteTree = {
352
+ path: `/${string}`
353
+ handles: RouteHandle[]
354
+ children?: RouteTree[]
355
+ }
356
+
357
+ export function treeFromRouteHandles(
358
+ handles: RouteHandle[],
359
+ ): RouteTree {
360
+ const handlesByPath = Array.groupBy(handles, handle => handle.routePath)
361
+ const paths = Record.keys(handlesByPath)
362
+ const root: RouteTree = {
363
+ path: "/",
364
+ handles: handlesByPath["/"] || [],
365
+ }
366
+
367
+ const nodeMap = new Map<string, RouteTree>([["/", root]])
368
+
369
+ for (const absolutePath of paths) {
370
+ if (absolutePath === "/") continue
371
+
372
+ // Find parent path
373
+ const segments = absolutePath.split("/").filter(Boolean)
374
+ const parentPath = segments.length === 1
375
+ ? "/"
376
+ : "/" + segments.slice(0, -1).join("/")
377
+
378
+ const parent = nodeMap.get(parentPath)
379
+ if (!parent) {
380
+ continue // Skip orphaned paths
381
+ }
382
+
383
+ // Create node with relative path
384
+ const relativePath = parent.path === "/"
385
+ ? absolutePath
386
+ : absolutePath.slice(parentPath.length)
387
+
388
+ const node: RouteTree = {
389
+ path: relativePath as `/${string}`,
390
+ handles: handlesByPath[absolutePath]!,
391
+ }
392
+
393
+ // Add to parent
394
+ if (!parent.children) {
395
+ parent.children = []
396
+ }
397
+
398
+ parent.children.push(node)
399
+
400
+ // Store for future children
401
+ nodeMap.set(absolutePath, node)
402
+ }
403
+
404
+ return root
405
+ }