ff-serv 0.1.3 → 0.1.4

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.
@@ -0,0 +1,110 @@
1
+ import { FetchHttpClient, FileSystem, HttpClient } from '@effect/platform';
2
+ import { describe, expect, expectTypeOf, it, layer } from '@effect/vitest';
3
+ import { Effect, Layer, type Scope } from 'effect';
4
+ import { serverTester } from './__test__/utils.ts';
5
+ import { basicHandler } from './basic.ts';
6
+ import { createFetchHandler, type Handler } from './fetch-handler.ts';
7
+
8
+ describe('type inferences', () => {
9
+ type DefaultRequirements = Scope.Scope;
10
+
11
+ it('no requirement', () => {
12
+ const handler = basicHandler('/dummy', () => new Response('ok'));
13
+ expectTypeOf(handler).toEqualTypeOf<Handler<'basicHandler', never>>();
14
+
15
+ expectTypeOf(createFetchHandler([handler])).toEqualTypeOf<
16
+ Effect.Effect<
17
+ (request: Request) => Promise<Response>,
18
+ never,
19
+ DefaultRequirements
20
+ >
21
+ >();
22
+ });
23
+
24
+ it('with requirement', () => {
25
+ const handler = basicHandler('/dummy', () =>
26
+ Effect.gen(function* () {
27
+ yield* HttpClient.HttpClient;
28
+ return new Response('ok');
29
+ }),
30
+ );
31
+ expectTypeOf(handler).toEqualTypeOf<
32
+ Handler<'basicHandler', HttpClient.HttpClient>
33
+ >();
34
+
35
+ expectTypeOf(createFetchHandler([handler])).toEqualTypeOf<
36
+ Effect.Effect<
37
+ (request: Request) => Promise<Response>,
38
+ never,
39
+ HttpClient.HttpClient | DefaultRequirements
40
+ >
41
+ >();
42
+ });
43
+
44
+ it('with requirements', () => {
45
+ expectTypeOf(
46
+ createFetchHandler([
47
+ basicHandler('/dummy', () =>
48
+ Effect.gen(function* () {
49
+ yield* HttpClient.HttpClient;
50
+ return new Response('ok');
51
+ }),
52
+ ),
53
+ basicHandler('/dummy', () =>
54
+ Effect.gen(function* () {
55
+ yield* FileSystem.FileSystem;
56
+ return new Response('ok');
57
+ }),
58
+ ),
59
+ ]),
60
+ ).toEqualTypeOf<
61
+ Effect.Effect<
62
+ (request: Request) => Promise<Response>,
63
+ never,
64
+ HttpClient.HttpClient | FileSystem.FileSystem | DefaultRequirements
65
+ >
66
+ >();
67
+ });
68
+ });
69
+
70
+ class Dummy extends Effect.Service<Dummy>()('dummy', {
71
+ sync: () => ({ message: 'ok-service' }),
72
+ }) {}
73
+
74
+ layer(Layer.mergeAll(FetchHttpClient.layer, Dummy.Default))((it) => {
75
+ it.effect('e2e', () =>
76
+ serverTester({
77
+ server: ({ port }) =>
78
+ Effect.gen(function* () {
79
+ return {
80
+ server: Bun.serve({
81
+ port: port,
82
+ fetch: yield* createFetchHandler([
83
+ basicHandler('/one', () => new Response('ok')),
84
+ basicHandler('/two', () =>
85
+ Effect.succeed(new Response('ok-effect')),
86
+ ),
87
+ basicHandler('/three', () =>
88
+ Effect.gen(function* () {
89
+ const svc = yield* Dummy;
90
+ return new Response(svc.message);
91
+ }),
92
+ ),
93
+ ]),
94
+ }),
95
+ };
96
+ }),
97
+ test: ({ server }) =>
98
+ Effect.gen(function* () {
99
+ const call = (path: string) =>
100
+ HttpClient.get(`http://localhost:${server.port}${path}`).pipe(
101
+ Effect.flatMap((e) => e.text),
102
+ );
103
+
104
+ expect(yield* call('/one')).toEqual('ok');
105
+ expect(yield* call('/two')).toEqual('ok-effect');
106
+ expect(yield* call('/three')).toEqual('ok-service');
107
+ }),
108
+ }),
109
+ );
110
+ });
@@ -0,0 +1,48 @@
1
+ import { Effect } from 'effect';
2
+ import { type AnyResponse, Handler } from './fetch-handler.js';
3
+
4
+ export namespace Path {
5
+ export type Type = `/${string}` | ((url: URL) => boolean);
6
+ export function matched(path: Type, url: URL) {
7
+ return typeof path === 'function' ? path(url) : path === url.pathname;
8
+ }
9
+ }
10
+
11
+ export namespace Fn {
12
+ type Input = [request: Request];
13
+
14
+ type OutputSync = AnyResponse;
15
+ type OutputEffect<R> = Effect.Effect<AnyResponse, unknown, R>;
16
+
17
+ export type FnSync = (...input: Input) => OutputSync;
18
+ export type FnEffect<R> = (...input: Input) => OutputEffect<R>;
19
+ export type FnAny<R> = (...input: Input) => OutputSync | OutputEffect<R>;
20
+
21
+ export function exec<R>(fn: FnAny<R>, ...[request]: Input) {
22
+ const response = fn(request);
23
+ if (!Effect.isEffect(response)) return Effect.succeed(response);
24
+ return response;
25
+ }
26
+ }
27
+
28
+ export function basicHandler(
29
+ path: Path.Type,
30
+ fn: Fn.FnSync,
31
+ ): Handler<'basicHandler', never>;
32
+ export function basicHandler<R>(
33
+ path: Path.Type,
34
+ fn: Fn.FnEffect<R>,
35
+ ): Handler<'basicHandler', R>;
36
+ export function basicHandler<R>(path: Path.Type, fn: Fn.FnAny<R>) {
37
+ return new Handler('basicHandler', ({ url, request }) => {
38
+ if (!Path.matched(path, url))
39
+ return Effect.succeed({ matched: false, response: undefined });
40
+
41
+ return Effect.gen(function* () {
42
+ return {
43
+ matched: true,
44
+ response: yield* Fn.exec(fn, request),
45
+ };
46
+ });
47
+ });
48
+ }
@@ -0,0 +1,78 @@
1
+ import { FetchHttpClient, HttpClient } from '@effect/platform';
2
+ import { expect, layer } from '@effect/vitest';
3
+ import { Cause, Data, Effect, Layer, Ref } from 'effect';
4
+ import { serverTester } from './__test__/utils.ts';
5
+ import { basicHandler } from './basic.ts';
6
+ import { createFetchHandler } from './fetch-handler.ts';
7
+
8
+ class AlsoError extends Error {}
9
+ class CustomError extends Data.TaggedError('CustomError') {}
10
+
11
+ layer(
12
+ Layer.mergeAll(
13
+ FetchHttpClient.layer,
14
+ // Logger.pretty
15
+ ),
16
+ )((it) => {
17
+ it.effect('e2e', () =>
18
+ serverTester({
19
+ server: ({ port }) =>
20
+ Effect.gen(function* () {
21
+ const errorsRef = yield* Ref.make<Array<Cause.Cause<unknown>>>([]);
22
+ return {
23
+ errors: errorsRef,
24
+ server: Bun.serve({
25
+ port: port,
26
+ fetch: yield* createFetchHandler(
27
+ [
28
+ basicHandler('/one', () => {
29
+ throw new AlsoError();
30
+ }),
31
+ basicHandler('/two', () =>
32
+ Effect.gen(function* () {
33
+ return yield* new CustomError();
34
+ }),
35
+ ),
36
+ ],
37
+ {
38
+ onError: ({ error }) =>
39
+ Effect.gen(function* () {
40
+ const errors = yield* Ref.get(errorsRef) ?? [];
41
+ errors.push(error);
42
+ yield* Ref.set(errorsRef, errors);
43
+ }),
44
+ },
45
+ ),
46
+ }),
47
+ };
48
+ }),
49
+ test: ({ errors, server }) =>
50
+ Effect.gen(function* () {
51
+ const call = (path: string) =>
52
+ HttpClient.get(`http://localhost:${server.port}${path}`).pipe(
53
+ Effect.flatMap((e) => e.text),
54
+ );
55
+
56
+ expect(yield* call('/one')).toEqual('Internal Server Error');
57
+ yield* Effect.gen(function* () {
58
+ const cause = (yield* Ref.get(errors))[0];
59
+ const isDie = cause._tag === 'Die';
60
+ expect(isDie, `Cause is ${cause._tag}`).toEqual(true);
61
+ if (isDie) {
62
+ expect(cause.defect).toBeInstanceOf(AlsoError);
63
+ }
64
+ });
65
+
66
+ expect(yield* call('/two')).toEqual('Internal Server Error');
67
+ yield* Effect.gen(function* () {
68
+ const cause = (yield* Ref.get(errors))[1];
69
+ const isFailType = Cause.isFailType(cause);
70
+ expect(isFailType, `Cause is ${cause._tag}`).toEqual(true);
71
+ if (isFailType) {
72
+ expect(cause.error).toBeInstanceOf(CustomError);
73
+ }
74
+ });
75
+ }),
76
+ }),
77
+ );
78
+ });
@@ -1,49 +1,53 @@
1
1
  import { Effect, FiberSet } from 'effect';
