@shivaedev/server 0.2.0 → 0.3.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 (57) hide show
  1. package/README.md +26 -2
  2. package/dist/AppHostFactory.d.ts +9 -8
  3. package/dist/AppHostFactory.d.ts.map +1 -1
  4. package/dist/AppHostInput.d.ts +4 -3
  5. package/dist/AppHostInput.d.ts.map +1 -1
  6. package/dist/appHost.d.ts +30 -5
  7. package/dist/appHost.d.ts.map +1 -1
  8. package/dist/appHost.js +4 -3
  9. package/dist/appHost.js.map +1 -1
  10. package/dist/appHostHttp.d.ts +44 -45
  11. package/dist/appHostHttp.d.ts.map +1 -1
  12. package/dist/appHostHttp.js +13 -42
  13. package/dist/appHostHttp.js.map +1 -1
  14. package/dist/healthRoute.d.ts.map +1 -1
  15. package/dist/healthRoute.js +3 -2
  16. package/dist/healthRoute.js.map +1 -1
  17. package/dist/hostApplicationRoutes.d.ts +3 -0
  18. package/dist/hostApplicationRoutes.d.ts.map +1 -0
  19. package/dist/hostApplicationRoutes.js +6 -0
  20. package/dist/hostApplicationRoutes.js.map +1 -0
  21. package/dist/hostChanges.js +1 -1
  22. package/dist/hostChanges.js.map +1 -1
  23. package/dist/hostOptions.d.ts +6 -2
  24. package/dist/hostOptions.d.ts.map +1 -1
  25. package/dist/hostRpc.d.ts +11 -0
  26. package/dist/hostRpc.d.ts.map +1 -0
  27. package/dist/hostRpc.js +20 -0
  28. package/dist/hostRpc.js.map +1 -0
  29. package/dist/hostRpcRoutes.d.ts +23 -0
  30. package/dist/hostRpcRoutes.d.ts.map +1 -0
  31. package/dist/hostRpcRoutes.js +29 -0
  32. package/dist/hostRpcRoutes.js.map +1 -0
  33. package/dist/nativeClientOrigins.d.ts +2 -1
  34. package/dist/nativeClientOrigins.d.ts.map +1 -1
  35. package/dist/nativeClientOrigins.js +11 -5
  36. package/dist/nativeClientOrigins.js.map +1 -1
  37. package/dist/nodeHttpServer.d.ts +7 -0
  38. package/dist/nodeHttpServer.d.ts.map +1 -0
  39. package/dist/nodeHttpServer.js +15 -0
  40. package/dist/nodeHttpServer.js.map +1 -0
  41. package/dist/otlpHttpClient.d.ts.map +1 -1
  42. package/dist/otlpHttpClient.js +2 -1
  43. package/dist/otlpHttpClient.js.map +1 -1
  44. package/package.json +11 -10
  45. package/src/AppHostFactory.ts +47 -44
  46. package/src/AppHostInput.ts +27 -4
  47. package/src/appHost.ts +25 -7
  48. package/src/appHostHttp.ts +65 -69
  49. package/src/healthRoute.ts +3 -2
  50. package/src/hostApplicationRoutes.ts +8 -0
  51. package/src/hostChanges.ts +2 -2
  52. package/src/hostOptions.ts +20 -2
  53. package/src/hostRpc.ts +26 -0
  54. package/src/hostRpcRoutes.ts +57 -0
  55. package/src/nativeClientOrigins.ts +16 -5
  56. package/src/nodeHttpServer.ts +24 -0
  57. package/src/otlpHttpClient.ts +2 -1
package/src/appHost.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { NodeCrypto } from "@effect/platform-node";
1
2
  import { Context, type Effect, Layer } from "effect";
2
3
  import type { Rpc } from "effect/rpc";
3
4
  import type { makeLiveServer } from "@shivaedev/contract/makeLiveServer.ts";
@@ -6,7 +7,7 @@ import type { AppHostFactory } from "#AppHostFactory.ts";
6
7
  import type { AppHostInput, HostPreflight } from "#AppHostInput.ts";
7
8
  import { appHostHttp, deferredHost, migratedHost } from "#appHostHttp.ts";
8
9
  import { type DatabaseConfig, databaseLayer } from "#databaseLayer.ts";
9
- import type { HostOptions, HostPolicy } from "#hostOptions.ts";
10
+ import type { HostCoreOptions, HostPolicy, HostRouteRequirements, HostRoutes } from "#hostOptions.ts";
10
11
  import type { MigrationOptions } from "#MigrationOptions.ts";
11
12
 
12
13
  export class AppChanges extends Context.Service<AppChanges, Effect.Success<typeof makeLiveServer>>()("@shivaedev/server/AppChanges") {}
@@ -32,8 +33,11 @@ export type AppHostOptions<
32
33
  TPreflight = never,
33
34
  TPreflightError = never,
34
35
  TPreflightRequirements = never,
36
+ TRouteError = never,
37
+ TRouteRequirements = HostRouteRequirements,
35
38
  > = HostPreflight<TPreflight, TPreflightError, TPreflightRequirements> &
36
- HostOptions<TRpcs, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements> & {
39
+ HostCoreOptions<TRpcs, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements> &
40
+ HostRoutes<TRouteError, TRouteRequirements> & {
37
41
  readonly migrations: AppDatabaseOptions<TMigrations>["migrations"];
38
42
  readonly policy?: undefined;
39
43
  readonly sessions: SessionPolicy<TSession>;
@@ -51,8 +55,11 @@ export type AppLocalHostOptions<
51
55
  TPreflight = never,
52
56
  TPreflightError = never,
53
57
  TPreflightRequirements = never,
58
+ TRouteError = never,
59
+ TRouteRequirements = HostRouteRequirements,
54
60
  > = HostPreflight<TPreflight, TPreflightError, TPreflightRequirements> &
55
- HostOptions<TRpcs, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements> & {
61
+ HostCoreOptions<TRpcs, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements> &
62
+ HostRoutes<TRouteError, TRouteRequirements> & {
56
63
  readonly migrations: AppDatabaseOptions<TMigrations>["migrations"];
57
64
  readonly policy: "loopback";
58
65
  readonly sessions?: never;
@@ -70,8 +77,11 @@ export type AppHttpHostOptions<
70
77
  TPreflight = never,
71
78
  TPreflightError = never,
72
79
  TPreflightRequirements = never,
80
+ TRouteError = never,
81
+ TRouteRequirements = HostRouteRequirements,
73
82
  > = HostPreflight<TPreflight, TPreflightError, TPreflightRequirements> &
74
- HostOptions<TRpcs, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements> &
83
+ HostCoreOptions<TRpcs, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements> &
84
+ HostRoutes<TRouteError, TRouteRequirements> &
75
85
  HostPolicy<TSession> & { readonly migrations?: undefined };
76
86
  export const appHost: AppHostFactory = <
77
87
  TRpcs extends Rpc.Any,
@@ -88,6 +98,8 @@ export const appHost: AppHostFactory = <
88
98
  TPreflight = never,
89
99
  TPreflightError = never,
90
100
  TPreflightRequirements = never,
101
+ TRouteError = never,
102
+ TRouteRequirements = HostRouteRequirements,
91
103
  >(
92
104
  options: AppHostInput<
93
105
  TRpcs,
@@ -103,15 +115,21 @@ export const appHost: AppHostFactory = <
103
115
  TApplicationRequirements,
104
116
  TPreflight,
105
117
  TPreflightError,
106
- TPreflightRequirements
118
+ TPreflightRequirements,
119
+ TRouteError,
120
+ TRouteRequirements
107
121
  >,
108
122
  ) => {
109
123
  if ("application" in options) {
110
- return deferredHost(options.application, appDatabase(options), AppChanges).pipe(Layer.provideMerge(options.preflight ?? Layer.empty));
124
+ return deferredHost(options.application, appDatabase(options), AppChanges).pipe(
125
+ Layer.provideMerge(options.preflight ?? Layer.empty),
126
+ Layer.provide(NodeCrypto.layer),
127
+ );
111
128
  }
112
129
  return options.migrations === undefined
113
- ? appHostHttp(options, AppChanges).pipe(Layer.provideMerge(options.preflight ?? Layer.empty))
130
+ ? appHostHttp(options, AppChanges).pipe(Layer.provideMerge(options.preflight ?? Layer.empty), Layer.provide(NodeCrypto.layer))
114
131
  : migratedHost({ ...options, migrations: options.migrations }, appDatabase({ ...options, migrations: options.migrations }), AppChanges).pipe(
115
132
  Layer.provideMerge(options.preflight ?? Layer.empty),
133
+ Layer.provide(NodeCrypto.layer),
116
134
  );
117
135
  };
@@ -1,30 +1,22 @@
1
- import { createServer } from "node:http";
2
- import { NodeHttpServer, NodeServices } from "@effect/platform-node";
3
- import { ByteSize, Config, Effect, Layer } from "effect";
4
- import { HttpEffect, HttpIncomingMessage, HttpRouter, HttpServerRequest, HttpServerResponse, HttpStaticServer } from "effect/http";
5
- import { type Rpc, RpcSerialization, RpcServer } from "effect/rpc";
6
- import type { Authenticated, MaybeAuthenticated, RequestTracing } from "@shivaedev/identity/middleware.ts";
7
- import { localRpcGuard } from "@shivaedev/identity/server/local.ts";
8
- import { authenticatedLayer, maybeAuthenticatedLayer, type SessionShape } from "@shivaedev/identity/server/session.ts";
9
- import { requestTracingLayer } from "@shivaedev/identity/server/tracing.ts";
1
+ import { NodeServices } from "@effect/platform-node";
2
+ import { Effect, Layer } from "effect";
3
+ import { HttpRouter, HttpStaticServer } from "effect/http";
4
+ import type { Rpc } from "effect/rpc";
5
+ import type { SessionShape } from "@shivaedev/identity/server/session.ts";
10
6
  import type { AppChanges, AppHttpHostOptions } from "#appHost.ts";
11
- import { boundedRpcRequest } from "#boundedRpcRequest.ts";
12
7
  import type { databaseLayer } from "#databaseLayer.ts";
13
8
  import { healthRoute } from "#healthRoute.ts";
14
- import { hostChanges } from "#hostChanges.ts";
15
- import type { HostOptions, HostPolicy } from "#hostOptions.ts";
9
+ import { hostApplicationRoutes } from "#hostApplicationRoutes.ts";
10
+ import type { HostOptions, HostPolicy, HostRouteRequirements } from "#hostOptions.ts";
11
+ import { hostRpcRoutes } from "#hostRpcRoutes.ts";
16
12
  import { hostTransport } from "#hostTransport.ts";
17
13
  import type { MigrationOptions } from "#MigrationOptions.ts";
18
14
  import { nativeClientOrigins } from "#nativeClientOrigins.ts";
15
+ import { nodeHttpServer } from "#nodeHttpServer.ts";
19
16
  import { otlpTracing } from "#otlpTracing.ts";
20
17
  import { RedactionPolicy } from "#RedactionPolicy.ts";
21
18
  import { redactingDiagnostics } from "#redactingDiagnostics.ts";
22
- import { redactingRpcSerialization } from "#redactingRpcSerialization.ts";
23
19
  import { schemaSensitiveKeys } from "#schemaSensitiveKeys.ts";
24
- import { tracedGroup } from "#tracedGroup.ts";
25
-
26
- const rpcCacheControl: HttpEffect.PreResponseHandler = (_, response) =>
27
- Effect.succeed(HttpServerResponse.setHeader(response, "cache-control", "private, no-store"));
28
20
 
29
21
  export function appHostHttp<
30
22
  TRpcs extends Rpc.Any,
@@ -35,67 +27,41 @@ export function appHostHttp<
35
27
  TServiceError,
36
28
  TServiceRequirements,
37
29
  TSession extends SessionShape,
30
+ TRouteError = never,
31
+ TRouteRequirements = HostRouteRequirements,
38
32
  >(
39
- options: HostOptions<TRpcs, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements> & HostPolicy<TSession>,
33
+ options: HostOptions<
34
+ TRpcs,
35
+ THandlers,
36
+ THandlerError,
37
+ THandlerRequirements,
38
+ TServices,
39
+ TServiceError,
40
+ TServiceRequirements,
41
+ TRouteError,
42
+ TRouteRequirements
43
+ > &
44
+ HostPolicy<TSession>,
40
45
  changesService: typeof AppChanges,
41
46
  ) {
42
47
  const sensitive = schemaSensitiveKeys(options.group, options.config?.sensitiveKeys);
43
48
  return Layer.unwrap(
44
49
  Effect.gen(function* () {
45
- const { bodySize, host, trustedProxy, nativeOrigins } = yield* hostTransport(options);
46
- const middleware: Layer.Layer<RequestTracing> | Layer.Layer<Authenticated | MaybeAuthenticated | RequestTracing> =
47
- options.policy === "loopback"
48
- ? requestTracingLayer({ sensitive })
49
- : Layer.mergeAll(authenticatedLayer(options.sessions), maybeAuthenticatedLayer(options.sessions), requestTracingLayer({ sensitive }));
50
- const routes = HttpRouter.use((router) =>
51
- Effect.gen(function* () {
52
- const wire = redactingRpcSerialization(yield* RpcSerialization.RpcSerialization, sensitive, sensitive.maskFailure);
53
- const handler = yield* RpcServer.toHttpEffect(tracedGroup(options.group)).pipe(
54
- Effect.provideService(RpcSerialization.RpcSerialization, wire),
55
- );
56
- const guarded = localRpcGuard(handler, trustedProxy, nativeOrigins);
57
- if (nativeOrigins.length > 0) {
58
- yield* router.add("OPTIONS", options.rpcPath ?? "/rpc", HttpServerResponse.text("Local same-origin requests only", { status: 403 }));
59
- }
60
- yield* router.add("POST", options.rpcPath ?? "/rpc", (request) => {
61
- HttpEffect.appendPreResponseHandlerUnsafe(request, rpcCacheControl);
62
- return guarded.pipe(
63
- Effect.provideService(HttpIncomingMessage.MaxBodySize, ByteSize.bytes(bodySize)),
64
- Effect.provideService(HttpServerRequest.HttpServerRequest, boundedRpcRequest(request)),
65
- Effect.provideService(RpcSerialization.RpcSerialization, wire),
66
- );
67
- });
68
- }),
69
- ).pipe(
70
- Layer.provide(Layer.merge(middleware, hostChanges(options.group, changesService).pipe(Layer.provideMerge(options.handlers)))),
71
- Layer.provide(RpcSerialization.layerNdjson),
72
- Layer.provide(nativeClientOrigins(nativeOrigins, trustedProxy)),
73
- );
50
+ const transport = yield* hostTransport(options);
51
+ const { bodySize, host, trustedProxy, nativeOrigins } = transport;
52
+ const routes = hostRpcRoutes(options, changesService, transport, sensitive);
74
53
  const assets =
75
54
  options.assets === undefined
76
55
  ? Layer.empty
77
56
  : HttpStaticServer.layer(typeof options.assets === "string" ? { root: options.assets } : options.assets);
78
57
  const application = Layer.mergeAll(options.services ?? Layer.empty);
79
- const server = Layer.unwrap(
80
- Effect.gen(function* () {
81
- const port = options.config?.port ?? (yield* Config.Int("PORT").pipe(Config.withDefault(3000)));
82
- return NodeHttpServer.layer(createServer, {
83
- gracefulShutdownTimeout: options.config?.gracefulShutdownTimeout,
84
- host,
85
- port,
86
- ...(options.config?.disablePreemptiveShutdown === undefined
87
- ? {}
88
- : { disablePreemptiveShutdown: options.config.disablePreemptiveShutdown }),
89
- });
90
- }),
91
- );
92
- const applicationRoutes = (options.routes ?? Layer.empty).pipe(
93
- Layer.provide(
94
- HttpRouter.middleware((effect) => Effect.provideService(effect, HttpIncomingMessage.MaxBodySize, ByteSize.bytes(bodySize))).layer,
95
- ),
96
- Layer.provide(options.nativeOrigins === undefined ? Layer.empty : nativeClientOrigins(nativeOrigins, trustedProxy, true)),
58
+ const server = nodeHttpServer(host, options.config);
59
+ const applicationRoutes = hostApplicationRoutes(options.routes, bodySize);
60
+ const httpRoutes = Layer.mergeAll(routes, healthRoute, assets, applicationRoutes).pipe(
61
+ Layer.provide(nativeClientOrigins(nativeOrigins, trustedProxy, false, options.rpcPath)),
62
+ Layer.provide(nativeClientOrigins(nativeOrigins, trustedProxy, options.nativeOrigins === undefined ? "preflight" : true)),
97
63
  );
98
- return HttpRouter.serve(Layer.mergeAll(routes, healthRoute, assets, applicationRoutes), { disableLogger: true }).pipe(
64
+ return HttpRouter.serve(httpRoutes, { disableLogger: true }).pipe(
99
65
  Layer.provideMerge(application),
100
66
  Layer.provideMerge(server),
101
67
  Layer.provide(NodeServices.layer),
@@ -114,9 +80,23 @@ export function migratedHost<
114
80
  TServiceRequirements,
115
81
  TMigrations,
116
82
  TSession extends SessionShape,
83
+ TRouteError = never,
84
+ TRouteRequirements = HostRouteRequirements,
117
85
  >(
118
- options: HostOptions<TRpcs, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements> &
119
- HostPolicy<TSession> & { readonly migrations: MigrationOptions<TMigrations> },
86
+ options: HostOptions<
87
+ TRpcs,
88
+ THandlers,
89
+ THandlerError,
90
+ THandlerRequirements,
91
+ TServices,
92
+ TServiceError,
93
+ TServiceRequirements,
94
+ TRouteError,
95
+ TRouteRequirements
96
+ > &
97
+ HostPolicy<TSession> & {
98
+ readonly migrations: MigrationOptions<TMigrations>;
99
+ },
120
100
  database: ReturnType<typeof databaseLayer<TMigrations>>,
121
101
  changesService: typeof AppChanges,
122
102
  ) {
@@ -136,9 +116,25 @@ export function deferredHost<
136
116
  TSession extends SessionShape,
137
117
  TApplicationError,
138
118
  TApplicationRequirements,
119
+ TRouteError = never,
120
+ TRouteRequirements = HostRouteRequirements,
139
121
  >(
140
122
  application: Effect.Effect<
141
- AppHttpHostOptions<TRpcs, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements, TSession>,
123
+ AppHttpHostOptions<
124
+ TRpcs,
125
+ THandlers,
126
+ THandlerError,
127
+ THandlerRequirements,
128
+ TServices,
129
+ TServiceError,
130
+ TServiceRequirements,
131
+ TSession,
132
+ never,
133
+ never,
134
+ never,
135
+ TRouteError,
136
+ TRouteRequirements
137
+ >,
142
138
  TApplicationError,
143
139
  TApplicationRequirements
144
140
  >,
@@ -1,5 +1,6 @@
1
1
  import { Effect, Fiber, Option } from "effect";
2
2
  import { HttpRouter, HttpServerResponse } from "effect/http";
3
+ import * as HttpStatus from "effect/http/HttpStatus";
3
4
  import { SqlClient } from "effect/sql";
4
5
 
5
6
  export const healthRoute = HttpRouter.use((router) =>
@@ -8,14 +9,14 @@ export const healthRoute = HttpRouter.use((router) =>
8
9
  const scope = yield* Effect.scope;
9
10
  const probe = Effect.gen(function* () {
10
11
  if (Option.isNone(database)) {
11
- return 503;
12
+ return HttpStatus.fromLiteral("ServiceUnavailable");
12
13
  }
13
14
  const query = yield* database.value`select 1`.pipe(Effect.forkIn(scope));
14
15
  return yield* Fiber.join(query).pipe(
15
16
  Effect.timeout("2 seconds"),
16
17
  // Driver cancellation may need network I/O; keep cleanup in the host scope without delaying the response.
17
18
  Effect.ensuring(Fiber.interrupt(query).pipe(Effect.forkIn(scope), Effect.asVoid)),
18
- Effect.match({ onFailure: () => 503, onSuccess: () => 200 }),
19
+ Effect.match({ onFailure: () => HttpStatus.fromLiteral("ServiceUnavailable"), onSuccess: () => HttpStatus.fromLiteral("OK") }),
19
20
  );
20
21
  });
21
22
  yield* router.add(
@@ -0,0 +1,8 @@
1
+ import { ByteSize, Effect, Layer } from "effect";
2
+ import { HttpIncomingMessage, HttpRouter } from "effect/http";
3
+
4
+ export function hostApplicationRoutes<TError, TRequirements>(routes: Layer.Layer<never, TError, TRequirements> | undefined, bodySize: number) {
5
+ return (routes ?? Layer.empty).pipe(
6
+ Layer.provide(HttpRouter.middleware((effect) => Effect.provideService(effect, HttpIncomingMessage.MaxBodySize, ByteSize.bytes(bodySize))).layer),
7
+ );
8
+ }
@@ -21,8 +21,8 @@ export function hostChanges<R extends Rpc.Any>(group: RpcGroup.RpcGroup<R>, chan
21
21
  }
22
22
  function handler({ queries }: { readonly queries: readonly QueryReference[] }) {
23
23
  return Stream.unwrap(
24
- Effect.map(resolveChanges(rpc, queries), ({ keys, context }) =>
25
- Option.isSome(changes) ? changes.value.stream(keys, context) : Stream.never,
24
+ Effect.map(resolveChanges(rpc, queries), ({ subscription, context }) =>
25
+ Option.isSome(changes) ? changes.value.subscribe(subscription, context) : Stream.never,
26
26
  ),
27
27
  );
28
28
  }
@@ -4,7 +4,9 @@ import type { Rpc, RpcGroup } from "effect/rpc";
4
4
  import type { SessionPolicy, SessionShape } from "@shivaedev/identity/server/session.ts";
5
5
  import type { AppDatabaseOptions } from "./appHost.ts";
6
6
 
7
- export type HostOptions<TRpcs extends Rpc.Any, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements> = {
7
+ export type HostRouteRequirements = HttpRouter.HttpRouter | HttpRouter.Request<"Error", unknown>;
8
+
9
+ export type HostCoreOptions<TRpcs extends Rpc.Any, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements> = {
8
10
  readonly assets?: string | Parameters<typeof HttpStaticServer.layer>[0];
9
11
  readonly config?: AppDatabaseOptions["config"] & {
10
12
  readonly port?: number;
@@ -18,13 +20,29 @@ export type HostOptions<TRpcs extends Rpc.Any, THandlers, THandlerError, THandle
18
20
  readonly group: RpcGroup.RpcGroup<TRpcs>;
19
21
  readonly handlers: Layer.Layer<THandlers, THandlerError, THandlerRequirements>;
20
22
  readonly nativeOrigins?: readonly string[];
21
- readonly routes?: Layer.Layer<never, never, HttpRouter.HttpRouter | HttpRouter.Request<"Error", unknown>>;
22
23
  readonly rpcPath?: HttpRouter.PathInput;
23
24
  } & (
24
25
  | { readonly services: Layer.Layer<TServices, TServiceError, TServiceRequirements> }
25
26
  | ([TServices | TServiceError | TServiceRequirements] extends [never] ? { readonly services?: never } : never)
26
27
  );
27
28
 
29
+ export interface HostRoutes<TRouteError = never, TRouteRequirements = HostRouteRequirements> {
30
+ readonly routes?: Layer.Layer<never, TRouteError, TRouteRequirements>;
31
+ }
32
+
33
+ export type HostOptions<
34
+ TRpcs extends Rpc.Any,
35
+ THandlers,
36
+ THandlerError,
37
+ THandlerRequirements,
38
+ TServices,
39
+ TServiceError,
40
+ TServiceRequirements,
41
+ TRouteError = never,
42
+ TRouteRequirements = HostRouteRequirements,
43
+ > = HostCoreOptions<TRpcs, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements> &
44
+ HostRoutes<TRouteError, TRouteRequirements>;
45
+
28
46
  export type HostPolicy<TSession extends SessionShape> =
29
47
  | { readonly policy?: undefined; readonly sessions: SessionPolicy<TSession> }
30
48
  | { readonly policy: "loopback"; readonly sessions?: never };
package/src/hostRpc.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { type HttpEffect, HttpServerResponse } from "effect/http";
3
+ import { type Rpc, type RpcGroup, RpcMiddleware, RpcServer } from "effect/rpc";
4
+ import { authorizeReads, CurrentViewer } from "@shivaedev/contract/readPolicy.ts";
5
+ import { MissingReadPolicy, QueryReadDenied } from "@shivaedev/contract/readPolicyErrors.ts";
6
+ import { tracedGroup } from "#tracedGroup.ts";
7
+
8
+ class HostedReadPolicy extends RpcMiddleware.Service<HostedReadPolicy>()("@shivaedev/server/HostedReadPolicy", {
9
+ error: Schema.Union([MissingReadPolicy, QueryReadDenied]),
10
+ }) {}
11
+
12
+ export type { HostedReadPolicy };
13
+
14
+ export const rpcCacheControl: HttpEffect.PreResponseHandler = (_, response) =>
15
+ Effect.succeed(HttpServerResponse.setHeader(response, "cache-control", "private, no-store"));
16
+
17
+ export const hostRpc = Effect.fnUntraced(function* <R extends Rpc.Any>(group: RpcGroup.RpcGroup<R>) {
18
+ const guarded = group.middleware(HostedReadPolicy);
19
+ // Keep the policy inside identity middleware so it sees the request's viewer.
20
+ for (const rpc of guarded.requests.values()) {
21
+ Object.assign(rpc, { middlewares: new Set([HostedReadPolicy, ...rpc.middlewares]) });
22
+ }
23
+ return yield* RpcServer.toHttpEffect(tracedGroup(guarded)).pipe(
24
+ Effect.provideService(HostedReadPolicy, (effect, request) => Effect.flatMap(CurrentViewer, (viewer) => authorizeReads(effect, request, viewer))),
25
+ );
26
+ });
@@ -0,0 +1,57 @@
1
+ import { ByteSize, Effect, Layer } from "effect";
2
+ import { HttpEffect, HttpIncomingMessage, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
3
+ import * as HttpStatus from "effect/http/HttpStatus";
4
+ import { type Rpc, type RpcGroup, RpcSerialization } from "effect/rpc";
5
+ import type { Authenticated, MaybeAuthenticated, RequestTracing } from "@shivaedev/identity/middleware.ts";
6
+ import { localRpcGuard } from "@shivaedev/identity/server/local.ts";
7
+ import { authenticatedLayer, maybeAuthenticatedLayer, type SessionShape } from "@shivaedev/identity/server/session.ts";
8
+ import { requestTracingLayer } from "@shivaedev/identity/server/tracing.ts";
9
+ import type { AppChanges } from "#appHost.ts";
10
+ import { boundedRpcRequest } from "#boundedRpcRequest.ts";
11
+ import { hostChanges } from "#hostChanges.ts";
12
+ import type { HostPolicy } from "#hostOptions.ts";
13
+ import { hostRpc, rpcCacheControl } from "#hostRpc.ts";
14
+ import type { hostTransport } from "#hostTransport.ts";
15
+ import { redactingRpcSerialization } from "#redactingRpcSerialization.ts";
16
+ import type { schemaSensitiveKeys } from "#schemaSensitiveKeys.ts";
17
+
18
+ export function hostRpcRoutes<TRpcs extends Rpc.Any, THandlers, TError, TRequirements, TSession extends SessionShape>(
19
+ options: {
20
+ readonly group: RpcGroup.RpcGroup<TRpcs>;
21
+ readonly handlers: Layer.Layer<THandlers, TError, TRequirements>;
22
+ readonly rpcPath?: HttpRouter.PathInput;
23
+ } & HostPolicy<TSession>,
24
+ changesService: typeof AppChanges,
25
+ { bodySize, trustedProxy, nativeOrigins }: Effect.Success<ReturnType<typeof hostTransport>>,
26
+ sensitive: ReturnType<typeof schemaSensitiveKeys>,
27
+ ) {
28
+ const middleware: Layer.Layer<RequestTracing> | Layer.Layer<Authenticated | MaybeAuthenticated | RequestTracing> =
29
+ options.policy === "loopback"
30
+ ? requestTracingLayer({ sensitive })
31
+ : Layer.mergeAll(authenticatedLayer(options.sessions), maybeAuthenticatedLayer(options.sessions), requestTracingLayer({ sensitive }));
32
+ return HttpRouter.use((router) =>
33
+ Effect.gen(function* () {
34
+ const wire = redactingRpcSerialization(yield* RpcSerialization.RpcSerialization, sensitive, sensitive.maskFailure);
35
+ const handler = yield* hostRpc(options.group).pipe(Effect.provideService(RpcSerialization.RpcSerialization, wire));
36
+ const guarded = localRpcGuard(handler, trustedProxy, nativeOrigins);
37
+ yield* router.add(
38
+ "OPTIONS",
39
+ options.rpcPath ?? "/rpc",
40
+ HttpServerResponse.text("Local same-origin requests only", {
41
+ status: HttpStatus.fromLiteral("Forbidden"),
42
+ }),
43
+ );
44
+ yield* router.add("POST", options.rpcPath ?? "/rpc", (request) => {
45
+ HttpEffect.appendPreResponseHandlerUnsafe(request, rpcCacheControl);
46
+ return guarded.pipe(
47
+ Effect.provideService(HttpIncomingMessage.MaxBodySize, ByteSize.bytes(bodySize)),
48
+ Effect.provideService(HttpServerRequest.HttpServerRequest, boundedRpcRequest(request)),
49
+ Effect.provideService(RpcSerialization.RpcSerialization, wire),
50
+ );
51
+ });
52
+ }),
53
+ ).pipe(
54
+ Layer.provide(Layer.merge(middleware, hostChanges(options.group, changesService).pipe(Layer.provideMerge(options.handlers)))),
55
+ Layer.provide(RpcSerialization.layerNdjson),
56
+ );
57
+ }
@@ -1,5 +1,6 @@
1
1
  import { Effect, Schema } from "effect";
2
2
  import { Headers, HttpMiddleware, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
3
+ import * as HttpStatus from "effect/http/HttpStatus";
3
4
  import { localTransport } from "@shivaedev/identity/server/local.ts";
4
5
 
5
6
  export const NativeOrigins = Schema.Array(
@@ -16,7 +17,13 @@ export const NativeOrigins = Schema.Array(
16
17
  ),
17
18
  );
18
19
 
19
- export function nativeClientOrigins(origins: readonly string[], trustedProxy: string | undefined, auth = false) {
20
+ export function nativeClientOrigins(
21
+ origins: readonly string[],
22
+ trustedProxy: string | undefined,
23
+ auth: boolean | "preflight" = false,
24
+ rpcPath: HttpRouter.PathInput = "/rpc",
25
+ ) {
26
+ const matchesPath = auth ? (path: string) => path === "/api/auth" || path.startsWith("/api/auth/") : (path: string) => path === rpcPath;
20
27
  const allowed = localTransport(trustedProxy, origins);
21
28
  const cors = HttpMiddleware.cors({
22
29
  allowedHeaders: ["Authorization", "Content-Type"],
@@ -28,16 +35,20 @@ export function nativeClientOrigins(origins: readonly string[], trustedProxy: st
28
35
  return HttpRouter.middleware((effect) =>
29
36
  Effect.gen(function* () {
30
37
  const request = yield* HttpServerRequest.HttpServerRequest;
31
- if (auth && !request.url.split("?")[0]?.startsWith("/api/auth/")) {
38
+ if (!matchesPath(request.url.split("?")[0] ?? "")) {
32
39
  return yield* effect;
33
40
  }
34
- if ((auth || request.method === "OPTIONS") && !allowed(request)) {
35
- return HttpServerResponse.text("Local same-origin requests only", { status: 403 });
41
+ if ((auth === true || request.method === "OPTIONS") && !allowed(request)) {
42
+ return HttpServerResponse.text("Local same-origin requests only", {
43
+ status: HttpStatus.fromLiteral("Forbidden"),
44
+ });
36
45
  }
37
46
  if (!origins.includes(request.headers.origin ?? "")) {
38
47
  return yield* effect;
39
48
  }
40
- const native = request.modify({ headers: Headers.remove(request.headers, "cookie") });
49
+ const native = request.modify({
50
+ headers: Headers.remove(request.headers, "cookie"),
51
+ });
41
52
  return yield* cors(effect).pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, native));
42
53
  }),
43
54
  ).layer;
@@ -0,0 +1,24 @@
1
+ import { createServer } from "node:http";
2
+ import { NodeHttpServer } from "@effect/platform-node";
3
+ import { Config, type Duration, Effect, Layer } from "effect";
4
+
5
+ export function nodeHttpServer(
6
+ host: string,
7
+ config?: {
8
+ readonly port?: number;
9
+ readonly gracefulShutdownTimeout?: Duration.Input;
10
+ readonly disablePreemptiveShutdown?: boolean;
11
+ },
12
+ ) {
13
+ return Layer.unwrap(
14
+ Effect.gen(function* () {
15
+ const port = config?.port ?? (yield* Config.Int("PORT").pipe(Config.withDefault(3000)));
16
+ return NodeHttpServer.layer(createServer, {
17
+ gracefulShutdownTimeout: config?.gracefulShutdownTimeout,
18
+ host,
19
+ port,
20
+ ...(config?.disablePreemptiveShutdown === undefined ? {} : { disablePreemptiveShutdown: config.disablePreemptiveShutdown }),
21
+ });
22
+ }),
23
+ );
24
+ }
@@ -1,5 +1,6 @@
1
1
  import { Effect, Layer } from "effect";
2
2
  import { FetchHttpClient, HttpClient, HttpClientError, type HttpClientRequest } from "effect/http";
3
+ import * as HttpStatus from "effect/http/HttpStatus";
3
4
  import { RedactionPolicy } from "#RedactionPolicy.ts";
4
5
  import { redactOtlpText } from "#safeOtlpSerialization.ts";
5
6
 
@@ -36,7 +37,7 @@ export function otlpHttpClient(timeout = 10_000) {
36
37
  ),
37
38
  ),
38
39
  HttpClient.tap((response) => {
39
- if (response.status >= 200 && response.status < 300) {
40
+ if (response.status >= HttpStatus.fromLiteral("OK") && response.status < HttpStatus.fromLiteral("MultipleChoices")) {
40
41
  warned = false;
41
42
  return Effect.void;
42
43
  }