effect-actors 0.0.1
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 +467 -0
- package/dist/actor/client.d.ts +11 -0
- package/dist/actor/client.js +68 -0
- package/dist/actor/definition.d.ts +77 -0
- package/dist/actor/definition.js +110 -0
- package/dist/actor/state/codec.d.ts +10 -0
- package/dist/actor/state/codec.js +117 -0
- package/dist/actor/state/compiled.d.ts +17 -0
- package/dist/actor/state/compiled.js +1 -0
- package/dist/actor/state/layer.d.ts +7 -0
- package/dist/actor/state/layer.js +167 -0
- package/dist/client/http/errors.d.ts +30 -0
- package/dist/client/http/errors.js +72 -0
- package/dist/client/http/events.d.ts +10 -0
- package/dist/client/http/events.js +30 -0
- package/dist/client/http/layer.d.ts +5 -0
- package/dist/client/http/layer.js +133 -0
- package/dist/client/http.d.ts +1 -0
- package/dist/client/http.js +1 -0
- package/dist/client/outcome.d.ts +4 -0
- package/dist/client/outcome.js +10 -0
- package/dist/client/service.d.ts +28 -0
- package/dist/client/service.js +4 -0
- package/dist/errors/index.d.ts +93 -0
- package/dist/errors/index.js +93 -0
- package/dist/gateway/access.d.ts +11 -0
- package/dist/gateway/access.js +38 -0
- package/dist/gateway/dispatch.d.ts +13 -0
- package/dist/gateway/dispatch.js +45 -0
- package/dist/gateway/fault.d.ts +32 -0
- package/dist/gateway/fault.js +85 -0
- package/dist/gateway/http/events.d.ts +8 -0
- package/dist/gateway/http/events.js +32 -0
- package/dist/gateway/http/response.d.ts +11 -0
- package/dist/gateway/http/response.js +57 -0
- package/dist/gateway/http/routes.d.ts +6 -0
- package/dist/gateway/http/routes.js +184 -0
- package/dist/gateway/http/validation.d.ts +19 -0
- package/dist/gateway/http/validation.js +92 -0
- package/dist/gateway/options.d.ts +31 -0
- package/dist/gateway/options.js +107 -0
- package/dist/gateway/security.d.ts +25 -0
- package/dist/gateway/security.js +4 -0
- package/dist/gateway/service.d.ts +113 -0
- package/dist/gateway/service.js +5 -0
- package/dist/gateway/websocket/operations.d.ts +20 -0
- package/dist/gateway/websocket/operations.js +96 -0
- package/dist/gateway/websocket.d.ts +8 -0
- package/dist/gateway/websocket.js +96 -0
- package/dist/gateway/wire.d.ts +172 -0
- package/dist/gateway/wire.js +102 -0
- package/dist/host/client.d.ts +9 -0
- package/dist/host/client.js +46 -0
- package/dist/host/definition.d.ts +71 -0
- package/dist/host/definition.js +68 -0
- package/dist/host/entity.d.ts +9 -0
- package/dist/host/entity.js +302 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/protocol/encoding.d.ts +29 -0
- package/dist/protocol/encoding.js +165 -0
- package/dist/protocol/index.d.ts +90 -0
- package/dist/protocol/index.js +1 -0
- package/dist/protocol/schemas.d.ts +49 -0
- package/dist/protocol/schemas.js +30 -0
- package/dist/runtime/cluster/address.d.ts +3 -0
- package/dist/runtime/cluster/address.js +21 -0
- package/dist/runtime/cluster/client.d.ts +9 -0
- package/dist/runtime/cluster/client.js +131 -0
- package/dist/runtime/cluster/protocol.d.ts +104 -0
- package/dist/runtime/cluster/protocol.js +69 -0
- package/dist/runtime/configuration.d.ts +4 -0
- package/dist/runtime/configuration.js +34 -0
- package/dist/runtime/dispatchers/layer.d.ts +6 -0
- package/dist/runtime/dispatchers/layer.js +53 -0
- package/dist/runtime/dispatchers/outbox.d.ts +4 -0
- package/dist/runtime/dispatchers/outbox.js +57 -0
- package/dist/runtime/layer.d.ts +6 -0
- package/dist/runtime/layer.js +43 -0
- package/dist/runtime/service.d.ts +40 -0
- package/dist/runtime/service.js +5 -0
- package/dist/store/memory/background.d.ts +39 -0
- package/dist/store/memory/background.js +206 -0
- package/dist/store/memory/model.d.ts +72 -0
- package/dist/store/memory/model.js +149 -0
- package/dist/store/memory/ownership.d.ts +11 -0
- package/dist/store/memory/ownership.js +82 -0
- package/dist/store/memory/service.d.ts +3 -0
- package/dist/store/memory/service.js +32 -0
- package/dist/store/memory/state.d.ts +13 -0
- package/dist/store/memory/state.js +109 -0
- package/dist/store/service.d.ts +89 -0
- package/dist/store/service.js +9 -0
- package/dist/store/sql/events.d.ts +13 -0
- package/dist/store/sql/events.js +89 -0
- package/dist/store/sql/model.d.ts +39 -0
- package/dist/store/sql/model.js +126 -0
- package/dist/store/sql/outbox.d.ts +15 -0
- package/dist/store/sql/outbox.js +130 -0
- package/dist/store/sql/ownership.d.ts +44 -0
- package/dist/store/sql/ownership.js +130 -0
- package/dist/store/sql/receipts.d.ts +25 -0
- package/dist/store/sql/receipts.js +93 -0
- package/dist/store/sql/schema.d.ts +9 -0
- package/dist/store/sql/schema.js +124 -0
- package/dist/store/sql/service.d.ts +5 -0
- package/dist/store/sql/service.js +42 -0
- package/dist/store/sql/state.d.ts +13 -0
- package/dist/store/sql/state.js +109 -0
- package/dist/store/sql/timers.d.ts +10 -0
- package/dist/store/sql/timers.js +90 -0
- package/dist/store/sql/transaction.d.ts +6 -0
- package/dist/store/sql/transaction.js +57 -0
- package/dist/store/sql/transition.d.ts +15 -0
- package/dist/store/sql/transition.js +154 -0
- package/dist/testing/index.d.ts +6 -0
- package/dist/testing/index.js +27 -0
- package/package.json +80 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Effect, Queue, Stream } from "effect";
|
|
2
|
+
import { Sse } from "effect/unstable/encoding";
|
|
3
|
+
import { boundary, decode, readCursor } from "../../protocol/index.js";
|
|
4
|
+
import { Event as EventSchema } from "../../protocol/schemas.js";
|
|
5
|
+
import { fromServiceError, safeError, slowConsumer, unavailable } from "../fault.js";
|
|
6
|
+
const checkedEvents = (client, address, after, eventSchema) => client.events(address, { after }).pipe(Stream.mapEffect((event) => decode(EventSchema, event).pipe(Effect.flatMap((checked) => boundary(() => readCursor(address, checked.cursor)).pipe(Effect.mapError(() => unavailable()), Effect.flatMap(() => decode(eventSchema, checked.value).pipe(Effect.as(checked))))), Effect.mapError(() => unavailable()))), Stream.mapError((error) => fromServiceError(error)));
|
|
7
|
+
export const boundedEvents = (client, address, after, eventSchema, options, recheck) => Effect.gen(function* () {
|
|
8
|
+
const queue = yield* Queue.make({
|
|
9
|
+
capacity: options.maxBufferedEvents,
|
|
10
|
+
strategy: "dropping",
|
|
11
|
+
});
|
|
12
|
+
yield* Effect.addFinalizer(() => Queue.shutdown(queue).pipe(Effect.asVoid));
|
|
13
|
+
const producer = Stream.runForEach(checkedEvents(client, address, after, eventSchema), (event) => Queue.offer(queue, event).pipe(Effect.flatMap((accepted) => (accepted ? Effect.void : Effect.fail(slowConsumer()))))).pipe(Effect.matchEffect({
|
|
14
|
+
onFailure: (error) => Queue.fail(queue, error).pipe(Effect.asVoid),
|
|
15
|
+
onSuccess: () => Queue.end(queue).pipe(Effect.asVoid),
|
|
16
|
+
}));
|
|
17
|
+
const rechecker = Effect.forever(Effect.sleep(options.authenticationRecheckMillis).pipe(Effect.andThen(recheck))).pipe(Effect.catchIf((error) => true, (error) => Queue.fail(queue, error).pipe(Effect.andThen(Queue.shutdown(queue)), Effect.asVoid)));
|
|
18
|
+
yield* Effect.forkScoped(producer);
|
|
19
|
+
yield* Effect.forkScoped(rechecker);
|
|
20
|
+
return Stream.fromQueue(queue);
|
|
21
|
+
});
|
|
22
|
+
export const sseBody = (events) => events.pipe(Stream.map((event) => Sse.encoder.write({
|
|
23
|
+
_tag: "Event",
|
|
24
|
+
id: event.cursor,
|
|
25
|
+
event: "actor-event",
|
|
26
|
+
data: JSON.stringify(event),
|
|
27
|
+
})), Stream.catchIf((error) => true, (error) => Stream.succeed(Sse.encoder.write({
|
|
28
|
+
_tag: "Event",
|
|
29
|
+
id: undefined,
|
|
30
|
+
event: "actor-error",
|
|
31
|
+
data: JSON.stringify(safeError(error)),
|
|
32
|
+
}))), Stream.encodeText);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { HttpServerResponse } from "effect/unstable/http";
|
|
2
|
+
import type { HttpServerRequest } from "effect/unstable/http";
|
|
3
|
+
import type { Options } from "../options.js";
|
|
4
|
+
import type { GatewayFault } from "../fault.js";
|
|
5
|
+
export declare const allowedOrigin: (request: HttpServerRequest.HttpServerRequest, options: Options) => string | undefined;
|
|
6
|
+
export declare const validateHttpOrigin: (request: HttpServerRequest.HttpServerRequest, options: Options) => GatewayFault | undefined;
|
|
7
|
+
export declare const validateWebSocketOrigin: (request: HttpServerRequest.HttpServerRequest, options: Options) => GatewayFault | undefined;
|
|
8
|
+
export declare const jsonResponse: (request: HttpServerRequest.HttpServerRequest, options: Options, body: unknown, status?: number, headers?: Record<string, string>) => HttpServerResponse.HttpServerResponse;
|
|
9
|
+
export declare const errorResponse: (request: HttpServerRequest.HttpServerRequest, options: Options, fault: GatewayFault) => HttpServerResponse.HttpServerResponse;
|
|
10
|
+
export declare const preflightResponse: (request: HttpServerRequest.HttpServerRequest, options: Options) => HttpServerResponse.HttpServerResponse | undefined;
|
|
11
|
+
export declare const sseHeaders: (request: HttpServerRequest.HttpServerRequest, options: Options) => Record<string, string>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { HttpServerResponse } from "effect/unstable/http";
|
|
2
|
+
import { safeError } from "../fault.js";
|
|
3
|
+
const origin = (request) => request.headers.origin;
|
|
4
|
+
export const allowedOrigin = (request, options) => {
|
|
5
|
+
const value = origin(request);
|
|
6
|
+
return value !== undefined && options.allowedOrigins.includes(value) ? value : undefined;
|
|
7
|
+
};
|
|
8
|
+
export const validateHttpOrigin = (request, options) => {
|
|
9
|
+
const value = origin(request);
|
|
10
|
+
return value === undefined || options.allowedOrigins.includes(value)
|
|
11
|
+
? undefined
|
|
12
|
+
: { _tag: "GatewayFault", code: "Unauthorized", status: 403, message: "Origin is not allowed" };
|
|
13
|
+
};
|
|
14
|
+
export const validateWebSocketOrigin = (request, options) => allowedOrigin(request, options) === undefined
|
|
15
|
+
? { _tag: "GatewayFault", code: "Unauthorized", status: 403, message: "Origin is not allowed" }
|
|
16
|
+
: undefined;
|
|
17
|
+
const corsHeaders = (request, options) => {
|
|
18
|
+
const value = allowedOrigin(request, options);
|
|
19
|
+
return value === undefined
|
|
20
|
+
? {}
|
|
21
|
+
: {
|
|
22
|
+
"access-control-allow-origin": value,
|
|
23
|
+
"access-control-allow-credentials": "true",
|
|
24
|
+
vary: "Origin",
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
export const jsonResponse = (request, options, body, status = 200, headers = {}) => HttpServerResponse.jsonUnsafe(body, {
|
|
28
|
+
status,
|
|
29
|
+
headers: { ...corsHeaders(request, options), ...headers, "cache-control": "no-store" },
|
|
30
|
+
});
|
|
31
|
+
export const errorResponse = (request, options, fault) => {
|
|
32
|
+
const retryAfter = fault.retryAfterMillis === undefined
|
|
33
|
+
? {}
|
|
34
|
+
: { "retry-after": String(Math.max(1, Math.ceil(fault.retryAfterMillis / 1_000))) };
|
|
35
|
+
return jsonResponse(request, options, { error: safeError(fault) }, fault.status, retryAfter);
|
|
36
|
+
};
|
|
37
|
+
export const preflightResponse = (request, options) => {
|
|
38
|
+
const value = allowedOrigin(request, options);
|
|
39
|
+
if (value === undefined)
|
|
40
|
+
return undefined;
|
|
41
|
+
return HttpServerResponse.empty({
|
|
42
|
+
status: 204,
|
|
43
|
+
headers: {
|
|
44
|
+
"access-control-allow-origin": value,
|
|
45
|
+
"access-control-allow-credentials": "true",
|
|
46
|
+
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
47
|
+
"access-control-allow-headers": "content-type, authorization, last-event-id",
|
|
48
|
+
"access-control-max-age": "600",
|
|
49
|
+
vary: "Origin, Access-Control-Request-Headers",
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
export const sseHeaders = (request, options) => ({
|
|
54
|
+
...corsHeaders(request, options),
|
|
55
|
+
"cache-control": "no-cache, no-transform",
|
|
56
|
+
"x-accel-buffering": "no",
|
|
57
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import { HttpRouter } from "effect/unstable/http";
|
|
3
|
+
import { Client } from "../../client/service.js";
|
|
4
|
+
import type { Options } from "../options.js";
|
|
5
|
+
import { Security } from "../security.js";
|
|
6
|
+
export declare const layer: (options: Options) => Layer.Layer<never, never, HttpRouter.HttpRouter | Client | Security>;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { Effect, Layer, Option, Ref } from "effect";
|
|
2
|
+
import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
|
|
3
|
+
import { Client } from "../../client/service.js";
|
|
4
|
+
import { boundary, decode, fingerprint, readCursor } from "../../protocol/index.js";
|
|
5
|
+
import { Receipt as WireReceipt } from "../../protocol/schemas.js";
|
|
6
|
+
import { boundedEvents, sseBody } from "./events.js";
|
|
7
|
+
import { errorResponse, jsonResponse, preflightResponse, sseHeaders, validateHttpOrigin, } from "./response.js";
|
|
8
|
+
import { addressFrom, readJson, readEventParameters, validateCommandId, validateCursor, validatePublicName, validateReceiptCommandId, validateRevision, validateRequestId, } from "./validation.js";
|
|
9
|
+
import { admit, assertTenant, authenticate, authorize, leaseConnection, recheck, } from "../access.js";
|
|
10
|
+
import { hostedProcedure, mapInputError, stateProcedure, validateOutcome, validatePayload, validateQueryResult, validateReceipt, } from "../dispatch.js";
|
|
11
|
+
import { CommandRequest, HostedRequest, QueryRequest, ReceiptRequest } from "../wire.js";
|
|
12
|
+
import { fromServiceError, isGatewayFault, notFound, unauthorized, unavailable } from "../fault.js";
|
|
13
|
+
import { makeRegistry } from "../options.js";
|
|
14
|
+
import { Security } from "../security.js";
|
|
15
|
+
import { handler as webSocketHandler } from "../websocket.js";
|
|
16
|
+
const path = (prefix, suffix) => `${prefix}${suffix}`;
|
|
17
|
+
const params = () => HttpRouter.params.pipe(Effect.mapError(mapInputError));
|
|
18
|
+
const safe = (request, options, effect) => effect.pipe(Effect.catchIf((fault) => true, (fault) => Effect.succeed(errorResponse(request, options, fault))));
|
|
19
|
+
const originGate = (request, options) => {
|
|
20
|
+
const fault = validateHttpOrigin(request, options);
|
|
21
|
+
return fault === undefined ? Effect.void : Effect.fail(fault);
|
|
22
|
+
};
|
|
23
|
+
const commandRoute = (registry, client, security) => (request) => safe(request, registry.options, Effect.gen(function* () {
|
|
24
|
+
yield* originGate(request, registry.options);
|
|
25
|
+
const body = yield* readJson(request, registry.options.maxRequestBytes, CommandRequest);
|
|
26
|
+
const route = yield* params();
|
|
27
|
+
const tenant = yield* validateRequestId(route.tenant ?? "");
|
|
28
|
+
const actorType = yield* validatePublicName(route.actorType ?? "", registry.isPublicOperation);
|
|
29
|
+
const command = yield* validatePublicName(route.name ?? "", registry.isPublicOperation);
|
|
30
|
+
const commandId = yield* validateCommandId(body.commandId);
|
|
31
|
+
const expectedRevision = body.expectedRevision === undefined
|
|
32
|
+
? undefined
|
|
33
|
+
: yield* validateRevision(body.expectedRevision);
|
|
34
|
+
const address = yield* addressFrom(registry.options.namespace, tenant, actorType, body.key);
|
|
35
|
+
const procedure = yield* stateProcedure(registry, address, body.protocolVersion, command, "command");
|
|
36
|
+
const principal = yield* authenticate(security, request);
|
|
37
|
+
yield* assertTenant(principal, address);
|
|
38
|
+
const access = { address, operation: "command", name: command };
|
|
39
|
+
yield* authorize(security, principal, access);
|
|
40
|
+
yield* admit(security, principal, access);
|
|
41
|
+
const payload = yield* validatePayload(procedure, body.payload);
|
|
42
|
+
const invocation = yield* boundary(() => ({
|
|
43
|
+
address,
|
|
44
|
+
command,
|
|
45
|
+
protocolVersion: body.protocolVersion,
|
|
46
|
+
payload,
|
|
47
|
+
commandId,
|
|
48
|
+
fingerprint: fingerprint({
|
|
49
|
+
command,
|
|
50
|
+
protocolVersion: body.protocolVersion,
|
|
51
|
+
payload,
|
|
52
|
+
...(expectedRevision === undefined ? {} : { expectedRevision }),
|
|
53
|
+
}),
|
|
54
|
+
...(expectedRevision === undefined ? {} : { expectedRevision }),
|
|
55
|
+
})).pipe(Effect.mapError(mapInputError));
|
|
56
|
+
const receipt = yield* client
|
|
57
|
+
.command(invocation)
|
|
58
|
+
.pipe(Effect.mapError((error) => fromServiceError(error, commandId)));
|
|
59
|
+
const checked = yield* validateReceipt(procedure, address, receipt);
|
|
60
|
+
return jsonResponse(request, registry.options, checked);
|
|
61
|
+
}));
|
|
62
|
+
const queryRoute = (registry, client, security) => (request) => safe(request, registry.options, Effect.gen(function* () {
|
|
63
|
+
yield* originGate(request, registry.options);
|
|
64
|
+
const body = yield* readJson(request, registry.options.maxRequestBytes, QueryRequest);
|
|
65
|
+
const route = yield* params();
|
|
66
|
+
const tenant = yield* validateRequestId(route.tenant ?? "");
|
|
67
|
+
const actorType = yield* validatePublicName(route.actorType ?? "", registry.isPublicOperation);
|
|
68
|
+
const query = yield* validatePublicName(route.name ?? "", registry.isPublicOperation);
|
|
69
|
+
const address = yield* addressFrom(registry.options.namespace, tenant, actorType, body.key);
|
|
70
|
+
const procedure = yield* stateProcedure(registry, address, body.protocolVersion, query, "query");
|
|
71
|
+
const principal = yield* authenticate(security, request);
|
|
72
|
+
yield* assertTenant(principal, address);
|
|
73
|
+
const access = { address, operation: "query", name: query };
|
|
74
|
+
yield* authorize(security, principal, access);
|
|
75
|
+
yield* admit(security, principal, access);
|
|
76
|
+
const payload = yield* validatePayload(procedure, body.payload);
|
|
77
|
+
const result = yield* client
|
|
78
|
+
.query({ address, query, protocolVersion: body.protocolVersion, payload })
|
|
79
|
+
.pipe(Effect.mapError(fromServiceError));
|
|
80
|
+
const checked = yield* validateQueryResult(procedure, address, result);
|
|
81
|
+
return jsonResponse(request, registry.options, checked);
|
|
82
|
+
}));
|
|
83
|
+
const receiptRoute = (registry, client, security) => (request) => safe(request, registry.options, Effect.gen(function* () {
|
|
84
|
+
yield* originGate(request, registry.options);
|
|
85
|
+
const body = yield* readJson(request, registry.options.maxRequestBytes, ReceiptRequest);
|
|
86
|
+
const route = yield* params();
|
|
87
|
+
const tenant = yield* validateRequestId(route.tenant ?? "");
|
|
88
|
+
const actorType = yield* validatePublicName(route.actorType ?? "", registry.isPublicOperation);
|
|
89
|
+
const commandId = yield* validateReceiptCommandId(route["*"] ?? "");
|
|
90
|
+
const address = yield* addressFrom(registry.options.namespace, tenant, actorType, body.key);
|
|
91
|
+
const protocol = registry.get(actorType);
|
|
92
|
+
if (!protocol || protocol.kind !== "state")
|
|
93
|
+
return yield* Effect.fail(notFound());
|
|
94
|
+
const principal = yield* authenticate(security, request);
|
|
95
|
+
yield* assertTenant(principal, address);
|
|
96
|
+
const access = { address, operation: "receipt" };
|
|
97
|
+
yield* authorize(security, principal, access);
|
|
98
|
+
yield* admit(security, principal, access);
|
|
99
|
+
const receipt = yield* client
|
|
100
|
+
.receipt(address, commandId)
|
|
101
|
+
.pipe(Effect.mapError(fromServiceError));
|
|
102
|
+
if (Option.isNone(receipt))
|
|
103
|
+
return jsonResponse(request, registry.options, { receipt: null }, 404);
|
|
104
|
+
const checked = yield* decode(WireReceipt, receipt.value).pipe(Effect.flatMap((value) => boundary(() => readCursor(address, value.cursor)).pipe(Effect.mapError(() => unavailable()), Effect.as(value))), Effect.mapError(() => unavailable()));
|
|
105
|
+
return jsonResponse(request, registry.options, { receipt: checked });
|
|
106
|
+
}));
|
|
107
|
+
const eventsRoute = (registry, connections, client, security) => (request) => safe(request, registry.options, Effect.gen(function* () {
|
|
108
|
+
yield* originGate(request, registry.options);
|
|
109
|
+
const route = yield* params();
|
|
110
|
+
const tenant = yield* validateRequestId(route.tenant ?? "");
|
|
111
|
+
const actorType = yield* validatePublicName(route.actorType ?? "", registry.isPublicOperation);
|
|
112
|
+
const parameters = yield* readEventParameters(request);
|
|
113
|
+
const address = yield* addressFrom(registry.options.namespace, tenant, actorType, parameters.key);
|
|
114
|
+
const protocol = registry.get(actorType);
|
|
115
|
+
if (!protocol || protocol.kind !== "state")
|
|
116
|
+
return yield* Effect.fail(notFound());
|
|
117
|
+
const after = parameters.after;
|
|
118
|
+
yield* validateCursor(address, after);
|
|
119
|
+
const principal = yield* authenticate(security, request);
|
|
120
|
+
yield* assertTenant(principal, address);
|
|
121
|
+
const access = { address, operation: "events" };
|
|
122
|
+
yield* authorize(security, principal, access);
|
|
123
|
+
yield* admit(security, principal, access);
|
|
124
|
+
yield* leaseConnection(connections, principal, registry.options.maxConnectionsPerPrincipal);
|
|
125
|
+
const events = yield* boundedEvents(client, address, after, protocol.eventSchema, registry.options, recheck(security, request, principal, access));
|
|
126
|
+
return HttpServerResponse.stream(sseBody(events), {
|
|
127
|
+
contentType: "text/event-stream",
|
|
128
|
+
headers: sseHeaders(request, registry.options),
|
|
129
|
+
});
|
|
130
|
+
}));
|
|
131
|
+
const hostedRoute = (registry, client, security) => (request) => safe(request, registry.options, Effect.gen(function* () {
|
|
132
|
+
yield* originGate(request, registry.options);
|
|
133
|
+
const body = yield* readJson(request, registry.options.maxRequestBytes, HostedRequest);
|
|
134
|
+
const route = yield* params();
|
|
135
|
+
const tenant = yield* validateRequestId(route.tenant ?? "");
|
|
136
|
+
const actorType = yield* validatePublicName(route.actorType ?? "", registry.isPublicOperation);
|
|
137
|
+
const action = yield* validatePublicName(route.name ?? "", registry.isPublicOperation);
|
|
138
|
+
const address = yield* addressFrom(registry.options.namespace, tenant, actorType, body.key);
|
|
139
|
+
const procedure = yield* hostedProcedure(registry, address, body.protocolVersion, action);
|
|
140
|
+
const principal = yield* authenticate(security, request);
|
|
141
|
+
yield* assertTenant(principal, address);
|
|
142
|
+
const access = { address, operation: "hosted", name: action };
|
|
143
|
+
yield* authorize(security, principal, access);
|
|
144
|
+
yield* admit(security, principal, access);
|
|
145
|
+
const payload = yield* validatePayload(procedure, body.payload);
|
|
146
|
+
const requestKey = body.requestKey === undefined ? undefined : yield* validateRequestId(body.requestKey);
|
|
147
|
+
const input = yield* boundary(() => ({
|
|
148
|
+
address,
|
|
149
|
+
action,
|
|
150
|
+
protocolVersion: body.protocolVersion,
|
|
151
|
+
payload,
|
|
152
|
+
...(requestKey === undefined ? {} : { requestKey }),
|
|
153
|
+
fingerprint: fingerprint({
|
|
154
|
+
command: action,
|
|
155
|
+
protocolVersion: body.protocolVersion,
|
|
156
|
+
payload,
|
|
157
|
+
}),
|
|
158
|
+
})).pipe(Effect.mapError(mapInputError));
|
|
159
|
+
const result = yield* client.hosted(input).pipe(Effect.mapError(fromServiceError));
|
|
160
|
+
const checked = yield* validateOutcome(procedure, result);
|
|
161
|
+
return jsonResponse(request, registry.options, checked);
|
|
162
|
+
}));
|
|
163
|
+
export const layer = (options) => {
|
|
164
|
+
const registry = makeRegistry(options);
|
|
165
|
+
return Layer.effectDiscard(Effect.gen(function* () {
|
|
166
|
+
const router = yield* HttpRouter.HttpRouter;
|
|
167
|
+
const client = yield* Client;
|
|
168
|
+
const security = yield* Security;
|
|
169
|
+
const connections = yield* Ref.make(new Map());
|
|
170
|
+
yield* router.add("OPTIONS", path(registry.path, "/*"), (request) => {
|
|
171
|
+
const response = preflightResponse(request, registry.options);
|
|
172
|
+
return Effect.succeed(response ?? errorResponse(request, registry.options, unauthorized()));
|
|
173
|
+
});
|
|
174
|
+
yield* router.add("POST", path(registry.path, "/:tenant/:actorType/commands/:name"), commandRoute(registry, client, security));
|
|
175
|
+
yield* router.add("POST", path(registry.path, "/:tenant/:actorType/queries/:name"), queryRoute(registry, client, security));
|
|
176
|
+
yield* router.add("POST", path(registry.path, "/:tenant/:actorType/receipts/*"), receiptRoute(registry, client, security));
|
|
177
|
+
yield* router.add("GET", path(registry.path, "/:tenant/:actorType/events"), eventsRoute(registry, connections, client, security));
|
|
178
|
+
yield* router.add("POST", path(registry.path, "/:tenant/:actorType/actions/:name"), hostedRoute(registry, client, security));
|
|
179
|
+
if (registry.options.enableWebSocket) {
|
|
180
|
+
const socket = webSocketHandler(registry, client, security, connections);
|
|
181
|
+
yield* router.add("GET", path(registry.path, "/connect"), (request) => safe(request, registry.options, socket(request)));
|
|
182
|
+
}
|
|
183
|
+
}));
|
|
184
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import type { HttpServerRequest } from "effect/unstable/http";
|
|
3
|
+
import type { Address } from "../../protocol/index.js";
|
|
4
|
+
import { slowConsumer } from "../fault.js";
|
|
5
|
+
import type { GatewayFault } from "../fault.js";
|
|
6
|
+
export declare const readJson: <S extends Schema.ConstraintDecoder<unknown>>(request: HttpServerRequest.HttpServerRequest, maximum: number, schema: S) => Effect.Effect<S["Type"], GatewayFault>;
|
|
7
|
+
export declare const addressFrom: (namespace: string, tenant: string, actorType: string, key: ReadonlyArray<string>) => Effect.Effect<Address, GatewayFault>;
|
|
8
|
+
export declare const validateCursor: (address: Address, cursor: string) => Effect.Effect<string, GatewayFault>;
|
|
9
|
+
export declare const validateRequestId: (value: string) => Effect.Effect<string, GatewayFault>;
|
|
10
|
+
export declare const validateCommandId: (value: string) => Effect.Effect<string, GatewayFault>;
|
|
11
|
+
export declare const validateReceiptCommandId: (value: string) => Effect.Effect<string, GatewayFault>;
|
|
12
|
+
export declare const validateRevision: (value: string) => Effect.Effect<string, GatewayFault>;
|
|
13
|
+
export declare const validatePublicName: (value: string, isPublicOperation: (name: string) => boolean) => Effect.Effect<string, GatewayFault>;
|
|
14
|
+
export interface EventParameters {
|
|
15
|
+
readonly after: string;
|
|
16
|
+
readonly key: ReadonlyArray<string>;
|
|
17
|
+
}
|
|
18
|
+
export declare const readEventParameters: (request: HttpServerRequest.HttpServerRequest) => Effect.Effect<EventParameters, GatewayFault>;
|
|
19
|
+
export declare const slowConsumerFault: typeof slowConsumer;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Effect, Encoding, Result, Schema, Stream } from "effect";
|
|
2
|
+
import { addressId, boundary, decode, readCursor, validateId, validateKey, } from "../../protocol/index.js";
|
|
3
|
+
import { isSequence } from "../../protocol/encoding.js";
|
|
4
|
+
import { invalidInput, isGatewayFault, slowConsumer } from "../fault.js";
|
|
5
|
+
const isBoundedText = (value, maximum) => value.length > 0 && value.length <= maximum && !/[\u0000-\u001f\u007f]/.test(value);
|
|
6
|
+
const textDecoder = new TextDecoder("utf-8", { fatal: true });
|
|
7
|
+
const bodyTooLarge = () => ({
|
|
8
|
+
_tag: "GatewayFault",
|
|
9
|
+
code: "InvalidInput",
|
|
10
|
+
status: 413,
|
|
11
|
+
message: "Request body is too large",
|
|
12
|
+
});
|
|
13
|
+
const mapInputError = (error) => isGatewayFault(error) ? error : invalidInput();
|
|
14
|
+
const parseContentLength = (request, maximum) => {
|
|
15
|
+
const contentLength = request.headers["content-length"];
|
|
16
|
+
if (contentLength === undefined)
|
|
17
|
+
return Effect.void;
|
|
18
|
+
if (!/^(0|[1-9][0-9]*)$/.test(contentLength))
|
|
19
|
+
return Effect.fail(invalidInput());
|
|
20
|
+
const length = Number(contentLength);
|
|
21
|
+
return Number.isSafeInteger(length) && length <= maximum
|
|
22
|
+
? Effect.void
|
|
23
|
+
: Effect.fail(bodyTooLarge());
|
|
24
|
+
};
|
|
25
|
+
const join = (chunks, length) => {
|
|
26
|
+
const body = new Uint8Array(length);
|
|
27
|
+
let offset = 0;
|
|
28
|
+
for (const chunk of chunks) {
|
|
29
|
+
body.set(chunk, offset);
|
|
30
|
+
offset += chunk.length;
|
|
31
|
+
}
|
|
32
|
+
return body;
|
|
33
|
+
};
|
|
34
|
+
export const readJson = (request, maximum, schema) => Effect.gen(function* () {
|
|
35
|
+
yield* parseContentLength(request, maximum);
|
|
36
|
+
const collected = yield* Stream.runFoldEffect(request.stream, () => ({ chunks: [], length: 0 }), (state, chunk) => {
|
|
37
|
+
const length = state.length + chunk.length;
|
|
38
|
+
return length > maximum
|
|
39
|
+
? Effect.fail(bodyTooLarge())
|
|
40
|
+
: Effect.succeed({ chunks: [...state.chunks, chunk], length });
|
|
41
|
+
}).pipe(Effect.mapError(mapInputError));
|
|
42
|
+
const text = yield* boundary(() => textDecoder.decode(join(collected.chunks, collected.length))).pipe(Effect.mapError(mapInputError));
|
|
43
|
+
const value = yield* boundary(() => JSON.parse(text)).pipe(Effect.mapError(mapInputError));
|
|
44
|
+
return yield* decode(schema, value).pipe(Effect.mapError(mapInputError));
|
|
45
|
+
});
|
|
46
|
+
export const addressFrom = (namespace, tenant, actorType, key) => boundary(() => {
|
|
47
|
+
const checkedKey = validateKey({ tenant, key });
|
|
48
|
+
const address = { namespace, tenant: checkedKey.tenant, actorType, key: checkedKey.key };
|
|
49
|
+
addressId(address);
|
|
50
|
+
return address;
|
|
51
|
+
}).pipe(Effect.mapError(mapInputError));
|
|
52
|
+
export const validateCursor = (address, cursor) => {
|
|
53
|
+
if (!isBoundedText(cursor, 16_384))
|
|
54
|
+
return Effect.fail(invalidInput());
|
|
55
|
+
return boundary(() => readCursor(address, cursor)).pipe(Effect.mapError(mapInputError));
|
|
56
|
+
};
|
|
57
|
+
export const validateRequestId = (value) => boundary(() => validateId(value)).pipe(Effect.mapError(mapInputError));
|
|
58
|
+
export const validateCommandId = (value) => validateRequestId(value).pipe(Effect.flatMap((commandId) => commandId.startsWith("~effect-actors/")
|
|
59
|
+
? Effect.fail(invalidInput())
|
|
60
|
+
: Effect.succeed(commandId)));
|
|
61
|
+
export const validateReceiptCommandId = (value) => boundary(() => validateId(value, 1_024)).pipe(Effect.mapError(mapInputError));
|
|
62
|
+
export const validateRevision = (value) => isSequence(value) ? Effect.succeed(value) : Effect.fail(invalidInput());
|
|
63
|
+
export const validatePublicName = (value, isPublicOperation) => isPublicOperation(value) ? Effect.succeed(value) : Effect.fail(invalidInput());
|
|
64
|
+
export const readEventParameters = (request) => boundary(() => {
|
|
65
|
+
const query = request.url.indexOf("?");
|
|
66
|
+
const values = new Map();
|
|
67
|
+
if (query !== -1) {
|
|
68
|
+
for (const pair of request.url.slice(query + 1).split("&")) {
|
|
69
|
+
if (pair.length === 0)
|
|
70
|
+
continue;
|
|
71
|
+
const separator = pair.indexOf("=");
|
|
72
|
+
const key = decodeURIComponent(separator === -1 ? pair : pair.slice(0, separator));
|
|
73
|
+
const value = decodeURIComponent(separator === -1 ? "" : pair.slice(separator + 1));
|
|
74
|
+
values.set(key, [...(values.get(key) ?? []), value]);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const afterValues = values.get("after") ?? [];
|
|
78
|
+
const keyValues = values.get("key") ?? [];
|
|
79
|
+
if (afterValues.length > 1 || keyValues.length !== 1)
|
|
80
|
+
throw new Error("invalid event parameters");
|
|
81
|
+
const header = request.headers["last-event-id"];
|
|
82
|
+
if (afterValues.length === 1 && header !== undefined && afterValues[0] !== header)
|
|
83
|
+
throw new Error("conflicting cursor");
|
|
84
|
+
const after = afterValues[0] ?? header;
|
|
85
|
+
if (after === undefined)
|
|
86
|
+
throw new Error("missing cursor");
|
|
87
|
+
const decoded = Encoding.decodeBase64UrlString(keyValues[0]);
|
|
88
|
+
if (Result.isFailure(decoded))
|
|
89
|
+
throw decoded.failure;
|
|
90
|
+
return { after, key: JSON.parse(decoded.success) };
|
|
91
|
+
}).pipe(Effect.mapError(mapInputError), Effect.flatMap((input) => decode(Schema.Array(Schema.String), input.key).pipe(Effect.map((key) => ({ after: input.after, key })), Effect.mapError(mapInputError))));
|
|
92
|
+
export const slowConsumerFault = slowConsumer;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Protocol as StateProtocol } from "../actor/definition.js";
|
|
2
|
+
import type { Protocol as HostProtocol } from "../host/definition.js";
|
|
3
|
+
import type { Procedure } from "../protocol/index.js";
|
|
4
|
+
export declare const defaultMaxInFlightRequestsPerConnection = 32;
|
|
5
|
+
export declare const defaultMaxSubscriptionsPerConnection = 8;
|
|
6
|
+
export interface Options {
|
|
7
|
+
readonly protocols: ReadonlyArray<StateProtocol | HostProtocol>;
|
|
8
|
+
readonly path: `/${string}`;
|
|
9
|
+
readonly namespace: string;
|
|
10
|
+
readonly allowedOrigins: ReadonlyArray<string>;
|
|
11
|
+
readonly maxRequestBytes: number;
|
|
12
|
+
readonly maxConnectionsPerPrincipal: number;
|
|
13
|
+
readonly maxBufferedEvents: number;
|
|
14
|
+
readonly maxInFlightRequestsPerConnection?: number;
|
|
15
|
+
readonly maxSubscriptionsPerConnection?: number;
|
|
16
|
+
readonly authenticationRecheckMillis: number;
|
|
17
|
+
readonly enableWebSocket: boolean;
|
|
18
|
+
}
|
|
19
|
+
type RegisteredProtocol = StateProtocol | HostProtocol;
|
|
20
|
+
export interface Registry {
|
|
21
|
+
readonly path: string;
|
|
22
|
+
readonly options: Options;
|
|
23
|
+
readonly maxInFlightRequestsPerConnection: number;
|
|
24
|
+
readonly maxSubscriptionsPerConnection: number;
|
|
25
|
+
readonly get: (actorType: string) => RegisteredProtocol | undefined;
|
|
26
|
+
readonly stateProcedure: (actorType: string, name: string, kind: "command" | "query") => Procedure | undefined;
|
|
27
|
+
readonly hostedProcedure: (actorType: string, name: string) => Procedure | undefined;
|
|
28
|
+
readonly isPublicOperation: (name: string) => boolean;
|
|
29
|
+
}
|
|
30
|
+
export declare const makeRegistry: (options: Options) => Registry;
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { assertIdentifier } from "../actor/state/codec.js";
|
|
2
|
+
export const defaultMaxInFlightRequestsPerConnection = 32;
|
|
3
|
+
export const defaultMaxSubscriptionsPerConnection = 8;
|
|
4
|
+
const isPublicOperation = (name) => {
|
|
5
|
+
try {
|
|
6
|
+
assertIdentifier(name);
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
const normalizePath = (path) => (path === "/" ? "" : path.replace(/\/+$/, ""));
|
|
14
|
+
const validateOrigin = (origin) => {
|
|
15
|
+
let parsed;
|
|
16
|
+
try {
|
|
17
|
+
parsed = new URL(origin);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
throw new TypeError("Gateway allowed origins must be normalized absolute origins");
|
|
21
|
+
}
|
|
22
|
+
if (parsed.origin !== origin ||
|
|
23
|
+
(parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
|
|
24
|
+
parsed.username !== "" ||
|
|
25
|
+
parsed.password !== "" ||
|
|
26
|
+
parsed.pathname !== "/" ||
|
|
27
|
+
parsed.search !== "" ||
|
|
28
|
+
parsed.hash !== "")
|
|
29
|
+
throw new TypeError("Gateway allowed origins must be normalized absolute origins");
|
|
30
|
+
};
|
|
31
|
+
const validateProtocol = (protocol) => {
|
|
32
|
+
if (!isPublicOperation(protocol.actorType)) {
|
|
33
|
+
throw new TypeError("Gateway actor types must use public operation names");
|
|
34
|
+
}
|
|
35
|
+
if (!Number.isSafeInteger(protocol.version) || protocol.version < 1) {
|
|
36
|
+
throw new TypeError("Gateway protocol versions must be positive integers");
|
|
37
|
+
}
|
|
38
|
+
const procedures = protocol.kind === "state"
|
|
39
|
+
? [...Object.keys(protocol.commands), ...Object.keys(protocol.queries)]
|
|
40
|
+
: Object.keys(protocol.actions);
|
|
41
|
+
for (const procedure of procedures) {
|
|
42
|
+
if (!isPublicOperation(procedure)) {
|
|
43
|
+
throw new TypeError("Gateway protocols cannot expose private or reserved operations");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
export const makeRegistry = (options) => {
|
|
48
|
+
if (!options.path.startsWith("/"))
|
|
49
|
+
throw new TypeError("Gateway paths must start with a slash");
|
|
50
|
+
if (options.namespace.length === 0 || options.namespace.length > 256) {
|
|
51
|
+
throw new TypeError("Gateway namespaces must be nonempty and bounded");
|
|
52
|
+
}
|
|
53
|
+
if (!Number.isSafeInteger(options.maxRequestBytes) || options.maxRequestBytes < 1) {
|
|
54
|
+
throw new TypeError("Gateway maxRequestBytes must be a positive integer");
|
|
55
|
+
}
|
|
56
|
+
if (!Number.isSafeInteger(options.maxConnectionsPerPrincipal) ||
|
|
57
|
+
options.maxConnectionsPerPrincipal < 1) {
|
|
58
|
+
throw new TypeError("Gateway maxConnectionsPerPrincipal must be a positive integer");
|
|
59
|
+
}
|
|
60
|
+
if (!Number.isSafeInteger(options.maxBufferedEvents) || options.maxBufferedEvents < 1) {
|
|
61
|
+
throw new TypeError("Gateway maxBufferedEvents must be a positive integer");
|
|
62
|
+
}
|
|
63
|
+
const maxInFlightRequestsPerConnection = options.maxInFlightRequestsPerConnection ?? defaultMaxInFlightRequestsPerConnection;
|
|
64
|
+
if (!Number.isSafeInteger(maxInFlightRequestsPerConnection) ||
|
|
65
|
+
maxInFlightRequestsPerConnection < 1) {
|
|
66
|
+
throw new TypeError("Gateway maxInFlightRequestsPerConnection must be a positive integer");
|
|
67
|
+
}
|
|
68
|
+
const maxSubscriptionsPerConnection = options.maxSubscriptionsPerConnection ?? defaultMaxSubscriptionsPerConnection;
|
|
69
|
+
if (!Number.isSafeInteger(maxSubscriptionsPerConnection) || maxSubscriptionsPerConnection < 1) {
|
|
70
|
+
throw new TypeError("Gateway maxSubscriptionsPerConnection must be a positive integer");
|
|
71
|
+
}
|
|
72
|
+
if (!Number.isSafeInteger(options.authenticationRecheckMillis) ||
|
|
73
|
+
options.authenticationRecheckMillis < 1) {
|
|
74
|
+
throw new TypeError("Gateway authenticationRecheckMillis must be a positive integer");
|
|
75
|
+
}
|
|
76
|
+
for (const origin of options.allowedOrigins)
|
|
77
|
+
validateOrigin(origin);
|
|
78
|
+
const protocols = new Map();
|
|
79
|
+
for (const protocol of options.protocols) {
|
|
80
|
+
validateProtocol(protocol);
|
|
81
|
+
if (protocols.has(protocol.actorType)) {
|
|
82
|
+
throw new TypeError(`Gateway protocol already registered for ${protocol.actorType}`);
|
|
83
|
+
}
|
|
84
|
+
protocols.set(protocol.actorType, protocol);
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
path: normalizePath(options.path),
|
|
88
|
+
options,
|
|
89
|
+
maxInFlightRequestsPerConnection,
|
|
90
|
+
maxSubscriptionsPerConnection,
|
|
91
|
+
get: (actorType) => protocols.get(actorType),
|
|
92
|
+
stateProcedure: (actorType, name, kind) => {
|
|
93
|
+
const protocol = protocols.get(actorType);
|
|
94
|
+
if (!protocol || protocol.kind !== "state" || !isPublicOperation(name))
|
|
95
|
+
return undefined;
|
|
96
|
+
const procedures = kind === "command" ? protocol.commands : protocol.queries;
|
|
97
|
+
return Object.hasOwn(procedures, name) ? procedures[name] : undefined;
|
|
98
|
+
},
|
|
99
|
+
hostedProcedure: (actorType, name) => {
|
|
100
|
+
const protocol = protocols.get(actorType);
|
|
101
|
+
if (!protocol || protocol.kind !== "hosted" || !isPublicOperation(name))
|
|
102
|
+
return undefined;
|
|
103
|
+
return Object.hasOwn(protocol.actions, name) ? protocol.actions[name] : undefined;
|
|
104
|
+
},
|
|
105
|
+
isPublicOperation,
|
|
106
|
+
};
|
|
107
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Context } from "effect";
|
|
2
|
+
import type { Effect } from "effect";
|
|
3
|
+
import type { HttpServerRequest } from "effect/unstable/http";
|
|
4
|
+
import type { Overloaded, Unauthorized } from "../errors/index.js";
|
|
5
|
+
import type { Address, Json } from "../protocol/index.js";
|
|
6
|
+
export interface Principal {
|
|
7
|
+
readonly subject: string;
|
|
8
|
+
readonly tenant: string;
|
|
9
|
+
readonly claims: Readonly<Record<string, Json>>;
|
|
10
|
+
}
|
|
11
|
+
export declare const connectionKey: (principal: Principal) => string;
|
|
12
|
+
export interface Access {
|
|
13
|
+
readonly address: Address;
|
|
14
|
+
readonly operation: "command" | "query" | "receipt" | "events" | "hosted";
|
|
15
|
+
readonly name?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface SecurityService {
|
|
18
|
+
readonly authenticate: (request: HttpServerRequest.HttpServerRequest) => Effect.Effect<Principal, Unauthorized>;
|
|
19
|
+
readonly authorize: (principal: Principal, access: Access) => Effect.Effect<void, Unauthorized>;
|
|
20
|
+
readonly admit: (principal: Principal, access: Access) => Effect.Effect<void, Overloaded>;
|
|
21
|
+
}
|
|
22
|
+
declare const Security_base: Context.ServiceClass<Security, "effect-actors/Gateway/Security", SecurityService>;
|
|
23
|
+
export declare class Security extends Security_base {
|
|
24
|
+
}
|
|
25
|
+
export {};
|