2
+ import type { Cause } from 'effect/Cause';
2
3
  import { nanoid } from 'nanoid';
3
4
  import { Logger } from '../logger.js';
4
5
 
6
+ // #region Handler
7
+
8
+ export type AnyResponse = Response | Promise<Response>;
9
+
5
10
  export type HandlerResult =
6
11
  | {
7
12
  matched: true;
8
- response: Response | Promise<Response>;
13
+ response: AnyResponse;
9
14
  }
10
15
  | {
11
16
  matched: false;
12
17
  response: undefined;
13
18
  };
14
19
 
15
- export type Handler<T extends string = string> = {
16
- _tag: T;
17
- handle: (opt: {
18
- url: URL;
19
- request: Request;
20
- }) => HandlerResult | Promise<HandlerResult>;
21
- };
20
+ export class Handler<NAME extends string, R> {
21
+ constructor(
22
+ readonly _tag: NAME,
23
+ readonly handle: (opt: {
24
+ url: URL;
25
+ request: Request;
26
+ }) => Effect.Effect<HandlerResult, unknown, R>,
27
+ ) {}
28
+ }
22
29
 
23
- export function basicHandler(
24
- path: string | ((url: URL) => boolean),
25
- handler: (request: Request) => Response | Promise<Response>,
26
- ): Handler<'basicHandler'> {
27
- return {
28
- _tag: 'basicHandler',
29
- handle: ({ url, request }) => {
30
- const matched =
31
- typeof path === 'function' ? path(url) : path === url.pathname;
32
- if (!matched) return { matched: false, response: undefined };
30
+ // #endregion
33
31
 
34
- return { matched: true, response: handler(request) };
35
- },
36
- };
37
- }
32
+ type ExtractRequirements<T> = T extends Handler<string, infer R> ? R : never;
38
33
 
