Skip to content

Instantly share code, notes, and snippets.

@fnimick
Last active September 22, 2024 00:20
Show Gist options
  • Save fnimick/c698b6b3a08e4acd3aff6aa96948e2fc to your computer and use it in GitHub Desktop.
Save fnimick/c698b6b3a08e4acd3aff6aa96948e2fc to your computer and use it in GitHub Desktop.
glue code for using Effect and Sveltekit together
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