diff --git a/examples/realworld/.env.local b/examples/realworld/.env.local index 507789cca..7bbbeac3a 100644 --- a/examples/realworld/.env.local +++ b/examples/realworld/.env.local @@ -5,3 +5,5 @@ VITE_DATABASE_PASSWORD=effect_dev VITE_DATABASE_NAME=realworld VITE_JWT_SECRET=replace_me_with_a_real_secret_not_in_an_env_var_pls + +VITE_OTEL_TRACE_URL=http://localhost:4318/v1/traces diff --git a/examples/realworld/src/api/infrastructure.ts b/examples/realworld/src/api/infrastructure.ts index ae34e0cb5..85d95b2d0 100644 --- a/examples/realworld/src/api/infrastructure.ts +++ b/examples/realworld/src/api/infrastructure.ts @@ -1,3 +1,6 @@ +import * as NodeSdk from "@effect/opentelemetry/NodeSdk" +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http" +import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base" import { getRandomValues } from "@typed/id" import { ArticlesLive } from "@typed/realworld/api/articles/infrastructure/ArticlesLive" import { CommentsLive } from "@typed/realworld/api/comments/infrastructure/CommentsLive" @@ -14,7 +17,15 @@ export const Live = Layer.mergeAll(ArticlesLive, CommentsLive, ProfilesLive, Tag // You probably shouldn't use import.meta.env in a real application // as it will leak information into your bundle since Vite replaces these values at build time Layer.provide(Layer.setConfigProvider(ConfigProvider.fromJson(import.meta.env))), - Layer.provideMerge(NodeSwaggerFiles.SwaggerFilesLive) + Layer.provideMerge(NodeSwaggerFiles.SwaggerFilesLive), + Layer.provideMerge(NodeSdk.layer(() => ({ + resource: { serviceName: "realworld" }, + spanProcessor: new BatchSpanProcessor( + new OTLPTraceExporter({ + url: import.meta.env.VITE_OTEL_TRACE_URL + }) + ) + }))) ) export { CurrentUserLive } from "@typed/realworld/api/users/infrastructure" diff --git a/examples/realworld/src/api/server.ts b/examples/realworld/src/api/server.ts index 4da10a4b1..116da792c 100644 --- a/examples/realworld/src/api/server.ts +++ b/examples/realworld/src/api/server.ts @@ -1,4 +1,5 @@ -import { ServerRouterBuilder } from "@typed/server" +import { ServerResponse, ServerRouterBuilder } from "@typed/server" +import { Effect } from "effect" import { handleArticles } from "./articles/handlers" import { handleComments } from "./comments/handlers" import { handleProfiles } from "./profiles/handlers" @@ -12,5 +13,9 @@ export const server = ServerRouterBuilder.make(Spec, { enableDocs: true, docsPat handleProfiles, handleGetTags, handleUsers, - ServerRouterBuilder.build + ServerRouterBuilder.build, + // Handle API errors + Effect.catchTag("RouteDecodeError", (_) => ServerResponse.json(_, { status: 400 })), + Effect.catchTag("Unauthorized", () => ServerResponse.empty({ status: 401 })), + Effect.catchTag("RouteNotMatched", () => ServerResponse.empty({ status: 404 })) ) diff --git a/examples/realworld/src/client.ts b/examples/realworld/src/client.ts index 8617e2df3..09a4db8e7 100644 --- a/examples/realworld/src/client.ts +++ b/examples/realworld/src/client.ts @@ -1,12 +1,11 @@ -import { hydrateFromWindow, hydrateToLayer } from "@typed/core" +import { hydrateFromWindow } from "@typed/core" import { Storage } from "@typed/dom/Storage" import "./styles.css" - import { Effect, Layer } from "effect" import * as Ui from "./ui" +import { UiClient } from "./ui/client" -Ui.main.pipe( - hydrateToLayer, +UiClient.pipe( Layer.provide(Ui.Live), Layer.provide(Storage.layer(localStorage)), Layer.provide(hydrateFromWindow(window, { rootElement: document.getElementById("app")! })), diff --git a/examples/realworld/src/server.ts b/examples/realworld/src/server.ts index 2dea85454..28fc0c61c 100644 --- a/examples/realworld/src/server.ts +++ b/examples/realworld/src/server.ts @@ -1,33 +1,18 @@ -import * as NodeSdk from "@effect/opentelemetry/NodeSdk" -import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http" -import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base" import * as Node from "@typed/core/Node" -import { toServerRouter } from "@typed/core/Platform" import * as Api from "@typed/realworld/api" import { CurrentUserLive, Live } from "@typed/realworld/api/infrastructure" -import * as Ui from "@typed/realworld/ui" -import { ServerHeaders, ServerResponse, ServerRouter } from "@typed/server" +import { ServerRouter } from "@typed/server" import { Effect, LogLevel } from "effect" import sms from "source-map-support" +import { UiServer } from "./ui/server" // Enable source maps for errors sms.install() -// Convert our UI router to a ServerRouter and provide a layout to construct a full HTML document. -toServerRouter(Ui.router, { layout: Ui.document }).pipe( - ServerRouter.catchAll((error) => - ServerResponse.empty({ - status: 303, - headers: ServerHeaders.fromInput({ - location: error._tag === "RedirectError" ? error.path.toString() : "/login" - }) - }) - ), +// Our server is a composition of our API and UI servers +UiServer.pipe( // Mount our API - ServerRouter.mountApp( - "/api", - Effect.catchTag(Api.server, "Unauthorized", () => ServerResponse.empty({ status: 401 })) - ), + ServerRouter.mountApp("/api", Api.server), // Provide all resources which change per-request Effect.provide(CurrentUserLive), // Start the server. Integrates with our Vite plugin to serve client assets using Vite for development and @@ -35,17 +20,6 @@ toServerRouter(Ui.router, { layout: Ui.document }).pipe( Node.listen({ port: 3000, serverDirectory: import.meta.dirname, logLevel: LogLevel.Debug }), // Provide all static resources which do not change per-request Effect.provide(Live), - // OpenTelemetry tracing - Effect.provide( - NodeSdk.layer(() => ({ - resource: { serviceName: "realworld" }, - spanProcessor: new BatchSpanProcessor( - new OTLPTraceExporter({ - url: "http://localhost:4318/v1/traces" - }) as any // Something weird with the types here "Type 'OTLPTraceExporter' is missing the following properties from type 'SpanExporter': export, shutdown" - ) - })) - ), // Kick off the application, capturing SIGINT and SIGTERM to gracefully shutdown the server // as well as respond to Vite's HMR requests to clean up resources when change occur during development. Node.run diff --git a/examples/realworld/src/ui/client.ts b/examples/realworld/src/ui/client.ts new file mode 100644 index 000000000..8c37c13ee --- /dev/null +++ b/examples/realworld/src/ui/client.ts @@ -0,0 +1,13 @@ +import { Fx, hydrateToLayer, Navigation, Router } from "@typed/core" +import { isAuthenticated } from "@typed/realworld/services" +import { Effect } from "effect" +import * as Routes from "./common/routes" +import { layout } from "./layout" +import { router } from "./router" + +const onNotFound = Effect.if(Fx.first(isAuthenticated), { + onFalse: () => new Navigation.RedirectError({ path: Routes.login.interpolate({}) }), + onTrue: () => new Navigation.RedirectError({ path: Routes.home.interpolate({}) }) +}) + +export const UiClient = router.pipe(Router.notFoundWith(onNotFound), layout, hydrateToLayer) diff --git a/examples/realworld/src/ui/components/EditArticle.ts b/examples/realworld/src/ui/components/EditArticle.ts index f63fe1e6d..5cba302c8 100644 --- a/examples/realworld/src/ui/components/EditArticle.ts +++ b/examples/realworld/src/ui/components/EditArticle.ts @@ -11,6 +11,7 @@ import { } from "@typed/realworld/model" import type { Unauthorized, Unprocessable } from "@typed/realworld/services/errors" import * as Routes from "@typed/realworld/ui/common/routes" +import type { Cause } from "effect" import { Effect } from "effect" export type EditArticleFields = Pick< @@ -21,14 +22,14 @@ export type EditArticleFields = Pick< export function useEditArticle( initial: RefSubject.Computed< EditArticleFields, - Unprocessable | Unauthorized | ParseError, + Unprocessable | Unauthorized | ParseError | Cause.NoSuchElementException, R >, onSubmit: ( updated: EditArticleFields ) => Effect.Effect< unknown, - Unprocessable | Unauthorized | ParseError | NavigationError, + Unprocessable | Unauthorized | ParseError | NavigationError | Cause.NoSuchElementException, R2 > ) { @@ -43,9 +44,9 @@ export function useEditArticle( Effect.flatMap(onSubmit), Effect.catchTags({ Unprocessable: (error) => RefSubject.set(errors, error.errors), - Unauthorized: () => Router.navigate(Routes.login, { relative: false }), - ParseError: (issue) => RefSubject.set(errors, [issue.message]) - }) + Unauthorized: () => Router.navigate(Routes.login, { relative: false }) + }), + Effect.catchAll((issue) => RefSubject.set(errors, [issue.message])) ) ) @@ -206,14 +207,14 @@ export function renderForm({ export function EditArticle( initial: RefSubject.Computed< EditArticleFields, - Unprocessable | Unauthorized | ParseError, + Unprocessable | Unauthorized | ParseError | Cause.NoSuchElementException, R >, onSubmit: ( updated: EditArticleFields ) => Effect.Effect< unknown, - Unprocessable | NavigationError | Unauthorized | ParseError, + Unprocessable | NavigationError | Unauthorized | ParseError | Cause.NoSuchElementException, R2 > ) { diff --git a/examples/realworld/src/ui/pages/settings.ts b/examples/realworld/src/ui/pages/settings.ts index 0e80ae65d..a717c28c9 100644 --- a/examples/realworld/src/ui/pages/settings.ts +++ b/examples/realworld/src/ui/pages/settings.ts @@ -1,5 +1,6 @@ import { ArrayFormatter } from "@effect/schema" import { AsyncData, Fx, RefAsyncData, RefSubject, Router } from "@typed/core" +import type { EventWithTarget } from "@typed/dom/EventTarget" import { parseFormData } from "@typed/realworld/lib/Schema" import { CurrentUser, isAuthenticatedGuard, Users } from "@typed/realworld/services" import { Unprocessable } from "@typed/realworld/services/errors" @@ -11,9 +12,9 @@ import { Effect, Option } from "effect" export const route = Routes.settings.pipe(isAuthenticatedGuard) -type SubmitEvent = Event & { target: HTMLFormElement } +type SubmitEvent = EventWithTarget -export const main = Fx.gen(function* (_) { +export const main = Fx.gen(function*(_) { const userErrors = yield* _(useCurrentUserErrors) const onSubmit = EventHandler.preventDefault((ev: SubmitEvent) => Effect.zipRight(updateUser(ev), userErrors.onSubmit) diff --git a/examples/realworld/src/ui/router.ts b/examples/realworld/src/ui/router.ts index 735c59a2e..78ab282f8 100644 --- a/examples/realworld/src/ui/router.ts +++ b/examples/realworld/src/ui/router.ts @@ -1,7 +1,4 @@ -import { Fx, Navigation, Router } from "@typed/core" -import { isAuthenticated } from "@typed/realworld/services" -import { Effect } from "effect" -import { layout } from "./layout" +import { Router } from "@typed/core" import * as pages from "./pages" export const router = Router @@ -13,10 +10,3 @@ export const router = Router .match(pages.editArticle.route, pages.editArticle.main) .match(pages.profile.route, pages.profile.main) .match(pages.home.route, pages.home.main) - -const onNotFound = Effect.if(Fx.first(isAuthenticated), { - onFalse: () => new Navigation.RedirectError({ path: pages.login.route.interpolate({}) }), - onTrue: () => new Navigation.RedirectError({ path: pages.home.route.interpolate({}) }) -}) - -export const main = layout(Router.notFoundWith(router, onNotFound)) diff --git a/examples/realworld/src/ui/server.ts b/examples/realworld/src/ui/server.ts new file mode 100644 index 000000000..13eab6b86 --- /dev/null +++ b/examples/realworld/src/ui/server.ts @@ -0,0 +1,12 @@ +import { toServerRouter } from "@typed/core/Platform" +import { ServerResponse, ServerRouter } from "@typed/server" +import { document } from "./document" +import { router } from "./router" + +// Convert our UI router to a ServerRouter and provide a layout to construct a full HTML document. +export const UiServer = toServerRouter(router, { layout: document }).pipe( + // Handle UI errors + ServerRouter.catchTag("RedirectError", (error) => ServerResponse.seeOther(error.path.toString())), + ServerRouter.catchTag("Unprocessable", (_) => ServerResponse.json(_, { status: 422 })), + ServerRouter.catchAll(() => ServerResponse.seeOther("/login")) +) diff --git a/packages/router/src/Matcher.ts b/packages/router/src/Matcher.ts index 39b599647..cf3759d71 100644 --- a/packages/router/src/Matcher.ts +++ b/packages/router/src/Matcher.ts @@ -12,6 +12,7 @@ import * as Data from "effect/Data" import * as Effect from "effect/Effect" import { dual, pipe } from "effect/Function" import * as Option from "effect/Option" +import { type Pipeable, pipeArguments } from "effect/Pipeable" import { hasProperty } from "effect/Predicate" import type * as Scope from "effect/Scope" import * as Unify from "effect/Unify" @@ -33,7 +34,7 @@ export type RouteMatcherTypeId = typeof RouteMatcherTypeId /** * @since 1.0.0 */ -export interface RouteMatcher { +export interface RouteMatcher extends Pipeable { readonly [RouteMatcherTypeId]: RouteMatcherTypeId readonly matches: ReadonlyArray @@ -162,6 +163,10 @@ class RouteMatcherImpl implements Rou ) { return this.match(input, Fx.map(f)) } + + pipe() { + return pipeArguments(this, arguments) + } } /** diff --git a/packages/server/src/Response.ts b/packages/server/src/Response.ts index 412bb309a..835b12c44 100644 --- a/packages/server/src/Response.ts +++ b/packages/server/src/Response.ts @@ -2,7 +2,19 @@ * @since 1.0.0 */ +import * as ServerHeaders from "@effect/platform/Http/Headers" +import * as ServerResponse from "@effect/platform/Http/ServerResponse" + /** * @since 1.0.0 */ export * from "@effect/platform/Http/ServerResponse" + +/** + * @since 1.0.0 + */ +export const seeOther = (location: string): ServerResponse.ServerResponse => + ServerResponse.empty({ + status: 303, + headers: ServerHeaders.fromInput({ location }) + })