39
- export const createFetchHandler = (
40
- handlers?: Handler | [Handler, ...Array<Handler>],
34
+ export const createFetchHandler = <
35
+ const HANDLERS extends [
36
+ Handler<string, unknown>,
37
+ ...Array<Handler<string, unknown>>,
38
+ ],
39
+ R = ExtractRequirements<HANDLERS[number]>,
40
+ >(
41
+ handlers: HANDLERS,
41
42
  opts?: {
42
43
  debug?: boolean;
44
+ onError?: (ctx: {
45
+ error: Cause<unknown>;
46
+ }) => Effect.Effect<unknown, unknown>;
43
47
  },
44
48
  ) =>
45
49
  Effect.gen(function* () {
46
- const runFork = yield* FiberSet.makeRuntimePromise();
50
+ const runFork = yield* FiberSet.makeRuntimePromise<R>();
47
51
  return async (request: Request) => {
48
52
  const urlObj = new URL(request.url);
49
53
  const requestId = nanoid(6);
@@ -54,14 +58,40 @@ export const createFetchHandler = (
54
58
  'Request started',
55
59
  );
56
60
 
57
- for (const handler of Array.isArray(handlers) ? handlers : [handlers]) {
61
+ for (const handler of handlers) {
58
62
  if (!handler) continue;
59
63
 
60
- const maybeResult = handler.handle({ url: urlObj, request });
61
- const result =
62
- maybeResult instanceof Promise
63
- ? yield* Effect.tryPromise(() => maybeResult)
64
- : maybeResult;
64
+ const result = yield* handler.handle({ url: urlObj, request }).pipe(
65
+ Effect.flatMap(({ matched, response }) =>
66
+ Effect.gen(function* () {
67
+ if (matched) {
68
+ return {
69
+ matched: true,
70
+ response:
71
+ response instanceof Promise
72
+ ? yield* Effect.tryPromise(() => response)
73
+ : response,
74
+ } as const;
75
+ }
76
+ return { matched: false, response: undefined } as const;
77
+ }),
78
+ ),
79
+ Effect.catchAllCause((error) =>
80
+ Effect.gen(function* () {
81
+ yield* Logger.error(
82
+ { error },
83
+ `Unhandled exception in HTTP handler '${handler._tag}'`,
84
+ );
85
+ if (opts?.onError) yield* opts.onError({ error });
86
+ return {
87
+ matched: true,
88
+ response: new Response('Internal Server Error', {
89
+ status: 500,
90
+ }),
91
+ } as const;
92
+ }),
93
+ ),
94
+ );
65
95
 
66
96
  if (opts?.debug)
67
97
  yield* Logger.debug(
@@ -75,32 +105,16 @@ export const createFetchHandler = (
75
105
 
76
106
  return new Response('Not Found', { status: 404 });
77
107
  }).pipe(
78
- Effect.flatMap((response) =>
79
- response instanceof Promise
80
- ? Effect.tryPromise(() => response)
81
- : Effect.succeed(response),
82
- ),
83
108
  Effect.tap((response) =>
84
109
  response.ok
85
110
  ? Logger.info(`Request completed with status ${response.status}`)
86
111
  : Logger.warn(`Request completed with status ${response.status}`),
87
112
  ),
88
- Effect.catchAll((error) =>
89
- Effect.gen(function* () {
90
- yield* Logger.error(
91
- { error },
92
- 'Unhandled exception in HTTP handler',
93
- );
94
- return new Response('Internal Server Error', {
95
- status: 500,
96
- });
97
- }),
98
- ),
99
113
  Effect.withSpan('http'),
100
114
  Effect.annotateLogs({ requestId }),
101
115
  Effect.scoped,
102
- );
116
+ ) as Effect.Effect<Response, never, R>;
103
117
 
104
118
  return runFork(effect);
105
119
  };
106
- });
120
+ });
package/src/http/index.ts CHANGED
@@ -1 +1,2 @@
1
- export * from './fetch-handler.js';
1
+ export { basicHandler } from './basic.js';
2
+ export { createFetchHandler } from './fetch-handler.js';
@@ -0,0 +1,43 @@
1
+ import { expect, it } from '@effect/vitest';
2
+ import { createORPCClient } from '@orpc/client';
3
+ import { RPCLink } from '@orpc/client/fetch';
4
+ import { os, type RouterClient } from '@orpc/server';
5
+ import { RPCHandler } from '@orpc/server/fetch';
6
+ import { Effect } from 'effect';
7
+ import { UnknownException } from 'effect/Cause';
8
+ import { wrapClient } from 'ff-effect';
9
+ import { serverTester } from './__test__/utils.ts';
10
+ import { createFetchHandler } from './fetch-handler.ts';
11
+ import { oRPCHandler } from './orpc.ts';
12
+
13
+ it.effect('e2e', () =>
14
+ serverTester({
15
+ server: ({ port }) =>
16
+ Effect.gen(function* () {
17
+ const router = {
18
+ health: os.handler(() => 'ok'),
19
+ };
20
+ const handler = new RPCHandler(router);
21
+
22
+ return {
23
+ router,
24
+ server: Bun.serve({
25
+ port: port,
26
+ fetch: yield* createFetchHandler([oRPCHandler(handler)]),
27
+ }),
28
+ };
29
+ }),
30
+ test: ({ server, router }) =>
31
+ Effect.gen(function* () {
32
+ const orpcClient: RouterClient<typeof router> = createORPCClient(
33
+ new RPCLink({ url: `http://localhost:${server.port}` }),
34
+ );
35
+ const call = wrapClient({
36
+ client: orpcClient,
37
+ error: ({ cause }) => new UnknownException(cause),
38
+ });
39
+
40
+ expect(yield* call((client) => client.health())).toEqual('ok');
41
+ }),
42
+ }),
43
+ );
package/src/http/orpc.ts CHANGED
@@ -1,18 +1,26 @@
1
1
  import type { Context } from '@orpc/server';
2
2
  import type { FetchHandler } from '@orpc/server/fetch';
3
3
  import type { FriendlyStandardHandleOptions } from '@orpc/server/standard';
4
- import type { Handler } from './fetch-handler.js';
4
+ import { Effect } from 'effect';
5
+ import { Handler } from './fetch-handler.js';
5
6
 
6
- type MaybeOptionalOptions<TOptions> = Record<never, never> extends TOptions
7
- ? [options?: TOptions]
8
- : [options: TOptions];
7
+ type MaybeOptionalOptions<TOptions> =
8
+ Record<never, never> extends TOptions
9
+ ? [options?: TOptions]
10
+ : [options: TOptions];
9
11
 
10
- export function oRPCHandler<T extends Context>(
12
+ export function oRPCHandler<T extends Context, E, R>(
11
13
  handler: FetchHandler<T>,
12
- ...rest: MaybeOptionalOptions<FriendlyStandardHandleOptions<T>>
13
- ): Handler<'oRPCHandler'> {
14
- return {
15
- _tag: 'oRPCHandler',
16
- handle: async ({ request }) => handler.handle(request, ...rest),
17
- };
14
+ opt?:
15
+ | FriendlyStandardHandleOptions<T>
16
+ | Effect.Effect<FriendlyStandardHandleOptions<T>, E, R>,
17
+ ) {
18
+ return new Handler('oRPCHandler', ({ request }) =>
19
+ Effect.gen(function* () {
20
+ const _opt = (
21
+ opt ? [Effect.isEffect(opt) ? yield* opt : opt] : []
22
+ ) as MaybeOptionalOptions<FriendlyStandardHandleOptions<T>>;
23
+ return yield* Effect.tryPromise(() => handler.handle(request, ..._opt));
24
+ }),
25
+ );
18
26
  }
package/src/index.ts CHANGED
@@ -1,3 +1,3 @@
1
- export { basicHandler, createFetchHandler } from './http/index.js';
1
+ export * from './http/index.js';
2
2
  export { Logger } from './logger.js';
3
- export { getPort } from './port.js';
3
+ export { getPort } from './port.js';
@@ -1,23 +0,0 @@
1
- import * as effect_Scope from 'effect/Scope';
2
- import { Effect } from 'effect';
3
-
4
- type HandlerResult = {
5
- matched: true;
6
- response: Response | Promise<Response>;
7
- } | {
8
- matched: false;
9
- response: undefined;
10
- };
11
- type Handler<T extends string = string> = {
12
- _tag: T;
13
- handle: (opt: {
14
- url: URL;
15
- request: Request;
16
- }) => HandlerResult | Promise<HandlerResult>;
17
- };
18
- declare function basicHandler(path: string | ((url: URL) => boolean), handler: (request: Request) => Response | Promise<Response>): Handler<'basicHandler'>;
19
- declare const createFetchHandler: (handlers?: Handler | [Handler, ...Array<Handler>], opts?: {
20
- debug?: boolean;
21
- }) => Effect.Effect<(request: Request) => Promise<Response>, never, effect_Scope.Scope>;
22
-
23
- export { type Handler as H, basicHandler as b, createFetchHandler as c };
@@ -1,23 +0,0 @@
1
- import * as effect_Scope from 'effect/Scope';
2
- import { Effect } from 'effect';
3
-
4
- type HandlerResult = {
5
- matched: true;
6
- response: Response | Promise<Response>;
7
- } | {
8
- matched: false;
9
- response: undefined;
10
- };
11
- type Handler<T extends string = string> = {
12
- _tag: T;
13
- handle: (opt: {
14
- url: URL;
15
- request: Request;
16
- }) => HandlerResult | Promise<HandlerResult>;
17
- };
18
- declare function basicHandler(path: string | ((url: URL) => boolean), handler: (request: Request) => Response | Promise<Response>): Handler<'basicHandler'>;
19
- declare const createFetchHandler: (handlers?: Handler | [Handler, ...Array<Handler>], opts?: {
20
- debug?: boolean;
21
- }) => Effect.Effect<(request: Request) => Promise<Response>, never, effect_Scope.Scope>;
22
-
23
- export { type Handler as H, basicHandler as b, createFetchHandler as c };