Last active
September 22, 2024 00:20
-
-
Save fnimick/c698b6b3a08e4acd3aff6aa96948e2fc to your computer and use it in GitHub Desktop.
glue code for using Effect and Sveltekit together
This file contains 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 { FetchHttpClient, HttpClient } from "@effect/platform"; | |
import { | |
error as sveltekitError, | |
fail as sveltekitFail, | |
redirect as sveltekitRedirect, | |
type RequestEvent, | |
type ServerLoadEvent, | |
type ActionFailure as SveltekitActionFailure, | |
} from "@sveltejs/kit"; | |
import { Cause, Context, Data, Effect, Exit, Layer, ManagedRuntime, Match, pipe } from "effect"; | |
import type { UnknownException } from "effect/Cause"; | |
// a demo service to demonstrate layer type handling | |
const make = { | |
doSomething: () => Effect.succeed("done"), | |
}; | |
class DemoService extends Effect.Tag("@service/DemoService")<DemoService, typeof make>() {} | |
const DemoServiceLive = Layer.succeed(DemoService, make); | |
// actual code below | |
// Copied from Sveltekit as allowable return types for load and action functions. | |
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-invalid-void-type | |
type SveltekitOutputData = Record<string, any> | void; | |
type SveltekitActionFailureData = Record<string, unknown> | undefined; | |
export class ErrorWithExtra extends Error { | |
constructor( | |
message: string, | |
public extra?: Record<string, unknown>, | |
) { | |
super(message); | |
} | |
} | |
export class ActionFailure< | |
T extends SveltekitActionFailureData = undefined, | |
> extends Data.TaggedError("@response/ActionFailure")<{ | |
readonly status: number; | |
readonly data: T; | |
}> {} | |
export function fail<T extends SveltekitActionFailureData = undefined>( | |
status: number, | |
data: T, | |
): Effect.Effect<never, ActionFailure<T>, never> { | |
return Effect.fail(new ActionFailure({ status, data })); | |
} | |
export class Redirect extends Data.TaggedError("@response/Redirect")<{ | |
readonly status: number; | |
readonly location: string | URL; | |
}> {} | |
export function redirect( | |
...[status, location]: Parameters<typeof sveltekitRedirect> | |
): Effect.Effect<never, Redirect, never> { | |
if (isNaN(status) || status < 300 || status > 308) { | |
return Effect.die(new ErrorWithExtra("Invalid status code", { status, location })); | |
} | |
return Effect.fail(new Redirect({ status, location })); | |
} | |
export class HttpError extends Data.TaggedError("@response/HttpError")<{ | |
readonly status: number; | |
readonly body: { message: string } extends App.Error ? App.Error | string | undefined : never; | |
}> {} | |
export function error( | |
...[status, body]: Parameters<typeof sveltekitError> | |
): Effect.Effect<never, HttpError, never> { | |
if (isNaN(status) || status < 400 || status > 599) { | |
return Effect.die( | |
new ErrorWithExtra( | |
`HTTP error status codes must be between 400 and 599 — ${status} is invalid`, | |
{ status, body }, | |
), | |
); | |
} | |
return Effect.fail(new HttpError({ status, body })); | |
} | |
type CommonEventKeys = | |
// request url, necessary to generate redirect for login | |
| "url" | |
// locals for looking up user session data | |
| "locals" | |
// fetch, used for tracking dependencies | |
| "fetch" | |
// request, used for other accesses such as header validation | |
| "request"; | |
export class EndpointRequestEvent extends Context.Tag("@value/EndpointRequestEvent")< | |
EndpointRequestEvent, | |
// Omit<ServerLoadEvent | RequestEvent, ""> | |
Pick<ServerLoadEvent, CommonEventKeys | "depends"> | Pick<RequestEvent, CommonEventKeys> | |
>() {} | |
const MainLive = pipe(DemoServiceLive, Layer.provideMerge(FetchHttpClient.layer)); | |
export type MainDependencies = Layer.Layer.Success<typeof MainLive>; | |
export type AllowedEventErrors = Redirect | HttpError | UnknownException; | |
const runtime = ManagedRuntime.make(MainLive); | |
/** | |
* Runs an effect. Does not handle any Sveltekit response exceptions. | |
* | |
* Provides a FetchHttpClient to the effect, using a default fetch implementation. | |
*/ | |
export async function runEffect<T>( | |
effect: Effect.Effect<T, UnknownException, MainDependencies | HttpClient.HttpClient.Service>, | |
): Promise<T> { | |
const res = await runtime.runPromiseExit(Effect.provide(effect, FetchHttpClient.layer)); | |
if (Exit.isSuccess(res)) { | |
return res.value; | |
} | |
const { cause } = res; | |
if (Cause.isFailType(cause)) { | |
throw cause.error.error; | |
} | |
if (Cause.isDieType(cause)) { | |
throw cause.defect; | |
} | |
throw new ErrorWithExtra("Unknown exception from effect", { cause }); | |
} | |
async function runEffectHandleSveltekitResponseExceptions<T>( | |
effect: Effect.Effect<T, AllowedEventErrors, MainDependencies>, | |
): Promise<T> { | |
const res = await runtime.runPromiseExit(effect); | |
if (Exit.isSuccess(res)) { | |
return res.value; | |
} | |
const { cause } = res; | |
if (Cause.isFailType(cause)) { | |
Match.value(cause.error).pipe( | |
Match.tag("@response/Redirect", (e) => { | |
sveltekitRedirect(e.status, e.location); | |
}), | |
Match.tag("@response/HttpError", (e) => sveltekitError(e.status, e.body)), | |
Match.tag("UnknownException", (e) => { | |
throw e.error; | |
}), | |
Match.exhaustive, | |
); | |
} | |
if (Cause.isDieType(cause)) { | |
throw cause.defect; | |
} | |
throw new ErrorWithExtra("Unknown exception from effect", { cause }); | |
} | |
async function runEffectToSveltekitActionResponse< | |
T extends SveltekitOutputData, | |
ActionFailureData extends Record<string, unknown> | undefined = undefined, | |
>( | |
effect: Effect.Effect<T, AllowedEventErrors | ActionFailure<ActionFailureData>, MainDependencies>, | |
): Promise<T | SveltekitActionFailure<ActionFailureData>> { | |
const res = await runtime.runPromiseExit(effect); | |
if (Exit.isSuccess(res)) { | |
return res.value; | |
} | |
const { cause } = res; | |
if (Cause.isFailType(cause)) { | |
const maybeFailure = Match.value(cause.error).pipe( | |
Match.tag("@response/Redirect", (e) => { | |
sveltekitRedirect(e.status, e.location); | |
}), | |
Match.tag("@response/HttpError", (e) => sveltekitError(e.status, e.body)), | |
Match.tag("@response/ActionFailure", (e) => sveltekitFail(e.status, e.data)), | |
Match.tag("UnknownException", (e) => { | |
throw e.error; | |
}), | |
Match.exhaustive, | |
); | |
return maybeFailure; | |
} | |
if (Cause.isDieType(cause)) { | |
throw cause.defect; | |
} | |
throw new ErrorWithExtra("Unknown exception from effect", { cause }); | |
} | |
/** | |
* Creates a layer which includes services which depend on the request context. | |
* | |
* Includes the EndpointRequestEvent, and provides the fetch implementation from the request event | |
* to the FetchHttpClient in the main application layer. | |
*/ | |
function getRequestContextLayer<E extends ServerLoadEvent | RequestEvent>(event: E) { | |
const EventFetch = Layer.succeed(FetchHttpClient.Fetch, fetch); | |
return Layer.merge(Layer.succeed(EndpointRequestEvent, event), EventFetch); | |
} | |
type RequestContextDependencies = Layer.Layer.Success<ReturnType<typeof getRequestContextLayer>>; | |
export type RequestContextEffect<T> = Effect.Effect< | |
T, | |
AllowedEventErrors, | |
MainDependencies | RequestContextDependencies | |
>; | |
export type RequestContextActionEffect< | |
T extends SveltekitOutputData, | |
ActionFailureData extends Record<string, unknown> | undefined = undefined, | |
> = Effect.Effect< | |
T, | |
AllowedEventErrors | ActionFailure<ActionFailureData>, | |
MainDependencies | RequestContextDependencies | |
>; | |
export function runEffectAsLoad<T extends SveltekitOutputData, E extends ServerLoadEvent>( | |
effect: RequestContextEffect<T> | ((event: E) => RequestContextEffect<T>), | |
) { | |
return async function load(event: E) { | |
const effectToRun = typeof effect === "function" ? effect(event) : effect; | |
const res = await runEffectHandleSveltekitResponseExceptions( | |
Effect.provide(effectToRun, getRequestContextLayer(event)), | |
); | |
return res; | |
}; | |
} | |
export function runEffectAsAction< | |
T extends SveltekitOutputData, | |
E extends RequestEvent, | |
ActionFailureData extends Record<string, unknown> | undefined = undefined, | |
>( | |
effect: | |
| RequestContextActionEffect<T, ActionFailureData> | |
| ((event: E) => RequestContextActionEffect<T, ActionFailureData>), | |
) { | |
return async function action(event: E) { | |
const effectToRun = typeof effect === "function" ? effect(event) : effect; | |
const res = await runEffectToSveltekitActionResponse( | |
Effect.provide(effectToRun, getRequestContextLayer(event)), | |
); | |
return res; | |
}; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment