@shivaedev/platform 0.6.1 → 0.8.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/CHANGELOG.md +30 -0
- package/README.md +21 -11
- package/dist/rpc/middleware.d.ts +1 -1
- package/dist/rpc/middleware.d.ts.map +1 -1
- package/dist/rpc/middleware.js +1 -1
- package/dist/rpc/middleware.js.map +1 -1
- package/dist/server/appHost.d.ts +27 -22
- package/dist/server/appHost.d.ts.map +1 -1
- package/dist/server/appHost.js +7 -48
- package/dist/server/appHost.js.map +1 -1
- package/dist/server/appHostHttp.d.ts +11 -0
- package/dist/server/appHostHttp.d.ts.map +1 -0
- package/dist/server/appHostHttp.js +51 -0
- package/dist/server/appHostHttp.js.map +1 -0
- package/dist/server/better-auth/rowAdapter.js +1 -1
- package/dist/server/better-auth/rowAdapter.js.map +1 -1
- package/dist/server/boundedRpcRequest.d.ts +3 -0
- package/dist/server/boundedRpcRequest.d.ts.map +1 -0
- package/dist/server/boundedRpcRequest.js +41 -0
- package/dist/server/boundedRpcRequest.js.map +1 -0
- package/dist/server/databaseLayer.d.ts +6 -0
- package/dist/server/databaseLayer.d.ts.map +1 -0
- package/dist/server/databaseLayer.js +29 -0
- package/dist/server/databaseLayer.js.map +1 -0
- package/dist/server/hostOptions.d.ts +28 -0
- package/dist/server/hostOptions.d.ts.map +1 -0
- package/dist/server/hostOptions.js +2 -0
- package/dist/server/hostOptions.js.map +1 -0
- package/dist/server/rpc-server/local.d.ts +3 -3
- package/dist/server/rpc-server/local.d.ts.map +1 -1
- package/dist/server/rpc-server/local.js +16 -12
- package/dist/server/rpc-server/local.js.map +1 -1
- package/dist/server/rpc-server/origin.d.ts +1 -1
- package/dist/server/rpc-server/origin.d.ts.map +1 -1
- package/dist/server/rpc-server/origin.js +1 -1
- package/dist/server/rpc-server/origin.js.map +1 -1
- package/dist/server/rpc-server/session.d.ts +1 -1
- package/dist/server/rpc-server/session.d.ts.map +1 -1
- package/dist/server/rpc-server/tracing.js +1 -1
- package/dist/server/rpc-server/tracing.js.map +1 -1
- package/dist/server/rpc-server/transportHeaders.d.ts +1 -1
- package/dist/server/rpc-server/transportHeaders.d.ts.map +1 -1
- package/dist/server/rpc-server/transportHeaders.js +1 -1
- package/dist/server/rpc-server/transportHeaders.js.map +1 -1
- package/dist/server/rpc-server/trustedProxy.d.ts +6 -0
- package/dist/server/rpc-server/trustedProxy.d.ts.map +1 -0
- package/dist/server/rpc-server/trustedProxy.js +35 -0
- package/dist/server/rpc-server/trustedProxy.js.map +1 -0
- package/dist/server/runtime/makePlatform.js +1 -1
- package/docs/roadmap.md +1 -1
- package/package.json +29 -29
- package/src/rpc/middleware.ts +1 -1
- package/src/server/appHost.ts +90 -94
- package/src/server/appHostHttp.ts +101 -0
- package/src/server/better-auth/rowAdapter.ts +1 -1
- package/src/server/boundedRpcRequest.ts +47 -0
- package/src/server/databaseLayer.ts +36 -0
- package/src/server/hostOptions.ts +25 -0
- package/src/server/rpc-server/adapters/betterAuthSessions.ts +1 -1
- package/src/server/rpc-server/local.ts +18 -16
- package/src/server/rpc-server/origin.ts +1 -1
- package/src/server/rpc-server/session.ts +1 -1
- package/src/server/rpc-server/tracing.ts +1 -1
- package/src/server/rpc-server/transportHeaders.ts +1 -1
- package/src/server/rpc-server/trustedProxy.ts +36 -0
- package/src/server/runtime/makePlatform.ts +1 -1
package/src/server/appHost.ts
CHANGED
|
@@ -1,23 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
8
|
-
import
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
import { type PostgresChangesOptions, postgresChanges } from "@shivaedev/effect-sql/postgresChanges.ts";
|
|
12
|
-
import type { Authenticated, MaybeAuthenticated, RequestTracing } from "#rpc/middleware.ts";
|
|
13
|
-
import { localRpcGuard } from "#rpc-server/local.ts";
|
|
14
|
-
import { authenticatedLayer, maybeAuthenticatedLayer, type SessionPolicy, type SessionShape } from "#rpc-server/session.ts";
|
|
15
|
-
import { requestTracingLayer } from "#rpc-server/tracing.ts";
|
|
1
|
+
import { Context, type Duration, type Effect, type Redacted } from "effect";
|
|
2
|
+
import type { Rpc } from "effect/rpc";
|
|
3
|
+
import type { Migrator } from "effect/sql";
|
|
4
|
+
import type { makeLiveServer } from "@shivaedev/effect-contract/makeLiveServer.ts";
|
|
5
|
+
import type { PostgresChangesOptions } from "@shivaedev/effect-sql/postgresChanges.ts";
|
|
6
|
+
import type { SessionPolicy, SessionShape } from "#rpc-server/session.ts";
|
|
7
|
+
import { appHostHttp, migratedHost } from "./appHostHttp.ts";
|
|
8
|
+
import { databaseLayer } from "./databaseLayer.ts";
|
|
9
|
+
import type { HostOptions, HostPolicy } from "./hostOptions.ts";
|
|
10
|
+
|
|
16
11
|
export class AppChanges extends Context.Service<AppChanges, Effect.Success<typeof makeLiveServer>>()("@shivaedev/platform/AppChanges") {}
|
|
17
12
|
export interface AppDatabaseOptions<R = never> {
|
|
18
13
|
readonly config?: {
|
|
19
14
|
readonly databaseUrl?: Redacted.Redacted<string>;
|
|
20
15
|
readonly applicationName?: string;
|
|
16
|
+
readonly maxConnections?: number;
|
|
21
17
|
readonly changes?: Omit<PostgresChangesOptions, "client" | "resync">;
|
|
22
18
|
};
|
|
23
19
|
readonly migrations: {
|
|
@@ -26,33 +22,11 @@ export interface AppDatabaseOptions<R = never> {
|
|
|
26
22
|
readonly lockTimeout?: Duration.Input;
|
|
27
23
|
};
|
|
28
24
|
}
|
|
25
|
+
|
|
29
26
|
export function appDatabase<R>(options: AppDatabaseOptions<R>) {
|
|
30
|
-
|
|
31
|
-
Effect.gen(function* () {
|
|
32
|
-
const url = options.config?.databaseUrl ?? (yield* Config.redacted("DATABASE_URL"));
|
|
33
|
-
return PgClient.layer({
|
|
34
|
-
url,
|
|
35
|
-
...(options.config?.applicationName === undefined ? {} : { applicationName: options.config.applicationName }),
|
|
36
|
-
});
|
|
37
|
-
}),
|
|
38
|
-
);
|
|
39
|
-
const migrated = Layer.effectDiscard(migratePostgres({ ...options.migrations, lockTimeout: options.migrations.lockTimeout ?? "5 seconds" })).pipe(
|
|
40
|
-
Layer.provideMerge(pool),
|
|
41
|
-
);
|
|
42
|
-
const live = Layer.effect(AppChanges, makeLiveServer).pipe(Layer.provideMerge(Reactivity.layer));
|
|
43
|
-
const base = Layer.merge(migrated, live);
|
|
44
|
-
const changes = options.config?.changes;
|
|
45
|
-
if (changes === undefined) {
|
|
46
|
-
return base;
|
|
47
|
-
}
|
|
48
|
-
return Layer.unwrap(
|
|
49
|
-
Effect.gen(function* () {
|
|
50
|
-
const client = yield* PgClient.PgClient;
|
|
51
|
-
const hints = yield* AppChanges;
|
|
52
|
-
return postgresChanges({ ...changes, client, resync: hints.resync });
|
|
53
|
-
}),
|
|
54
|
-
).pipe(Layer.provideMerge(base));
|
|
27
|
+
return databaseLayer(options, AppChanges);
|
|
55
28
|
}
|
|
29
|
+
|
|
56
30
|
export interface AppHostOptions<
|
|
57
31
|
TRpcs extends Rpc.Any,
|
|
58
32
|
THandlers,
|
|
@@ -63,18 +37,77 @@ export interface AppHostOptions<
|
|
|
63
37
|
TServiceRequirements,
|
|
64
38
|
TMigrations,
|
|
65
39
|
TSession extends SessionShape,
|
|
66
|
-
> extends
|
|
67
|
-
readonly
|
|
68
|
-
readonly
|
|
69
|
-
readonly port?: number;
|
|
70
|
-
readonly host?: string;
|
|
71
|
-
readonly trustedProxy?: string;
|
|
72
|
-
};
|
|
73
|
-
readonly group: RpcGroup.RpcGroup<TRpcs>;
|
|
74
|
-
readonly handlers: Layer.Layer<THandlers, THandlerError, THandlerRequirements>;
|
|
75
|
-
readonly services: Layer.Layer<TServices, TServiceError, TServiceRequirements>;
|
|
40
|
+
> extends HostOptions<TRpcs, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements> {
|
|
41
|
+
readonly migrations: AppDatabaseOptions<TMigrations>["migrations"];
|
|
42
|
+
readonly policy?: undefined;
|
|
76
43
|
readonly sessions: SessionPolicy<TSession>;
|
|
77
44
|
}
|
|
45
|
+
|
|
46
|
+
export type AppLocalHostOptions<
|
|
47
|
+
TRpcs extends Rpc.Any,
|
|
48
|
+
THandlers,
|
|
49
|
+
THandlerError,
|
|
50
|
+
THandlerRequirements,
|
|
51
|
+
TServices,
|
|
52
|
+
TServiceError,
|
|
53
|
+
TServiceRequirements,
|
|
54
|
+
TMigrations,
|
|
55
|
+
> = HostOptions<TRpcs, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements> & {
|
|
56
|
+
readonly migrations: AppDatabaseOptions<TMigrations>["migrations"];
|
|
57
|
+
readonly policy: "loopback";
|
|
58
|
+
readonly sessions?: never;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type AppHttpHostOptions<
|
|
62
|
+
TRpcs extends Rpc.Any,
|
|
63
|
+
THandlers,
|
|
64
|
+
THandlerError,
|
|
65
|
+
THandlerRequirements,
|
|
66
|
+
TServices,
|
|
67
|
+
TServiceError,
|
|
68
|
+
TServiceRequirements,
|
|
69
|
+
TSession extends SessionShape,
|
|
70
|
+
> = HostOptions<TRpcs, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements> &
|
|
71
|
+
HostPolicy<TSession> & { readonly migrations?: undefined };
|
|
72
|
+
|
|
73
|
+
export function appHost<
|
|
74
|
+
TRpcs extends Rpc.Any,
|
|
75
|
+
THandlers,
|
|
76
|
+
THandlerError,
|
|
77
|
+
THandlerRequirements,
|
|
78
|
+
TServices,
|
|
79
|
+
TServiceError,
|
|
80
|
+
TServiceRequirements,
|
|
81
|
+
TMigrations,
|
|
82
|
+
TSession extends SessionShape,
|
|
83
|
+
>(
|
|
84
|
+
options:
|
|
85
|
+
| AppHostOptions<TRpcs, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements, TMigrations, TSession>
|
|
86
|
+
| AppLocalHostOptions<TRpcs, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements, TMigrations>,
|
|
87
|
+
): ReturnType<
|
|
88
|
+
typeof migratedHost<TRpcs, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements, TMigrations, TSession>
|
|
89
|
+
>;
|
|
90
|
+
export function appHost<TRpcs extends Rpc.Any, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements>(
|
|
91
|
+
options: HostOptions<TRpcs, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements> & {
|
|
92
|
+
readonly policy: "loopback";
|
|
93
|
+
readonly sessions?: never;
|
|
94
|
+
readonly migrations?: undefined;
|
|
95
|
+
},
|
|
96
|
+
): ReturnType<
|
|
97
|
+
typeof appHostHttp<TRpcs, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements, SessionShape>
|
|
98
|
+
>;
|
|
99
|
+
export function appHost<
|
|
100
|
+
TRpcs extends Rpc.Any,
|
|
101
|
+
THandlers,
|
|
102
|
+
THandlerError,
|
|
103
|
+
THandlerRequirements,
|
|
104
|
+
TServices,
|
|
105
|
+
TServiceError,
|
|
106
|
+
TServiceRequirements,
|
|
107
|
+
TSession extends SessionShape,
|
|
108
|
+
>(
|
|
109
|
+
options: AppHttpHostOptions<TRpcs, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements, TSession>,
|
|
110
|
+
): ReturnType<typeof appHostHttp<TRpcs, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements, TSession>>;
|
|
78
111
|
export function appHost<
|
|
79
112
|
TRpcs extends Rpc.Any,
|
|
80
113
|
THandlers,
|
|
@@ -86,49 +119,12 @@ export function appHost<
|
|
|
86
119
|
TMigrations,
|
|
87
120
|
TSession extends SessionShape,
|
|
88
121
|
>(
|
|
89
|
-
options:
|
|
90
|
-
TRpcs,
|
|
91
|
-
THandlers,
|
|
92
|
-
THandlerError,
|
|
93
|
-
THandlerRequirements,
|
|
94
|
-
TServices,
|
|
95
|
-
TServiceError,
|
|
96
|
-
TServiceRequirements,
|
|
97
|
-
TMigrations,
|
|
98
|
-
TSession
|
|
99
|
-
>,
|
|
122
|
+
options:
|
|
123
|
+
| AppHostOptions<TRpcs, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements, TMigrations, TSession>
|
|
124
|
+
| AppLocalHostOptions<TRpcs, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements, TMigrations>
|
|
125
|
+
| AppHttpHostOptions<TRpcs, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements, TSession>,
|
|
100
126
|
) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
maybeAuthenticatedLayer(options.sessions),
|
|
105
|
-
requestTracingLayer(),
|
|
106
|
-
);
|
|
107
|
-
const routes = HttpRouter.use((router) =>
|
|
108
|
-
Effect.gen(function* () {
|
|
109
|
-
const trustedProxy = options.config?.trustedProxy ?? Option.getOrUndefined(yield* Config.string("TRUSTED_PROXY").pipe(Config.option));
|
|
110
|
-
const wire = yield* RpcSerialization.RpcSerialization;
|
|
111
|
-
const handler = yield* RpcServer.toHttpEffect(options.group);
|
|
112
|
-
yield* router.add("POST", "/rpc", (request) =>
|
|
113
|
-
localRpcGuard(handler, trustedProxy).pipe(
|
|
114
|
-
Effect.provideService(HttpServerRequest.HttpServerRequest, request),
|
|
115
|
-
Effect.provideService(RpcSerialization.RpcSerialization, wire),
|
|
116
|
-
),
|
|
117
|
-
);
|
|
118
|
-
}),
|
|
119
|
-
).pipe(Layer.provide(Layer.merge(options.handlers, middleware)), Layer.provide(RpcSerialization.layerNdjson));
|
|
120
|
-
const assets = options.assets === undefined ? Layer.empty : HttpStaticServer.layer({ root: options.assets });
|
|
121
|
-
const application = options.services.pipe(Layer.provideMerge(appDatabase(options)));
|
|
122
|
-
const server = Layer.unwrap(
|
|
123
|
-
Effect.gen(function* () {
|
|
124
|
-
const port = options.config?.port ?? (yield* Config.int("PORT").pipe(Config.withDefault(3000)));
|
|
125
|
-
const host = options.config?.host ?? (yield* Config.string("HOST").pipe(Config.withDefault("127.0.0.1")));
|
|
126
|
-
return NodeHttpServer.layer(createServer, { host, port });
|
|
127
|
-
}),
|
|
128
|
-
);
|
|
129
|
-
return HttpRouter.serve(Layer.merge(routes, assets), { disableLogger: true }).pipe(
|
|
130
|
-
Layer.provideMerge(application),
|
|
131
|
-
Layer.provideMerge(server),
|
|
132
|
-
Layer.provide(NodeServices.layer),
|
|
133
|
-
);
|
|
127
|
+
return options.migrations === undefined
|
|
128
|
+
? appHostHttp(options)
|
|
129
|
+
: migratedHost({ ...options, migrations: options.migrations }, appDatabase({ ...options, migrations: options.migrations }));
|
|
134
130
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { NodeHttpServer, NodeServices } from "@effect/platform-node";
|
|
3
|
+
import { ByteSize, Config, Effect, Layer, Option, Schema } from "effect";
|
|
4
|
+
import { HttpIncomingMessage, HttpRouter, HttpServerRequest, HttpStaticServer } from "effect/http";
|
|
5
|
+
import { type Rpc, RpcSerialization, RpcServer } from "effect/rpc";
|
|
6
|
+
import { localRpcGuard } from "#rpc-server/local.ts";
|
|
7
|
+
import { authenticatedLayer, maybeAuthenticatedLayer, type SessionShape } from "#rpc-server/session.ts";
|
|
8
|
+
import { requestTracingLayer } from "#rpc-server/tracing.ts";
|
|
9
|
+
import { TrustedProxy } from "#rpc-server/trustedProxy.ts";
|
|
10
|
+
import type { AppHostOptions, AppLocalHostOptions } from "./appHost.ts";
|
|
11
|
+
import { boundedRpcRequest } from "./boundedRpcRequest.ts";
|
|
12
|
+
import type { databaseLayer } from "./databaseLayer.ts";
|
|
13
|
+
import type { HostOptions, HostPolicy } from "./hostOptions.ts";
|
|
14
|
+
export function appHostHttp<
|
|
15
|
+
TRpcs extends Rpc.Any,
|
|
16
|
+
THandlers,
|
|
17
|
+
THandlerError,
|
|
18
|
+
THandlerRequirements,
|
|
19
|
+
TServices,
|
|
20
|
+
TServiceError,
|
|
21
|
+
TServiceRequirements,
|
|
22
|
+
TSession extends SessionShape,
|
|
23
|
+
>(
|
|
24
|
+
options: HostOptions<TRpcs, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements> & HostPolicy<TSession>,
|
|
25
|
+
) {
|
|
26
|
+
return Layer.unwrap(
|
|
27
|
+
Effect.gen(function* () {
|
|
28
|
+
const maxBodySize = options.config?.maxBodySize ?? (yield* Config.Int("MAX_BODY_SIZE").pipe(Config.withDefault(1024 * 1024)));
|
|
29
|
+
const bodySize = yield* Schema.decodeUnknownEffect(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)))(maxBodySize).pipe(
|
|
30
|
+
Effect.mapError((cause) => new Config.ConfigError(cause)),
|
|
31
|
+
);
|
|
32
|
+
const trustedProxy =
|
|
33
|
+
options.policy === "loopback"
|
|
34
|
+
? undefined
|
|
35
|
+
: (options.config?.trustedProxy ?? Option.getOrUndefined(yield* Config.String("TRUSTED_PROXY").pipe(Config.option)));
|
|
36
|
+
if (trustedProxy !== undefined) {
|
|
37
|
+
yield* Schema.decodeUnknownEffect(TrustedProxy)(trustedProxy).pipe(Effect.mapError((cause) => new Config.ConfigError(cause)));
|
|
38
|
+
}
|
|
39
|
+
const middleware =
|
|
40
|
+
options.policy === "loopback"
|
|
41
|
+
? requestTracingLayer()
|
|
42
|
+
: Layer.mergeAll(authenticatedLayer(options.sessions), maybeAuthenticatedLayer(options.sessions), requestTracingLayer());
|
|
43
|
+
const routes = HttpRouter.use((router) =>
|
|
44
|
+
Effect.gen(function* () {
|
|
45
|
+
const wire = yield* RpcSerialization.RpcSerialization;
|
|
46
|
+
const handler = yield* RpcServer.toHttpEffect(options.group);
|
|
47
|
+
const guarded = localRpcGuard(handler, trustedProxy);
|
|
48
|
+
yield* router.add("POST", options.rpcPath ?? "/rpc", (request) =>
|
|
49
|
+
guarded.pipe(
|
|
50
|
+
Effect.provideService(HttpIncomingMessage.MaxBodySize, ByteSize.bytes(bodySize)),
|
|
51
|
+
Effect.provideService(HttpServerRequest.HttpServerRequest, boundedRpcRequest(request)),
|
|
52
|
+
Effect.provideService(RpcSerialization.RpcSerialization, wire),
|
|
53
|
+
),
|
|
54
|
+
);
|
|
55
|
+
}),
|
|
56
|
+
).pipe(Layer.provide(Layer.merge(options.handlers, middleware)), Layer.provide(RpcSerialization.layerNdjson));
|
|
57
|
+
const assets =
|
|
58
|
+
options.assets === undefined
|
|
59
|
+
? Layer.empty
|
|
60
|
+
: HttpStaticServer.layer(typeof options.assets === "string" ? { root: options.assets } : options.assets);
|
|
61
|
+
const application = options.services;
|
|
62
|
+
const server = Layer.unwrap(
|
|
63
|
+
Effect.gen(function* () {
|
|
64
|
+
const port = options.config?.port ?? (yield* Config.Int("PORT").pipe(Config.withDefault(3000)));
|
|
65
|
+
const host = options.config?.host ?? (yield* Config.String("HOST").pipe(Config.withDefault("127.0.0.1")));
|
|
66
|
+
return NodeHttpServer.layer(createServer, {
|
|
67
|
+
host,
|
|
68
|
+
port,
|
|
69
|
+
...(options.config?.disablePreemptiveShutdown === undefined
|
|
70
|
+
? {}
|
|
71
|
+
: { disablePreemptiveShutdown: options.config.disablePreemptiveShutdown }),
|
|
72
|
+
});
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
return HttpRouter.serve(Layer.mergeAll(routes, assets, options.routes ?? Layer.empty), { disableLogger: true }).pipe(
|
|
76
|
+
Layer.provideMerge(application),
|
|
77
|
+
Layer.provideMerge(server),
|
|
78
|
+
Layer.provide(NodeServices.layer),
|
|
79
|
+
);
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function migratedHost<
|
|
85
|
+
TRpcs extends Rpc.Any,
|
|
86
|
+
THandlers,
|
|
87
|
+
THandlerError,
|
|
88
|
+
THandlerRequirements,
|
|
89
|
+
TServices,
|
|
90
|
+
TServiceError,
|
|
91
|
+
TServiceRequirements,
|
|
92
|
+
TMigrations,
|
|
93
|
+
TSession extends SessionShape,
|
|
94
|
+
>(
|
|
95
|
+
options:
|
|
96
|
+
| AppHostOptions<TRpcs, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements, TMigrations, TSession>
|
|
97
|
+
| AppLocalHostOptions<TRpcs, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements, TMigrations>,
|
|
98
|
+
database: ReturnType<typeof databaseLayer<TMigrations>>,
|
|
99
|
+
) {
|
|
100
|
+
return appHostHttp({ ...options, services: options.services.pipe(Layer.provideMerge(database)) });
|
|
101
|
+
}
|
|
@@ -27,7 +27,7 @@ function rowTyped(adapter: unknown): unknown {
|
|
|
27
27
|
}
|
|
28
28
|
function rejectJoin(join: unknown): void {
|
|
29
29
|
if (join !== undefined) {
|
|
30
|
-
throw new TypeError("The Effect Prisma Better Auth adapter does not support
|
|
30
|
+
throw new TypeError("The Effect Prisma Better Auth adapter does not support native joins");
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
function namesId(condition: CleanedWhere): boolean {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { NodeHttpServerRequest, NodeStream } from "@effect/platform-node";
|
|
2
|
+
import { Effect, Stream } from "effect";
|
|
3
|
+
import { HttpIncomingMessage, HttpServerError, type HttpServerRequest } from "effect/http";
|
|
4
|
+
|
|
5
|
+
export function boundedRpcRequest(request: HttpServerRequest.HttpServerRequest): HttpServerRequest.HttpServerRequest {
|
|
6
|
+
const incoming = NodeHttpServerRequest.toIncomingMessage(request);
|
|
7
|
+
function error(cause: unknown) {
|
|
8
|
+
return new HttpServerError.HttpServerError({ reason: new HttpServerError.RequestParseError({ cause, request }) });
|
|
9
|
+
}
|
|
10
|
+
const text = Effect.gen(function* () {
|
|
11
|
+
const limit = yield* HttpIncomingMessage.MaxBodySize;
|
|
12
|
+
const chunks: Uint8Array[] = [];
|
|
13
|
+
let size = 0n;
|
|
14
|
+
yield* NodeStream.fromReadable<Uint8Array, HttpServerError.HttpServerError>({
|
|
15
|
+
closeOnDone: false,
|
|
16
|
+
evaluate: () => incoming,
|
|
17
|
+
onError: error,
|
|
18
|
+
}).pipe(
|
|
19
|
+
Stream.runForEach((chunk) => {
|
|
20
|
+
size += BigInt(chunk.byteLength);
|
|
21
|
+
if (limit !== undefined && size > limit) {
|
|
22
|
+
return Effect.fail(error(new Error("RPC body exceeds MAX_BODY_SIZE")));
|
|
23
|
+
}
|
|
24
|
+
chunks.push(chunk);
|
|
25
|
+
return Effect.void;
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
28
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
29
|
+
}).pipe(
|
|
30
|
+
Effect.onError(() => Effect.sync(() => incoming.resume())),
|
|
31
|
+
Effect.cached,
|
|
32
|
+
Effect.runSync,
|
|
33
|
+
);
|
|
34
|
+
const arrayBuffer = Effect.map(text, (body) => new TextEncoder().encode(body).buffer);
|
|
35
|
+
// The native text reader destroys the HTTP socket on overflow, preventing a 4xx response.
|
|
36
|
+
return new Proxy(request, {
|
|
37
|
+
get: (target, key, receiver) => {
|
|
38
|
+
if (key === "text") {
|
|
39
|
+
return text;
|
|
40
|
+
}
|
|
41
|
+
if (key === "arrayBuffer") {
|
|
42
|
+
return arrayBuffer;
|
|
43
|
+
}
|
|
44
|
+
return Reflect.get(target, key, receiver);
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import * as PgClient from "@effect/sql-pg/PgClient";
|
|
2
|
+
import { Config, Effect, Layer } from "effect";
|
|
3
|
+
import * as Reactivity from "effect/reactivity/Reactivity";
|
|
4
|
+
import { makeLiveServer } from "@shivaedev/effect-contract/makeLiveServer.ts";
|
|
5
|
+
import { migratePostgres } from "@shivaedev/effect-sql/migrations.ts";
|
|
6
|
+
import { postgresChanges } from "@shivaedev/effect-sql/postgresChanges.ts";
|
|
7
|
+
import type { AppChanges, AppDatabaseOptions } from "./appHost.ts";
|
|
8
|
+
|
|
9
|
+
export function databaseLayer<R>(options: AppDatabaseOptions<R>, changesService: typeof AppChanges) {
|
|
10
|
+
const pool = Layer.unwrap(
|
|
11
|
+
Effect.gen(function* () {
|
|
12
|
+
const url = options.config?.databaseUrl ?? (yield* Config.Redacted("DATABASE_URL"));
|
|
13
|
+
return PgClient.layer({
|
|
14
|
+
url,
|
|
15
|
+
...(options.config?.maxConnections === undefined ? {} : { maxConnections: options.config.maxConnections }),
|
|
16
|
+
...(options.config?.applicationName === undefined ? {} : { applicationName: options.config.applicationName }),
|
|
17
|
+
});
|
|
18
|
+
}),
|
|
19
|
+
);
|
|
20
|
+
const migrated = Layer.effectDiscard(migratePostgres({ ...options.migrations, lockTimeout: options.migrations.lockTimeout ?? "5 seconds" })).pipe(
|
|
21
|
+
Layer.provideMerge(pool),
|
|
22
|
+
);
|
|
23
|
+
const live = Layer.effect(changesService, makeLiveServer).pipe(Layer.provideMerge(Reactivity.layer));
|
|
24
|
+
const base = Layer.merge(migrated, live);
|
|
25
|
+
const changes = options.config?.changes;
|
|
26
|
+
if (changes === undefined) {
|
|
27
|
+
return base;
|
|
28
|
+
}
|
|
29
|
+
return Layer.unwrap(
|
|
30
|
+
Effect.gen(function* () {
|
|
31
|
+
const client = yield* PgClient.PgClient;
|
|
32
|
+
const hints = yield* changesService;
|
|
33
|
+
return postgresChanges({ ...changes, client, resync: hints.resync });
|
|
34
|
+
}),
|
|
35
|
+
).pipe(Layer.provideMerge(base));
|
|
36
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Layer } from "effect";
|
|
2
|
+
import type { HttpRouter, HttpStaticServer } from "effect/http";
|
|
3
|
+
import type { Rpc, RpcGroup } from "effect/rpc";
|
|
4
|
+
import type { SessionPolicy, SessionShape } from "#rpc-server/session.ts";
|
|
5
|
+
import type { AppDatabaseOptions } from "./appHost.ts";
|
|
6
|
+
|
|
7
|
+
export interface HostOptions<TRpcs extends Rpc.Any, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements> {
|
|
8
|
+
readonly assets?: string | Parameters<typeof HttpStaticServer.layer>[0];
|
|
9
|
+
readonly config?: AppDatabaseOptions["config"] & {
|
|
10
|
+
readonly port?: number;
|
|
11
|
+
readonly host?: string;
|
|
12
|
+
readonly trustedProxy?: string;
|
|
13
|
+
readonly maxBodySize?: number;
|
|
14
|
+
readonly disablePreemptiveShutdown?: boolean;
|
|
15
|
+
};
|
|
16
|
+
readonly group: RpcGroup.RpcGroup<TRpcs>;
|
|
17
|
+
readonly handlers: Layer.Layer<THandlers, THandlerError, THandlerRequirements>;
|
|
18
|
+
readonly routes?: Layer.Layer<never, never, HttpRouter.HttpRouter | HttpRouter.Request<"Error", unknown>>;
|
|
19
|
+
readonly rpcPath?: HttpRouter.PathInput;
|
|
20
|
+
readonly services: Layer.Layer<TServices, TServiceError, TServiceRequirements>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type HostPolicy<TSession extends SessionShape> =
|
|
24
|
+
| { readonly policy?: undefined; readonly sessions: SessionPolicy<TSession> }
|
|
25
|
+
| { readonly policy: "loopback"; readonly sessions?: never };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Effect, Option } from "effect";
|
|
2
|
-
import type { Headers } from "effect/
|
|
2
|
+
import type { Headers } from "effect/http";
|
|
3
3
|
import { AuthUnavailable } from "#errors/taxonomy.ts";
|
|
4
4
|
import { redact } from "#rpc-server/redact.ts";
|
|
5
5
|
import type { SessionProvider, SessionShape } from "#rpc-server/session.ts";
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import type { BlockList } from "node:net";
|
|
1
2
|
import { Effect, Option } from "effect";
|
|
2
|
-
import { HttpServerRequest, HttpServerResponse } from "effect/
|
|
3
|
-
import { RpcSerialization } from "effect/
|
|
3
|
+
import { HttpServerRequest, HttpServerResponse } from "effect/http";
|
|
4
|
+
import { RpcSerialization } from "effect/rpc";
|
|
5
|
+
import { proxyAddresses, proxyMatches } from "./trustedProxy.ts";
|
|
4
6
|
|
|
5
7
|
function loopbackAddress(address: string): boolean {
|
|
6
8
|
const unmapped = unmapAddress(address);
|
|
@@ -40,8 +42,8 @@ export function loopbackOnly<E, R>(
|
|
|
40
42
|
? handler(request)
|
|
41
43
|
: Effect.succeed(HttpServerResponse.text("Only loopback hosts are served", { status: 403 }));
|
|
42
44
|
}
|
|
43
|
-
function proxyOrigin(request: HttpServerRequest.HttpServerRequest, trustedProxy:
|
|
44
|
-
if (trustedProxy === undefined || !Option.exists(request.remoteAddress, (address) =>
|
|
45
|
+
function proxyOrigin(request: HttpServerRequest.HttpServerRequest, trustedProxy: BlockList | undefined): boolean {
|
|
46
|
+
if (trustedProxy === undefined || !Option.exists(request.remoteAddress, (address) => proxyMatches(trustedProxy, address))) {
|
|
45
47
|
return false;
|
|
46
48
|
}
|
|
47
49
|
const host = request.headers["x-forwarded-host"];
|
|
@@ -56,15 +58,15 @@ function proxyOrigin(request: HttpServerRequest.HttpServerRequest, trustedProxy:
|
|
|
56
58
|
return false;
|
|
57
59
|
}
|
|
58
60
|
}
|
|
59
|
-
export
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
61
|
+
export function localRpcGuard<E, R>(effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>, trustedProxy?: string) {
|
|
62
|
+
const addresses = trustedProxy === undefined ? undefined : proxyAddresses(trustedProxy);
|
|
63
|
+
return Effect.gen(function* () {
|
|
64
|
+
const request = yield* HttpServerRequest.HttpServerRequest;
|
|
65
|
+
const serialization = yield* RpcSerialization.RpcSerialization;
|
|
66
|
+
const local = loopbackHost(request.headers.host) && Option.exists(request.remoteAddress, loopbackAddress);
|
|
67
|
+
const contentType = request.headers["content-type"]?.split(";")[0]?.trim().toLowerCase();
|
|
68
|
+
return ((local && sameOrigin(request)) || proxyOrigin(request, addresses)) && contentType === serialization.contentType
|
|
69
|
+
? yield* effect
|
|
70
|
+
: HttpServerResponse.text("Local same-origin requests only", { status: 403 });
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Effect, Layer, Option } from "effect";
|
|
2
|
-
import type { Headers } from "effect/
|
|
2
|
+
import type { Headers } from "effect/http";
|
|
3
3
|
import { type AuthUnavailable, Forbidden, Unauthorized } from "#errors/taxonomy.ts";
|
|
4
4
|
import { Identity, OptionalIdentity } from "#rpc/identity.ts";
|
|
5
5
|
import { Authenticated, MaybeAuthenticated } from "#rpc/middleware.ts";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Cause, Effect, Layer, Option, Predicate, Random } from "effect";
|
|
2
|
-
import { Headers } from "effect/
|
|
2
|
+
import { Headers } from "effect/http";
|
|
3
3
|
import { RequestId } from "#rpc/identity.ts";
|
|
4
4
|
import { RequestTracing } from "#rpc/middleware.ts";
|
|
5
5
|
import { redact } from "./redact.ts";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Effect, Option } from "effect";
|
|
2
|
-
import { type Headers, HttpServerRequest } from "effect/
|
|
2
|
+
import { type Headers, HttpServerRequest } from "effect/http";
|
|
3
3
|
export function transportHeaders(rpcHeaders: Headers.Headers): Effect.Effect<Headers.Headers> {
|
|
4
4
|
return Effect.map(
|
|
5
5
|
Effect.serviceOption(HttpServerRequest.HttpServerRequest),
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { BlockList, isIP } from "node:net";
|
|
2
|
+
import { Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
function addProxyAddress(addresses: BlockList, entry: string): boolean {
|
|
5
|
+
const parts = entry.trim().split("/");
|
|
6
|
+
const address = parts[0] ?? "";
|
|
7
|
+
const family = isIP(address);
|
|
8
|
+
if (family === 0 || address.includes("%") || parts.length > 2) {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
const type = family === 4 ? "ipv4" : "ipv6";
|
|
12
|
+
const prefix = parts[1];
|
|
13
|
+
if (prefix === undefined) {
|
|
14
|
+
addresses.addAddress(address, type);
|
|
15
|
+
} else {
|
|
16
|
+
if (!/^\d+$/u.test(prefix) || Number(prefix) > (family === 4 ? 32 : 128)) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
addresses.addSubnet(address, Number(prefix), type);
|
|
20
|
+
}
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
export function proxyAddresses(value: string): BlockList | undefined {
|
|
24
|
+
const addresses = new BlockList();
|
|
25
|
+
return value.split(",").every((entry) => addProxyAddress(addresses, entry)) ? addresses : undefined;
|
|
26
|
+
}
|
|
27
|
+
export const TrustedProxy = Schema.String.check(
|
|
28
|
+
Schema.makeFilter((value) => proxyAddresses(value) !== undefined, {
|
|
29
|
+
message: "Expected a comma-separated list of IPv4 or IPv6 addresses or CIDR ranges",
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
export function proxyMatches(addresses: BlockList, address: string): boolean {
|
|
33
|
+
const unmapped = address.replace(/^::ffff:(?=\d+\.)/iu, "");
|
|
34
|
+
const family = isIP(unmapped);
|
|
35
|
+
return family !== 0 && !unmapped.includes("%") && addresses.check(unmapped, family === 4 ? "ipv4" : "ipv6");
|
|
36
|
+
}
|
|
@@ -47,7 +47,7 @@ function make<TServices, TBuildError>(layer: Layer.Layer<TServices, TBuildError>
|
|
|
47
47
|
};
|
|
48
48
|
return runtime;
|
|
49
49
|
}
|
|
50
|
-
const production = Effect.map(Effect.orDie(Config.
|
|
50
|
+
const production = Effect.map(Effect.orDie(Config.String("NODE_ENV").pipe(Config.withDefault(""))), (environment) => environment === "production");
|
|
51
51
|
export function makePlatformRuntime<TServices, TBuildError>(
|
|
52
52
|
layer: Layer.Layer<TServices, TBuildError>,
|
|
53
53
|
options: PlatformRuntimeOptions = {},
|