@alchemy.run/effect-cluster 0.0.0 → 2.0.0-beta.86
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 +201 -0
- package/README.md +101 -4
- package/lib/AWS/Database.d.ts +54 -0
- package/lib/AWS/Database.d.ts.map +1 -0
- package/lib/AWS/Database.js +96 -0
- package/lib/AWS/Database.js.map +1 -0
- package/lib/AWS/EcsPool.d.ts +56 -0
- package/lib/AWS/EcsPool.d.ts.map +1 -0
- package/lib/AWS/EcsPool.js +87 -0
- package/lib/AWS/EcsPool.js.map +1 -0
- package/lib/AWS/index.d.ts +7 -0
- package/lib/AWS/index.d.ts.map +1 -0
- package/lib/AWS/index.js +7 -0
- package/lib/AWS/index.js.map +1 -0
- package/lib/AWS/internal/Gateway.d.ts +23 -0
- package/lib/AWS/internal/Gateway.d.ts.map +1 -0
- package/lib/AWS/internal/Gateway.js +70 -0
- package/lib/AWS/internal/Gateway.js.map +1 -0
- package/lib/Cloudflare/DurableObjectPool.d.ts +42 -0
- package/lib/Cloudflare/DurableObjectPool.d.ts.map +1 -0
- package/lib/Cloudflare/DurableObjectPool.js +136 -0
- package/lib/Cloudflare/DurableObjectPool.js.map +1 -0
- package/lib/Cloudflare/PoolWorker.d.ts +186 -0
- package/lib/Cloudflare/PoolWorker.d.ts.map +1 -0
- package/lib/Cloudflare/PoolWorker.js +75 -0
- package/lib/Cloudflare/PoolWorker.js.map +1 -0
- package/lib/Cloudflare/index.d.ts +7 -0
- package/lib/Cloudflare/index.d.ts.map +1 -0
- package/lib/Cloudflare/index.js +7 -0
- package/lib/Cloudflare/index.js.map +1 -0
- package/lib/Cloudflare/internal/Objects.d.ts +38 -0
- package/lib/Cloudflare/internal/Objects.d.ts.map +1 -0
- package/lib/Cloudflare/internal/Objects.js +102 -0
- package/lib/Cloudflare/internal/Objects.js.map +1 -0
- package/lib/Cron.d.ts +143 -0
- package/lib/Cron.d.ts.map +1 -0
- package/lib/Cron.js +83 -0
- package/lib/Cron.js.map +1 -0
- package/lib/Database.d.ts +95 -0
- package/lib/Database.d.ts.map +1 -0
- package/lib/Database.js +38 -0
- package/lib/Database.js.map +1 -0
- package/lib/Docker/Database.d.ts +42 -0
- package/lib/Docker/Database.d.ts.map +1 -0
- package/lib/Docker/Database.js +53 -0
- package/lib/Docker/Database.js.map +1 -0
- package/lib/Docker/SwarmPool.d.ts +44 -0
- package/lib/Docker/SwarmPool.d.ts.map +1 -0
- package/lib/Docker/SwarmPool.js +56 -0
- package/lib/Docker/SwarmPool.js.map +1 -0
- package/lib/Docker/index.d.ts +7 -0
- package/lib/Docker/index.d.ts.map +1 -0
- package/lib/Docker/index.js +7 -0
- package/lib/Docker/index.js.map +1 -0
- package/lib/Entity.d.ts +348 -0
- package/lib/Entity.d.ts.map +1 -0
- package/lib/Entity.js +308 -0
- package/lib/Entity.js.map +1 -0
- package/lib/Fly/Database.d.ts +50 -0
- package/lib/Fly/Database.d.ts.map +1 -0
- package/lib/Fly/Database.js +56 -0
- package/lib/Fly/Database.js.map +1 -0
- package/lib/Fly/MachinePool.d.ts +39 -0
- package/lib/Fly/MachinePool.d.ts.map +1 -0
- package/lib/Fly/MachinePool.js +51 -0
- package/lib/Fly/MachinePool.js.map +1 -0
- package/lib/Fly/index.d.ts +7 -0
- package/lib/Fly/index.d.ts.map +1 -0
- package/lib/Fly/index.js +7 -0
- package/lib/Fly/index.js.map +1 -0
- package/lib/GCP/ComputeEnginePool.d.ts +41 -0
- package/lib/GCP/ComputeEnginePool.d.ts.map +1 -0
- package/lib/GCP/ComputeEnginePool.js +172 -0
- package/lib/GCP/ComputeEnginePool.js.map +1 -0
- package/lib/GCP/Database.d.ts +48 -0
- package/lib/GCP/Database.d.ts.map +1 -0
- package/lib/GCP/Database.js +115 -0
- package/lib/GCP/Database.js.map +1 -0
- package/lib/GCP/index.d.ts +7 -0
- package/lib/GCP/index.d.ts.map +1 -0
- package/lib/GCP/index.js +7 -0
- package/lib/GCP/index.js.map +1 -0
- package/lib/Hetzner/Database.d.ts +48 -0
- package/lib/Hetzner/Database.d.ts.map +1 -0
- package/lib/Hetzner/Database.js +91 -0
- package/lib/Hetzner/Database.js.map +1 -0
- package/lib/Hetzner/ServerPool.d.ts +35 -0
- package/lib/Hetzner/ServerPool.d.ts.map +1 -0
- package/lib/Hetzner/ServerPool.js +98 -0
- package/lib/Hetzner/ServerPool.js.map +1 -0
- package/lib/Hetzner/index.d.ts +7 -0
- package/lib/Hetzner/index.d.ts.map +1 -0
- package/lib/Hetzner/index.js +7 -0
- package/lib/Hetzner/index.js.map +1 -0
- package/lib/Host.d.ts +60 -0
- package/lib/Host.d.ts.map +1 -0
- package/lib/Host.js +31 -0
- package/lib/Host.js.map +1 -0
- package/lib/Kubernetes/Database.d.ts +40 -0
- package/lib/Kubernetes/Database.d.ts.map +1 -0
- package/lib/Kubernetes/Database.js +56 -0
- package/lib/Kubernetes/Database.js.map +1 -0
- package/lib/Kubernetes/DeploymentPool.d.ts +54 -0
- package/lib/Kubernetes/DeploymentPool.d.ts.map +1 -0
- package/lib/Kubernetes/DeploymentPool.js +49 -0
- package/lib/Kubernetes/DeploymentPool.js.map +1 -0
- package/lib/Kubernetes/index.d.ts +7 -0
- package/lib/Kubernetes/index.d.ts.map +1 -0
- package/lib/Kubernetes/index.js +7 -0
- package/lib/Kubernetes/index.js.map +1 -0
- package/lib/Pool.d.ts +66 -0
- package/lib/Pool.d.ts.map +1 -0
- package/lib/Pool.js +47 -0
- package/lib/Pool.js.map +1 -0
- package/lib/Providers.d.ts +19 -0
- package/lib/Providers.d.ts.map +1 -0
- package/lib/Providers.js +31 -0
- package/lib/Providers.js.map +1 -0
- package/lib/ShardGroups.d.ts +24 -0
- package/lib/ShardGroups.d.ts.map +1 -0
- package/lib/ShardGroups.js +19 -0
- package/lib/ShardGroups.js.map +1 -0
- package/lib/Singleton.d.ts +110 -0
- package/lib/Singleton.d.ts.map +1 -0
- package/lib/Singleton.js +60 -0
- package/lib/Singleton.js.map +1 -0
- package/lib/Workflow.d.ts +251 -0
- package/lib/Workflow.d.ts.map +1 -0
- package/lib/Workflow.js +288 -0
- package/lib/Workflow.js.map +1 -0
- package/lib/index.d.ts +12 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +12 -0
- package/lib/index.js.map +1 -0
- package/lib/internal/Bootstrap.d.ts +28 -0
- package/lib/internal/Bootstrap.d.ts.map +1 -0
- package/lib/internal/Bootstrap.js +257 -0
- package/lib/internal/Bootstrap.js.map +1 -0
- package/lib/internal/EntityRuntime.d.ts +40 -0
- package/lib/internal/EntityRuntime.d.ts.map +1 -0
- package/lib/internal/EntityRuntime.js +698 -0
- package/lib/internal/EntityRuntime.js.map +1 -0
- package/lib/internal/Gateway.d.ts +82 -0
- package/lib/internal/Gateway.d.ts.map +1 -0
- package/lib/internal/Gateway.js +218 -0
- package/lib/internal/Gateway.js.map +1 -0
- package/lib/internal/HostedPlatform.d.ts +36 -0
- package/lib/internal/HostedPlatform.d.ts.map +1 -0
- package/lib/internal/HostedPlatform.js +36 -0
- package/lib/internal/HostedPlatform.js.map +1 -0
- package/lib/internal/HostedRuntime.d.ts +58 -0
- package/lib/internal/HostedRuntime.d.ts.map +1 -0
- package/lib/internal/HostedRuntime.js +72 -0
- package/lib/internal/HostedRuntime.js.map +1 -0
- package/lib/internal/Naming.d.ts +12 -0
- package/lib/internal/Naming.d.ts.map +1 -0
- package/lib/internal/Naming.js +40 -0
- package/lib/internal/Naming.js.map +1 -0
- package/lib/internal/ObjectSharding.d.ts +141 -0
- package/lib/internal/ObjectSharding.d.ts.map +1 -0
- package/lib/internal/ObjectSharding.js +337 -0
- package/lib/internal/ObjectSharding.js.map +1 -0
- package/lib/internal/PoolHost.d.ts +80 -0
- package/lib/internal/PoolHost.d.ts.map +1 -0
- package/lib/internal/PoolHost.js +203 -0
- package/lib/internal/PoolHost.js.map +1 -0
- package/lib/internal/PoolHostService.d.ts +25 -0
- package/lib/internal/PoolHostService.d.ts.map +1 -0
- package/lib/internal/PoolHostService.js +10 -0
- package/lib/internal/PoolHostService.js.map +1 -0
- package/lib/internal/Wire.d.ts +60 -0
- package/lib/internal/Wire.d.ts.map +1 -0
- package/lib/internal/Wire.js +36 -0
- package/lib/internal/Wire.js.map +1 -0
- package/lib/internal/WorkflowGateway.d.ts +36 -0
- package/lib/internal/WorkflowGateway.d.ts.map +1 -0
- package/lib/internal/WorkflowGateway.js +124 -0
- package/lib/internal/WorkflowGateway.js.map +1 -0
- package/lib/internal/external-runner.d.ts +2 -0
- package/lib/internal/external-runner.d.ts.map +1 -0
- package/lib/internal/external-runner.js +7 -0
- package/lib/internal/external-runner.js.map +1 -0
- package/lib/internal/runner.d.ts +2 -0
- package/lib/internal/runner.d.ts.map +1 -0
- package/lib/internal/runner.js +5 -0
- package/lib/internal/runner.js.map +1 -0
- package/package.json +128 -3
- package/src/AWS/Database.ts +139 -0
- package/src/AWS/EcsPool.ts +118 -0
- package/src/AWS/index.ts +6 -0
- package/src/AWS/internal/Gateway.ts +82 -0
- package/src/Cloudflare/DurableObjectPool.ts +198 -0
- package/src/Cloudflare/PoolWorker.ts +124 -0
- package/src/Cloudflare/index.ts +6 -0
- package/src/Cloudflare/internal/Objects.ts +160 -0
- package/src/Cron.ts +202 -0
- package/src/Database.ts +116 -0
- package/src/Docker/Database.ts +94 -0
- package/src/Docker/SwarmPool.ts +83 -0
- package/src/Docker/index.ts +6 -0
- package/src/Entity.ts +621 -0
- package/src/Fly/Database.ts +102 -0
- package/src/Fly/MachinePool.ts +72 -0
- package/src/Fly/index.ts +6 -0
- package/src/GCP/ComputeEnginePool.ts +203 -0
- package/src/GCP/Database.ts +168 -0
- package/src/GCP/index.ts +6 -0
- package/src/Hetzner/Database.ts +142 -0
- package/src/Hetzner/ServerPool.ts +116 -0
- package/src/Hetzner/index.ts +6 -0
- package/src/Host.ts +68 -0
- package/src/Kubernetes/Database.ts +89 -0
- package/src/Kubernetes/DeploymentPool.ts +77 -0
- package/src/Kubernetes/index.ts +6 -0
- package/src/Pool.ts +104 -0
- package/src/Providers.ts +49 -0
- package/src/ShardGroups.ts +41 -0
- package/src/Singleton.ts +153 -0
- package/src/Workflow.ts +556 -0
- package/src/index.ts +11 -0
- package/src/internal/Bootstrap.ts +368 -0
- package/src/internal/EntityRuntime.ts +996 -0
- package/src/internal/Gateway.ts +366 -0
- package/src/internal/HostedPlatform.ts +83 -0
- package/src/internal/HostedRuntime.ts +118 -0
- package/src/internal/Naming.ts +44 -0
- package/src/internal/ObjectSharding.ts +578 -0
- package/src/internal/PoolHost.ts +330 -0
- package/src/internal/PoolHostService.ts +25 -0
- package/src/internal/Wire.ts +83 -0
- package/src/internal/WorkflowGateway.ts +188 -0
- package/src/internal/external-runner.ts +10 -0
- package/src/internal/runner.ts +5 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { WorkerEnvironment } from "alchemy/Workers/WorkerEnvironment";
|
|
2
|
+
/**
|
|
3
|
+
* How callers reach entities. Inside the cluster (a runner, where
|
|
4
|
+
* `Sharding` is in context) calls go straight through Effect Cluster's own
|
|
5
|
+
* client. Anywhere else they go through the gateway the pool serves: each
|
|
6
|
+
* entity's RPCs as an RPC group over HTTP, addressed by entity id, behind a
|
|
7
|
+
* bearer token, so a caller needs no database access and no route to the
|
|
8
|
+
* pool.
|
|
9
|
+
*
|
|
10
|
+
* Either way the caller's client middleware runs in the caller, and the
|
|
11
|
+
* headers it sets reach the entity's own middleware on the runner.
|
|
12
|
+
*
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
15
|
+
import { type Entity, Sharding } from "effect/cluster";
|
|
16
|
+
import {
|
|
17
|
+
AlreadyProcessingMessage,
|
|
18
|
+
EntityNotAssignedToRunner,
|
|
19
|
+
MailboxFull,
|
|
20
|
+
PersistenceError,
|
|
21
|
+
} from "effect/cluster/ClusterError";
|
|
22
|
+
import * as Context from "effect/Context";
|
|
23
|
+
import * as Effect from "effect/Effect";
|
|
24
|
+
import * as FetchHttpClient from "effect/http/FetchHttpClient";
|
|
25
|
+
import * as Headers from "effect/http/Headers";
|
|
26
|
+
import * as HttpClient from "effect/http/HttpClient";
|
|
27
|
+
import * as HttpClientRequest from "effect/http/HttpClientRequest";
|
|
28
|
+
import * as HttpRouter from "effect/http/HttpRouter";
|
|
29
|
+
import * as HttpServerRequest from "effect/http/HttpServerRequest";
|
|
30
|
+
import * as HttpServerResponse from "effect/http/HttpServerResponse";
|
|
31
|
+
import * as Layer from "effect/Layer";
|
|
32
|
+
import * as Option from "effect/Option";
|
|
33
|
+
import {
|
|
34
|
+
Rpc,
|
|
35
|
+
RpcClient,
|
|
36
|
+
RpcGroup,
|
|
37
|
+
type RpcMiddleware,
|
|
38
|
+
RpcSchema,
|
|
39
|
+
RpcSerialization,
|
|
40
|
+
RpcServer,
|
|
41
|
+
} from "effect/rpc";
|
|
42
|
+
import * as Schema from "effect/Schema";
|
|
43
|
+
import * as Stream from "effect/Stream";
|
|
44
|
+
import type { Workflow, WorkflowEngine } from "effect/workflow";
|
|
45
|
+
import { WorkflowRpcs, workflowHandlers } from "./WorkflowGateway.ts";
|
|
46
|
+
|
|
47
|
+
/** The path every gateway serves its RPCs on. */
|
|
48
|
+
export const GATEWAY_PATH = "/rpc";
|
|
49
|
+
|
|
50
|
+
/** The port runners serve the gateway on inside their network. */
|
|
51
|
+
export const GATEWAY_PORT = 34500;
|
|
52
|
+
|
|
53
|
+
/** The port runners talk to each other on inside their network. */
|
|
54
|
+
export const RUNNER_PORT = 34431;
|
|
55
|
+
|
|
56
|
+
/** Where a caller reaches an entity, and how it proves it may. */
|
|
57
|
+
export interface GatewayTarget {
|
|
58
|
+
readonly url: string;
|
|
59
|
+
readonly token: string;
|
|
60
|
+
/**
|
|
61
|
+
* The environment key of a service binding to the gateway, when the
|
|
62
|
+
* caller is a Worker and the gateway is one too: Workers can't fetch
|
|
63
|
+
* another Worker's `workers.dev` URL in the same account.
|
|
64
|
+
*/
|
|
65
|
+
readonly service?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Give the runtime being deployed a direct route, bound under `key`, to the
|
|
70
|
+
* gateway Worker `worker`. A host whose gateway is a Worker registers one;
|
|
71
|
+
* it binds nothing for a caller that isn't a Worker.
|
|
72
|
+
*/
|
|
73
|
+
export type RouteBinder = (key: string, worker: unknown) => Effect.Effect<void, never, any>;
|
|
74
|
+
|
|
75
|
+
const routeBinders: Array<RouteBinder> = ((globalThis as Record<symbol, unknown>)[
|
|
76
|
+
Symbol.for("alchemy/Cluster/RouteBinders")
|
|
77
|
+
] ??= []) as Array<RouteBinder>;
|
|
78
|
+
|
|
79
|
+
/** The gateway Workers hosted types are served by, while deploying. */
|
|
80
|
+
const routedTypes: Map<string, unknown> = ((globalThis as Record<symbol, unknown>)[
|
|
81
|
+
Symbol.for("alchemy/Cluster/RoutedTypes")
|
|
82
|
+
] ??= new Map()) as Map<string, unknown>;
|
|
83
|
+
|
|
84
|
+
/** Register a host's {@link RouteBinder}. @internal */
|
|
85
|
+
export const registerRouteBinder = (binder: RouteBinder) => {
|
|
86
|
+
if (!routeBinders.includes(binder)) routeBinders.push(binder);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** A host's gateway Worker serves `type` (an entity type, or `Workflow/${name}`). @internal */
|
|
90
|
+
export const routeType = (type: string, worker: unknown) => {
|
|
91
|
+
routedTypes.set(type, worker);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* While deploying, bind a route to the gateway Worker serving `type` into
|
|
96
|
+
* the runtime being deployed, under `key`. Returns the key when one exists.
|
|
97
|
+
*/
|
|
98
|
+
export const bindRoute = (type: string, key: string) =>
|
|
99
|
+
Effect.gen(function* () {
|
|
100
|
+
const worker = routedTypes.get(type);
|
|
101
|
+
if (worker === undefined) return undefined;
|
|
102
|
+
for (const binder of routeBinders) yield* binder(key, worker);
|
|
103
|
+
return key;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The `fetch` of the service binding under `key`, inside a Worker that has
|
|
108
|
+
* one; otherwise the global `fetch`.
|
|
109
|
+
*/
|
|
110
|
+
export const gatewayFetch = (key: string | undefined) =>
|
|
111
|
+
Effect.map(Effect.serviceOption(WorkerEnvironment), (env) => {
|
|
112
|
+
const service = key === undefined ? undefined : Option.getOrUndefined(env)?.[key];
|
|
113
|
+
return typeof service?.fetch === "function"
|
|
114
|
+
? (service.fetch.bind(service) as typeof globalThis.fetch)
|
|
115
|
+
: globalThis.fetch;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
type AnyEntity = Entity.Entity<string, Rpc.Any>;
|
|
119
|
+
|
|
120
|
+
const clientErrors = [MailboxFull, AlreadyProcessingMessage, PersistenceError] as const;
|
|
121
|
+
const requestErrors = [...clientErrors, EntityNotAssignedToRunner] as const;
|
|
122
|
+
|
|
123
|
+
/** The errors an RPC's middleware can fail with, which the gateway must carry too. */
|
|
124
|
+
const middlewareErrors = (rpc: Rpc.AnyWithProps) =>
|
|
125
|
+
[...rpc.middlewares].map((middleware) => middleware.error);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Each entity RPC as a gateway RPC: `${type}.${tag}` and its `Discard`
|
|
129
|
+
* twin, taking the entity id, the payload and the caller's headers. Like
|
|
130
|
+
* Effect Cluster's `EntityProxy`, plus the headers and middleware errors.
|
|
131
|
+
*/
|
|
132
|
+
const groupOf = (entity: AnyEntity) => {
|
|
133
|
+
const rpcs: Array<Rpc.Any> = [];
|
|
134
|
+
for (const parent_ of entity.protocol.requests.values()) {
|
|
135
|
+
const parent = parent_ as unknown as Rpc.AnyWithProps;
|
|
136
|
+
const payload = Schema.Struct({
|
|
137
|
+
entityId: Schema.String,
|
|
138
|
+
payload: parent.payloadSchema,
|
|
139
|
+
headers: Schema.Record(Schema.String, Schema.String),
|
|
140
|
+
});
|
|
141
|
+
rpcs.push(
|
|
142
|
+
Rpc.make(`${entity.type}.${parent._tag}`, {
|
|
143
|
+
payload,
|
|
144
|
+
error: Schema.Union([parent.errorSchema, ...middlewareErrors(parent), ...requestErrors]),
|
|
145
|
+
success: parent.successSchema,
|
|
146
|
+
}).annotateMerge(parent.annotations),
|
|
147
|
+
Rpc.make(`${entity.type}.${parent._tag}Discard`, {
|
|
148
|
+
payload,
|
|
149
|
+
error: Schema.Union([...middlewareErrors(parent), ...clientErrors]),
|
|
150
|
+
}).annotateMerge(parent.annotations),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return RpcGroup.make(...rpcs) as RpcGroup.RpcGroup<Rpc.Any>;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
interface CallOptions {
|
|
157
|
+
readonly discard?: boolean;
|
|
158
|
+
readonly headers?: Headers.Input;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Run the caller's client middleware for `rpc` and return the headers it
|
|
163
|
+
* set. Middleware is looked up the way `RpcClient` does, from the caller's
|
|
164
|
+
* context (`RpcMiddleware.layerClient`); a middleware with no client side
|
|
165
|
+
* is skipped.
|
|
166
|
+
*/
|
|
167
|
+
const clientHeaders = (
|
|
168
|
+
services: Context.Context<never>,
|
|
169
|
+
rpc: Rpc.AnyWithProps,
|
|
170
|
+
payload: unknown,
|
|
171
|
+
options: CallOptions | undefined,
|
|
172
|
+
) =>
|
|
173
|
+
Effect.gen(function* () {
|
|
174
|
+
let headers = Headers.merge(
|
|
175
|
+
yield* RpcClient.CurrentHeaders,
|
|
176
|
+
Headers.fromInput(options?.headers ?? {}),
|
|
177
|
+
);
|
|
178
|
+
const middlewares = [...rpc.middlewares].flatMap((tag) => {
|
|
179
|
+
const client = services.mapUnsafe.get(`${tag.key}/Client`) as
|
|
180
|
+
| RpcMiddleware.RpcMiddlewareClient<unknown, unknown, never>
|
|
181
|
+
| undefined;
|
|
182
|
+
return client === undefined ? [] : [client];
|
|
183
|
+
});
|
|
184
|
+
const loop = (request: any, index: number): Effect.Effect<any, unknown> =>
|
|
185
|
+
index === -1
|
|
186
|
+
? Effect.sync(() => {
|
|
187
|
+
headers = request.headers;
|
|
188
|
+
})
|
|
189
|
+
: middlewares[index]!({
|
|
190
|
+
rpc,
|
|
191
|
+
request,
|
|
192
|
+
next: (next) => loop(next, index - 1),
|
|
193
|
+
});
|
|
194
|
+
yield* loop(
|
|
195
|
+
{ _tag: "Request", id: 0n, tag: rpc._tag, payload, headers },
|
|
196
|
+
middlewares.length - 1,
|
|
197
|
+
);
|
|
198
|
+
return headers;
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* A caller's view of one entity type: `getByName(entityId)` returns a
|
|
203
|
+
* client with the same shape as Effect Cluster's `Entity.client(entityId)`.
|
|
204
|
+
*/
|
|
205
|
+
export const makeNamespace = (entity: AnyEntity, target: GatewayTarget) => {
|
|
206
|
+
const group = groupOf(entity);
|
|
207
|
+
const protocol = RpcClient.layerProtocolHttp({
|
|
208
|
+
url: `${target.url.replace(/\/+$/, "")}${GATEWAY_PATH}`,
|
|
209
|
+
transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken(target.token)),
|
|
210
|
+
}).pipe(Layer.provide([FetchHttpClient.layer, RpcSerialization.layerJson]));
|
|
211
|
+
|
|
212
|
+
const getByName = (entityId: string) =>
|
|
213
|
+
Effect.gen(function* () {
|
|
214
|
+
const services = yield* Effect.context<never>();
|
|
215
|
+
// Inside the cluster, Effect Cluster routes the call itself.
|
|
216
|
+
const sharding = Option.getOrUndefined(yield* Effect.serviceOption(Sharding.Sharding));
|
|
217
|
+
const native =
|
|
218
|
+
sharding === undefined
|
|
219
|
+
? undefined
|
|
220
|
+
: ((yield* Effect.provideService(entity.client, Sharding.Sharding, sharding))(
|
|
221
|
+
entityId,
|
|
222
|
+
) as unknown as Record<string, (payload: unknown, options: object) => any>);
|
|
223
|
+
|
|
224
|
+
const methods: Record<string, unknown> = {};
|
|
225
|
+
for (const [tag, rpc_] of entity.protocol.requests) {
|
|
226
|
+
const rpc = rpc_ as unknown as Rpc.AnyWithProps;
|
|
227
|
+
const isStream = RpcSchema.isStreamSchema(rpc.successSchema);
|
|
228
|
+
const send = (payload: unknown, options: CallOptions | undefined) =>
|
|
229
|
+
Effect.gen(function* () {
|
|
230
|
+
const headers = yield* clientHeaders(services, rpc, payload, options);
|
|
231
|
+
const discard = options?.discard === true;
|
|
232
|
+
if (native !== undefined) return native[tag]!(payload, { headers, discard });
|
|
233
|
+
const client = (yield* RpcClient.make(group)) as unknown as Record<
|
|
234
|
+
string,
|
|
235
|
+
(input: unknown) => any
|
|
236
|
+
>;
|
|
237
|
+
return client[`${entity.type}.${tag}${discard ? "Discard" : ""}`]!({
|
|
238
|
+
entityId,
|
|
239
|
+
payload,
|
|
240
|
+
headers: Object.fromEntries(Object.entries(headers)),
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
methods[tag] = isStream
|
|
244
|
+
? (payload: unknown, options?: CallOptions) =>
|
|
245
|
+
Stream.unwrap(
|
|
246
|
+
Effect.map(gatewayFetch(target.service), (fetch) =>
|
|
247
|
+
Stream.unwrap(send(payload, options)).pipe(
|
|
248
|
+
Stream.scoped,
|
|
249
|
+
Stream.provide(protocol),
|
|
250
|
+
Stream.provideService(FetchHttpClient.Fetch, fetch),
|
|
251
|
+
),
|
|
252
|
+
),
|
|
253
|
+
)
|
|
254
|
+
: (payload: unknown, options?: CallOptions) =>
|
|
255
|
+
Effect.flatMap(send(payload, options), (result) => result).pipe(
|
|
256
|
+
Effect.scoped,
|
|
257
|
+
Effect.provide(protocol),
|
|
258
|
+
Effect.provideServiceEffect(FetchHttpClient.Fetch, gatewayFetch(target.service)),
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
return methods;
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
return { getByName };
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The gateway handlers for one entity: each call goes to Effect Cluster's
|
|
269
|
+
* own client with the caller's headers, so the entity's middleware sees
|
|
270
|
+
* them. Requires the runner's `Sharding`, which routes each call to the
|
|
271
|
+
* runner that owns the entity.
|
|
272
|
+
*/
|
|
273
|
+
const handlersOf = (entity: AnyEntity) =>
|
|
274
|
+
Layer.effectContext(
|
|
275
|
+
Effect.gen(function* () {
|
|
276
|
+
const context = yield* Effect.context<never>();
|
|
277
|
+
const client = yield* entity.client;
|
|
278
|
+
const handlers = new Map<string, unknown>();
|
|
279
|
+
for (const parent of entity.protocol.requests.values()) {
|
|
280
|
+
const tag = `${entity.type}.${parent._tag}`;
|
|
281
|
+
const call =
|
|
282
|
+
(discard: boolean) =>
|
|
283
|
+
({
|
|
284
|
+
entityId,
|
|
285
|
+
payload,
|
|
286
|
+
headers,
|
|
287
|
+
}: {
|
|
288
|
+
entityId: string;
|
|
289
|
+
payload: unknown;
|
|
290
|
+
headers: Record<string, string>;
|
|
291
|
+
}) =>
|
|
292
|
+
(client(entityId) as unknown as Record<string, (p: unknown, o: object) => unknown>)[
|
|
293
|
+
parent._tag
|
|
294
|
+
]!(payload, { headers, discard });
|
|
295
|
+
handlers.set(`effect/rpc/Rpc/${tag}`, { context, tag, handler: call(false) });
|
|
296
|
+
handlers.set(`effect/rpc/Rpc/${tag}Discard`, {
|
|
297
|
+
context,
|
|
298
|
+
tag: `${tag}Discard`,
|
|
299
|
+
handler: call(true),
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
return Context.makeUnsafe(handlers) as Context.Context<never>;
|
|
303
|
+
}),
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* What a gateway serves: one RPC group with every hosted entity's RPCs (and
|
|
308
|
+
* the workflow RPCs, when it hosts workflows), and the handlers that route
|
|
309
|
+
* each call to Effect Cluster.
|
|
310
|
+
*/
|
|
311
|
+
export const gatewayServer = (
|
|
312
|
+
entities: ReadonlyArray<AnyEntity>,
|
|
313
|
+
workflows: ReadonlyMap<string, Workflow.Any> = new Map(),
|
|
314
|
+
) => {
|
|
315
|
+
const groups = [...entities.map(groupOf), ...(workflows.size > 0 ? [WorkflowRpcs] : [])];
|
|
316
|
+
const [first, ...rest] = groups as [RpcGroup.RpcGroup<Rpc.Any>, ...RpcGroup.RpcGroup<Rpc.Any>[]];
|
|
317
|
+
const group = rest.reduce((all, next) => all.merge(next), first ?? RpcGroup.make());
|
|
318
|
+
const handlers = Layer.mergeAll(
|
|
319
|
+
...([
|
|
320
|
+
...entities.map(handlersOf),
|
|
321
|
+
...(workflows.size > 0 ? [workflowHandlers(workflows)] : []),
|
|
322
|
+
] as unknown as [Layer.Layer<never, never, Sharding.Sharding | WorkflowEngine.WorkflowEngine>]),
|
|
323
|
+
);
|
|
324
|
+
return { group, handlers };
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
/** Serve `app` to callers with the token; `/health` answers anyone. */
|
|
328
|
+
export const authorized =
|
|
329
|
+
(token: string) =>
|
|
330
|
+
<E, R>(app: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>) =>
|
|
331
|
+
Effect.gen(function* () {
|
|
332
|
+
const request = yield* HttpServerRequest.HttpServerRequest;
|
|
333
|
+
// Load balancers check the gateway without the token.
|
|
334
|
+
if (request.url === "/health" || request.url.endsWith("/health")) {
|
|
335
|
+
return HttpServerResponse.text("ok");
|
|
336
|
+
}
|
|
337
|
+
return request.headers.authorization === `Bearer ${token}`
|
|
338
|
+
? yield* app
|
|
339
|
+
: HttpServerResponse.empty({ status: 401 });
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
/** The gateway a runner serves: every hosted entity's RPCs, behind the token. */
|
|
343
|
+
export function gatewayLayer(
|
|
344
|
+
entities: ReadonlyArray<AnyEntity>,
|
|
345
|
+
token: string,
|
|
346
|
+
): Layer.Layer<never, never, Sharding.Sharding>;
|
|
347
|
+
/** With the workflows the runner hosts, served on its own `WorkflowEngine`. */
|
|
348
|
+
export function gatewayLayer(
|
|
349
|
+
entities: ReadonlyArray<AnyEntity>,
|
|
350
|
+
token: string,
|
|
351
|
+
workflows: ReadonlyMap<string, Workflow.Any>,
|
|
352
|
+
): Layer.Layer<never, never, Sharding.Sharding | WorkflowEngine.WorkflowEngine>;
|
|
353
|
+
export function gatewayLayer(
|
|
354
|
+
entities: ReadonlyArray<AnyEntity>,
|
|
355
|
+
token: string,
|
|
356
|
+
workflows: ReadonlyMap<string, Workflow.Any> = new Map(),
|
|
357
|
+
): Layer.Layer<never, never, any> {
|
|
358
|
+
const { group, handlers } = gatewayServer(entities, workflows);
|
|
359
|
+
return HttpRouter.serve(
|
|
360
|
+
RpcServer.layerHttp({ group, path: GATEWAY_PATH, protocol: "http" }).pipe(
|
|
361
|
+
Layer.provide(handlers),
|
|
362
|
+
Layer.provide(RpcSerialization.layerJson),
|
|
363
|
+
),
|
|
364
|
+
{ disableListenLog: true, middleware: authorized(token) },
|
|
365
|
+
);
|
|
366
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Platform and provider behind a class a pool hosts that callers never
|
|
3
|
+
* address (a singleton, a cron): a resource whose only attribute is the
|
|
4
|
+
* shard group of the pool that hosts it.
|
|
5
|
+
*
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
import type { InputProps } from "alchemy/Input";
|
|
9
|
+
import { Platform, type PlatformProps } from "alchemy/Platform";
|
|
10
|
+
import * as Provider from "alchemy/Provider";
|
|
11
|
+
import type { Resource } from "alchemy/Resource";
|
|
12
|
+
import type * as Context from "effect/Context";
|
|
13
|
+
import * as Effect from "effect/Effect";
|
|
14
|
+
import type * as Layer from "effect/Layer";
|
|
15
|
+
import type { EntityBindingContract } from "../Entity.ts";
|
|
16
|
+
import type { Providers } from "../Providers.ts";
|
|
17
|
+
import { type HostedRuntimeContext, makeHostedRuntimeContext } from "./HostedRuntime.ts";
|
|
18
|
+
|
|
19
|
+
export interface HostedProps extends PlatformProps {
|
|
20
|
+
/**
|
|
21
|
+
* The module whose default export is this class, or the Layer its
|
|
22
|
+
* `.make(...)` returned. Usually `import.meta.url`.
|
|
23
|
+
*/
|
|
24
|
+
main: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface HostedAttributes {
|
|
28
|
+
/** The shard group of the pool that hosts it. */
|
|
29
|
+
pool: string | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface HostedResource<
|
|
33
|
+
Type extends string,
|
|
34
|
+
Props extends HostedProps = HostedProps,
|
|
35
|
+
> extends Resource<Type, Props, HostedAttributes, EntityBindingContract, Providers> {}
|
|
36
|
+
|
|
37
|
+
export const makeHostedPlatform = <
|
|
38
|
+
Type extends string,
|
|
39
|
+
Hosted,
|
|
40
|
+
Props extends HostedProps = HostedProps,
|
|
41
|
+
>(
|
|
42
|
+
typeId: Type,
|
|
43
|
+
build: (id: string, returned: unknown, context: Context.Context<never>, props: Props) => Hosted,
|
|
44
|
+
check?: (id: string, context: Context.Context<never>, props: Props) => Effect.Effect<void>,
|
|
45
|
+
) => {
|
|
46
|
+
const platform = Platform(typeId, {
|
|
47
|
+
createRuntimeContext: makeHostedRuntimeContext<Hosted>(typeId, build, check),
|
|
48
|
+
}) as unknown as Platform<
|
|
49
|
+
HostedResource<Type, Props>,
|
|
50
|
+
any,
|
|
51
|
+
unknown,
|
|
52
|
+
HostedRuntimeContext<Hosted>
|
|
53
|
+
>;
|
|
54
|
+
|
|
55
|
+
const provider = () =>
|
|
56
|
+
Provider.succeed(platform, {
|
|
57
|
+
stables: [],
|
|
58
|
+
reconcile: ({ bindings }) =>
|
|
59
|
+
Effect.sync(() => {
|
|
60
|
+
const attrs: HostedAttributes = { pool: undefined };
|
|
61
|
+
for (const { data } of bindings) attrs.pool = data.pool ?? attrs.pool;
|
|
62
|
+
return attrs;
|
|
63
|
+
}),
|
|
64
|
+
delete: () => Effect.void,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The class: with `impl`, the class itself is the implementation;
|
|
69
|
+
* without, a declaration with `.make(props, impl)`, its implementation as
|
|
70
|
+
* a Layer.
|
|
71
|
+
*/
|
|
72
|
+
const declare = (id: string, members: object, props?: InputProps<Props>, impl?: unknown) => {
|
|
73
|
+
if (impl !== undefined) return (platform as any)()(id, props, impl);
|
|
74
|
+
const tag = (platform as any)()(id);
|
|
75
|
+
return Object.assign(class {}, members, {
|
|
76
|
+
LogicalId: id,
|
|
77
|
+
make: (props: InputProps<Props>, impl: unknown) =>
|
|
78
|
+
tag.make(props, impl) as Layer.Layer<never, never, any>,
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return { platform, provider, declare };
|
|
83
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runtime context of something a pool hosts (an entity, a workflow):
|
|
3
|
+
* while deploying, its pool Layer registers how to deploy it, and it is
|
|
4
|
+
* deployed once its Construction phase has run; on a runner, the built
|
|
5
|
+
* class is kept for the runner to run.
|
|
6
|
+
*
|
|
7
|
+
* @internal
|
|
8
|
+
*/
|
|
9
|
+
import { createHostRuntimeContext, type HostRuntimeContext } from "alchemy/Runtime/Process";
|
|
10
|
+
import { Self } from "alchemy/Self";
|
|
11
|
+
import * as Context from "effect/Context";
|
|
12
|
+
import * as Data from "effect/Data";
|
|
13
|
+
import * as Effect from "effect/Effect";
|
|
14
|
+
import * as Option from "effect/Option";
|
|
15
|
+
import { PoolHost } from "./PoolHostService.ts";
|
|
16
|
+
|
|
17
|
+
/** Declares the pool resources for one hosted class, given its collected env. */
|
|
18
|
+
export type DeployToPool = (env: Record<string, unknown>) => Effect.Effect<void, never, any>;
|
|
19
|
+
|
|
20
|
+
export interface HostedRuntimeContext<Hosted> extends HostRuntimeContext {
|
|
21
|
+
readonly Type: string;
|
|
22
|
+
readonly alwaysServe: true;
|
|
23
|
+
/**
|
|
24
|
+
* Called by a pool Layer while the Construction phase runs: `deploy`
|
|
25
|
+
* declares the pool that hosts this class, once its env is collected.
|
|
26
|
+
*/
|
|
27
|
+
readonly registerPool: (deploy: DeployToPool) => Effect.Effect<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Called by a pool Layer with the services it was built with: those
|
|
30
|
+
* provided to it, like an entity's RPC middleware, stay with the class
|
|
31
|
+
* after the Construction phase that provided them ends.
|
|
32
|
+
*/
|
|
33
|
+
readonly provideServices: (services: Context.Context<never>) => Effect.Effect<void>;
|
|
34
|
+
/** At runtime, the built class, once its Construction phase has run. */
|
|
35
|
+
readonly hosted: () => Hosted | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A hosted class as its pool sees it: the resource, with its runtime context. */
|
|
39
|
+
export interface HostedClass {
|
|
40
|
+
readonly Type: string;
|
|
41
|
+
readonly LogicalId: string;
|
|
42
|
+
readonly Props: { readonly main: string; readonly handler?: string };
|
|
43
|
+
readonly registerPool: (deploy: DeployToPool) => Effect.Effect<void>;
|
|
44
|
+
readonly provideServices: (services: Context.Context<never>) => Effect.Effect<void>;
|
|
45
|
+
readonly bind: any;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The class was deployed without a pool Layer, so nothing would host it. */
|
|
49
|
+
export class HasNoPool extends Data.TaggedError("Cluster.HasNoPool")<{
|
|
50
|
+
readonly message: string;
|
|
51
|
+
}> {}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `build` turns what the Construction phase returned, the services it ran
|
|
55
|
+
* with and the class's props into what the runner runs. `check`, while
|
|
56
|
+
* deploying, fails the deploy when those services can't run it.
|
|
57
|
+
*/
|
|
58
|
+
export const makeHostedRuntimeContext =
|
|
59
|
+
<Hosted>(
|
|
60
|
+
typeId: string,
|
|
61
|
+
build: (id: string, returned: unknown, context: Context.Context<never>, props: any) => Hosted,
|
|
62
|
+
check?: (id: string, context: Context.Context<never>, props: any) => Effect.Effect<void>,
|
|
63
|
+
) =>
|
|
64
|
+
(id: string): HostedRuntimeContext<Hosted> => {
|
|
65
|
+
const base = createHostRuntimeContext(typeId)(id);
|
|
66
|
+
let deploy: DeployToPool | undefined;
|
|
67
|
+
let hosted: Hosted | undefined;
|
|
68
|
+
let services = Context.empty() as Context.Context<never>;
|
|
69
|
+
|
|
70
|
+
const ctx: HostedRuntimeContext<Hosted> = {
|
|
71
|
+
...base,
|
|
72
|
+
Type: typeId,
|
|
73
|
+
// The Construction phase returns what the runner runs rather than a
|
|
74
|
+
// handler shape: always hand it to `serve`.
|
|
75
|
+
alwaysServe: true,
|
|
76
|
+
registerPool: (next) =>
|
|
77
|
+
Effect.sync(() => {
|
|
78
|
+
deploy = next;
|
|
79
|
+
}),
|
|
80
|
+
provideServices: (next) =>
|
|
81
|
+
Effect.sync(() => {
|
|
82
|
+
services = Context.merge(services, next);
|
|
83
|
+
}),
|
|
84
|
+
hosted: () => hosted,
|
|
85
|
+
serve: (_handler, options) =>
|
|
86
|
+
Effect.gen(function* () {
|
|
87
|
+
// Runners provided to the class's Layer rather than its
|
|
88
|
+
// implementation: ask them to host it now.
|
|
89
|
+
const host = Option.getOrUndefined(yield* Effect.serviceOption(PoolHost));
|
|
90
|
+
const self = Option.getOrUndefined(yield* Effect.serviceOption(Self));
|
|
91
|
+
if (deploy === undefined && host !== undefined && self !== undefined) {
|
|
92
|
+
yield* host.host(self as unknown as HostedClass);
|
|
93
|
+
}
|
|
94
|
+
const context = Context.merge(services, yield* Effect.context<never>());
|
|
95
|
+
// The props are on the resource, which `Self` is.
|
|
96
|
+
const props = (self as { Props?: unknown } | undefined)?.Props;
|
|
97
|
+
if (globalThis.__ALCHEMY_RUNTIME__) {
|
|
98
|
+
hosted = build(id, options?.shape, context, props);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (deploy === undefined) {
|
|
102
|
+
return yield* Effect.die(
|
|
103
|
+
new HasNoPool({
|
|
104
|
+
message:
|
|
105
|
+
`${typeId} '${id}' has no pool. Provide a pool Layer to its implementation, ` +
|
|
106
|
+
`for example ClusterDocker.SwarmPool("Default").`,
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (check !== undefined) {
|
|
111
|
+
yield* check(id, context, props);
|
|
112
|
+
}
|
|
113
|
+
// The Construction phase has run, so every Config value it read is in `env`.
|
|
114
|
+
yield* deploy(base.env);
|
|
115
|
+
}),
|
|
116
|
+
};
|
|
117
|
+
return ctx;
|
|
118
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Names every pool Layer shares, kept apart from the bundling in
|
|
3
|
+
* `PoolHost.ts` so a runtime that bundles a pool Layer (a Worker) never
|
|
4
|
+
* pulls in the bundler.
|
|
5
|
+
*
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { Stack } from "alchemy/Stack";
|
|
10
|
+
import * as Effect from "effect/Effect";
|
|
11
|
+
import { EntityTypeId } from "../Entity.ts";
|
|
12
|
+
|
|
13
|
+
/** The resource types a pool hosts. */
|
|
14
|
+
export const HOSTED_TYPES = new Set<string>([
|
|
15
|
+
EntityTypeId,
|
|
16
|
+
"Cluster.Workflow",
|
|
17
|
+
"Cluster.Singleton",
|
|
18
|
+
"Cluster.Cron",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
/** `label` as a lowercase name of at most `max` characters, ending in a hash when shortened. */
|
|
22
|
+
const shorten = Effect.fn(function* (label: string, max: number) {
|
|
23
|
+
const clean = label
|
|
24
|
+
.toLowerCase()
|
|
25
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
26
|
+
.replace(/^-+|-+$/g, "");
|
|
27
|
+
if (clean.length <= max) return clean;
|
|
28
|
+
const hash = yield* Effect.sync(() =>
|
|
29
|
+
createHash("sha256").update(clean).digest("hex").slice(0, 8),
|
|
30
|
+
);
|
|
31
|
+
return `${clean.slice(0, max - 9).replace(/-+$/, "")}-${hash}`;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `${stack}-${stage}-${id}` as a lowercase DNS label of at most `max`
|
|
36
|
+
* characters. A longer one keeps its start and ends in a hash of the whole.
|
|
37
|
+
*/
|
|
38
|
+
export const stackDnsLabel = Effect.fn(function* (id: string, max = 63) {
|
|
39
|
+
const stack = yield* Stack;
|
|
40
|
+
return yield* shorten(`${stack.name}-${stack.stage}-${id}`, max);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** `id` as a lowercase label of at most `max` characters, ending in a hash when shortened. */
|
|
44
|
+
export const shortLabel = (id: string, max: number) => shorten(id, max);
|