@effectify/react-remix 0.4.6 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -8
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.js +4 -0
- package/dist/src/lib/context.d.ts +9 -0
- package/dist/src/lib/context.js +5 -0
- package/dist/src/lib/http-api-handler.d.ts +12 -0
- package/dist/src/lib/http-api-handler.js +17 -0
- package/dist/src/lib/http-response.d.ts +34 -0
- package/dist/src/lib/http-response.js +14 -0
- package/dist/src/lib/runtime.d.ts +25 -0
- package/dist/src/lib/runtime.js +101 -0
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ Create a server runtime with your Effect layers:
|
|
|
26
26
|
|
|
27
27
|
```typescript
|
|
28
28
|
// lib/server-runtime.ts
|
|
29
|
-
import { Runtime } from "@effectify/react-remix"
|
|
29
|
+
import { Runtime } from "@effectify/react-remix"
|
|
30
30
|
import * as Layer from "effect/Layer"
|
|
31
31
|
|
|
32
32
|
const layers = Layer.empty
|
|
@@ -38,7 +38,7 @@ export const { withLoaderEffect, withActionEffect } = Runtime.make(layers)
|
|
|
38
38
|
|
|
39
39
|
Use the Effect-based loaders and actions in your Remix routes:
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
````typescript
|
|
42
42
|
// routes/home.tsx
|
|
43
43
|
import type * as Route from "./+types.home";
|
|
44
44
|
import { httpSuccess, httpFailure, LoaderArgsContext } from "@effectify/react-remix";
|
|
@@ -74,15 +74,16 @@ For error handling, use the `httpFailure` helper:
|
|
|
74
74
|
return yield* httpFailure("Something went wrong")
|
|
75
75
|
// or with more complex error objects
|
|
76
76
|
return yield* httpFailure({ code: 'VALIDATION_ERROR', message: 'Invalid input' })
|
|
77
|
-
|
|
77
|
+
````
|
|
78
78
|
|
|
79
79
|
### Redirects
|
|
80
80
|
|
|
81
81
|
For redirects, use the `httpRedirect` helper:
|
|
82
|
+
|
|
82
83
|
```typescript
|
|
83
|
-
return yield* httpRedirect(
|
|
84
|
+
return yield * httpRedirect("/login")
|
|
84
85
|
// or with custom status/headers
|
|
85
|
-
return yield* httpRedirect(
|
|
86
|
+
return yield * httpRedirect("/dashboard", { status: 301 })
|
|
86
87
|
```
|
|
87
88
|
|
|
88
89
|
## API
|
|
@@ -98,12 +99,14 @@ Creates Effect-based runtime helpers for Remix.
|
|
|
98
99
|
#### Returns
|
|
99
100
|
|
|
100
101
|
An object containing:
|
|
102
|
+
|
|
101
103
|
- `withLoaderEffect`: Wrapper for Remix loaders using Effect
|
|
102
104
|
- `withActionEffect`: Wrapper for Remix actions using Effect
|
|
103
105
|
|
|
104
106
|
### `LoaderArgsContext`
|
|
105
107
|
|
|
106
108
|
Effect context providing access to Remix loader arguments including:
|
|
109
|
+
|
|
107
110
|
- `request`: The incoming Request object
|
|
108
111
|
- `params`: Route parameters
|
|
109
112
|
- `context`: Additional context data
|
|
@@ -133,7 +136,7 @@ The library provides comprehensive error handling with full ErrorBoundary suppor
|
|
|
133
136
|
|
|
134
137
|
```typescript
|
|
135
138
|
// The library automatically logs errors like this:
|
|
136
|
-
Effect.tapError((cause) => Effect.logError(
|
|
139
|
+
Effect.tapError((cause) => Effect.logError("Loader effect failed", cause))
|
|
137
140
|
|
|
138
141
|
// Loader errors are automatically converted to Response objects for ErrorBoundary:
|
|
139
142
|
// - Effect errors → Response with { ok: false, errors: [...] } and status 500
|
|
@@ -150,7 +153,7 @@ const runtime = make(pipe(
|
|
|
150
153
|
// Your app layers
|
|
151
154
|
MyAppLayer,
|
|
152
155
|
// Custom logger layer
|
|
153
|
-
Logger.replace(Logger.defaultLogger, customLogger)
|
|
156
|
+
Logger.replace(Logger.defaultLogger, customLogger),
|
|
154
157
|
))
|
|
155
158
|
```
|
|
156
159
|
|
|
@@ -161,4 +164,4 @@ const runtime = make(pipe(
|
|
|
161
164
|
|
|
162
165
|
## License
|
|
163
166
|
|
|
164
|
-
MIT
|
|
167
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
|
|
2
|
+
import * as Context from "effect/Context";
|
|
3
|
+
declare const ActionArgsContext_base: Context.TagClass<ActionArgsContext, "ActionArgsContext", ActionFunctionArgs>;
|
|
4
|
+
export declare class ActionArgsContext extends ActionArgsContext_base {
|
|
5
|
+
}
|
|
6
|
+
declare const LoaderArgsContext_base: Context.TagClass<LoaderArgsContext, "LoaderArgsContext", LoaderFunctionArgs>;
|
|
7
|
+
export declare class LoaderArgsContext extends LoaderArgsContext_base {
|
|
8
|
+
}
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type * as HttpApi from "@effect/platform/HttpApi";
|
|
2
|
+
import * as HttpApiScalar from "@effect/platform/HttpApiScalar";
|
|
3
|
+
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
|
|
4
|
+
import * as Layer from "effect/Layer";
|
|
5
|
+
export type HttpApiOptions = {
|
|
6
|
+
apiLive: Layer.Layer<HttpApi.Api, never, never>;
|
|
7
|
+
scalar?: HttpApiScalar.ScalarConfig;
|
|
8
|
+
};
|
|
9
|
+
export type RoutePath = "/" | `/${string}/`;
|
|
10
|
+
export declare const make: (options: HttpApiOptions & {
|
|
11
|
+
pathPrefix?: RoutePath;
|
|
12
|
+
}) => ({ request }: ActionFunctionArgs | LoaderFunctionArgs) => Promise<Response>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as HttpApiBuilder from "@effect/platform/HttpApiBuilder";
|
|
2
|
+
import * as HttpApiScalar from "@effect/platform/HttpApiScalar";
|
|
3
|
+
import * as HttpServer from "@effect/platform/HttpServer";
|
|
4
|
+
import { Option } from "effect";
|
|
5
|
+
import { pipe } from "effect/Function";
|
|
6
|
+
import * as Layer from "effect/Layer";
|
|
7
|
+
export const make = (options) => ({ request }) => pipe(Option.fromNullable(options.scalar), Option.map((scalar) => HttpApiScalar.layer({
|
|
8
|
+
path: `${options.pathPrefix || "/api/"}docs`,
|
|
9
|
+
scalar: {
|
|
10
|
+
...scalar,
|
|
11
|
+
baseServerURL: new URL(request.url).origin,
|
|
12
|
+
},
|
|
13
|
+
}).pipe(Layer.provide(options.apiLive))), Option.getOrElse(() => Layer.empty), (ApiDocsLive) => {
|
|
14
|
+
const EnvLive = Layer.mergeAll(options.apiLive, ApiDocsLive, HttpServer.layerContext);
|
|
15
|
+
const { handler } = HttpApiBuilder.toWebHandler(EnvLive);
|
|
16
|
+
return handler(request);
|
|
17
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ResponseInit } from "@effect/platform-node/Undici";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
declare const HttpResponseSuccess_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").Equals<A, {}> extends true ? void : { readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }) => Readonly<A> & {
|
|
4
|
+
readonly _tag: "HttpResponseSuccess";
|
|
5
|
+
};
|
|
6
|
+
export declare class HttpResponseSuccess<T> extends HttpResponseSuccess_base<{
|
|
7
|
+
readonly data: T;
|
|
8
|
+
}> {
|
|
9
|
+
}
|
|
10
|
+
declare const HttpResponseFailure_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").Equals<A, {}> extends true ? void : { readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }) => Readonly<A> & {
|
|
11
|
+
readonly _tag: "HttpResponseFailure";
|
|
12
|
+
};
|
|
13
|
+
export declare class HttpResponseFailure<T = unknown> extends HttpResponseFailure_base<{
|
|
14
|
+
readonly cause: T;
|
|
15
|
+
}> {
|
|
16
|
+
}
|
|
17
|
+
declare const HttpResponseRedirect_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").Equals<A, {}> extends true ? void : { readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }) => Readonly<A> & {
|
|
18
|
+
readonly _tag: "HttpResponseRedirect";
|
|
19
|
+
};
|
|
20
|
+
export declare class HttpResponseRedirect extends HttpResponseRedirect_base<{
|
|
21
|
+
readonly to: string;
|
|
22
|
+
readonly init?: number | ResponseInit | undefined;
|
|
23
|
+
}> {
|
|
24
|
+
}
|
|
25
|
+
export type HttpResponse<T> = HttpResponseRedirect | HttpResponseSuccess<T> | HttpResponseFailure<unknown>;
|
|
26
|
+
export declare const matchHttpResponse: <T>() => <P extends {
|
|
27
|
+
readonly HttpResponseSuccess: (_: HttpResponseSuccess<T>) => any;
|
|
28
|
+
readonly HttpResponseFailure: (_: HttpResponseFailure<unknown>) => any;
|
|
29
|
+
readonly HttpResponseRedirect: (_: HttpResponseRedirect) => any;
|
|
30
|
+
} & { readonly [Tag in Exclude<keyof P, "HttpResponseSuccess" | "HttpResponseFailure" | "HttpResponseRedirect">]: never; }>(fields: P) => (input: HttpResponse<T>) => import("effect/Unify").Unify<ReturnType<P[keyof P]>>;
|
|
31
|
+
export declare const httpSuccess: <T>(data: T) => Effect.Effect<HttpResponseSuccess<T>, never, never>;
|
|
32
|
+
export declare const httpFailure: <T = unknown>(cause: T) => Effect.Effect<HttpResponseFailure<T>, never, never>;
|
|
33
|
+
export declare const httpRedirect: (to: string, init?: number | ResponseInit) => Effect.Effect<HttpResponseRedirect, never, never>;
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import * as Data from "effect/Data";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Match from "effect/Match";
|
|
4
|
+
export class HttpResponseSuccess extends Data.TaggedClass("HttpResponseSuccess") {
|
|
5
|
+
}
|
|
6
|
+
export class HttpResponseFailure extends Data.TaggedClass("HttpResponseFailure") {
|
|
7
|
+
}
|
|
8
|
+
export class HttpResponseRedirect extends Data.TaggedClass("HttpResponseRedirect") {
|
|
9
|
+
}
|
|
10
|
+
export const matchHttpResponse = () => Match.typeTags();
|
|
11
|
+
// Helper functions for better DX
|
|
12
|
+
export const httpSuccess = (data) => Effect.succeed(new HttpResponseSuccess({ data }));
|
|
13
|
+
export const httpFailure = (cause) => Effect.succeed(new HttpResponseFailure({ cause }));
|
|
14
|
+
export const httpRedirect = (to, init) => Effect.succeed(new HttpResponseRedirect({ to, init }));
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Response } from "@effect/platform-node/Undici";
|
|
2
|
+
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import type * as Layer from "effect/Layer";
|
|
5
|
+
import { ActionArgsContext, LoaderArgsContext } from "./context.js";
|
|
6
|
+
import { type HttpResponse } from "./http-response.js";
|
|
7
|
+
export declare const make: <R, E>(layer: Layer.Layer<R, E, never>) => {
|
|
8
|
+
withLoaderEffect: <A, B>(self: Effect.Effect<HttpResponse<A> | Response, B, R | LoaderArgsContext>) => (args: LoaderFunctionArgs) => Promise<{
|
|
9
|
+
ok: true;
|
|
10
|
+
data: A;
|
|
11
|
+
} | {
|
|
12
|
+
ok: false;
|
|
13
|
+
errors: string[];
|
|
14
|
+
}>;
|
|
15
|
+
withActionEffect: <A, B_1>(self: Effect.Effect<HttpResponse<A> | Response, B_1, R | ActionArgsContext>) => (args: ActionFunctionArgs) => Promise<import("@remix-run/node").TypedResponse<{
|
|
16
|
+
ok: false;
|
|
17
|
+
errors: string[];
|
|
18
|
+
}> | {
|
|
19
|
+
ok: true;
|
|
20
|
+
response: A;
|
|
21
|
+
} | import("@remix-run/node").TypedResponse<{
|
|
22
|
+
ok: false;
|
|
23
|
+
errors: string[];
|
|
24
|
+
}> | import("@remix-run/node").TypedResponse<never>>;
|
|
25
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { Response } from "@effect/platform-node/Undici";
|
|
2
|
+
import { json, redirect } from "@remix-run/node";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as Exit from "effect/Exit";
|
|
5
|
+
import { pipe } from "effect/Function";
|
|
6
|
+
import * as Logger from "effect/Logger";
|
|
7
|
+
import * as ManagedRuntime from "effect/ManagedRuntime";
|
|
8
|
+
import { ActionArgsContext, LoaderArgsContext } from "./context.js";
|
|
9
|
+
import { matchHttpResponse } from "./http-response.js";
|
|
10
|
+
export const make = (layer) => {
|
|
11
|
+
const runtime = ManagedRuntime.make(layer);
|
|
12
|
+
const withLoaderEffect = (self) => (args) => {
|
|
13
|
+
const runnable = pipe(self, Effect.provide(Logger.pretty), Effect.provideService(LoaderArgsContext, args), Effect.tapError((cause) => Effect.logError("Loader effect failed", cause)));
|
|
14
|
+
return runtime.runPromiseExit(runnable).then(Exit.match({
|
|
15
|
+
onFailure: (cause) => {
|
|
16
|
+
if (cause._tag === "Fail") {
|
|
17
|
+
// Preserve the original error for ErrorBoundary
|
|
18
|
+
const error = cause.error;
|
|
19
|
+
if (error instanceof Response) {
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
if (error instanceof Error) {
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
// Convert other errors to Response for ErrorBoundary with ok: false
|
|
26
|
+
const errorData = { ok: false, errors: [String(error)] };
|
|
27
|
+
throw new Response(JSON.stringify(errorData), {
|
|
28
|
+
status: 500,
|
|
29
|
+
headers: { "Content-Type": "application/json" },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
// Handle other types of failures (interrupts, defects, etc.)
|
|
33
|
+
const errorData = { ok: false, errors: ["Internal server error"] };
|
|
34
|
+
throw new Response(JSON.stringify(errorData), {
|
|
35
|
+
status: 500,
|
|
36
|
+
headers: { "Content-Type": "application/json" },
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
onSuccess: (result) => {
|
|
40
|
+
// If the result is a Response, throw it directly to preserve headers (including Set-Cookie)
|
|
41
|
+
// This is how React Router expects to handle Responses from loaders/actions
|
|
42
|
+
if (result instanceof Response) {
|
|
43
|
+
throw result;
|
|
44
|
+
}
|
|
45
|
+
// Otherwise, match the HttpResponse types
|
|
46
|
+
return matchHttpResponse()({
|
|
47
|
+
HttpResponseSuccess: ({ data: response }) => ({ ok: true, data: response }),
|
|
48
|
+
HttpResponseFailure: ({ cause }) => {
|
|
49
|
+
// Convert HttpResponseFailure to Response for ErrorBoundary with ok: false
|
|
50
|
+
const errorMessage = typeof cause === "string" ? cause : String(cause);
|
|
51
|
+
const errorData = { ok: false, errors: [errorMessage] };
|
|
52
|
+
throw new Response(JSON.stringify(errorData), {
|
|
53
|
+
status: 500,
|
|
54
|
+
headers: { "Content-Type": "application/json" },
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
HttpResponseRedirect: ({ to, init = {} }) => {
|
|
58
|
+
redirect(to, init);
|
|
59
|
+
return { ok: false, errors: ["Redirecting..."] };
|
|
60
|
+
},
|
|
61
|
+
})(result);
|
|
62
|
+
},
|
|
63
|
+
}));
|
|
64
|
+
};
|
|
65
|
+
// Don't throw the Error requests, handle them in the normal UI. No ErrorBoundary
|
|
66
|
+
const withActionEffect = (self) => (args) => {
|
|
67
|
+
const runnable = pipe(self, Effect.provide(Logger.pretty), Effect.provideService(ActionArgsContext, args), Effect.tapError((cause) => {
|
|
68
|
+
if (!(cause instanceof Response)) {
|
|
69
|
+
return Effect.logError("Action effect failed", cause);
|
|
70
|
+
}
|
|
71
|
+
return Effect.void;
|
|
72
|
+
}));
|
|
73
|
+
return runtime.runPromiseExit(runnable).then(Exit.match({
|
|
74
|
+
onFailure: (cause) => {
|
|
75
|
+
if (cause._tag === "Fail") {
|
|
76
|
+
const error = cause.error;
|
|
77
|
+
// If the error is a Response, throw it directly to preserve headers
|
|
78
|
+
if (error instanceof Response) {
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
return json({ ok: false, errors: [String(error)] }, { status: 400 });
|
|
82
|
+
}
|
|
83
|
+
return json({ ok: false, errors: ["Internal server error"] }, { status: 400 });
|
|
84
|
+
},
|
|
85
|
+
onSuccess: (result) => {
|
|
86
|
+
// If the result is a Response, throw it directly to preserve headers (including Set-Cookie)
|
|
87
|
+
// This is how React Router expects to handle Responses from actions
|
|
88
|
+
if (result instanceof Response) {
|
|
89
|
+
throw result;
|
|
90
|
+
}
|
|
91
|
+
// Otherwise, match the HttpResponse types
|
|
92
|
+
return matchHttpResponse()({
|
|
93
|
+
HttpResponseSuccess: ({ data: response }) => ({ ok: true, response }),
|
|
94
|
+
HttpResponseFailure: ({ cause }) => json({ ok: false, errors: [String(cause)] }, { status: 400 }),
|
|
95
|
+
HttpResponseRedirect: ({ to, init = {} }) => redirect(to, init),
|
|
96
|
+
})(result);
|
|
97
|
+
},
|
|
98
|
+
}));
|
|
99
|
+
};
|
|
100
|
+
return { withLoaderEffect, withActionEffect };
|
|
101
|
+
};
|
package/package.json
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effectify/react-remix",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Integration of Remix with Effect for React applications",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "./dist/index.js",
|
|
7
|
-
"module": "./dist/index.js",
|
|
8
|
-
"types": "./dist/index.d.ts",
|
|
6
|
+
"main": "./dist/src/index.js",
|
|
7
|
+
"module": "./dist/src/index.js",
|
|
8
|
+
"types": "./dist/src/index.d.ts",
|
|
9
9
|
"publishConfig": {
|
|
10
10
|
"access": "public"
|
|
11
11
|
},
|
|
12
12
|
"exports": {
|
|
13
13
|
".": {
|
|
14
14
|
"@effectify/source": "./src/index.ts",
|
|
15
|
-
"types": "./dist/index.d.ts",
|
|
16
|
-
"import": "./dist/index.js",
|
|
17
|
-
"default": "./dist/index.js"
|
|
15
|
+
"types": "./dist/src/index.d.ts",
|
|
16
|
+
"import": "./dist/src/index.js",
|
|
17
|
+
"default": "./dist/src/index.js"
|
|
18
18
|
}
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@effect/platform": "0.94.0",
|
|
26
26
|
"@effect/platform-node": "0.104.0",
|
|
27
|
-
"effect": "3.19.
|
|
27
|
+
"effect": "3.19.15",
|
|
28
28
|
"@remix-run/react": "2.17.2",
|
|
29
29
|
"@remix-run/node": "2.17.2"
|
|
30
30
|
},
|