Last active
July 28, 2024 02:30
-
-
Save xesrevinu/4e41ce0836ba342eb771e41b72b95402 to your computer and use it in GitHub Desktop.
remix effect
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
| export class InternalServerError extends Schema.TaggedError<InternalServerError>()("InternalServerError", { | |
| message: Schema.string, | |
| stack: Schema.optional(Schema.string), | |
| }) { | |
| readonly code = 500 | |
| encode() { | |
| return Schema.encode(InternalServerError)(this) | |
| } | |
| decode() { | |
| return Schema.decode(InternalServerError)(this) | |
| } | |
| } | |
| export class AppError extends Schema.TaggedError<AppError>()("AppError", { | |
| message: Schema.string, | |
| stack: Schema.optional(Schema.string), | |
| }) { | |
| readonly code = 400 | |
| encode() { | |
| return Schema.encode(AppError)(this) | |
| } | |
| decode() { | |
| return Schema.decode(AppError)(this) | |
| } | |
| } | |
| export class BadRequestError extends Schema.TaggedError<BadRequestError>()("BadRequestError", { | |
| message: Schema.string, | |
| stack: Schema.optional(Schema.string), | |
| }) { | |
| readonly code = 400 | |
| encode() { | |
| return Schema.encode(BadRequestError)(this) | |
| } | |
| decode() { | |
| return Schema.decode(BadRequestError)(this) | |
| } | |
| } | |
| export class UnauthorizedError extends Schema.TaggedError<UnauthorizedError>()("UnauthorizedError", { | |
| message: Schema.string, | |
| stack: Schema.optional(Schema.string), | |
| }) { | |
| readonly code = 401 | |
| encode() { | |
| return Schema.encode(UnauthorizedError)(this) | |
| } | |
| decode() { | |
| return Schema.decode(UnauthorizedError)(this) | |
| } | |
| } | |
| export class ForbiddenError extends Schema.TaggedError<ForbiddenError>()("ForbiddenError", { | |
| message: Schema.string, | |
| stack: Schema.optional(Schema.string), | |
| }) { | |
| readonly code = 403 | |
| encode() { | |
| return Schema.encode(ForbiddenError)(this) | |
| } | |
| decode() { | |
| return Schema.decode(ForbiddenError)(this) | |
| } | |
| } | |
| export class NotFoundError extends Schema.TaggedError<NotFoundError>()("NotFoundError", { | |
| message: Schema.string, | |
| stack: Schema.optional(Schema.string), | |
| }) { | |
| readonly code = 404 | |
| encode() { | |
| return Schema.encode(NotFoundError)(this) | |
| } | |
| decode() { | |
| return Schema.decode(NotFoundError)(this) | |
| } | |
| } | |
| // Application error can be returned to the user through a return value. | |
| export type RemixAppError = | |
| | InternalServerError | |
| | BadRequestError | |
| | UnauthorizedError | |
| | ForbiddenError | |
| | NotFoundError | |
| | RatelimitError | |
| | AppError | |
| // Default handling of errors, not handled on the user side | |
| export type RemixInternalError = | |
| | InternalServerError | |
| | BadRequestError | |
| | UnauthorizedError | |
| | ForbiddenError | |
| | NotFoundError | |
| | RatelimitError | |
| export const RemixInternalErrorTags = [ | |
| InternalServerError.identifier, | |
| BadRequestError.identifier, | |
| UnauthorizedError.identifier, | |
| ForbiddenError.identifier, | |
| NotFoundError.identifier, | |
| RatelimitError.identifier, | |
| ] | |
| export const isRemixInternalError = (error: any): error is RemixInternalError => { | |
| return RemixInternalErrorTags.includes(error._tag) | |
| } | |
| export class RemixFormDataParseError extends Data.TaggedError("RemixFormDataParseError")<{ | |
| error: ParseResult.ParseError | |
| }> { | |
| get message() { | |
| return this.toString() | |
| } | |
| toString() { | |
| return TreeFormatter.formatError(this.error) | |
| } | |
| toJSON() { | |
| return { | |
| _tag: "RemixFormDataParseError", | |
| message: this.toString(), | |
| detail: this.error.stack, | |
| } | |
| } | |
| } | |
| export class RemixBodyParseError extends Data.TaggedError("RemixBodyParseError")<{ | |
| error: ParseResult.ParseError | |
| }> { | |
| get message() { | |
| return this.toString() | |
| } | |
| toString() { | |
| return TreeFormatter.formatError(this.error) | |
| } | |
| toJSON() { | |
| return { | |
| _tag: "RemixFormDataParseError", | |
| message: this.toString(), | |
| detail: this.error.stack, | |
| } | |
| } | |
| } | |
| export class RemixSearchParamsParseError extends Data.TaggedError("RemixSearchParamsParseError")<{ | |
| error: ParseResult.ParseError | |
| }> { | |
| get message() { | |
| return this.toString() | |
| } | |
| toString() { | |
| return TreeFormatter.formatError(this.error) | |
| } | |
| toJSON() { | |
| return { | |
| _tag: "RemixSearchParamsParseError", | |
| message: this.toString(), | |
| detail: this.error.stack, | |
| } | |
| } | |
| } | |
| export class RemixParamsParseError extends Data.TaggedError("RemixParamsParseError")<{ | |
| error: ParseResult.ParseError | |
| }> { | |
| get message() { | |
| return this.toString() | |
| } | |
| toString() { | |
| return TreeFormatter.formatError(this.error) | |
| } | |
| toJSON() { | |
| return { | |
| _tag: "RemixParamsParseError", | |
| message: this.toString(), | |
| detail: this.error.stack, | |
| } | |
| } | |
| } | |
| export class RemixRequestError extends Data.TaggedClass("RemixRequestError")<{ | |
| error: | |
| | InternalServerError | |
| | NotFoundError | |
| | RemixFormDataParseError | |
| | RemixSearchParamsParseError | |
| | RemixBodyParseError | |
| | RemixParamsParseError | |
| | RatelimitError | |
| headers: HeadersInit | |
| }> {} | |
| export type RemixDataError = | |
| | RemixSearchParamsParseError | |
| | RemixFormDataParseError | |
| | RemixBodyParseError | |
| | RemixParamsParseError | |
| | ParseResult.ParseError | |
| export class RemixRedirect extends Data.TaggedClass("RemixRedirect")<{ | |
| response: TypedResponse | |
| headers: HeadersInit | |
| }> { | |
| get url() { | |
| return this.response.headers.get("Location") | |
| } | |
| toString() { | |
| return `RemixRedirect: ${this.response.status} ${this.url}` | |
| } | |
| toJSON() { | |
| return { | |
| _tag: "RemixRedirect", | |
| url: this.url, | |
| status: this.response.status, | |
| } | |
| } | |
| } | |
| export type RemixResult<A, E> = RemixResultSuccess<A> | RemixResultFailure<E> | |
| export interface RemixResultSuccess<A> { | |
| _tag: "RemixResultSuccess" | |
| readonly success: true | |
| readonly result: A | |
| headers: HeadersInit | |
| } | |
| export interface RemixResultFailure<E> { | |
| _tag: "RemixResultFailure" | |
| readonly success: false | |
| readonly error: E | |
| headers: HeadersInit | |
| } | |
| export const RemixResult = { | |
| Success: <A, E>(_: { result: A; headers: HeadersInit }): RemixResult<A, E> => ({ | |
| _tag: "RemixResultSuccess", | |
| success: true, | |
| result: _.result, | |
| headers: _.headers, | |
| }), | |
| Failure: <A, E>(_: { error: E; headers: HeadersInit }): RemixResult<A, E> => ({ | |
| _tag: "RemixResultFailure", | |
| success: false, | |
| error: _.error, | |
| headers: _.headers, | |
| }), | |
| } | |
| export interface RemixDataSuccess<A> { | |
| readonly success: true | |
| readonly result: A | |
| } | |
| export interface RemixDataFailure<E> { | |
| readonly success: false | |
| readonly error: E | |
| } | |
| export type RemixData<A, E> = RemixDataSuccess<A> | RemixDataFailure<E> | |
| const assignHeaders = (headers: HeadersInit | Record<string, string | number>, res: Response) => { | |
| Object.entries(headers).forEach(([key, value]) => { | |
| res.headers.set(key, value) | |
| }) | |
| } | |
| export const toResponse = | |
| (init?: number | RequestInit | undefined) => | |
| <A, E>(exit: RemixRedirect | RemixResult<A, E>): TypedResponse<RemixData<A, E>> => { | |
| const value = exit | |
| if (value._tag === "RemixRedirect") { | |
| assignHeaders(value.headers, value.response) | |
| throw value.response | |
| } | |
| if (value._tag === "RemixResultFailure") { | |
| const error = value.error as RemixAppError | |
| if (error._tag === "InternalServerError") { | |
| const encodeError = Effect.runSync(error.encode()) | |
| const response = remixJson(stripError(encodeError), { | |
| status: error.code, | |
| headers: value.headers, | |
| }) | |
| throw response | |
| } | |
| } | |
| if (value._tag === "RemixResultFailure") { | |
| const error = value.error as RemixAppError | |
| const encodeError = Effect.runSync(error.encode() as Effect.Effect<E, never, never>) | |
| const data: RemixData<A, E> = { | |
| success: false, | |
| error: stripError(encodeError) as E, | |
| } | |
| const userInit = typeof init === "number" ? { status: init } : init ? { ...init } : {} | |
| const status = userInit.status || (value.error as any).code || 400 | |
| const response = remixJson(data, { | |
| ...userInit, | |
| status: status, | |
| }) | |
| assignHeaders(value.headers, response) | |
| return response | |
| } | |
| const data: RemixData<A, E> = { | |
| success: true, | |
| result: value.result, | |
| } | |
| const response = remixJson(data, init) | |
| assignHeaders(value.headers, response) | |
| return response | |
| } | |
| export type ExcludeServerError<T> = T extends RemixDataError ? BadRequestError : T | |
| export type ExcludeInternalError<T> = T extends RemixInternalError ? never : T | |
| export interface RequestContext { | |
| readonly request: Request | |
| readonly params: Params<string> | |
| readonly context: AppLoadContext | |
| } | |
| export const RequestContext = Context.GenericTag<RequestContext>("@lib/RequestContext") | |
| const withRequestContext = <E, A>(effect: (context: RequestContext) => Effect.Effect<A, E>) => | |
| pipe( | |
| Effect.context<never>(), | |
| Effect.map((ctx) => Context.get(ctx as Context.Context<RequestContext>, RequestContext)), | |
| Effect.andThen(effect), | |
| ) | |
| export const request = withRequestContext((context) => Effect.succeed(context.request)) | |
| export const context = withRequestContext((context) => Effect.succeed(context.context)) | |
| export const getFormDataEntries = withRequestContext( | |
| (context): Effect.Effect<Record<string, any>, RemixFormDataParseError, never> => | |
| pipe( | |
| Effect.tryPromise({ | |
| try: () => context.request.formData(), | |
| catch: (e: unknown) => | |
| new RemixFormDataParseError({ | |
| error: new ParseResult.ParseError({ | |
| error: new ParseResult.Forbidden( | |
| Schema.unknown.ast, | |
| "", | |
| `Unable to parse the form data: ${(e as Error).message}`, | |
| ), | |
| }), | |
| }), | |
| }), | |
| Effect.map((formData) => Object.fromEntries(formData as any)), | |
| Effect.withSpan("remix.getFormDataEntries"), | |
| ), | |
| ) | |
| export const getFormData = <A, I>(schema: Schema.Schema<A, I>): Effect.Effect<A, RemixFormDataParseError, never> => | |
| pipe( | |
| getFormDataEntries, | |
| Effect.andThen((entries) => Schema.decodeUnknown(schema)(entries)), | |
| Effect.catchTag("ParseError", (_) => Effect.fail(new RemixFormDataParseError({ error: _ }))), | |
| Effect.withSpan("remix.decodeFormData"), | |
| ) | |
| export const getBody = <A, I>(schema: Schema.Schema<A, I>): Effect.Effect<A, RemixBodyParseError, never> => | |
| withRequestContext((context) => | |
| pipe( | |
| Effect.tryPromise({ | |
| try: () => context.request.json(), | |
| catch: (e: unknown) => | |
| new RemixBodyParseError({ | |
| error: new ParseResult.ParseError({ | |
| error: new ParseResult.Forbidden( | |
| schema.ast, | |
| "", | |
| `Unable to parse the request body as JSON: ${(e as Error).message}`, | |
| ), | |
| }), | |
| }), | |
| }), | |
| Effect.flatMap((json) => Schema.decodeUnknown(schema)(json)), | |
| Effect.catchTag("ParseError", (_) => Effect.fail(new RemixBodyParseError({ error: _ }))), | |
| Effect.withSpan("remix.decodeBody"), | |
| ), | |
| ) | |
| export const getSearchParams = <A, I>( | |
| schema: Schema.Schema<A, I>, | |
| ): Effect.Effect<A, RemixSearchParamsParseError, never> => | |
| withRequestContext((context) => | |
| pipe( | |
| Effect.try({ | |
| try: () => Object.fromEntries(new URL(context.request.url).searchParams.entries()), | |
| catch: (e: unknown) => | |
| new RemixSearchParamsParseError({ | |
| error: new ParseResult.ParseError({ | |
| error: new ParseResult.Forbidden( | |
| schema.ast, | |
| "", | |
| `Unable to parse the search params: ${(e as Error).message}`, | |
| ), | |
| }), | |
| }), | |
| }), | |
| Effect.flatMap((_) => Schema.decodeUnknown(schema)(_)), | |
| Effect.catchTag("ParseError", (_) => Effect.fail(new RemixSearchParamsParseError({ error: _ }))), | |
| Effect.withSpan("remix.decodeSearchParams"), | |
| ), | |
| ) | |
| export const getParams = <I, A>(schema: Schema.Schema<A, I>): Effect.Effect<A, RemixParamsParseError, never> => | |
| withRequestContext((context) => | |
| pipe( | |
| Effect.succeed(context.params), | |
| Effect.flatMap((_) => Schema.decodeUnknown(schema)(_)), | |
| Effect.mapError((_) => new RemixParamsParseError({ error: _ })), | |
| Effect.withSpan("remix.decodeParams"), | |
| ), | |
| ) | |
| export const Cookies = { | |
| string: withRequestContext( | |
| (context): Effect.Effect<string | undefined, never, never> => | |
| pipe( | |
| Effect.sync(() => context.request.headers.get("Cookie") || undefined), | |
| Effect.withSpan("remix.cookies"), | |
| ), | |
| ), | |
| get: (key: string, fallback?: () => string): Effect.Effect<string | undefined, never, never> => | |
| withRequestContext((context) => | |
| pipe( | |
| Effect.map( | |
| Effect.sync(() => context.request.headers.get("Cookie")), | |
| (cookie) => { | |
| if (cookie) { | |
| return findCookieByName(key, cookie) || fallback?.() | |
| } | |
| return fallback?.() | |
| }, | |
| ), | |
| Effect.withSpan("remix.cookies"), | |
| ), | |
| ), | |
| } | |
| export const redirect = (url: string, init?: number | ResponseInit | undefined): Effect.Effect<void, never, never> => { | |
| return pipe( | |
| Effect.suspend(() => | |
| Effect.die( | |
| new RemixRedirect({ | |
| response: remixRedirect(url, init), | |
| headers: {}, | |
| }), | |
| ), | |
| ), | |
| Effect.zip( | |
| Effect.succeed(0).pipe( | |
| Effect.withSpan("remix.redirect"), | |
| Effect.annotateSpans("url", url), | |
| Effect.annotateSpans("init", init), | |
| ), | |
| { | |
| batching: true, | |
| concurrent: true, | |
| }, | |
| ), | |
| Effect.asUnit, | |
| ) | |
| } | |
| export interface RequestLoadContext<E, R> extends AppLoadContext { | |
| waitUntil?: (promise: Promise<void>) => void | |
| passThroughOnException?: () => void | |
| runtime: MangedRuntime.ManagedRuntime<R, never> | |
| } | |
| export interface RequestHandlerConfig { | |
| /** | |
| * The name of the span | |
| * | |
| * @example "remix.effect" | |
| */ | |
| name: string | |
| /** | |
| * Trace span options | |
| */ | |
| spanOptions?: | |
| | { | |
| attributes?: Record<string, unknown> | undefined | |
| links?: ReadonlyArray<SpanLink> | undefined | |
| parent?: ParentSpan | undefined | |
| root?: boolean | undefined | |
| context?: Context.Context<never> | undefined | |
| } | |
| | undefined | |
| /** | |
| * The ratelimit configuration | |
| */ | |
| ratelimit?: | |
| | (() => RatelimitConfig & { | |
| identifier?: "ip" | Effect.Effect<string> | |
| }) | |
| | undefined | |
| /** | |
| * Skip the ratelimit check | |
| */ | |
| skipRatelimit?: boolean | undefined | |
| /** | |
| * Skip the tracing | |
| */ | |
| skipTracing?: boolean | undefined | |
| } | |
| export const shouldSkipRatelimit = (request: Request) => { | |
| // "Uptime-Kuma/0.0.0" | |
| const userAgent = request.headers.get("User-Agent") || "" | |
| const skipList = ["Uptime-Kuma"] | |
| const devMode = globalThis.env.NODE_ENV === "development" || globalThis.env.NODE_ENV === "test" | |
| return devMode || skipList.some((_) => userAgent.indexOf(_) !== -1) | |
| } | |
| export const shouldSkipTracing = (request: Request) => { | |
| const userAgent = request.headers.get("User-Agent") || "" | |
| const skipList = ["Uptime-Kuma"] | |
| return skipList.some((_) => userAgent.indexOf(_) !== -1) | |
| } | |
| export type RequestHandlerType = "loader" | "action" | |
| export const createRequestHandler = | |
| (type: RequestHandlerType, config?: RequestHandlerConfig) => | |
| <A, E, R, E1>( | |
| args: LoaderFunctionArgs | ActionFunctionArgs, | |
| body: Effect.Effect<A, E, R>, | |
| transform: (_: RemixResult<A, E>) => Effect.Effect<RemixResult<A, E>>, | |
| ) => { | |
| const ip = args.request.headers.get("X-Forwarded-For") ?? args.request.headers.get("x-real-ip") ?? undefined | |
| const spanName = `remix.${config?.name ? `${config.name}.${type}` : type}` | |
| const logLevel = globalThis.env.logLevel | |
| ? LogLevel.fromLiteral(globalThis.env.logLevel) | |
| : globalThis.env.NODE_ENV === "development" || globalThis.env.NODE_ENV === "test" | |
| ? LogLevel.All | |
| : LogLevel.Error | |
| let main = body | |
| const headers = {} | |
| if (config?.ratelimit) { | |
| const skipRateLimit = config.skipRatelimit || shouldSkipRatelimit(args.request) | |
| if (!skipRateLimit) { | |
| const { cf } = args.request as unknown as { | |
| cf?: { | |
| city?: string | |
| region?: string | |
| country?: string | |
| } | |
| } | |
| main = pipe( | |
| Effect.sync(() => config.ratelimit!()), | |
| Effect.flatMap((ratelimitConfig) => { | |
| if (ratelimitConfig.identifier && Effect.isEffect(ratelimitConfig.identifier)) { | |
| return ratelimitConfig.identifier.pipe( | |
| Effect.zip(Effect.succeed(ratelimitConfig)), | |
| Effect.withSpan("remix.ratelimit.get-identifier"), | |
| ) | |
| } | |
| return Effect.succeed([ip ?? "global", ratelimitConfig] as const) | |
| }), | |
| Effect.flatMap(([identifier, ratelimitConfig]) => { | |
| const ratelimitReq = { | |
| geo: { | |
| city: cf?.city, | |
| country: cf?.country, | |
| region: cf?.region, | |
| ip, | |
| }, | |
| } | |
| return pipe( | |
| take(ratelimitConfig), | |
| Effect.flatMap((api) => | |
| pipe( | |
| api.limit(identifier, ratelimitReq), | |
| Effect.tapBoth({ | |
| onFailure: (error) => | |
| pipe( | |
| Effect.logDebug("ratelimit failed"), | |
| Effect.annotateLogs({ | |
| prefix: api.prefix, | |
| identifier, | |
| geo: ratelimitReq.geo, | |
| remaining: error.headers.remaining, | |
| limit: error.headers.limit, | |
| reset: new Date(error.headers.reset).toLocaleString(), | |
| }), | |
| Effect.annotateSpans({ | |
| prefix: api.prefix, | |
| identifier, | |
| geo: ratelimitReq.geo, | |
| remaining: error.headers.remaining, | |
| limit: error.headers.limit, | |
| reset: error.headers.reset / 1000, | |
| }), | |
| Effect.tap(() => { | |
| const errorHeaders = error.headers | |
| headers["X-RateLimit-Limit"] = errorHeaders.limit | |
| headers["X-RateLimit-Remaining"] = errorHeaders.remaining | |
| headers["X-RateLimit-Reset"] = errorHeaders.reset / 1000 | |
| headers["Retry-After"] = errorHeaders.reset / 1000 | |
| }), | |
| ), | |
| onSuccess: (res) => | |
| pipe( | |
| Effect.logDebug("ratelimit pass"), | |
| Effect.annotateLogs({ | |
| prefix: api.prefix, | |
| identifier, | |
| geo: ratelimitReq.geo, | |
| remaining: res.remaining, | |
| limit: res.limit, | |
| reset: new Date(res.reset).toLocaleString(), | |
| }), | |
| Effect.annotateSpans({ | |
| prefix: api.prefix, | |
| identifier, | |
| geo: ratelimitReq.geo, | |
| remaining: res.remaining, | |
| limit: res.limit, | |
| reset: res.reset / 1000, | |
| }), | |
| Effect.tap(() => { | |
| headers["X-RateLimit-Limit"] = res.limit | |
| headers["X-RateLimit-Remaining"] = res.remaining | |
| headers["X-RateLimit-Reset"] = res.reset / 1000 | |
| headers["Retry-After"] = res.reset / 1000 | |
| }), | |
| ), | |
| }), | |
| Effect.catchTag("RatelimitError", (e) => { | |
| // when the ratelimit service is down, let the request pass | |
| if (e.reason === "UnknownError") { | |
| return Effect.unit | |
| } | |
| return Effect.fail(e as E) | |
| }), | |
| ), | |
| ), | |
| ) | |
| }), | |
| Effect.withSpan("remix.ratelimit.check"), | |
| Effect.flatMap(() => body), | |
| ) | |
| } | |
| } | |
| let requestHandler = Effect.flatMap(main, (_) => transform(RemixResult.Success({ result: _, headers }))) | |
| if (config?.name) { | |
| requestHandler = pipe(requestHandler, Effect.withLogSpan(spanName), Effect.withSpan(spanName, config.spanOptions)) | |
| } | |
| const skipTracing = config?.skipTracing || shouldSkipTracing(args.request) | |
| requestHandler = Effect.withTracerEnabled(withParentRequestTrace(args.request, requestHandler), !skipTracing) | |
| const program = pipe( | |
| Effect.sandbox(requestHandler), | |
| Effect.catchAll((e) => { | |
| const cause = e as Cause.Cause<RemixRequestError["error"]> | |
| if (cause._tag === "Die") { | |
| if (cause.defect && Cause.isRuntimeException(cause.defect)) { | |
| const error = new InternalServerError({ | |
| message: `RuntimeException: ${cause.defect.message}`, | |
| stack: cause.defect.stack, | |
| }) | |
| return Effect.succeed( | |
| RemixResult.Failure({ | |
| error, | |
| headers, | |
| }), | |
| ) as Effect.Effect<RemixRedirect | RemixResult<A, E1>> | |
| } | |
| if (cause.defect && cause.defect instanceof RemixRedirect) { | |
| Object.assign(cause.defect.headers, headers) | |
| return Effect.succeed(cause.defect) as Effect.Effect<RemixRedirect | RemixResult<A, E1>> | |
| } | |
| if (cause.defect && cause.defect instanceof Error) { | |
| const error = new InternalServerError({ | |
| message: `${cause.defect.name}: ${cause.defect.message}`, | |
| stack: cause.defect.stack, | |
| }) | |
| return Effect.succeed( | |
| RemixResult.Failure({ | |
| error, | |
| headers, | |
| }), | |
| ) as Effect.Effect<RemixRedirect | RemixResult<A, E1>> | |
| } | |
| } | |
| // Remix Request Error -> Remix App Error | |
| if (cause._tag === "Fail") { | |
| const error = cause.error | |
| let appError: RemixAppError | null = null | |
| // Uncatcha parse error | |
| if (error && isParseError(error)) { | |
| appError = new BadRequestError({ | |
| message: (error as ParseResult.ParseError).message, | |
| stack: (error as ParseResult.ParseError).stack, | |
| }) | |
| } else if ( | |
| error._tag === "RemixBodyParseError" || | |
| error._tag === "RemixFormDataParseError" || | |
| error._tag === "RemixSearchParamsParseError" || | |
| error._tag === "RemixParamsParseError" | |
| ) { | |
| appError = new BadRequestError({ | |
| message: error.message, | |
| stack: error.stack | |
| }) | |
| } else if (error._tag) { | |
| const casueError = error as | |
| | NotFoundError | |
| | RatelimitError | |
| | ForbiddenError | |
| | UnauthorizedError | |
| | InternalServerError | |
| appError = casueError | |
| } | |
| if (!appError) { | |
| appError = new InternalServerError({ | |
| message: String(cause.error.message), | |
| stack: cause.error.stack, | |
| }) | |
| } | |
| return Effect.succeed( | |
| RemixResult.Failure({ | |
| error: appError, | |
| headers, | |
| }), | |
| ) as Effect.Effect<RemixRedirect | RemixResult<A, E1>> | |
| } | |
| const error = new InternalServerError({ | |
| message: Cause.pretty(cause) || "Unexpected error", | |
| }) | |
| return Effect.succeed( | |
| RemixResult.Failure({ | |
| error, | |
| headers, | |
| }), | |
| ) as Effect.Effect<RemixRedirect | RemixResult<A, E1>> | |
| }), | |
| Effect.provideService( | |
| RequestContext, | |
| RequestContext.of({ request: args.request, params: args.params, context: args.context }), | |
| ), | |
| Effect.tapDefect((cause) => { | |
| if (Cause.isInterrupted(cause)) { | |
| return Effect.unit | |
| } | |
| const message = Cause.pretty(cause) | |
| if (message.indexOf("AggregateError") !== -1) { | |
| return Effect.unit | |
| } | |
| return Effect.logError(message) | |
| }), | |
| Logger.withMinimumLogLevel(logLevel), | |
| ) | |
| return program as Effect.Effect<RemixRedirect | RemixResult<A, E1>, never> | |
| } | |
| type BuiltInR = KVStorage | Database | I18n | |
| const make = | |
| (type: RequestHandlerType) => | |
| <A, E, R extends BuiltInR>(body: Effect.Effect<A, E, R>, config?: RequestHandlerConfig | undefined) => { | |
| const handler = createRequestHandler(type, config) | |
| const json_ = | |
| (init?: number | ResponseInit | undefined) => | |
| (args: LoaderFunctionArgs): Promise<TypedResponse<RemixData<A, ExcludeServerError<E>>>> => { | |
| const context = args.context as RequestLoadContext<E, R> | |
| const run = handler<A, E, R, ExcludeServerError<E>>(args, body, (result) => Effect.succeed(result)) | |
| return context.runtime.runPromise(run, { signal: args.request.signal }).then(toResponse(init)) | |
| } | |
| const schema = | |
| <A2>(schema: Schema.Schema<A, A2>, init?: number | RequestInit | undefined) => | |
| (args: LoaderFunctionArgs): Promise<TypedResponse<RemixData<A2, ExcludeServerError<E>>>> => { | |
| const context = args.context as RequestLoadContext<E, R> | |
| const encode = Schema.encodeUnknown(schema) | |
| const run = handler<A, E, R, ExcludeServerError<E>>(args, body, (result) => { | |
| if (result._tag === "RemixResultFailure") { | |
| return Effect.succeed(result) as Effect.Effect<RemixResult<A, E>> | |
| } | |
| return pipe( | |
| encode(result.result), | |
| Effect.map( | |
| (res) => | |
| RemixResult.Success({ | |
| result: res, | |
| headers: result.headers, | |
| }) as RemixResult<A, E>, | |
| ), | |
| Effect.catchAll((error) => | |
| Effect.succeed( | |
| RemixResult.Failure({ | |
| error: new InternalServerError({ | |
| message: `Failed to encode response: ${error.message}`, | |
| stack: error.stack, | |
| }), | |
| headers: result.headers, | |
| }) as RemixResult<A, E>, | |
| ), | |
| ), | |
| Effect.withSpan(`remix.${type}.schema`), | |
| ) | |
| }) | |
| return context.runtime.runPromise(run, { signal: args.request.signal }).then(toResponse(init)) as Promise< | |
| TypedResponse<RemixData<A2, ExcludeServerError<E>>> | |
| > | |
| } | |
| const effect = ( | |
| args: LoaderFunctionArgs, | |
| ): Effect.Effect<RemixRedirect | RemixResult<A, ExcludeServerError<E>>, never, never> => { | |
| const context = args.context as RequestLoadContext<E, R> | |
| const run = handler<A, E, R, ExcludeServerError<E>>(args, body, (_) => Effect.succeed(_)) | |
| return Effect.promise((s) => context.runtime.runPromise(run, { signal: s })) | |
| } | |
| return { json: json_, schema, effect } as const | |
| } | |
| export const loader = make("loader") | |
| export const action = make("action") | |
| export const witTestRequest = (context: Partial<RequestContext>) => { | |
| return Effect.provideService( | |
| RequestContext, | |
| RequestContext.of({ | |
| request: new Request("http://localhost", { | |
| method: "GET", | |
| }), | |
| context: {}, | |
| params: {}, | |
| ...context, | |
| }), | |
| ) | |
| } | |
| export type ReduceError<T extends RemixData<any, any>> = T extends RemixDataFailure<infer E> | |
| ? E extends never | |
| ? never | |
| : T | |
| : T | |
| export type ExtractSuccess<T> = T extends RemixDataSuccess<infer A> ? A : never | |
| export type ExtractFailure<T> = T extends RemixDataFailure<infer E> ? E : never | |
| export type SafeLoaderData<T extends (args: LoaderFunctionArgs) => Promise<RemixData<any, any>>> = | |
| ReturnType<T> extends Promise<RemixData<infer A, infer E>> ? RemixData<A, ExcludeInternalError<E>> : never | |
| export const useRouteLoaderData = useRouteLoaderData_ as < | |
| T extends (args: LoaderFunctionArgs) => Promise<RemixData<any, any>>, | |
| >( | |
| routeId: string, | |
| ) => ReduceError<SafeLoaderData<T>> | |
| export function useLoaderData<T extends (args: LoaderFunctionArgs) => Promise<RemixData<any, any>>>() { | |
| const data = useLoaderData_() as ReduceError<SafeLoaderData<T>> | |
| if (!data.success && isRemixInternalError(data.error as RemixInternalError)) { | |
| throw data.error | |
| } | |
| return data | |
| } | |
| export function useActionData<T extends (args: ActionFunctionArgs) => Promise<RemixData<any, any>>>(options: { | |
| onFailure?: (error: ExtractFailure<ReduceError<SafeLoaderData<T>>>) => void | |
| onInternalError?: (error: RemixInternalError) => void | |
| }) { | |
| const data = useActionData_() as ReduceError<SafeLoaderData<T>> | |
| const onFailure = useStableHandler(options.onFailure || (() => {})) | |
| const onInternalError = useStableHandler(options.onInternalError || (() => {})) | |
| useEffect(() => { | |
| if (!data.success) { | |
| const error = data.error | |
| if (isRemixInternalError(error)) { | |
| if (options.onInternalError) { | |
| onInternalError(error) | |
| return | |
| } | |
| toast.error(formatInternalError(error)) | |
| return | |
| } | |
| } | |
| }, [data, onFailure, options.onInternalError]) | |
| return data | |
| } | |
| export function useFetcherData<T extends (args: LoaderFunctionArgs) => Promise<RemixData<any, any>>>(options: { | |
| key?: string | |
| onSuccess?: (result: ExtractSuccess<SafeLoaderData<T>>) => void | |
| onFailure?: (error: ExtractFailure<ReduceError<SafeLoaderData<T>>>) => void | |
| onInternalError?: (error: RemixInternalError) => void | |
| }) { | |
| const fetcher = useFetcher(options.key ? { key: options.key } : {}) as FetcherWithComponents<SafeLoaderData<T>> | |
| const onFailure = useStableHandler(options.onFailure || (() => {})) | |
| const onSuccess = useStableHandler(options.onSuccess || (() => {})) | |
| const onInternalError = useStableHandler(options.onInternalError || (() => {})) | |
| const data = fetcher.data as RemixData<any, any> | |
| useEffect(() => { | |
| if (fetcher.state === "idle") { | |
| if (!data) { | |
| return | |
| } | |
| if (data.success) { | |
| onSuccess(data.result) | |
| return | |
| } | |
| if (isRemixInternalError(data.error as RemixInternalError)) { | |
| if (options.onInternalError) { | |
| onInternalError(data.error as RemixInternalError) | |
| return | |
| } | |
| toast.error(formatInternalError(data.error)) | |
| return | |
| } | |
| onFailure(data.error) | |
| return | |
| } | |
| }, [fetcher.state, data, onFailure, onSuccess, onInternalError]) | |
| return fetcher | |
| } | |
| const formatInternalError = (error: RemixInternalError) => { | |
| return error._tag + (error.message ? `: ${error.message}` : "") | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment