ff-serv 0.1.2 → 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
+ });
@@ -0,0 +1,120 @@
1
+ import { Effect, FiberSet } from 'effect';
2
+ import type { Cause } from 'effect/Cause';
3
+ import { nanoid } from 'nanoid';
4
+ import { Logger } from '../logger.js';
5
+
6
+ // #region Handler
7
+
8
+ export type AnyResponse = Response | Promise<Response>;
9
+
10
+ export type HandlerResult =
11
+ | {
12
+ matched: true;
13
+ response: AnyResponse;
14
+ }
15
+ | {
16
+ matched: false;
17
+ response: undefined;
18
+ };
19
+
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
+ }
29
+
30
+ // #endregion
31
+
32
+ type ExtractRequirements<T> = T extends Handler<string, infer R> ? R : never;
33
+
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,
42
+ opts?: {
43
+ debug?: boolean;
44
+ onError?: (ctx: {
45
+ error: Cause<unknown>;
46
+ }) => Effect.Effect<unknown, unknown>;
47
+ },
48
+ ) =>
49
+ Effect.gen(function* () {
50
+ const runFork = yield* FiberSet.makeRuntimePromise<R>();
51
+ return async (request: Request) => {
52
+ const urlObj = new URL(request.url);
53
+ const requestId = nanoid(6);
54
+
55
+ const effect = Effect.gen(function* () {
56
+ yield* Logger.info(
57
+ { request: { pathname: urlObj.pathname } },
58
+ 'Request started',
59
+ );
60
+
61
+ for (const handler of handlers) {
62
+ if (!handler) continue;
63
+
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
+ );
95
+
96
+ if (opts?.debug)
97
+ yield* Logger.debug(
98
+ { handler: handler._tag, request, result },
99
+ 'Processed handler',
100
+ );
101
+
102
+ if (!result.matched) continue;
103
+ return result.response;
104
+ }
105
+
106
+ return new Response('Not Found', { status: 404 });
107
+ }).pipe(
108
+ Effect.tap((response) =>
109
+ response.ok
110
+ ? Logger.info(`Request completed with status ${response.status}`)
111
+ : Logger.warn(`Request completed with status ${response.status}`),
112
+ ),
113
+ Effect.withSpan('http'),
114
+ Effect.annotateLogs({ requestId }),
115
+ Effect.scoped,
116
+ ) as Effect.Effect<Response, never, R>;
117
+
118
+ return runFork(effect);
119
+ };
120
+ });
@@ -0,0 +1,2 @@
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
+ );
@@ -0,0 +1,26 @@
1
+ import type { Context } from '@orpc/server';
2
+ import type { FetchHandler } from '@orpc/server/fetch';
3
+ import type { FriendlyStandardHandleOptions } from '@orpc/server/standard';
4
+ import { Effect } from 'effect';
5
+ import { Handler } from './fetch-handler.js';
6
+
7
+ type MaybeOptionalOptions<TOptions> =
8
+ Record<never, never> extends TOptions
9
+ ? [options?: TOptions]
10
+ : [options: TOptions];
11
+
12
+ export function oRPCHandler<T extends Context, E, R>(
13
+ handler: FetchHandler<T>,
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
+ );
26
+ }
package/src/index.ts CHANGED
@@ -1 +1,3 @@
1
+ export * from './http/index.js';
1
2
  export { Logger } from './logger.js';
3
+ export { getPort } from './port.js';
package/src/port.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { Effect } from 'effect';
2
+ import * as GetPort from 'get-port';
3
+
4
+ export const getPort = (options?: GetPort.Options) =>
5
+ Effect.tryPromise(() => GetPort.default(options));