effect-start 0.10.0 → 0.11.1

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.
@@ -1,9 +1,11 @@
1
1
  import {
2
2
  HttpRouter,
3
+ HttpServerRequest,
3
4
  HttpServerResponse,
4
5
  } from "@effect/platform"
5
6
  import * as HttpApp from "@effect/platform/HttpApp"
6
7
  import * as HttpMiddleware from "@effect/platform/HttpMiddleware"
8
+
7
9
  import {
8
10
  RequestError,
9
11
  RouteNotFound,
@@ -27,10 +29,242 @@ type GraciousError =
27
29
  | RequestError
28
30
  | ParseResult.ParseError
29
31
 
32
+ type StackFrame = {
33
+ function: string
34
+ file: string
35
+ type: "application" | "framework" | "node_modules"
36
+ }
37
+
38
+ const ERROR_PAGE_CSS = `
39
+ :root {
40
+ --error-red: #c00;
41
+ --error-red-dark: #a00;
42
+ --bg-error: #fee;
43
+ --bg-light: #f5f5f5;
44
+ --bg-white: #fff;
45
+ --border-color: #ddd;
46
+ --text-dark: #333;
47
+ --text-gray: #666;
48
+ --text-mono: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
49
+ }
50
+
51
+ * { box-sizing: border-box; }
52
+
53
+ body {
54
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
55
+ margin: 0;
56
+ padding: 0;
57
+ color: var(--text-dark);
58
+ line-height: 1.6;
59
+ min-height: 100dvh;
60
+ }
61
+
62
+ .error-page { width: 100%; margin: 0; }
63
+
64
+ .error-header {
65
+ background: var(--error-red);
66
+ color: white;
67
+ padding: 2rem 2.5rem;
68
+ margin: 0;
69
+ font-family: var(--text-mono);
70
+ }
71
+
72
+ .error-header h1 {
73
+ margin: 0 0 0.5rem 0;
74
+ font-size: 2rem;
75
+ font-weight: 600;
76
+ font-family: var(--text-mono);
77
+ }
78
+
79
+ .error-message {
80
+ margin: 0;
81
+ font-size: 1.1rem;
82
+ opacity: 0.95;
83
+ font-family: var(--text-mono);
84
+ }
85
+
86
+ .error-content {
87
+ background: var(--bg-white);
88
+ padding: 2rem 2.5rem;
89
+ }
90
+
91
+ .stack-trace {
92
+ margin: 1.5rem 0;
93
+ border: 1px solid var(--border-color);
94
+ border-radius: 4px;
95
+ overflow: hidden;
96
+ }
97
+
98
+ .stack-trace-header {
99
+ font-weight: 600;
100
+ padding: 0.75rem 1rem;
101
+ background: var(--bg-light);
102
+ border-bottom: 1px solid var(--border-color);
103
+ }
104
+
105
+ .stack-list {
106
+ list-style: none;
107
+ padding: 0;
108
+ margin: 0;
109
+ max-height: 400px;
110
+ overflow-y: auto;
111
+ }
112
+
113
+ .stack-list li {
114
+ padding: 0.5rem 1rem;
115
+ font-family: var(--text-mono);
116
+ font-size: 0.875rem;
117
+ border-bottom: 1px solid var(--border-color);
118
+ background: var(--bg-white);
119
+ }
120
+
121
+ .stack-list li:last-child { border-bottom: none; }
122
+
123
+ .stack-list li:hover { background: #fafafa; }
124
+
125
+ .stack-list code {
126
+ background: transparent;
127
+ padding: 0;
128
+ font-weight: 600;
129
+ color: var(--error-red-dark);
130
+ }
131
+
132
+ .stack-list .path { color: var(--text-gray); margin-left: 0.5rem; }
133
+
134
+ .request-info {
135
+ margin: 1.5rem 0;
136
+ border: 1px solid var(--border-color);
137
+ border-radius: 4px;
138
+ overflow: hidden;
139
+ }
140
+
141
+ .request-info-header {
142
+ font-weight: 700;
143
+ padding: 0.75rem 1rem;
144
+ background: var(--bg-light);
145
+ border-bottom: 1px solid var(--border-color);
146
+ }
147
+
148
+ .request-info-content {
149
+ padding: 1rem;
150
+ font-family: var(--text-mono);
151
+ font-size: 0.875rem;
152
+ white-space: pre-wrap;
153
+ word-break: break-all;
154
+ }
155
+
156
+ @media (max-width: 768px) {
157
+ .error-header, .error-content { padding: 1.5rem 1rem; }
158
+ .error-header h1 { font-size: 1.5rem; }
159
+ }
160
+ `
161
+
162
+ type ErrorHtmlData = {
163
+ status: number
164
+ tag: string
165
+ message?: string
166
+ details?: object
167
+ requestContext?: RequestContext
168
+ errorName?: string
169
+ }
170
+
171
+ function errorHtml(data: ErrorHtmlData): HttpServerResponse.HttpServerResponse {
172
+ let detailsHtml = ""
173
+
174
+ if (data.details) {
175
+ const detailsObj = data.details as Record<string, unknown>
176
+
177
+ if ("stack" in detailsObj && Array.isArray(detailsObj.stack)) {
178
+ const stackFrames = detailsObj.stack as StackFrame[]
179
+ detailsHtml = renderStackTrace(stackFrames)
180
+ }
181
+ }
182
+
183
+ const requestHtml = data.requestContext
184
+ ? renderRequestContext(data.requestContext)
185
+ : ""
186
+
187
+ const messageHtml = data.message
188
+ ? `<p class="error-message">${escapeHtml(data.message)}</p>`
189
+ : ""
190
+
191
+ const headerTitle = data.errorName ?? "UnexpectedError"
192
+
193
+ const html = `<!DOCTYPE html>
194
+ <html lang="en">
195
+ <head>
196
+ <meta charset="UTF-8">
197
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
198
+ <title>${headerTitle} - Error ${data.status}</title>
199
+ <style>${ERROR_PAGE_CSS}</style>
200
+ </head>
201
+ <body>
202
+ <div class="error-header">
203
+ <h1>${escapeHtml(headerTitle)}</h1>
204
+ ${messageHtml}
205
+ </div>
206
+ <div class="error-content">
207
+ ${detailsHtml}
208
+ ${requestHtml}
209
+ </div>
210
+ </body>
211
+ </html>`
212
+ return HttpServerResponse.text(html, {
213
+ status: data.status,
214
+ contentType: "text/html",
215
+ })
216
+ }
217
+
218
+ function errorText(
219
+ status: number,
220
+ tag: string,
221
+ details?: object,
222
+ ): HttpServerResponse.HttpServerResponse {
223
+ const text = details ? `${tag}\n${JSON.stringify(details, null, 2)}` : tag
224
+ return HttpServerResponse.text(text, { status })
225
+ }
226
+
227
+ function respondWithError(
228
+ accept: string,
229
+ status: number,
230
+ tag: string,
231
+ message?: string,
232
+ details?: object,
233
+ requestContext?: RequestContext,
234
+ errorName?: string,
235
+ ): HttpServerResponse.HttpServerResponse {
236
+ if (accept.includes("text/html")) {
237
+ return errorHtml({
238
+ status,
239
+ tag,
240
+ message,
241
+ details,
242
+ requestContext,
243
+ errorName,
244
+ })
245
+ }
246
+ if (accept.includes("text/plain")) {
247
+ return errorText(status, tag, details)
248
+ }
249
+ return HttpServerResponse.unsafeJson(
250
+ { error: { _tag: tag, ...details } },
251
+ { status },
252
+ )
253
+ }
254
+
30
255
  export const renderError = (
31
256
  error: unknown,
257
+ accept: string = "",
32
258
  ) =>
33
259
  Effect.gen(function*() {
260
+ const request = yield* HttpServerRequest.HttpServerRequest
261
+
262
+ const requestContext: RequestContext = {
263
+ url: request.url,
264
+ method: request.method,
265
+ headers: filterSensitiveHeaders(request.headers),
266
+ }
267
+
34
268
  let unwrappedError: GraciousError | undefined
35
269
 
36
270
  if (Cause.isCause(error)) {
@@ -45,68 +279,175 @@ export const renderError = (
45
279
 
46
280
  switch (unwrappedError?._tag) {
47
281
  case "RouteNotFound":
48
- return yield* HttpServerResponse.unsafeJson({
49
- error: {
50
- _tag: unwrappedError._tag,
51
- },
52
- }, {
53
- status: 404,
54
- })
282
+ return respondWithError(
283
+ accept,
284
+ 404,
285
+ "RouteNotFound",
286
+ "The page you were looking for doesn't exist",
287
+ undefined,
288
+ requestContext,
289
+ )
55
290
  case "RequestError": {
56
291
  const message = unwrappedError.reason === "Decode"
57
292
  ? "Request body is invalid"
58
- : undefined
293
+ : "Request could not be processed"
59
294
 
60
- return yield* HttpServerResponse.unsafeJson({
61
- error: {
62
- _tag: unwrappedError._tag,
295
+ return respondWithError(
296
+ accept,
297
+ 400,
298
+ "RequestError",
299
+ message,
300
+ {
63
301
  reason: unwrappedError.reason,
64
- message,
65
302
  },
66
- }, {
67
- status: 400,
68
- })
303
+ requestContext,
304
+ )
69
305
  }
70
306
  case "ParseError": {
71
307
  const issues = yield* ParseResult.ArrayFormatter.formatIssue(
72
308
  unwrappedError.issue,
73
309
  )
74
- const cleanIssues = issues.map(v => Record.remove(v, "_tag"))
310
+ const cleanIssues = issues.map((v) => Record.remove(v, "_tag"))
75
311
 
76
- return yield* HttpServerResponse.unsafeJson({
77
- error: {
78
- _tag: unwrappedError._tag,
312
+ return respondWithError(
313
+ accept,
314
+ 400,
315
+ "ParseError",
316
+ "Validation failed",
317
+ {
79
318
  issues: cleanIssues,
80
319
  },
81
- }, {
82
- status: 400,
83
- })
320
+ requestContext,
321
+ )
84
322
  }
85
323
  }
86
324
 
87
- return yield* HttpServerResponse.unsafeJson({
88
- error: {
89
- _tag: "UnexpectedError",
90
- },
91
- }, {
92
- status: 500,
93
- })
325
+ if (Cause.isCause(error)) {
326
+ const defects = [...Cause.defects(error)]
327
+ const defect = defects[0]
328
+ if (defect instanceof Error) {
329
+ const stackFrames = extractPrettyStack(defect.stack ?? "")
330
+ return respondWithError(
331
+ accept,
332
+ 500,
333
+ "UnexpectedError",
334
+ defect.message,
335
+ {
336
+ name: defect.name,
337
+ stack: stackFrames,
338
+ },
339
+ requestContext,
340
+ defect.name,
341
+ )
342
+ }
343
+ }
344
+
345
+ return respondWithError(
346
+ accept,
347
+ 500,
348
+ "UnexpectedError",
349
+ "An unexpected error occurred",
350
+ undefined,
351
+ requestContext,
352
+ "UnexpectedError",
353
+ )
94
354
  })
95
355
 
96
- function extractPrettyStack(stack: string) {
356
+ function parseStackFrame(line: string): StackFrame | null {
357
+ const match = line.trim().match(StackLinePattern)
358
+ if (!match) return null
359
+
360
+ const [_, fn, fullPath] = match
361
+ const relativePath = fullPath.replace(process.cwd(), ".")
362
+
363
+ let type: "application" | "framework" | "node_modules"
364
+ if (relativePath.includes("node_modules")) {
365
+ type = "node_modules"
366
+ } else if (
367
+ relativePath.startsWith("./src")
368
+ || relativePath.startsWith("./examples")
369
+ ) {
370
+ type = "application"
371
+ } else {
372
+ type = "framework"
373
+ }
374
+
375
+ return {
376
+ function: fn,
377
+ file: relativePath,
378
+ type,
379
+ }
380
+ }
381
+
382
+ function extractPrettyStack(stack: string): StackFrame[] {
97
383
  return stack
98
384
  .split("\n")
99
385
  .slice(1)
100
- .map((line) => {
101
- const match = line.trim().match(StackLinePattern)
386
+ .map(parseStackFrame)
387
+ .filter((frame): frame is StackFrame => frame !== null)
388
+ }
102
389
 
103
- if (!match) return line
390
+ function renderStackFrames(frames: StackFrame[]): string {
391
+ if (frames.length === 0) {
392
+ return "<li>No stack frames</li>"
393
+ }
394
+ return frames
395
+ .map(
396
+ (f) =>
397
+ `<li><code>${f.function}</code> at <span class="path">${f.file}</span></li>`,
398
+ )
399
+ .join("")
400
+ }
104
401
 
105
- const [_, fn, path] = match
106
- const relativePath = path.replace(process.cwd(), ".")
107
- return [fn, relativePath]
108
- })
109
- .filter(Boolean)
402
+ function renderStackTrace(frames: StackFrame[]): string {
403
+ return `
404
+ <div class="stack-trace">
405
+ <div class="stack-trace-header">Stack Trace (${frames.length})</div>
406
+ <ul class="stack-list">${renderStackFrames(frames)}</ul>
407
+ </div>
408
+ `
409
+ }
410
+
411
+ function escapeHtml(unsafe: string): string {
412
+ return unsafe
413
+ .replace(/&/g, "&amp;")
414
+ .replace(/</g, "&lt;")
415
+ .replace(/>/g, "&gt;")
416
+ .replace(/"/g, "&quot;")
417
+ .replace(/'/g, "&#039;")
418
+ }
419
+
420
+ function filterSensitiveHeaders(
421
+ headers: Record<string, string>,
422
+ ): Record<string, string> {
423
+ const sensitive = ["authorization", "cookie", "x-api-key"]
424
+ return Object.fromEntries(
425
+ Object.entries(headers).filter(
426
+ ([key]) => !sensitive.includes(key.toLowerCase()),
427
+ ),
428
+ )
429
+ }
430
+
431
+ type RequestContext = {
432
+ url: string
433
+ method: string
434
+ headers: Record<string, string>
435
+ }
436
+
437
+ function renderRequestContext(context: RequestContext): string {
438
+ const headersText = Object
439
+ .entries(context.headers)
440
+ .map(([key, value]) => `${key}: ${value}`)
441
+ .join("\n")
442
+
443
+ const requestText = `${context.method} ${context.url}\n${headersText}`
444
+
445
+ return `
446
+ <div class="request-info">
447
+ <div class="request-info-header">Request</div>
448
+ <div class="request-info-content">${escapeHtml(requestText)}</div>
449
+ </div>
450
+ `
110
451
  }
111
452
 
112
453
  export function handleErrors<
@@ -114,14 +455,25 @@ export function handleErrors<
114
455
  R,
115
456
  >(
116
457
  app: HttpApp.Default<E, R>,
117
- ): HttpApp.Default<Exclude<E, RouteNotFound>, R> {
118
- return app.pipe(
119
- Effect.catchAllCause(renderError),
120
- )
458
+ ): HttpApp.Default<
459
+ Exclude<E, RouteNotFound>,
460
+ R | HttpServerRequest.HttpServerRequest
461
+ > {
462
+ return Effect.gen(function*() {
463
+ const request = yield* HttpServerRequest.HttpServerRequest
464
+ const accept = request.headers.accept ?? ""
465
+ return yield* app.pipe(
466
+ Effect.catchAllCause((cause) => renderError(cause, accept)),
467
+ )
468
+ })
121
469
  }
122
470
 
123
- export const withErrorHandled = HttpMiddleware.make(app => {
124
- return app.pipe(
125
- Effect.catchAllCause(renderError),
126
- )
127
- })
471
+ export const withErrorHandled = HttpMiddleware.make(app =>
472
+ Effect.gen(function*() {
473
+ const request = yield* HttpServerRequest.HttpServerRequest
474
+ const accept = request.headers.accept ?? ""
475
+ return yield* app.pipe(
476
+ Effect.catchAllCause((cause) => renderError(cause, accept)),
477
+ )
478
+ })
479
+ )
package/src/Route.test.ts CHANGED
@@ -472,6 +472,37 @@ t.it("context has only request and url when no schemas provided", () => {
472
472
  )
473
473
  })
474
474
 
475
+ t.it("context.next() returns correct type for text handler", () => {
476
+ Route.text(function*(context) {
477
+ const next = context.next()
478
+ type NextType = Effect.Effect.Success<typeof next>
479
+ type _check = [NextType] extends [string] ? true : false
480
+ const _assert: _check = true
481
+ return "hello"
482
+ })
483
+ })
484
+
485
+ t.it("context.next() returns correct type for html handler", () => {
486
+ Route.html(function*(context) {
487
+ const next = context.next()
488
+ type NextType = Effect.Effect.Success<typeof next>
489
+ type _check = [NextType] extends [string | Route.GenericJsxObject] ? true
490
+ : false
491
+ const _assert: _check = true
492
+ return "<div>hello</div>"
493
+ })
494
+ })
495
+
496
+ t.it("context.next() returns correct type for json handler", () => {
497
+ Route.json(function*(context) {
498
+ const next = context.next()
499
+ type NextType = Effect.Effect.Success<typeof next>
500
+ type _check = [NextType] extends [Route.JsonValue] ? true : false
501
+ const _assert: _check = true
502
+ return { message: "hello" }
503
+ })
504
+ })
505
+
475
506
  t.it("schemas work with all media types", () => {
476
507
  const PathSchema = Schema.Struct({
477
508
  id: Schema.String,
@@ -479,23 +510,19 @@ t.it("schemas work with all media types", () => {
479
510
 
480
511
  Route
481
512
  .schemaPathParams(PathSchema)
482
- .html(
483
- (context) => {
484
- Function.satisfies<string>()(context.pathParams.id)
513
+ .html((context) => {
514
+ Function.satisfies<string>()(context.pathParams.id)
485
515
 
486
- return Effect.succeed("<h1>Hello</h1>")
487
- },
488
- )
516
+ return Effect.succeed("<h1>Hello</h1>")
517
+ })
489
518
 
490
519
  Route
491
520
  .schemaPathParams(PathSchema)
492
- .json(
493
- (context) => {
494
- Function.satisfies<string>()(context.pathParams.id)
521
+ .json((context) => {
522
+ Function.satisfies<string>()(context.pathParams.id)
495
523
 
496
- return Effect.succeed({ message: "hello" })
497
- },
498
- )
524
+ return Effect.succeed({ message: "hello" })
525
+ })
499
526
  })
500
527
 
501
528
  t.it("schemas work with generator functions", () => {
@@ -505,13 +532,11 @@ t.it("schemas work with generator functions", () => {
505
532
 
506
533
  Route
507
534
  .schemaPathParams(IdSchema)
508
- .text(
509
- function*(context) {
510
- Function.satisfies<string>()(context.pathParams.id)
535
+ .text(function*(context) {
536
+ Function.satisfies<string>()(context.pathParams.id)
511
537
 
512
- return "hello"
513
- },
514
- )
538
+ return "hello"
539
+ })
515
540
  })
516
541
 
517
542
  t.it("schema property is correctly set on RouteSet", () => {
@@ -901,7 +926,7 @@ t.it("Route.layer creates RouteLayer with middleware", () => {
901
926
 
902
927
  t.it("Route.layer merges multiple route sets", () => {
903
928
  const routes1 = Route.html(Effect.succeed("<div>1</div>"))
904
- const routes2 = Route.text(Effect.succeed("text"))
929
+ const routes2 = Route.text("text")
905
930
 
906
931
  const layer = Route.layer(routes1, routes2)
907
932
 
@@ -1093,7 +1118,7 @@ t.it("Route.matches returns false for different methods", () => {
1093
1118
 
1094
1119
  t.it("Route.matches returns false for different media types", () => {
1095
1120
  const route1 = Route.get(Route.html(Effect.succeed("<div>test</div>")))
1096
- const route2 = Route.get(Route.json(Effect.succeed({ data: "test" })))
1121
+ const route2 = Route.get(Route.json({ data: "test" }))
1097
1122
 
1098
1123
  t.expect(Route.matches(route1.set[0]!, route2.set[0]!)).toBe(false)
1099
1124
  })
@@ -1119,7 +1144,8 @@ t.it("Route.matches returns true when one route has wildcard method", () => {
1119
1144
 
1120
1145
  t.describe("Route.merge", () => {
1121
1146
  t.it("types merged routes with union of methods", () => {
1122
- const textRoute = Route.text(Effect.succeed("hello"))
1147
+ const textRoute = Route.text("hello")
1148
+
1123
1149
  const htmlRoute = Route.html(Effect.succeed("<div>world</div>"))
1124
1150
 
1125
1151
  const merged = Route.merge(textRoute, htmlRoute)
@@ -1138,8 +1164,8 @@ t.describe("Route.merge", () => {
1138
1164
  })
1139
1165
 
1140
1166
  t.it("types merged routes with different methods", () => {
1141
- const getRoute = Route.get(Route.text(Effect.succeed("get")))
1142
- const postRoute = Route.post(Route.json(Effect.succeed({ ok: true })))
1167
+ const getRoute = Route.get(Route.text("get"))
1168
+ const postRoute = Route.post(Route.json({ ok: true }))
1143
1169
 
1144
1170
  const merged = Route.merge(getRoute, postRoute)
1145
1171
 
@@ -1186,8 +1212,8 @@ t.describe("Route.merge", () => {
1186
1212
  })
1187
1213
 
1188
1214
  t.it("merged route does content negotiation for text/plain", async () => {
1189
- const textRoute = Route.text(Effect.succeed("plain text"))
1190
- const htmlRoute = Route.html(Effect.succeed("<div>html</div>"))
1215
+ const textRoute = Route.text("plain text")
1216
+ const htmlRoute = Route.html("<div>html</div>")
1191
1217
 
1192
1218
  const merged = Route.merge(textRoute, htmlRoute)
1193
1219
  const route = merged.set[0]!
@@ -1217,8 +1243,8 @@ t.describe("Route.merge", () => {
1217
1243
  })
1218
1244
 
1219
1245
  t.it("merged route does content negotiation for text/html", async () => {
1220
- const textRoute = Route.text(Effect.succeed("plain text"))
1221
- const htmlRoute = Route.html(Effect.succeed("<div>html</div>"))
1246
+ const textRoute = Route.text("plain text")
1247
+ const htmlRoute = Route.html("<div>html</div>")
1222
1248
 
1223
1249
  const merged = Route.merge(textRoute, htmlRoute)
1224
1250
  const route = merged.set[0]!
@@ -1250,8 +1276,8 @@ t.describe("Route.merge", () => {
1250
1276
  t.it(
1251
1277
  "merged route does content negotiation for application/json",
1252
1278
  async () => {
1253
- const textRoute = Route.text(Effect.succeed("plain text"))
1254
- const jsonRoute = Route.json(Effect.succeed({ message: "json" }))
1279
+ const textRoute = Route.text("plain text")
1280
+ const jsonRoute = Route.json({ message: "json" })
1255
1281
 
1256
1282
  const merged = Route.merge(textRoute, jsonRoute)
1257
1283
  const route = merged.set[0]!
@@ -1282,8 +1308,8 @@ t.describe("Route.merge", () => {
1282
1308
  )
1283
1309
 
1284
1310
  t.it("merged route defaults to html for */* accept", async () => {
1285
- const textRoute = Route.text(Effect.succeed("plain text"))
1286
- const htmlRoute = Route.html(Effect.succeed("<div>html</div>"))
1311
+ const textRoute = Route.text("plain text")
1312
+ const htmlRoute = Route.html("<div>html</div>")
1287
1313
 
1288
1314
  const merged = Route.merge(textRoute, htmlRoute)
1289
1315
  const route = merged.set[0]!
@@ -1314,8 +1340,8 @@ t.describe("Route.merge", () => {
1314
1340
  t.it(
1315
1341
  "merged route defaults to first route when no Accept header",
1316
1342
  async () => {
1317
- const textRoute = Route.text(Effect.succeed("plain text"))
1318
- const htmlRoute = Route.html(Effect.succeed("<div>html</div>"))
1343
+ const textRoute = Route.text("plain text")
1344
+ const htmlRoute = Route.html("<div>html</div>")
1319
1345
 
1320
1346
  const merged = Route.merge(textRoute, htmlRoute)
1321
1347
  const route = merged.set[0]!