@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.
Files changed (66) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +21 -11
  3. package/dist/rpc/middleware.d.ts +1 -1
  4. package/dist/rpc/middleware.d.ts.map +1 -1
  5. package/dist/rpc/middleware.js +1 -1
  6. package/dist/rpc/middleware.js.map +1 -1
  7. package/dist/server/appHost.d.ts +27 -22
  8. package/dist/server/appHost.d.ts.map +1 -1
  9. package/dist/server/appHost.js +7 -48
  10. package/dist/server/appHost.js.map +1 -1
  11. package/dist/server/appHostHttp.d.ts +11 -0
  12. package/dist/server/appHostHttp.d.ts.map +1 -0
  13. package/dist/server/appHostHttp.js +51 -0
  14. package/dist/server/appHostHttp.js.map +1 -0
  15. package/dist/server/better-auth/rowAdapter.js +1 -1
  16. package/dist/server/better-auth/rowAdapter.js.map +1 -1
  17. package/dist/server/boundedRpcRequest.d.ts +3 -0
  18. package/dist/server/boundedRpcRequest.d.ts.map +1 -0
  19. package/dist/server/boundedRpcRequest.js +41 -0
  20. package/dist/server/boundedRpcRequest.js.map +1 -0
  21. package/dist/server/databaseLayer.d.ts +6 -0
  22. package/dist/server/databaseLayer.d.ts.map +1 -0
  23. package/dist/server/databaseLayer.js +29 -0
  24. package/dist/server/databaseLayer.js.map +1 -0
  25. package/dist/server/hostOptions.d.ts +28 -0
  26. package/dist/server/hostOptions.d.ts.map +1 -0
  27. package/dist/server/hostOptions.js +2 -0
  28. package/dist/server/hostOptions.js.map +1 -0
  29. package/dist/server/rpc-server/local.d.ts +3 -3
  30. package/dist/server/rpc-server/local.d.ts.map +1 -1
  31. package/dist/server/rpc-server/local.js +16 -12
  32. package/dist/server/rpc-server/local.js.map +1 -1
  33. package/dist/server/rpc-server/origin.d.ts +1 -1
  34. package/dist/server/rpc-server/origin.d.ts.map +1 -1
  35. package/dist/server/rpc-server/origin.js +1 -1
  36. package/dist/server/rpc-server/origin.js.map +1 -1
  37. package/dist/server/rpc-server/session.d.ts +1 -1
  38. package/dist/server/rpc-server/session.d.ts.map +1 -1
  39. package/dist/server/rpc-server/tracing.js +1 -1
  40. package/dist/server/rpc-server/tracing.js.map +1 -1
  41. package/dist/server/rpc-server/transportHeaders.d.ts +1 -1
  42. package/dist/server/rpc-server/transportHeaders.d.ts.map +1 -1
  43. package/dist/server/rpc-server/transportHeaders.js +1 -1
  44. package/dist/server/rpc-server/transportHeaders.js.map +1 -1
  45. package/dist/server/rpc-server/trustedProxy.d.ts +6 -0
  46. package/dist/server/rpc-server/trustedProxy.d.ts.map +1 -0
  47. package/dist/server/rpc-server/trustedProxy.js +35 -0
  48. package/dist/server/rpc-server/trustedProxy.js.map +1 -0
  49. package/dist/server/runtime/makePlatform.js +1 -1
  50. package/docs/roadmap.md +1 -1
  51. package/package.json +29 -29
  52. package/src/rpc/middleware.ts +1 -1
  53. package/src/server/appHost.ts +90 -94
  54. package/src/server/appHostHttp.ts +101 -0
  55. package/src/server/better-auth/rowAdapter.ts +1 -1
  56. package/src/server/boundedRpcRequest.ts +47 -0
  57. package/src/server/databaseLayer.ts +36 -0
  58. package/src/server/hostOptions.ts +25 -0
  59. package/src/server/rpc-server/adapters/betterAuthSessions.ts +1 -1
  60. package/src/server/rpc-server/local.ts +18 -16
  61. package/src/server/rpc-server/origin.ts +1 -1
  62. package/src/server/rpc-server/session.ts +1 -1
  63. package/src/server/rpc-server/tracing.ts +1 -1
  64. package/src/server/rpc-server/transportHeaders.ts +1 -1
  65. package/src/server/rpc-server/trustedProxy.ts +36 -0
  66. package/src/server/runtime/makePlatform.ts +1 -1
@@ -1,23 +1,19 @@
1
- import { createServer } from "node:http";
2
- import { NodeHttpServer, NodeServices } from "@effect/platform-node";
3
- import * as PgClient from "@effect/sql-pg/PgClient";
4
- import { Config, Context, type Duration, Effect, Layer, Option, type Redacted } from "effect";
5
- import { HttpRouter, HttpServerRequest, HttpStaticServer } from "effect/unstable/http";
6
- import * as Reactivity from "effect/unstable/reactivity/Reactivity";
7
- import { type Rpc, type RpcGroup, RpcSerialization, RpcServer } from "effect/unstable/rpc";
8
- import type { Migrator } from "effect/unstable/sql";
9
- import { makeLiveServer } from "@shivaedev/effect-contract/makeLiveServer.ts";
10
- import { migratePostgres } from "@shivaedev/effect-sql/migrations.ts";
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
- const pool = Layer.unwrap(
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 AppDatabaseOptions<TMigrations> {
67
- readonly assets?: string;
68
- readonly config?: AppDatabaseOptions<TMigrations>["config"] & {
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: AppHostOptions<
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
- // Named middleware types keep NodeNext declarations on the package aliases.
102
- const middleware: Layer.Layer<Authenticated | MaybeAuthenticated | RequestTracing> = Layer.mergeAll(
103
- authenticatedLayer(options.sessions),
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 experimental native joins");
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/unstable/http";
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/unstable/http";
3
- import { RpcSerialization } from "effect/unstable/rpc";
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: string | undefined): boolean {
44
- if (trustedProxy === undefined || !Option.exists(request.remoteAddress, (address) => unmapAddress(address) === unmapAddress(trustedProxy))) {
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 const localRpcGuard = Effect.fnUntraced(function* <E, R>(
60
- effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>,
61
- trustedProxy?: string,
62
- ) {
63
- const request = yield* HttpServerRequest.HttpServerRequest;
64
- const serialization = yield* RpcSerialization.RpcSerialization;
65
- const local = loopbackHost(request.headers.host) && Option.exists(request.remoteAddress, loopbackAddress);
66
- const contentType = request.headers["content-type"]?.split(";")[0]?.trim().toLowerCase();
67
- return ((local && sameOrigin(request)) || proxyOrigin(request, trustedProxy)) && contentType === serialization.contentType
68
- ? yield* effect
69
- : HttpServerResponse.text("Local same-origin requests only", { status: 403 });
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 { Option } from "effect";
2
- import { Headers } from "effect/unstable/http";
2
+ import { Headers } from "effect/http";
3
3
  export interface OriginRequest {
4
4
  readonly headers: Headers.Headers;
5
5
  readonly origin: Option.Option<string>;
@@ -1,5 +1,5 @@
1
1
  import { Effect, Layer, Option } from "effect";
2
- import type { Headers } from "effect/unstable/http";
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/unstable/http";
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/unstable/http";
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.string("NODE_ENV").pipe(Config.withDefault(""))), (environment) => environment === "production");
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 = {},