Last active
August 7, 2025 20:01
-
-
Save sillvva/f16c88b65f8816d80ae1c3e6b6caef26 to your computer and use it in GitHub Desktop.
Effect + Sveltekit
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { dev } from "$app/environment"; | |
| import { getRequestEvent } from "$app/server"; | |
| import { privateEnv } from "$lib/env/private"; | |
| import type { AppLogSchema, UserId } from "$lib/schemas"; | |
| import { db, runQuery } from "$lib/server/db"; | |
| import { appLogs } from "$lib/server/db/schema"; | |
| import { removeTrace } from "$lib/util"; | |
| import { isInstanceOfClass } from "@sillvva/utils"; | |
| import { error, isHttpError, isRedirect, type NumericRange } from "@sveltejs/kit"; | |
| import { Cause, Effect, Exit, HashMap, Layer, Logger } from "effect"; | |
| import { isFunction, isTupleOf } from "effect/Predicate"; | |
| import type { YieldWrap } from "effect/Utils"; | |
| // ------------------------------------------------------------------------------------------------- | |
| // Logs | |
| // ------------------------------------------------------------------------------------------------- | |
| export type Annotations = { | |
| routeId: string | null; | |
| params: Partial<Record<string, string>>; | |
| userId: UserId | undefined; | |
| username: string | undefined; | |
| extra: object; | |
| }; | |
| const logLevel = Logger.withMinimumLogLevel(privateEnv.LOG_LEVEL); | |
| const dbLogger = Logger.replace( | |
| Logger.defaultLogger, | |
| Logger.make((log) => { | |
| const values = { | |
| label: (log.message as string[]).join(" | "), | |
| timestamp: log.date, | |
| level: log.logLevel.label, | |
| annotations: Object.fromEntries(HashMap.toEntries(log.annotations)) as Annotations | |
| } satisfies Omit<AppLogSchema, "id">; | |
| runQuery(db.insert(appLogs).values([values]).returning({ id: appLogs.id })).pipe( | |
| Effect.andThen((logs) => { | |
| if (dev && isTupleOf(logs, 1)) | |
| console.log(logs[0].id, dev && ["ERROR", "DEBUG"].includes(values.level) ? values : JSON.stringify(values), "\n"); | |
| }), | |
| Effect.runPromise | |
| ); | |
| }) | |
| ); | |
| function annotate(extra: Record<PropertyKey, any> = {}) { | |
| const event = getRequestEvent(); | |
| return Effect.annotateLogs({ | |
| userId: event.locals.user?.id, | |
| username: event.locals.user?.name, | |
| routeId: event.route.id, | |
| params: event.params, | |
| extra | |
| } satisfies Annotations); | |
| } | |
| export const Log = { | |
| info: (message: string, extra?: Record<PropertyKey, any>, logger: Layer.Layer<never> = dbLogger) => | |
| Effect.logInfo(message).pipe(logLevel, annotate(extra), Effect.provide(logger)), | |
| error: (message: string, extra?: Record<PropertyKey, any>, logger: Layer.Layer<never> = dbLogger) => | |
| Effect.logError(message).pipe(logLevel, annotate(extra), Effect.provide(logger)), | |
| debug: (message: string, extra?: Record<PropertyKey, any>, logger: Layer.Layer<never> = dbLogger) => | |
| Effect.logDebug(message).pipe(logLevel, annotate(extra), Effect.provide(logger)) | |
| }; | |
| // ------------------------------------------------------------------------------------------------- | |
| // Run | |
| // ------------------------------------------------------------------------------------------------- | |
| // Overload signatures | |
| export async function runOrThrow<A, B extends InstanceType<ErrorClass>, T extends YieldWrap<Effect.Effect<A, B>>, X, Y>( | |
| program: () => Generator<T, X, Y> | |
| ): Promise<X>; | |
| export async function runOrThrow<A, B extends InstanceType<ErrorClass>>( | |
| program: Effect.Effect<A, B> | (() => Effect.Effect<A, B>) | |
| ): Promise<A>; | |
| // Implementation | |
| export async function runOrThrow<A, B extends InstanceType<ErrorClass>, T extends YieldWrap<Effect.Effect<A, B>>, X, Y>( | |
| program: Effect.Effect<A, B> | (() => Effect.Effect<A, B>) | (() => Generator<T, X, Y>) | |
| ): Promise<A | X> { | |
| const effect = Effect.fn(function* () { | |
| if (isFunction(program)) { | |
| return yield* program(); | |
| } else { | |
| return yield* program; | |
| } | |
| }); | |
| const result = await Effect.runPromiseExit(effect()); | |
| return Exit.match(result, { | |
| onSuccess: (result) => result, | |
| onFailure: (cause) => { | |
| const { message, status } = handleCause(cause); | |
| throw error(status, message); | |
| } | |
| }); | |
| } | |
| export type EffectSuccess<A> = { ok: true; data: A }; | |
| export type EffectFailure = { | |
| ok: false; | |
| error: { message: string; status: NumericRange<400, 599>; extra: Record<string, unknown> }; | |
| }; | |
| export type EffectResult<A> = EffectSuccess<A> | EffectFailure; | |
| // Overload signatures | |
| export async function runOrReturn<A, B extends InstanceType<ErrorClass>, T extends YieldWrap<Effect.Effect<A, B>>, X, Y>( | |
| program: () => Generator<T, X, Y> | |
| ): Promise<EffectResult<X>>; | |
| export async function runOrReturn<A, B extends InstanceType<ErrorClass>>( | |
| program: Effect.Effect<A, B> | (() => Effect.Effect<A, B>) | |
| ): Promise<EffectResult<A>>; | |
| // Implementation | |
| export async function runOrReturn<A, B extends InstanceType<ErrorClass>, T extends YieldWrap<Effect.Effect<A, B>>, X, Y>( | |
| program: Effect.Effect<A, B> | (() => Effect.Effect<A, B>) | (() => Generator<T, X, Y>) | |
| ): Promise<EffectResult<A | X>> { | |
| const effect = Effect.fn(function* () { | |
| if (isFunction(program)) { | |
| return yield* program(); | |
| } else { | |
| return yield* program; | |
| } | |
| }); | |
| const result = await Effect.runPromiseExit(effect()); | |
| return Exit.match(result, { | |
| onSuccess: (result) => ({ ok: true, data: result }), | |
| onFailure: (cause) => ({ ok: false, error: handleCause(cause) }) | |
| }); | |
| } | |
| function handleCause<B extends InstanceType<ErrorClass>>(cause: Cause.Cause<B>) { | |
| let message = Cause.pretty(cause); | |
| let status: NumericRange<400, 599> = 500; | |
| const extra: Record<string, unknown> = {}; | |
| if (Cause.isFailType(cause)) { | |
| const error = cause.error; | |
| status = error.status; | |
| for (const key in error) { | |
| if (!["_tag", "_op", "pipe", "name"].includes(key)) { | |
| extra[key] = error[key]; | |
| } | |
| } | |
| } | |
| if (Cause.isDieType(cause)) { | |
| const defect = cause.defect; | |
| // This will propagate redirects and http errors directly to SvelteKit | |
| if (isRedirect(defect)) { | |
| Effect.runFork(AppLog.info(`Redirect to ${defect.location}`, defect)); | |
| throw defect; | |
| } else if (isHttpError(defect)) { | |
| Effect.runFork(AppLog.error(`HttpError [${defect.status}] ${defect.body.message}`, defect)); | |
| throw defect; | |
| } else if (typeof defect === "object" && defect !== null && "stack" in defect) { | |
| extra.stack = defect.stack; | |
| } | |
| } | |
| Effect.runFork(AppLog.error(message, extra)); | |
| if (!dev) message = removeTrace(message); | |
| return { message, status, extra }; | |
| } | |
| // ------------------------------------------------------------------------------------------------- | |
| // Errors | |
| // ------------------------------------------------------------------------------------------------- | |
| export interface ErrorParams { | |
| message: string; | |
| status: NumericRange<400, 599>; | |
| cause?: unknown; | |
| [key: string]: unknown; | |
| } | |
| export interface ErrorClass { | |
| new (...args: any[]): { _tag: string } & ErrorParams; | |
| } | |
| export function isTaggedError(error: unknown): error is InstanceType<ErrorClass> { | |
| return ( | |
| isInstanceOfClass(error) && | |
| "status" in error && | |
| typeof error.status === "number" && | |
| error.status >= 400 && | |
| error.status <= 599 && | |
| "cause" in error && | |
| "message" in error && | |
| typeof error.message === "string" && | |
| "_tag" in error && | |
| typeof error._tag === "string" | |
| ); | |
| } | |
| // Example Usage | |
| /* | |
| export class CharacterNotFoundError extends Data.TaggedError("CharacterNotFoundError")<ErrorParams> { | |
| constructor(err?: unknown) { | |
| super({ message: "Character not found", status: 404, cause: err }); | |
| } | |
| } | |
| */ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment