@shivaedev/server 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +323 -2
- package/dist/AppHostFactory.d.ts +15 -0
- package/dist/AppHostFactory.d.ts.map +1 -0
- package/dist/AppHostFactory.js +2 -0
- package/dist/AppHostFactory.js.map +1 -0
- package/dist/AppHostInput.d.ts +13 -0
- package/dist/AppHostInput.d.ts.map +1 -0
- package/dist/AppHostInput.js +2 -0
- package/dist/AppHostInput.js.map +1 -0
- package/dist/MigrationOptions.d.ts +15 -0
- package/dist/MigrationOptions.d.ts.map +1 -0
- package/dist/MigrationOptions.js +2 -0
- package/dist/MigrationOptions.js.map +1 -0
- package/dist/RedactionPolicy.d.ts +4 -0
- package/dist/RedactionPolicy.d.ts.map +1 -0
- package/dist/RedactionPolicy.js +4 -0
- package/dist/RedactionPolicy.js.map +1 -0
- package/dist/appHost.d.ts +47 -0
- package/dist/appHost.d.ts.map +1 -0
- package/dist/appHost.js +17 -0
- package/dist/appHost.js.map +1 -0
- package/dist/appHostHttp.d.ts +88 -0
- package/dist/appHostHttp.d.ts.map +1 -0
- package/dist/appHostHttp.js +67 -0
- package/dist/appHostHttp.js.map +1 -0
- package/dist/boundedRpcRequest.d.ts +3 -0
- package/dist/boundedRpcRequest.d.ts.map +1 -0
- package/dist/boundedRpcRequest.js +22 -0
- package/dist/boundedRpcRequest.js.map +1 -0
- package/dist/databaseAddress.d.ts +4 -0
- package/dist/databaseAddress.d.ts.map +1 -0
- package/dist/databaseAddress.js +25 -0
- package/dist/databaseAddress.js.map +1 -0
- package/dist/databaseLayer.d.ts +14 -0
- package/dist/databaseLayer.d.ts.map +1 -0
- package/dist/databaseLayer.js +100 -0
- package/dist/databaseLayer.js.map +1 -0
- package/dist/development.d.ts +10 -0
- package/dist/development.d.ts.map +1 -0
- package/dist/development.js +20 -0
- package/dist/development.js.map +1 -0
- package/dist/healthRoute.d.ts +3 -0
- package/dist/healthRoute.d.ts.map +1 -0
- package/dist/healthRoute.js +18 -0
- package/dist/healthRoute.js.map +1 -0
- package/dist/hostChanges.d.ts +7 -0
- package/dist/hostChanges.d.ts.map +1 -0
- package/dist/hostChanges.js +25 -0
- package/dist/hostChanges.js.map +1 -0
- package/dist/hostOptions.d.ts +34 -0
- package/dist/hostOptions.d.ts.map +1 -0
- package/dist/hostOptions.js +2 -0
- package/dist/hostOptions.js.map +1 -0
- package/dist/hostTransport.d.ts +16 -0
- package/dist/hostTransport.d.ts.map +1 -0
- package/dist/hostTransport.js +16 -0
- package/dist/hostTransport.js.map +1 -0
- package/dist/nativeClientOrigins.d.ts +4 -0
- package/dist/nativeClientOrigins.d.ts.map +1 -0
- package/dist/nativeClientOrigins.js +38 -0
- package/dist/nativeClientOrigins.js.map +1 -0
- package/dist/node-http/subscriptionSignal.d.ts +12 -0
- package/dist/node-http/subscriptionSignal.d.ts.map +1 -0
- package/dist/node-http/subscriptionSignal.js +55 -0
- package/dist/node-http/subscriptionSignal.js.map +1 -0
- package/dist/otlpHttpClient.d.ts +4 -0
- package/dist/otlpHttpClient.d.ts.map +1 -0
- package/dist/otlpHttpClient.js +36 -0
- package/dist/otlpHttpClient.js.map +1 -0
- package/dist/otlpTracing.d.ts +3 -0
- package/dist/otlpTracing.d.ts.map +1 -0
- package/dist/otlpTracing.js +35 -0
- package/dist/otlpTracing.js.map +1 -0
- package/dist/redactOtlpUrl.d.ts +2 -0
- package/dist/redactOtlpUrl.d.ts.map +1 -0
- package/dist/redactOtlpUrl.js +41 -0
- package/dist/redactOtlpUrl.js.map +1 -0
- package/dist/redactingDiagnostics.d.ts +5 -0
- package/dist/redactingDiagnostics.d.ts.map +1 -0
- package/dist/redactingDiagnostics.js +25 -0
- package/dist/redactingDiagnostics.js.map +1 -0
- package/dist/redactingRpcSerialization.d.ts +4 -0
- package/dist/redactingRpcSerialization.d.ts.map +1 -0
- package/dist/redactingRpcSerialization.js +74 -0
- package/dist/redactingRpcSerialization.js.map +1 -0
- package/dist/redactingTracer.d.ts +4 -0
- package/dist/redactingTracer.d.ts.map +1 -0
- package/dist/redactingTracer.js +32 -0
- package/dist/redactingTracer.js.map +1 -0
- package/dist/rpc-server/publicConfigLayer.d.ts +4 -0
- package/dist/rpc-server/publicConfigLayer.d.ts.map +1 -0
- package/dist/rpc-server/publicConfigLayer.js +9 -0
- package/dist/rpc-server/publicConfigLayer.js.map +1 -0
- package/dist/runtime/makePlatform.d.ts +4 -0
- package/dist/runtime/makePlatform.d.ts.map +1 -0
- package/dist/runtime/makePlatform.js +65 -0
- package/dist/runtime/makePlatform.js.map +1 -0
- package/dist/runtime/types.d.ts +19 -0
- package/dist/runtime/types.d.ts.map +1 -0
- package/dist/runtime/types.js +2 -0
- package/dist/runtime/types.js.map +1 -0
- package/dist/safeOtlpSerialization.d.ts +6 -0
- package/dist/safeOtlpSerialization.d.ts.map +1 -0
- package/dist/safeOtlpSerialization.js +67 -0
- package/dist/safeOtlpSerialization.js.map +1 -0
- package/dist/schemaSensitiveKeys.d.ts +5 -0
- package/dist/schemaSensitiveKeys.d.ts.map +1 -0
- package/dist/schemaSensitiveKeys.js +143 -0
- package/dist/schemaSensitiveKeys.js.map +1 -0
- package/dist/setupDatabases.d.ts +3 -0
- package/dist/setupDatabases.d.ts.map +1 -0
- package/dist/setupDatabases.js +12 -0
- package/dist/setupDatabases.js.map +1 -0
- package/dist/tracedGroup.d.ts +4 -0
- package/dist/tracedGroup.d.ts.map +1 -0
- package/dist/tracedGroup.js +13 -0
- package/dist/tracedGroup.js.map +1 -0
- package/package.json +110 -3
- package/src/AppHostFactory.ts +121 -0
- package/src/AppHostInput.ts +74 -0
- package/src/MigrationOptions.ts +15 -0
- package/src/RedactionPolicy.ts +4 -0
- package/src/appHost.ts +117 -0
- package/src/appHostHttp.ts +147 -0
- package/src/boundedRpcRequest.ts +28 -0
- package/src/databaseAddress.ts +25 -0
- package/src/databaseLayer.ts +127 -0
- package/src/development.ts +34 -0
- package/src/healthRoute.ts +27 -0
- package/src/hostChanges.ts +34 -0
- package/src/hostOptions.ts +30 -0
- package/src/hostTransport.ts +26 -0
- package/src/nativeClientOrigins.ts +44 -0
- package/src/node-http/subscriptionSignal.ts +64 -0
- package/src/otlpHttpClient.ts +54 -0
- package/src/otlpTracing.ts +61 -0
- package/src/redactOtlpUrl.ts +48 -0
- package/src/redactingDiagnostics.ts +33 -0
- package/src/redactingRpcSerialization.ts +85 -0
- package/src/redactingTracer.ts +37 -0
- package/src/rpc-server/publicConfigLayer.ts +12 -0
- package/src/runtime/makePlatform.ts +75 -0
- package/src/runtime/types.ts +24 -0
- package/src/safeOtlpSerialization.ts +73 -0
- package/src/schemaSensitiveKeys.ts +148 -0
- package/src/setupDatabases.ts +12 -0
- package/src/tracedGroup.ts +14 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { NodeRuntime, NodeServices } from "@effect/platform-node";
|
|
3
|
+
import { Effect } from "effect";
|
|
4
|
+
import { ChildProcess, ChildProcessSpawner } from "effect/process";
|
|
5
|
+
|
|
6
|
+
export interface DevelopmentOptions {
|
|
7
|
+
readonly conditions?: readonly string[];
|
|
8
|
+
readonly server: string;
|
|
9
|
+
readonly worker: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const development = Effect.fn("Platform.development")(function* ({ server, worker, conditions = [] }: DevelopmentOptions) {
|
|
13
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
14
|
+
const options = { stderr: "inherit", stdout: "inherit" } as const;
|
|
15
|
+
const args = [...conditions.map((condition) => `--conditions=${condition}`), "--watch"];
|
|
16
|
+
return yield* Effect.raceAll([
|
|
17
|
+
spawner.exitCode(ChildProcess.make(process.execPath, [...args, server], options)),
|
|
18
|
+
spawner.exitCode(ChildProcess.make(process.execPath, [...args, worker], options)),
|
|
19
|
+
spawner.exitCode(ChildProcess.make("pnpm", ["exec", "vite"], options)),
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export function runDevelopment(options: DevelopmentOptions): void {
|
|
24
|
+
NodeRuntime.runMain(
|
|
25
|
+
development(options).pipe(
|
|
26
|
+
Effect.tap((code) =>
|
|
27
|
+
Effect.sync(() => {
|
|
28
|
+
process.exitCode = code;
|
|
29
|
+
}),
|
|
30
|
+
),
|
|
31
|
+
Effect.provide(NodeServices.layer),
|
|
32
|
+
),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Effect, Fiber, Option } from "effect";
|
|
2
|
+
import { HttpRouter, HttpServerResponse } from "effect/http";
|
|
3
|
+
import { SqlClient } from "effect/sql";
|
|
4
|
+
|
|
5
|
+
export const healthRoute = HttpRouter.use((router) =>
|
|
6
|
+
Effect.gen(function* () {
|
|
7
|
+
const database = yield* Effect.serviceOption(SqlClient.SqlClient);
|
|
8
|
+
const scope = yield* Effect.scope;
|
|
9
|
+
const probe = Effect.gen(function* () {
|
|
10
|
+
if (Option.isNone(database)) {
|
|
11
|
+
return 503;
|
|
12
|
+
}
|
|
13
|
+
const query = yield* database.value`select 1`.pipe(Effect.forkIn(scope));
|
|
14
|
+
return yield* Fiber.join(query).pipe(
|
|
15
|
+
Effect.timeout("2 seconds"),
|
|
16
|
+
// Driver cancellation may need network I/O; keep cleanup in the host scope without delaying the response.
|
|
17
|
+
Effect.ensuring(Fiber.interrupt(query).pipe(Effect.forkIn(scope), Effect.asVoid)),
|
|
18
|
+
Effect.match({ onFailure: () => 503, onSuccess: () => 200 }),
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
yield* router.add(
|
|
22
|
+
"GET",
|
|
23
|
+
"/health",
|
|
24
|
+
Effect.map(probe, (status) => HttpServerResponse.empty({ headers: { "cache-control": "no-store" }, status })),
|
|
25
|
+
);
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Option, Stream } from "effect";
|
|
2
|
+
import type { Rpc, RpcGroup } from "effect/rpc";
|
|
3
|
+
import type { QueryReference } from "@shivaedev/contract/serverMetadata.ts";
|
|
4
|
+
import { resolveChanges } from "@shivaedev/identity/server/sessionKeys.ts";
|
|
5
|
+
import type { AppChanges } from "./appHost.ts";
|
|
6
|
+
|
|
7
|
+
export function hostChanges<R extends Rpc.Any>(group: RpcGroup.RpcGroup<R>, changesService: typeof AppChanges) {
|
|
8
|
+
return Layer.effectContext(
|
|
9
|
+
Effect.gen(function* () {
|
|
10
|
+
const changes = yield* Effect.serviceOption(changesService);
|
|
11
|
+
const services = yield* Effect.context<never>();
|
|
12
|
+
const handlers = new Map<string, unknown>();
|
|
13
|
+
for (const rpc of group.requests.values()) {
|
|
14
|
+
if (!rpc._tag.endsWith(".$changes")) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
const provided = services.mapUnsafe.get(rpc.key);
|
|
18
|
+
if (Option.isNone(changes) && provided !== undefined) {
|
|
19
|
+
handlers.set(rpc.key, provided);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
function handler({ queries }: { readonly queries: readonly QueryReference[] }) {
|
|
23
|
+
return Stream.unwrap(
|
|
24
|
+
Effect.map(resolveChanges(rpc, queries), ({ keys, context }) =>
|
|
25
|
+
Option.isSome(changes) ? changes.value.stream(keys, context) : Stream.never,
|
|
26
|
+
),
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
handlers.set(rpc.key, { context: services, handler, tag: rpc._tag });
|
|
30
|
+
}
|
|
31
|
+
return Context.makeUnsafe<Rpc.ToHandler<Extract<R, { readonly _tag: `${string}.$changes` }>>>(handlers);
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Duration, 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 "@shivaedev/identity/server/session.ts";
|
|
5
|
+
import type { AppDatabaseOptions } from "./appHost.ts";
|
|
6
|
+
|
|
7
|
+
export type 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 sensitiveKeys?: readonly string[];
|
|
15
|
+
readonly disablePreemptiveShutdown?: boolean;
|
|
16
|
+
readonly gracefulShutdownTimeout?: Duration.Input;
|
|
17
|
+
};
|
|
18
|
+
readonly group: RpcGroup.RpcGroup<TRpcs>;
|
|
19
|
+
readonly handlers: Layer.Layer<THandlers, THandlerError, THandlerRequirements>;
|
|
20
|
+
readonly nativeOrigins?: readonly string[];
|
|
21
|
+
readonly routes?: Layer.Layer<never, never, HttpRouter.HttpRouter | HttpRouter.Request<"Error", unknown>>;
|
|
22
|
+
readonly rpcPath?: HttpRouter.PathInput;
|
|
23
|
+
} & (
|
|
24
|
+
| { readonly services: Layer.Layer<TServices, TServiceError, TServiceRequirements> }
|
|
25
|
+
| ([TServices | TServiceError | TServiceRequirements] extends [never] ? { readonly services?: never } : never)
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
export type HostPolicy<TSession extends SessionShape> =
|
|
29
|
+
| { readonly policy?: undefined; readonly sessions: SessionPolicy<TSession> }
|
|
30
|
+
| { readonly policy: "loopback"; readonly sessions?: never };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Config, Effect, Option, Schema } from "effect";
|
|
2
|
+
import { TrustedProxy } from "@shivaedev/identity/server/trustedProxy.ts";
|
|
3
|
+
import { NativeOrigins } from "#nativeClientOrigins.ts";
|
|
4
|
+
|
|
5
|
+
interface Options {
|
|
6
|
+
readonly config?: { readonly maxBodySize?: number; readonly trustedProxy?: string };
|
|
7
|
+
readonly nativeOrigins?: readonly string[];
|
|
8
|
+
readonly policy?: "loopback" | undefined;
|
|
9
|
+
}
|
|
10
|
+
export const hostTransport = Effect.fn("Platform.hostTransport")(function* (options: Options) {
|
|
11
|
+
const maxBodySize = options.config?.maxBodySize ?? (yield* Config.Int("MAX_BODY_SIZE").pipe(Config.withDefault(1024 * 1024)));
|
|
12
|
+
const bodySize = yield* Schema.decodeUnknownEffect(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)))(maxBodySize).pipe(
|
|
13
|
+
Effect.mapError((cause) => new Config.ConfigError(cause)),
|
|
14
|
+
);
|
|
15
|
+
const trustedProxy =
|
|
16
|
+
options.policy === "loopback"
|
|
17
|
+
? undefined
|
|
18
|
+
: (options.config?.trustedProxy ?? Option.getOrUndefined(yield* Config.String("TRUSTED_PROXY").pipe(Config.option)));
|
|
19
|
+
if (trustedProxy !== undefined) {
|
|
20
|
+
yield* Schema.decodeUnknownEffect(TrustedProxy)(trustedProxy).pipe(Effect.mapError((cause) => new Config.ConfigError(cause)));
|
|
21
|
+
}
|
|
22
|
+
const nativeOrigins = yield* Schema.decodeUnknownEffect(NativeOrigins)(options.nativeOrigins ?? []).pipe(
|
|
23
|
+
Effect.mapError((cause) => new Config.ConfigError(cause)),
|
|
24
|
+
);
|
|
25
|
+
return { bodySize, nativeOrigins, trustedProxy };
|
|
26
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import { Headers, HttpMiddleware, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
|
|
3
|
+
import { localTransport } from "@shivaedev/identity/server/local.ts";
|
|
4
|
+
|
|
5
|
+
export const NativeOrigins = Schema.Array(
|
|
6
|
+
Schema.String.check(
|
|
7
|
+
Schema.makeFilter((origin) => {
|
|
8
|
+
try {
|
|
9
|
+
const url = new URL(origin);
|
|
10
|
+
const authority = url.origin === "null" ? `${url.protocol}//${url.host}` : url.origin;
|
|
11
|
+
return url.host !== "" && !origin.includes("*") && authority === origin;
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}),
|
|
16
|
+
),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
export function nativeClientOrigins(origins: readonly string[], trustedProxy: string | undefined, auth = false) {
|
|
20
|
+
const allowed = localTransport(trustedProxy, origins);
|
|
21
|
+
const cors = HttpMiddleware.cors({
|
|
22
|
+
allowedHeaders: ["Authorization", "Content-Type"],
|
|
23
|
+
allowedMethods: auth ? ["GET", "POST"] : ["POST"],
|
|
24
|
+
allowedOrigins: (origin) => origins.includes(origin),
|
|
25
|
+
credentials: false,
|
|
26
|
+
exposedHeaders: auth ? ["set-auth-token"] : [],
|
|
27
|
+
});
|
|
28
|
+
return HttpRouter.middleware((effect) =>
|
|
29
|
+
Effect.gen(function* () {
|
|
30
|
+
const request = yield* HttpServerRequest.HttpServerRequest;
|
|
31
|
+
if (auth && !request.url.split("?")[0]?.startsWith("/api/auth/")) {
|
|
32
|
+
return yield* effect;
|
|
33
|
+
}
|
|
34
|
+
if ((auth || request.method === "OPTIONS") && !allowed(request)) {
|
|
35
|
+
return HttpServerResponse.text("Local same-origin requests only", { status: 403 });
|
|
36
|
+
}
|
|
37
|
+
if (!origins.includes(request.headers.origin ?? "")) {
|
|
38
|
+
return yield* effect;
|
|
39
|
+
}
|
|
40
|
+
const native = request.modify({ headers: Headers.remove(request.headers, "cookie") });
|
|
41
|
+
return yield* cors(effect).pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, native));
|
|
42
|
+
}),
|
|
43
|
+
).layer;
|
|
44
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { IncomingMessage } from "node:http";
|
|
2
|
+
export interface NodeSubscriptionSignalOptions {
|
|
3
|
+
readonly nodeRequest?: IncomingMessage | undefined;
|
|
4
|
+
readonly request?: Request | undefined;
|
|
5
|
+
readonly signals?: Iterable<AbortSignal | undefined>;
|
|
6
|
+
}
|
|
7
|
+
export interface NodeSubscriptionSignal {
|
|
8
|
+
readonly dispose: () => void;
|
|
9
|
+
readonly signal: AbortSignal;
|
|
10
|
+
}
|
|
11
|
+
function property(value: unknown, key: string): unknown {
|
|
12
|
+
return typeof value === "object" && value !== null ? Reflect.get(value, key) : undefined;
|
|
13
|
+
}
|
|
14
|
+
function isIncomingMessage(value: unknown): value is IncomingMessage {
|
|
15
|
+
return typeof property(value, "socket") === "object" && typeof property(value, "once") === "function";
|
|
16
|
+
}
|
|
17
|
+
function nodeRequestFrom(request: Request | undefined): IncomingMessage | undefined {
|
|
18
|
+
const req = property(property(property(request, "runtime"), "node"), "req");
|
|
19
|
+
return isIncomingMessage(req) ? req : undefined;
|
|
20
|
+
}
|
|
21
|
+
// Under Bun's node:http compatibility layer a Web request signal may miss an abandoned socket, so node close events cover long-lived responses.
|
|
22
|
+
export function nodeSubscriptionSignal(options: NodeSubscriptionSignalOptions): NodeSubscriptionSignal {
|
|
23
|
+
const controller = new AbortController();
|
|
24
|
+
const signals: AbortSignal[] = [controller.signal];
|
|
25
|
+
if (options.request !== undefined) {
|
|
26
|
+
signals.push(options.request.signal);
|
|
27
|
+
}
|
|
28
|
+
for (const signal of options.signals ?? []) {
|
|
29
|
+
if (signal !== undefined) {
|
|
30
|
+
signals.push(signal);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const nodeRequest = options.nodeRequest ?? nodeRequestFrom(options.request);
|
|
34
|
+
if (nodeRequest === undefined) {
|
|
35
|
+
return { dispose: (): void => undefined, signal: AbortSignal.any(signals) };
|
|
36
|
+
}
|
|
37
|
+
const request = nodeRequest;
|
|
38
|
+
const socket = request.socket;
|
|
39
|
+
function onRequestClose() {
|
|
40
|
+
if (!request.complete) {
|
|
41
|
+
controller.abort();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function onSocketClose() {
|
|
45
|
+
return controller.abort();
|
|
46
|
+
}
|
|
47
|
+
nodeRequest.once("close", onRequestClose);
|
|
48
|
+
socket.once("close", onSocketClose);
|
|
49
|
+
if (nodeRequest.destroyed || socket.destroyed) {
|
|
50
|
+
controller.abort();
|
|
51
|
+
}
|
|
52
|
+
let disposed = false;
|
|
53
|
+
return {
|
|
54
|
+
dispose: () => {
|
|
55
|
+
if (disposed) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
disposed = true;
|
|
59
|
+
nodeRequest.removeListener("close", onRequestClose);
|
|
60
|
+
socket.removeListener("close", onSocketClose);
|
|
61
|
+
},
|
|
62
|
+
signal: AbortSignal.any(signals),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Effect, Layer } from "effect";
|
|
2
|
+
import { FetchHttpClient, HttpClient, HttpClientError, type HttpClientRequest } from "effect/http";
|
|
3
|
+
import { RedactionPolicy } from "#RedactionPolicy.ts";
|
|
4
|
+
import { redactOtlpText } from "#safeOtlpSerialization.ts";
|
|
5
|
+
|
|
6
|
+
function timeoutError(request: HttpClientRequest.HttpClientRequest) {
|
|
7
|
+
return new HttpClientError.HttpClientError({
|
|
8
|
+
reason: new HttpClientError.TransportError({ description: "OTLP export request timed out", request }),
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function otlpHttpClient(timeout = 10_000) {
|
|
13
|
+
return Layer.effect(
|
|
14
|
+
HttpClient.HttpClient,
|
|
15
|
+
Effect.gen(function* () {
|
|
16
|
+
const client = yield* HttpClient.HttpClient;
|
|
17
|
+
const sensitive = yield* RedactionPolicy;
|
|
18
|
+
let warned = false;
|
|
19
|
+
function warnOnce(diagnostic: Effect.Effect<Record<string, unknown>>) {
|
|
20
|
+
return Effect.suspend(() => {
|
|
21
|
+
if (warned) {
|
|
22
|
+
return Effect.void;
|
|
23
|
+
}
|
|
24
|
+
warned = true;
|
|
25
|
+
return Effect.flatMap(diagnostic, (annotations) => Effect.logWarning("OTLP trace export failed").pipe(Effect.annotateLogs(annotations)));
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return client.pipe(
|
|
29
|
+
HttpClient.tap((response) => response.arrayBuffer),
|
|
30
|
+
HttpClient.transform((requestEffect, request) =>
|
|
31
|
+
requestEffect.pipe(
|
|
32
|
+
Effect.timeoutOrElse({
|
|
33
|
+
duration: timeout,
|
|
34
|
+
orElse: () => Effect.fail(timeoutError(request)),
|
|
35
|
+
}),
|
|
36
|
+
),
|
|
37
|
+
),
|
|
38
|
+
HttpClient.tap((response) => {
|
|
39
|
+
if (response.status >= 200 && response.status < 300) {
|
|
40
|
+
warned = false;
|
|
41
|
+
return Effect.void;
|
|
42
|
+
}
|
|
43
|
+
return warnOnce(
|
|
44
|
+
response.text.pipe(
|
|
45
|
+
Effect.orElseSucceed(() => "Unable to read collector response"),
|
|
46
|
+
Effect.map((body) => ({ "otlp.response": redactOtlpText(body, "", sensitive), "otlp.status": response.status })),
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
}),
|
|
50
|
+
HttpClient.tapError((error) => warnOnce(Effect.succeed({ "otlp.failure": error.reason._tag }))),
|
|
51
|
+
);
|
|
52
|
+
}),
|
|
53
|
+
).pipe(Layer.provide(FetchHttpClient.layer));
|
|
54
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Config, Effect, Layer, Schema } from "effect";
|
|
2
|
+
import { OtlpSerialization, OtlpTracer } from "effect/observability";
|
|
3
|
+
import { otlpHttpClient } from "#otlpHttpClient.ts";
|
|
4
|
+
import { RedactionPolicy } from "#RedactionPolicy.ts";
|
|
5
|
+
import { redactingTracer } from "#redactingTracer.ts";
|
|
6
|
+
import { safeOtlpSerialization } from "#safeOtlpSerialization.ts";
|
|
7
|
+
|
|
8
|
+
export const otlpTracing: Layer.Layer<never, Config.ConfigError> = Layer.unwrap(
|
|
9
|
+
Effect.gen(function* () {
|
|
10
|
+
const endpoint = yield* Config.URL("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT").pipe(
|
|
11
|
+
Config.orElse(() =>
|
|
12
|
+
Config.URL("OTEL_EXPORTER_OTLP_ENDPOINT").pipe(
|
|
13
|
+
Config.map((url) => {
|
|
14
|
+
url.pathname = `${url.pathname.replace(/\/$/u, "")}/v1/traces`;
|
|
15
|
+
return url;
|
|
16
|
+
}),
|
|
17
|
+
),
|
|
18
|
+
),
|
|
19
|
+
Config.withDefault(undefined),
|
|
20
|
+
);
|
|
21
|
+
if (endpoint === undefined) {
|
|
22
|
+
return Layer.empty;
|
|
23
|
+
}
|
|
24
|
+
const configuredProtocol = yield* Config.String("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL").pipe(
|
|
25
|
+
Config.orElse(() => Config.String("OTEL_EXPORTER_OTLP_PROTOCOL")),
|
|
26
|
+
Config.withDefault("http/protobuf"),
|
|
27
|
+
);
|
|
28
|
+
const protocol = yield* Schema.decodeUnknownEffect(Schema.Literals(["http/protobuf", "http/json"]), { reportInput: true })(
|
|
29
|
+
configuredProtocol,
|
|
30
|
+
).pipe(Effect.mapError((cause) => new Config.ConfigError(cause)));
|
|
31
|
+
const attributes: Record<string, string> = yield* Config.Record(
|
|
32
|
+
Schema.StringFromUriComponent,
|
|
33
|
+
Schema.StringFromUriComponent,
|
|
34
|
+
"OTEL_RESOURCE_ATTRIBUTES",
|
|
35
|
+
).pipe(Config.withDefault({}));
|
|
36
|
+
const serviceName = yield* Config.String("OTEL_SERVICE_NAME").pipe(Config.withDefault(attributes["service.name"] ?? "unknown_service"));
|
|
37
|
+
const serviceVersion = yield* Config.String("OTEL_SERVICE_VERSION").pipe(Config.withDefault(undefined));
|
|
38
|
+
const headers = yield* Config.Record(Schema.String, Schema.StringFromUriComponent, "OTEL_EXPORTER_OTLP_HEADERS").pipe(Config.withDefault({}));
|
|
39
|
+
const requestTimeout = yield* Config.Int("OTEL_EXPORTER_OTLP_TRACES_TIMEOUT").pipe(
|
|
40
|
+
Config.orElse(() => Config.Int("OTEL_EXPORTER_OTLP_TIMEOUT")),
|
|
41
|
+
Config.withDefault(10_000),
|
|
42
|
+
);
|
|
43
|
+
const exportInterval = yield* Config.Int("OTEL_BSP_SCHEDULE_DELAY").pipe(Config.withDefault(5000));
|
|
44
|
+
const maxBatchSize = yield* Config.Int("OTEL_BSP_MAX_EXPORT_BATCH_SIZE").pipe(Config.withDefault(1000));
|
|
45
|
+
const exportTimeout = yield* Config.Int("OTEL_BSP_EXPORT_TIMEOUT").pipe(Config.withDefault(3000));
|
|
46
|
+
const tracer = OtlpTracer.layer({
|
|
47
|
+
exportInterval,
|
|
48
|
+
headers,
|
|
49
|
+
maxBatchSize,
|
|
50
|
+
resource: { attributes, serviceName, serviceVersion },
|
|
51
|
+
shutdownTimeout: Math.min(exportTimeout, requestTimeout),
|
|
52
|
+
url: endpoint.toString(),
|
|
53
|
+
}).pipe(
|
|
54
|
+
Layer.provide(
|
|
55
|
+
safeOtlpSerialization.pipe(Layer.provide(protocol === "http/protobuf" ? OtlpSerialization.layerProtobuf : OtlpSerialization.layerJson)),
|
|
56
|
+
),
|
|
57
|
+
Layer.provide(otlpHttpClient(requestTimeout)),
|
|
58
|
+
);
|
|
59
|
+
return redactingTracer(yield* RedactionPolicy).pipe(Layer.provideMerge(tracer));
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { REDACTED } from "@shivaedev/errors/sensitive.ts";
|
|
2
|
+
|
|
3
|
+
function query(value: string): string {
|
|
4
|
+
return value
|
|
5
|
+
.split("&")
|
|
6
|
+
.map((part) => (part === "" ? part : `${part.split("=", 1)[0]}=${REDACTED}`))
|
|
7
|
+
.join("&");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function url(value: string): string {
|
|
11
|
+
const fragment = value.indexOf("#");
|
|
12
|
+
const target = fragment === -1 ? value : value.slice(0, fragment);
|
|
13
|
+
const separator = target.indexOf("?");
|
|
14
|
+
return (
|
|
15
|
+
(separator === -1 ? target : `${target.slice(0, separator + 1)}${query(target.slice(separator + 1))}`).replaceAll(
|
|
16
|
+
/(?<authority>(?:\b[a-z][a-z0-9+.-]*:)?\/\/)[^/?#]*@/giu,
|
|
17
|
+
`$<authority>${REDACTED}@`,
|
|
18
|
+
) + (fragment === -1 ? "" : `#${REDACTED}`)
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function structured(value: string): string | undefined {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.stringify(JSON.parse(value, (key: string, item: unknown) => (typeof item === "string" ? redactOtlpUrl(item, key) : item)));
|
|
25
|
+
} catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function redactOtlpUrl(value: string, key = ""): string {
|
|
31
|
+
if (/(?:^|[._-])(?:fragment|userinfo|username|password)$/iu.test(key)) {
|
|
32
|
+
return REDACTED;
|
|
33
|
+
}
|
|
34
|
+
if (/(?:^|[._-])query$/iu.test(key)) {
|
|
35
|
+
return url(`?${value}`).slice(1);
|
|
36
|
+
}
|
|
37
|
+
if (/(?:^|[._-])(?:url|uri|target)(?:$|[._-])/iu.test(key) || /(?:^|[._-])(?:referer|referrer|location|origin)$/iu.test(key)) {
|
|
38
|
+
return url(value);
|
|
39
|
+
}
|
|
40
|
+
if (/^\s*[[{]/u.test(value)) {
|
|
41
|
+
const redacted = structured(value);
|
|
42
|
+
if (redacted !== undefined) {
|
|
43
|
+
return redacted;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Quotes and whitespace can belong to secret values, so free text has no safe closing delimiter.
|
|
47
|
+
return value.replaceAll(/(?:[a-z][a-z0-9+.-]*:\/\/|[/?#]).*/gisu, url);
|
|
48
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Context, Effect, ErrorReporter, Layer, Logger, References } from "effect";
|
|
2
|
+
import { redact } from "@shivaedev/errors/redact.ts";
|
|
3
|
+
import { redactDiagnosticCause, redactingErrorReporter } from "@shivaedev/errors/redactCause.ts";
|
|
4
|
+
import type { SensitiveKey } from "@shivaedev/errors/sensitive.ts";
|
|
5
|
+
|
|
6
|
+
export function redactRecord(values: Readonly<Record<string, unknown>>, sensitive: SensitiveKey): Record<string, unknown> {
|
|
7
|
+
return Object.fromEntries(Object.entries(values).map(([key, value]) => [key, sensitive(key) ? "<redacted>" : redact(value, sensitive)]));
|
|
8
|
+
}
|
|
9
|
+
export function redactingDiagnostics(sensitive: SensitiveKey) {
|
|
10
|
+
return Layer.effectContext(
|
|
11
|
+
Effect.gen(function* () {
|
|
12
|
+
const loggers = yield* Logger.CurrentLoggers;
|
|
13
|
+
const reporters = yield* ErrorReporter.CurrentErrorReporters;
|
|
14
|
+
const safeLoggers = new Set(
|
|
15
|
+
[...loggers].map((logger) =>
|
|
16
|
+
Logger.make((options) => {
|
|
17
|
+
const context = options.fiber.context;
|
|
18
|
+
const annotations = redactRecord(options.fiber.getRef(References.CurrentLogAnnotations), sensitive);
|
|
19
|
+
options.fiber.setContext(Context.add(context, References.CurrentLogAnnotations, annotations));
|
|
20
|
+
try {
|
|
21
|
+
return logger.log({ ...options, cause: redactDiagnosticCause(options.cause, sensitive), message: redact(options.message, sensitive) });
|
|
22
|
+
} finally {
|
|
23
|
+
options.fiber.setContext(context);
|
|
24
|
+
}
|
|
25
|
+
}),
|
|
26
|
+
),
|
|
27
|
+
);
|
|
28
|
+
return Context.make(Logger.CurrentLoggers, safeLoggers).pipe(
|
|
29
|
+
Context.add(ErrorReporter.CurrentErrorReporters, new Set([...reporters].map((reporter) => redactingErrorReporter(reporter, sensitive)))),
|
|
30
|
+
);
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Predicate } from "effect";
|
|
2
|
+
import type { RpcSerialization } from "effect/rpc";
|
|
3
|
+
import { redactWire } from "@shivaedev/errors/redact.ts";
|
|
4
|
+
import { REDACTED, type SensitiveKey } from "@shivaedev/errors/sensitive.ts";
|
|
5
|
+
|
|
6
|
+
function isRequestId(value: unknown): value is string | number {
|
|
7
|
+
return typeof value === "string" || typeof value === "number";
|
|
8
|
+
}
|
|
9
|
+
export function redactingRpcSerialization(
|
|
10
|
+
wire: RpcSerialization.RpcSerialization["Service"],
|
|
11
|
+
sensitive: SensitiveKey,
|
|
12
|
+
maskFailure: (tag: string | undefined, reason: "Fail" | "Die") => boolean = () => false,
|
|
13
|
+
): typeof wire {
|
|
14
|
+
function reason(value: unknown, tag: string | undefined): unknown {
|
|
15
|
+
const redacted = redactWire(value, sensitive);
|
|
16
|
+
if (Predicate.isObjectOrArray(redacted)) {
|
|
17
|
+
if (Predicate.isTagged(value, "Fail") && maskFailure(tag, "Fail")) {
|
|
18
|
+
return { ...redacted, _tag: "Fail", error: REDACTED };
|
|
19
|
+
}
|
|
20
|
+
if (Predicate.isTagged(value, "Die") && maskFailure(tag, "Die")) {
|
|
21
|
+
return { ...redacted, _tag: "Die", defect: REDACTED };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (
|
|
25
|
+
(Predicate.isTagged(value, "Fail") || Predicate.isTagged(value, "Die") || Predicate.isTagged(value, "Interrupt"))
|
|
26
|
+
&& Predicate.isObjectOrArray(redacted)
|
|
27
|
+
) {
|
|
28
|
+
return { ...redacted, _tag: value._tag };
|
|
29
|
+
}
|
|
30
|
+
return redacted;
|
|
31
|
+
}
|
|
32
|
+
function exit(value: unknown, tag: string | undefined): unknown {
|
|
33
|
+
if (Predicate.isTagged(value, "Failure") && Predicate.hasProperty(value, "cause")) {
|
|
34
|
+
const cause = Array.isArray(value.cause) ? value.cause.map((item) => reason(item, tag)) : redactWire(value.cause, sensitive);
|
|
35
|
+
return { ...value, cause };
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
...wire,
|
|
41
|
+
makeUnsafe: () => {
|
|
42
|
+
const parser = wire.makeUnsafe();
|
|
43
|
+
const requests = new Map<string | number, string>();
|
|
44
|
+
function response(value: unknown): unknown {
|
|
45
|
+
if (Array.isArray(value)) {
|
|
46
|
+
return value.map(response);
|
|
47
|
+
}
|
|
48
|
+
if (Predicate.isTagged(value, "Defect") && Predicate.hasProperty(value, "defect")) {
|
|
49
|
+
return { ...value, defect: maskFailure(undefined, "Die") ? REDACTED : redactWire(value.defect, sensitive) };
|
|
50
|
+
}
|
|
51
|
+
if (
|
|
52
|
+
Predicate.isTagged(value, "Exit")
|
|
53
|
+
&& Predicate.hasProperty(value, "requestId")
|
|
54
|
+
&& isRequestId(value.requestId)
|
|
55
|
+
&& Predicate.hasProperty(value, "exit")
|
|
56
|
+
) {
|
|
57
|
+
const tag = requests.get(value.requestId);
|
|
58
|
+
requests.delete(value.requestId);
|
|
59
|
+
return { ...value, exit: exit(value.exit, tag) };
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
...parser,
|
|
65
|
+
decode: (data) => {
|
|
66
|
+
const decoded = parser.decode(data);
|
|
67
|
+
for (const request of decoded) {
|
|
68
|
+
if (
|
|
69
|
+
Predicate.isTagged(request, "Request")
|
|
70
|
+
&& Predicate.hasProperty(request, "id")
|
|
71
|
+
&& isRequestId(request.id)
|
|
72
|
+
&& Predicate.hasProperty(request, "tag")
|
|
73
|
+
&& typeof request.tag === "string"
|
|
74
|
+
&& !requests.has(request.id)
|
|
75
|
+
) {
|
|
76
|
+
requests.set(request.id, request.tag);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return decoded;
|
|
80
|
+
},
|
|
81
|
+
encode: (value) => parser.encode(response(value)),
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Effect, Exit, Layer, Tracer } from "effect";
|
|
2
|
+
import { redact } from "@shivaedev/errors/redact.ts";
|
|
3
|
+
import { redactDiagnosticCause } from "@shivaedev/errors/redactCause.ts";
|
|
4
|
+
import { REDACTED, type SensitiveKey } from "@shivaedev/errors/sensitive.ts";
|
|
5
|
+
import { redactRecord } from "#redactingDiagnostics.ts";
|
|
6
|
+
|
|
7
|
+
function redactLinks(values: readonly Tracer.SpanLink[], sensitive: SensitiveKey) {
|
|
8
|
+
return values.map((link) => ({ ...link, attributes: redactRecord(link.attributes, sensitive) }));
|
|
9
|
+
}
|
|
10
|
+
function redactSpan(span: Tracer.Span, sensitive: SensitiveKey) {
|
|
11
|
+
const end = span.end.bind(span);
|
|
12
|
+
const attribute = span.attribute.bind(span);
|
|
13
|
+
const event = span.event.bind(span);
|
|
14
|
+
const addLinks = span.addLinks.bind(span);
|
|
15
|
+
return Object.assign(span, {
|
|
16
|
+
addLinks: (values: readonly Tracer.SpanLink[]) => addLinks(redactLinks(values, sensitive)),
|
|
17
|
+
attribute: (key: string, value: unknown) => attribute(key, sensitive(key) ? REDACTED : redact(value, sensitive)),
|
|
18
|
+
end: (time: bigint, exit: Exit.Exit<unknown, unknown>) => {
|
|
19
|
+
for (const [key, value] of span.attributes) {
|
|
20
|
+
attribute(key, sensitive(key) ? REDACTED : redact(value, sensitive));
|
|
21
|
+
}
|
|
22
|
+
end(time, Exit.isFailure(exit) ? Exit.failCause(redactDiagnosticCause(exit.cause, sensitive)) : exit);
|
|
23
|
+
},
|
|
24
|
+
event: (name: string, time: bigint, attributes?: Record<string, unknown>) => event(name, time, attributes && redactRecord(attributes, sensitive)),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
export function redactingTracer(sensitive: SensitiveKey) {
|
|
28
|
+
return Layer.effect(
|
|
29
|
+
Tracer.Tracer,
|
|
30
|
+
Effect.map(Tracer.Tracer, (tracer) =>
|
|
31
|
+
Tracer.make({
|
|
32
|
+
...tracer,
|
|
33
|
+
span: (options) => redactSpan(tracer.span({ ...options, links: redactLinks(options.links, sensitive) }), sensitive),
|
|
34
|
+
}),
|
|
35
|
+
),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Config, type ConfigProvider, Effect } from "effect";
|
|
2
|
+
import type { publicConfig } from "@shivaedev/contract/publicConfig.ts";
|
|
3
|
+
|
|
4
|
+
export function publicConfigLayer<T, E>(declaration: ReturnType<typeof publicConfig<T, E>>, path?: string | ConfigProvider.Path) {
|
|
5
|
+
const application = declaration.application;
|
|
6
|
+
return application.toLayer(
|
|
7
|
+
Effect.gen(function* () {
|
|
8
|
+
const value = yield* Config.schema(declaration.declaration.queries[0].success, path);
|
|
9
|
+
return application.of({ "platform.publicConfig.get": () => Effect.succeed(value) });
|
|
10
|
+
}),
|
|
11
|
+
);
|
|
12
|
+
}
|