@alchemy.run/boat 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 +66 -4
- package/lib/AiProxy.d.ts +89 -0
- package/lib/AiProxy.d.ts.map +1 -0
- package/lib/AiProxy.js +125 -0
- package/lib/AiProxy.js.map +1 -0
- package/lib/ApiKey.d.ts +105 -0
- package/lib/ApiKey.d.ts.map +1 -0
- package/lib/ApiKey.js +134 -0
- package/lib/ApiKey.js.map +1 -0
- package/lib/AuthProvider.d.ts +71 -0
- package/lib/AuthProvider.d.ts.map +1 -0
- package/lib/AuthProvider.js +322 -0
- package/lib/AuthProvider.js.map +1 -0
- package/lib/BoatBindings.d.ts +52 -0
- package/lib/BoatBindings.d.ts.map +1 -0
- package/lib/BoatBindings.js +104 -0
- package/lib/BoatBindings.js.map +1 -0
- package/lib/Bootstrap.d.ts +8 -0
- package/lib/Bootstrap.d.ts.map +1 -0
- package/lib/Bootstrap.js +28 -0
- package/lib/Bootstrap.js.map +1 -0
- package/lib/ConnectSandbox.d.ts +56 -0
- package/lib/ConnectSandbox.d.ts.map +1 -0
- package/lib/ConnectSandbox.js +3 -0
- package/lib/ConnectSandbox.js.map +1 -0
- package/lib/ConnectSandboxHttp.d.ts +21 -0
- package/lib/ConnectSandboxHttp.d.ts.map +1 -0
- package/lib/ConnectSandboxHttp.js +46 -0
- package/lib/ConnectSandboxHttp.js.map +1 -0
- package/lib/Contributions.d.ts +7 -0
- package/lib/Contributions.d.ts.map +1 -0
- package/lib/Contributions.js +6 -0
- package/lib/Contributions.js.map +1 -0
- package/lib/CreateSandbox.d.ts +134 -0
- package/lib/CreateSandbox.d.ts.map +1 -0
- package/lib/CreateSandbox.js +3 -0
- package/lib/CreateSandbox.js.map +1 -0
- package/lib/CreateSandboxHttp.d.ts +21 -0
- package/lib/CreateSandboxHttp.d.ts.map +1 -0
- package/lib/CreateSandboxHttp.js +93 -0
- package/lib/CreateSandboxHttp.js.map +1 -0
- package/lib/CredentialProfile.d.ts +157 -0
- package/lib/CredentialProfile.d.ts.map +1 -0
- package/lib/CredentialProfile.js +245 -0
- package/lib/CredentialProfile.js.map +1 -0
- package/lib/Credentials.d.ts +15 -0
- package/lib/Credentials.d.ts.map +1 -0
- package/lib/Credentials.js +23 -0
- package/lib/Credentials.js.map +1 -0
- package/lib/Environment.d.ts +219 -0
- package/lib/Environment.d.ts.map +1 -0
- package/lib/Environment.js +273 -0
- package/lib/Environment.js.map +1 -0
- package/lib/Errors.d.ts +50 -0
- package/lib/Errors.d.ts.map +1 -0
- package/lib/Errors.js +17 -0
- package/lib/Errors.js.map +1 -0
- package/lib/Providers.d.ts +33 -0
- package/lib/Providers.d.ts.map +1 -0
- package/lib/Providers.js +47 -0
- package/lib/Providers.js.map +1 -0
- package/lib/Sandbox.d.ts +205 -0
- package/lib/Sandbox.d.ts.map +1 -0
- package/lib/Sandbox.js +368 -0
- package/lib/Sandbox.js.map +1 -0
- package/lib/SandboxClient.d.ts +159 -0
- package/lib/SandboxClient.d.ts.map +1 -0
- package/lib/SandboxClient.js +1 -0
- package/lib/SandboxClient.js.map +1 -0
- package/lib/SandboxEvents.d.ts +111 -0
- package/lib/SandboxEvents.d.ts.map +1 -0
- package/lib/SandboxEvents.js +60 -0
- package/lib/SandboxEvents.js.map +1 -0
- package/lib/SandboxEventsLive.d.ts +13 -0
- package/lib/SandboxEventsLive.d.ts.map +1 -0
- package/lib/SandboxEventsLive.js +63 -0
- package/lib/SandboxEventsLive.js.map +1 -0
- package/lib/Snapshot.d.ts +123 -0
- package/lib/Snapshot.d.ts.map +1 -0
- package/lib/Snapshot.js +186 -0
- package/lib/Snapshot.js.map +1 -0
- package/lib/Webhook.d.ts +56 -0
- package/lib/Webhook.d.ts.map +1 -0
- package/lib/Webhook.js +98 -0
- package/lib/Webhook.js.map +1 -0
- package/lib/index.d.ts +19 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +19 -0
- package/lib/index.js.map +1 -0
- package/lib/internal/Declared.d.ts +34 -0
- package/lib/internal/Declared.d.ts.map +1 -0
- package/lib/internal/Declared.js +38 -0
- package/lib/internal/Declared.js.map +1 -0
- package/lib/internal/Org.d.ts +22 -0
- package/lib/internal/Org.d.ts.map +1 -0
- package/lib/internal/Org.js +23 -0
- package/lib/internal/Org.js.map +1 -0
- package/lib/internal/Ownership.d.ts +29 -0
- package/lib/internal/Ownership.d.ts.map +1 -0
- package/lib/internal/Ownership.js +50 -0
- package/lib/internal/Ownership.js.map +1 -0
- package/lib/internal/Program.d.ts +69 -0
- package/lib/internal/Program.d.ts.map +1 -0
- package/lib/internal/Program.js +178 -0
- package/lib/internal/Program.js.map +1 -0
- package/lib/internal/Ref.d.ts +14 -0
- package/lib/internal/Ref.d.ts.map +1 -0
- package/lib/internal/Ref.js +7 -0
- package/lib/internal/Ref.js.map +1 -0
- package/lib/internal/SandboxHandle.d.ts +17 -0
- package/lib/internal/SandboxHandle.d.ts.map +1 -0
- package/lib/internal/SandboxHandle.js +249 -0
- package/lib/internal/SandboxHandle.js.map +1 -0
- package/lib/internal/WebhookDelivery.d.ts +16 -0
- package/lib/internal/WebhookDelivery.d.ts.map +1 -0
- package/lib/internal/WebhookDelivery.js +109 -0
- package/lib/internal/WebhookDelivery.js.map +1 -0
- package/package.json +67 -3
- package/src/AiProxy.ts +171 -0
- package/src/ApiKey.ts +250 -0
- package/src/AuthProvider.ts +472 -0
- package/src/BoatBindings.ts +136 -0
- package/src/Bootstrap.ts +55 -0
- package/src/ConnectSandbox.ts +60 -0
- package/src/ConnectSandboxHttp.ts +59 -0
- package/src/Contributions.ts +9 -0
- package/src/CreateSandbox.ts +143 -0
- package/src/CreateSandboxHttp.ts +115 -0
- package/src/CredentialProfile.ts +350 -0
- package/src/Credentials.ts +33 -0
- package/src/Environment.ts +416 -0
- package/src/Errors.ts +34 -0
- package/src/Providers.ts +71 -0
- package/src/Sandbox.ts +647 -0
- package/src/SandboxClient.ts +199 -0
- package/src/SandboxEvents.ts +125 -0
- package/src/SandboxEventsLive.ts +88 -0
- package/src/Snapshot.ts +284 -0
- package/src/Webhook.ts +163 -0
- package/src/globals.d.ts +2 -0
- package/src/index.ts +18 -0
- package/src/internal/Declared.ts +87 -0
- package/src/internal/Org.ts +31 -0
- package/src/internal/Ownership.ts +75 -0
- package/src/internal/Program.ts +250 -0
- package/src/internal/Ref.ts +15 -0
- package/src/internal/SandboxHandle.ts +425 -0
- package/src/internal/WebhookDelivery.ts +179 -0
package/src/Webhook.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import * as boat from "@distilled.cloud/boat-dev/boat";
|
|
2
|
+
import { Unowned } from "alchemy/AdoptPolicy";
|
|
3
|
+
import * as Provider from "alchemy/Provider";
|
|
4
|
+
import { Resource } from "alchemy/Resource";
|
|
5
|
+
import * as Effect from "effect/Effect";
|
|
6
|
+
import * as Redacted from "effect/Redacted";
|
|
7
|
+
import { boatName, isGeneratedName } from "./internal/Ownership.ts";
|
|
8
|
+
import type { Providers } from "./Providers.ts";
|
|
9
|
+
|
|
10
|
+
/** A sandbox lifecycle event Boat delivers. */
|
|
11
|
+
export type WebhookEvent =
|
|
12
|
+
| "sandbox.ready"
|
|
13
|
+
| "sandbox.hydrated"
|
|
14
|
+
| "sandbox.error"
|
|
15
|
+
| "sandbox.archived"
|
|
16
|
+
| "sandbox.degraded"
|
|
17
|
+
| "sandbox.recovered";
|
|
18
|
+
|
|
19
|
+
/** Every sandbox lifecycle event Boat sends. */
|
|
20
|
+
export const ALL_SANDBOX_EVENTS: ReadonlyArray<WebhookEvent> = [
|
|
21
|
+
"sandbox.ready",
|
|
22
|
+
"sandbox.hydrated",
|
|
23
|
+
"sandbox.error",
|
|
24
|
+
"sandbox.archived",
|
|
25
|
+
"sandbox.degraded",
|
|
26
|
+
"sandbox.recovered",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export interface WebhookProps {
|
|
30
|
+
/** The public HTTPS endpoint (port 443) Boat POSTs events to. Updated in place. */
|
|
31
|
+
url: string;
|
|
32
|
+
/** The events to deliver. Updated in place. */
|
|
33
|
+
events: WebhookEvent[];
|
|
34
|
+
/**
|
|
35
|
+
* Display name. If omitted, a unique name is generated from the stack,
|
|
36
|
+
* stage and logical ID. Renamed in place.
|
|
37
|
+
*/
|
|
38
|
+
name?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface WebhookAttributes {
|
|
42
|
+
/** Boat's webhook id (`wh_…`). */
|
|
43
|
+
webhookId: string;
|
|
44
|
+
name: string;
|
|
45
|
+
url: string;
|
|
46
|
+
events: WebhookEvent[];
|
|
47
|
+
/**
|
|
48
|
+
* The secret Boat signs deliveries with. Boat shows it once, at creation;
|
|
49
|
+
* Alchemy keeps it in state.
|
|
50
|
+
*/
|
|
51
|
+
secret: Redacted.Redacted<string>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface Webhook extends Resource<
|
|
55
|
+
"Boat.Webhook",
|
|
56
|
+
WebhookProps,
|
|
57
|
+
WebhookAttributes,
|
|
58
|
+
never,
|
|
59
|
+
Providers
|
|
60
|
+
> {}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A Boat webhook: Boat POSTs sandbox lifecycle events (`sandbox.ready`,
|
|
64
|
+
* `sandbox.error`, `sandbox.archived`, `sandbox.hydrated`,
|
|
65
|
+
* `sandbox.degraded`, `sandbox.recovered`) to your endpoint, signed with
|
|
66
|
+
* `secret`.
|
|
67
|
+
*
|
|
68
|
+
* ### Receiving sandbox events
|
|
69
|
+
* **Example:** Deliver events to a Worker
|
|
70
|
+
* ```typescript
|
|
71
|
+
* const receiver = yield* Receiver;
|
|
72
|
+
* const hook = yield* Boat.Webhook("Events", {
|
|
73
|
+
* url: Output.interpolate`${receiver.url}/boat`,
|
|
74
|
+
* events: ["sandbox.ready", "sandbox.archived"],
|
|
75
|
+
* });
|
|
76
|
+
* ```
|
|
77
|
+
*
|
|
78
|
+
* @resource
|
|
79
|
+
* @product Webhook
|
|
80
|
+
*/
|
|
81
|
+
export const Webhook = Resource<Webhook>("Boat.Webhook");
|
|
82
|
+
|
|
83
|
+
const redact = (value: string | Redacted.Redacted<string>) =>
|
|
84
|
+
Redacted.isRedacted(value) ? value : Redacted.make(value);
|
|
85
|
+
|
|
86
|
+
const observeWebhook = (webhookId: string) =>
|
|
87
|
+
boat.getWebhook({ webhookId }).pipe(
|
|
88
|
+
Effect.map((got): boat.Webhook | undefined => got.webhook),
|
|
89
|
+
Effect.catchTag("NotFound", () => Effect.undefined),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const toAttributes = (
|
|
93
|
+
webhook: boat.Webhook,
|
|
94
|
+
secret: Redacted.Redacted<string>,
|
|
95
|
+
): WebhookAttributes => ({
|
|
96
|
+
webhookId: webhook.id,
|
|
97
|
+
name: webhook.name ?? "",
|
|
98
|
+
url: webhook.url,
|
|
99
|
+
events: [...webhook.events],
|
|
100
|
+
secret,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const sameEvents = (a: ReadonlyArray<string>, b: ReadonlyArray<string>) =>
|
|
104
|
+
JSON.stringify([...a].sort()) === JSON.stringify([...b].sort());
|
|
105
|
+
|
|
106
|
+
export const WebhookProvider = () =>
|
|
107
|
+
Provider.effect(
|
|
108
|
+
Webhook,
|
|
109
|
+
Effect.gen(function* () {
|
|
110
|
+
return {
|
|
111
|
+
stables: ["webhookId"],
|
|
112
|
+
read: Effect.fn(function* ({ olds, output }) {
|
|
113
|
+
// Boat shows the signing secret only at creation, so a webhook is
|
|
114
|
+
// only usable through state.
|
|
115
|
+
if (output === undefined) return undefined;
|
|
116
|
+
const webhook = yield* observeWebhook(output.webhookId);
|
|
117
|
+
if (webhook === undefined) return undefined;
|
|
118
|
+
const attrs = toAttributes(webhook, output.secret);
|
|
119
|
+
return olds !== undefined || isGeneratedName(webhook.name) ? attrs : Unowned(attrs);
|
|
120
|
+
}),
|
|
121
|
+
reconcile: Effect.fn(function* ({ id, news, output, session }) {
|
|
122
|
+
const name = yield* boatName(id, news.name ?? output?.name, 100);
|
|
123
|
+
|
|
124
|
+
// Observe
|
|
125
|
+
let webhook = output === undefined ? undefined : yield* observeWebhook(output.webhookId);
|
|
126
|
+
|
|
127
|
+
// Ensure
|
|
128
|
+
if (webhook === undefined || output === undefined) {
|
|
129
|
+
yield* session.note(`Creating webhook ${name}...`);
|
|
130
|
+
const created = yield* boat.createWebhook({ name, url: news.url, events: news.events });
|
|
131
|
+
return toAttributes(created.webhook, redact(created.secret));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Sync — name, URL and events, against what Boat reports.
|
|
135
|
+
if (
|
|
136
|
+
webhook.name !== name ||
|
|
137
|
+
webhook.url !== news.url ||
|
|
138
|
+
!sameEvents(webhook.events, news.events)
|
|
139
|
+
) {
|
|
140
|
+
const updated = yield* boat.updateWebhook({
|
|
141
|
+
webhookId: webhook.id,
|
|
142
|
+
name,
|
|
143
|
+
url: news.url,
|
|
144
|
+
events: news.events,
|
|
145
|
+
});
|
|
146
|
+
webhook = updated.webhook;
|
|
147
|
+
}
|
|
148
|
+
return toAttributes(webhook, output.secret);
|
|
149
|
+
}),
|
|
150
|
+
delete: Effect.fn(function* ({ output }) {
|
|
151
|
+
yield* boat
|
|
152
|
+
.deleteWebhook({ webhookId: output.webhookId })
|
|
153
|
+
.pipe(Effect.catchTag("NotFound", () => Effect.void));
|
|
154
|
+
}),
|
|
155
|
+
list: Effect.fn(function* () {
|
|
156
|
+
const listed = yield* boat.listWebhooks({});
|
|
157
|
+
return listed.webhooks
|
|
158
|
+
.filter((webhook) => isGeneratedName(webhook.name))
|
|
159
|
+
.map((webhook) => toAttributes(webhook, Redacted.make("")));
|
|
160
|
+
}),
|
|
161
|
+
};
|
|
162
|
+
}),
|
|
163
|
+
);
|
package/src/globals.d.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export * from "./AiProxy.ts";
|
|
2
|
+
export * from "./ApiKey.ts";
|
|
3
|
+
export * from "./AuthProvider.ts";
|
|
4
|
+
export * from "./ConnectSandbox.ts";
|
|
5
|
+
export * from "./ConnectSandboxHttp.ts";
|
|
6
|
+
export * from "./CreateSandbox.ts";
|
|
7
|
+
export * from "./CreateSandboxHttp.ts";
|
|
8
|
+
export * from "./CredentialProfile.ts";
|
|
9
|
+
export * from "./Credentials.ts";
|
|
10
|
+
export * from "./Environment.ts";
|
|
11
|
+
export * from "./Errors.ts";
|
|
12
|
+
export * from "./Providers.ts";
|
|
13
|
+
export * from "./Sandbox.ts";
|
|
14
|
+
export * from "./SandboxClient.ts";
|
|
15
|
+
export * from "./SandboxEvents.ts";
|
|
16
|
+
export * from "./SandboxEventsLive.ts";
|
|
17
|
+
export * from "./Snapshot.ts";
|
|
18
|
+
export * from "./Webhook.ts";
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { PropsInput } from "alchemy/Input";
|
|
2
|
+
import * as Output from "alchemy/Output";
|
|
3
|
+
import { isResource } from "alchemy/Resource";
|
|
4
|
+
import * as Config from "effect/Config";
|
|
5
|
+
import * as Effect from "effect/Effect";
|
|
6
|
+
import * as Redacted from "effect/Redacted";
|
|
7
|
+
import type { Providers } from "../Providers.ts";
|
|
8
|
+
|
|
9
|
+
type Leaf =
|
|
10
|
+
| string
|
|
11
|
+
| number
|
|
12
|
+
| boolean
|
|
13
|
+
| bigint
|
|
14
|
+
| symbol
|
|
15
|
+
| null
|
|
16
|
+
| undefined
|
|
17
|
+
| Redacted.Redacted<any>
|
|
18
|
+
| Uint8Array
|
|
19
|
+
| Date
|
|
20
|
+
| ((...args: any[]) => any);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A prop value, or the declaration of a resource that has it: `template: Node`
|
|
24
|
+
* instead of `template: yield* Node`, at any depth.
|
|
25
|
+
*/
|
|
26
|
+
export type Declared<T> = T extends Leaf
|
|
27
|
+
? T
|
|
28
|
+
: T extends Output.Output<any>
|
|
29
|
+
? T
|
|
30
|
+
: T extends ReadonlyArray<infer U>
|
|
31
|
+
? Declared<U>[] | Effect.Effect<T, never, Providers>
|
|
32
|
+
: { [K in keyof T]: Declared<T[K]> } | Effect.Effect<T, never, Providers>;
|
|
33
|
+
|
|
34
|
+
/** Props where any value may be a resource declaration. */
|
|
35
|
+
export type DeclaredProps<P> = { [K in keyof PropsInput<P>]: Declared<PropsInput<P>[K]> };
|
|
36
|
+
|
|
37
|
+
const isPlain = (value: unknown): value is Record<string, unknown> =>
|
|
38
|
+
typeof value === "object" &&
|
|
39
|
+
value !== null &&
|
|
40
|
+
(Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A declaration still to be yielded: an Effect that isn't an Output, a
|
|
44
|
+
* resource, or a Config (which the engine resolves as a prop value).
|
|
45
|
+
*/
|
|
46
|
+
export const isDeclaration = (value: unknown): value is Effect.Effect<unknown, never, Providers> =>
|
|
47
|
+
Effect.isEffect(value) &&
|
|
48
|
+
!Output.isOutput(value) &&
|
|
49
|
+
!isResource(value) &&
|
|
50
|
+
!Config.isConfig(value);
|
|
51
|
+
|
|
52
|
+
const hasDeclarations = (value: unknown): boolean =>
|
|
53
|
+
isDeclaration(value) ||
|
|
54
|
+
(Array.isArray(value) && value.some(hasDeclarations)) ||
|
|
55
|
+
(isPlain(value) && !isResource(value) && Object.values(value).some(hasDeclarations));
|
|
56
|
+
|
|
57
|
+
/** Yield every declaration in `value`, at any depth. */
|
|
58
|
+
export const resolveDeclarations = (value: unknown): Effect.Effect<unknown, never, Providers> =>
|
|
59
|
+
isDeclaration(value)
|
|
60
|
+
? value
|
|
61
|
+
: Array.isArray(value)
|
|
62
|
+
? Effect.forEach(value, resolveDeclarations)
|
|
63
|
+
: isPlain(value) && !isResource(value) && hasDeclarations(value)
|
|
64
|
+
? Effect.map(
|
|
65
|
+
Effect.forEach(Object.entries(value), ([key, entry]) =>
|
|
66
|
+
Effect.map(resolveDeclarations(entry), (resolved) => [key, resolved] as const),
|
|
67
|
+
),
|
|
68
|
+
Object.fromEntries,
|
|
69
|
+
)
|
|
70
|
+
: Effect.succeed(value);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Let a resource's props hold declarations of other resources, which are
|
|
74
|
+
* yielded when the resource is: `Boat.Sandbox("Box", { snapshot: Golden })`.
|
|
75
|
+
* Props without declarations are passed through unchanged.
|
|
76
|
+
*/
|
|
77
|
+
export const declarable =
|
|
78
|
+
<R extends { readonly Props: any }>() =>
|
|
79
|
+
<C extends object>(
|
|
80
|
+
resource: C,
|
|
81
|
+
): C & ((id: string, props: DeclaredProps<R["Props"]>) => Effect.Effect<R, never, Providers>) =>
|
|
82
|
+
new Proxy(resource as C & ((...args: unknown[]) => unknown), {
|
|
83
|
+
apply: (target, self, args: unknown[]) =>
|
|
84
|
+
typeof args[0] === "string" && hasDeclarations(args[1])
|
|
85
|
+
? Reflect.apply(target, self, [args[0], resolveDeclarations(args[1])])
|
|
86
|
+
: Reflect.apply(target, self, args),
|
|
87
|
+
}) as never;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Credentials } from "@distilled.cloud/boat-dev/Credentials";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The stack's org (`Boat.providers({ org })`, or the profile's), or
|
|
6
|
+
* `undefined` for none, and for `personal`: both mean what is yours.
|
|
7
|
+
* `/environments` routes ignore `X-Boat-Org`, so the org the protocol sends
|
|
8
|
+
* on every other call is passed there as the `org` query.
|
|
9
|
+
*/
|
|
10
|
+
export const stackOrg = Effect.gen(function* () {
|
|
11
|
+
const { org } = yield* yield* Credentials;
|
|
12
|
+
const named = org?.trim();
|
|
13
|
+
return named && named.toLowerCase() !== "personal" ? named : undefined;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/** The same organization, however it was spelled (Boat ignores case). */
|
|
17
|
+
export const sameOrg = (a: string | undefined, b: string | undefined) =>
|
|
18
|
+
(a?.toLowerCase() ?? "personal") === (b?.toLowerCase() ?? "personal");
|
|
19
|
+
|
|
20
|
+
/** The `org` query parameter, when there is an org. */
|
|
21
|
+
export const inOrg = (org: string | undefined) => (org ? { org } : {});
|
|
22
|
+
|
|
23
|
+
/** Whether an account (a person or an organization) is the one `org` names. */
|
|
24
|
+
export const isOrgAccount = (
|
|
25
|
+
account: { id: string; kind: "personal" | "org" | (string & {}); name: string },
|
|
26
|
+
org: string | undefined,
|
|
27
|
+
) =>
|
|
28
|
+
org === undefined
|
|
29
|
+
? account.kind === "personal"
|
|
30
|
+
: account.kind === "org" &&
|
|
31
|
+
(account.id === org || account.name.toLowerCase() === org.toLowerCase());
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Naming and ownership helpers shared by every Boat resource and binding.
|
|
3
|
+
*
|
|
4
|
+
* Boat sandboxes carry labels, set once at create. Label keys can't hold
|
|
5
|
+
* `:`, so Alchemy's `alchemy::stack` / `alchemy::stage` / `alchemy::id`
|
|
6
|
+
* ownership tags are written as `alchemy.stack` / `alchemy.stage` /
|
|
7
|
+
* `alchemy.id`. Environments, snapshots, webhooks and API keys carry only a
|
|
8
|
+
* name, so a resource whose name the engine generated is recognised as ours
|
|
9
|
+
* by that name; an explicit name found without prior state is `Unowned`.
|
|
10
|
+
*/
|
|
11
|
+
import { createPhysicalName } from "alchemy/PhysicalName";
|
|
12
|
+
import { createInternalTags } from "alchemy/Tags";
|
|
13
|
+
import * as Effect from "effect/Effect";
|
|
14
|
+
|
|
15
|
+
const INTERNAL_PREFIX = "alchemy::";
|
|
16
|
+
|
|
17
|
+
/** Labels as Boat returns them. */
|
|
18
|
+
export type Labels = Readonly<Record<string, string | undefined>> | null | undefined;
|
|
19
|
+
|
|
20
|
+
/** The prefix Alchemy's ownership labels share. */
|
|
21
|
+
export const ALCHEMY_LABEL_PREFIX = "alchemy.";
|
|
22
|
+
|
|
23
|
+
/** `alchemy::stack` → `alchemy.stack`. */
|
|
24
|
+
export const toLabelKey = (key: string) =>
|
|
25
|
+
key.startsWith(INTERNAL_PREFIX)
|
|
26
|
+
? `${ALCHEMY_LABEL_PREFIX}${key.slice(INTERNAL_PREFIX.length)}`
|
|
27
|
+
: key;
|
|
28
|
+
|
|
29
|
+
/** This resource's ownership labels: `alchemy.stack`, `alchemy.stage`, `alchemy.id`. */
|
|
30
|
+
export const ownershipLabels = Effect.fn(function* (id: string) {
|
|
31
|
+
const tags = yield* createInternalTags(id);
|
|
32
|
+
return Object.fromEntries(
|
|
33
|
+
Object.entries(tags).map(([key, value]) => [toLabelKey(key), value]),
|
|
34
|
+
) as Record<string, string>;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** Whether labels carry this resource's ownership labels. */
|
|
38
|
+
export const hasOwnershipLabels = Effect.fn(function* (id: string, labels: Labels) {
|
|
39
|
+
const expected = yield* ownershipLabels(id);
|
|
40
|
+
return Object.entries(expected).every(([key, value]) => labels?.[key] === value);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** Whether labels were set by any Alchemy stack or binding (for nuke). */
|
|
44
|
+
export const hasAnyAlchemyLabel = (labels: Labels) =>
|
|
45
|
+
labels !== undefined && labels !== null && `${ALCHEMY_LABEL_PREFIX}stack` in labels;
|
|
46
|
+
|
|
47
|
+
/** Labels without Alchemy's own. */
|
|
48
|
+
export const userLabels = (labels: Labels): Record<string, string> =>
|
|
49
|
+
Object.fromEntries(
|
|
50
|
+
Object.entries(labels ?? {}).filter(
|
|
51
|
+
(entry): entry is [string, string] =>
|
|
52
|
+
entry[1] !== undefined && !entry[0].startsWith(ALCHEMY_LABEL_PREFIX),
|
|
53
|
+
),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
/** `key=value` filters for `listSandboxes`. */
|
|
57
|
+
export const labelFilter = (labels: Readonly<Record<string, string>>) =>
|
|
58
|
+
Object.entries(labels).map(([key, value]) => `${key}=${value}`);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A Boat name: the user's, or one generated from the stack, stage and
|
|
62
|
+
* logical id. Lowercase letters, digits and dashes fit every Boat name rule
|
|
63
|
+
* (snapshot names allow nothing else).
|
|
64
|
+
*/
|
|
65
|
+
export const boatName = (id: string, name: string | undefined, maxLength = 63) =>
|
|
66
|
+
name !== undefined
|
|
67
|
+
? Effect.succeed(name)
|
|
68
|
+
: createPhysicalName({ id, lowercase: true, maxLength, delimiter: "-" });
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether a name looks generated by `createPhysicalName` (lowercase, ending
|
|
72
|
+
* in a base32 instance suffix). `list` reports only these, for nuke.
|
|
73
|
+
*/
|
|
74
|
+
export const isGeneratedName = (name: string | null | undefined): boolean =>
|
|
75
|
+
typeof name === "string" && /^[a-z0-9][a-z0-9_-]*-[a-z2-7]{8,16}$/.test(name);
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import * as boat from "@distilled.cloud/boat-dev/boat";
|
|
2
|
+
import * as Bundle from "alchemy/Bundle/Bundle";
|
|
3
|
+
import { findCwdForBundle, resolveMainPath } from "alchemy/Bundle/TempRoot";
|
|
4
|
+
import { sha256Object } from "alchemy/Util/sha256";
|
|
5
|
+
import * as Data from "effect/Data";
|
|
6
|
+
import * as Effect from "effect/Effect";
|
|
7
|
+
import * as HttpClient from "effect/http/HttpClient";
|
|
8
|
+
import * as HttpClientRequest from "effect/http/HttpClientRequest";
|
|
9
|
+
import * as Redacted from "effect/Redacted";
|
|
10
|
+
import * as Schedule from "effect/Schedule";
|
|
11
|
+
import type * as rolldown from "rolldown";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* INTERNAL — the program a `Boat.Sandbox` with `main` runs: bundled with
|
|
15
|
+
* rolldown, uploaded into the sandbox, and run with Node as a background
|
|
16
|
+
* command.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The program didn't come up: it exited, or never listened on its port. */
|
|
20
|
+
export class ProgramStartFailed extends Data.TaggedError("Boat.ProgramStartFailed")<{
|
|
21
|
+
readonly message: string;
|
|
22
|
+
readonly stdout: string;
|
|
23
|
+
readonly stderr: string;
|
|
24
|
+
}> {}
|
|
25
|
+
|
|
26
|
+
/** What a sandbox's program is: the props that shape its bundle. */
|
|
27
|
+
export interface ProgramSource {
|
|
28
|
+
readonly main: string;
|
|
29
|
+
readonly handler?: string;
|
|
30
|
+
readonly isExternal?: boolean;
|
|
31
|
+
readonly build?: Bundle.BundleConfig;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The running program, as recorded in the sandbox's attributes. */
|
|
35
|
+
export interface ProgramState {
|
|
36
|
+
/** Port the program listens on. */
|
|
37
|
+
readonly port: number;
|
|
38
|
+
/** Boat's process id for the background command running it. */
|
|
39
|
+
readonly processId: number;
|
|
40
|
+
/** Content hash of the bundled program. */
|
|
41
|
+
readonly codeHash: string;
|
|
42
|
+
/** Hash of the process environment. */
|
|
43
|
+
readonly envHash: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const DEFAULT_PORT = 3000;
|
|
47
|
+
const DIR = "/home/user/.alchemy/program";
|
|
48
|
+
|
|
49
|
+
/** Boat writes files up to 5 MiB in one call; larger ones go through a signed upload URL. */
|
|
50
|
+
const MAX_INLINE_BYTES = 4 * 1024 * 1024;
|
|
51
|
+
|
|
52
|
+
/** The generated entry: a shim importing only the bootstrap and `main`. */
|
|
53
|
+
const makeBootstrap =
|
|
54
|
+
(handler: string) =>
|
|
55
|
+
(importPath: string): string =>
|
|
56
|
+
`
|
|
57
|
+
import { bootstrap } from "@alchemy.run/boat/Bootstrap";
|
|
58
|
+
import { ${handler} as entrypoint } from ${JSON.stringify(importPath)};
|
|
59
|
+
|
|
60
|
+
await bootstrap(entrypoint);
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
const shellQuote = (value: unknown) => {
|
|
64
|
+
const text = typeof value === "string" ? value : JSON.stringify(value ?? null);
|
|
65
|
+
return `'${text.replaceAll("'", `'\\''`)}'`;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** The process environment as a shell file, sorted so its hash is stable. */
|
|
69
|
+
export const renderEnv = (env: Record<string, unknown>) =>
|
|
70
|
+
Object.entries(env)
|
|
71
|
+
.filter(([, value]) => value !== undefined)
|
|
72
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
73
|
+
.map(
|
|
74
|
+
([key, value]) =>
|
|
75
|
+
`${key}=${shellQuote(Redacted.isRedacted(value) ? Redacted.value(value) : value)}`,
|
|
76
|
+
)
|
|
77
|
+
.join("\n") + "\n";
|
|
78
|
+
|
|
79
|
+
/** Bundle `main` for Node, with the Boat bootstrap around an Effect program. */
|
|
80
|
+
export const bundleProgram = Effect.fn(function* (source: ProgramSource) {
|
|
81
|
+
const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin;
|
|
82
|
+
const realMain = yield* resolveMainPath(source.main);
|
|
83
|
+
const cwd = yield* findCwdForBundle(realMain);
|
|
84
|
+
const plugins: rolldown.RolldownPluginOption = source.isExternal
|
|
85
|
+
? undefined
|
|
86
|
+
: virtualEntryPlugin(makeBootstrap(source.handler ?? "default"));
|
|
87
|
+
const output = yield* Bundle.build(
|
|
88
|
+
{
|
|
89
|
+
...source.build?.input,
|
|
90
|
+
input: realMain,
|
|
91
|
+
cwd,
|
|
92
|
+
platform: "node",
|
|
93
|
+
resolve: {
|
|
94
|
+
conditionNames: [...Bundle.NODE_CONDITION_NAMES],
|
|
95
|
+
...source.build?.input?.resolve,
|
|
96
|
+
},
|
|
97
|
+
plugins: [source.build?.input?.plugins, plugins],
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
...source.build?.output,
|
|
101
|
+
format: "esm",
|
|
102
|
+
sourcemap: source.build?.output?.sourcemap ?? false,
|
|
103
|
+
minify: source.build?.output?.minify ?? false,
|
|
104
|
+
entryFileNames: "index.mjs",
|
|
105
|
+
},
|
|
106
|
+
source.build,
|
|
107
|
+
);
|
|
108
|
+
const files = output.files.map((file) => ({
|
|
109
|
+
path: file.path,
|
|
110
|
+
content:
|
|
111
|
+
typeof file.content === "string" ? new TextEncoder().encode(file.content) : file.content,
|
|
112
|
+
}));
|
|
113
|
+
return { files, hash: (yield* sha256Object({ bundle: output.hash })).slice(0, 16) };
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/** The hash of a process environment. */
|
|
117
|
+
export const envHashOf = (env: Record<string, unknown>) =>
|
|
118
|
+
Effect.map(sha256Object({ env: renderEnv(env) }), (hash) => hash.slice(0, 16));
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Whether the program's process still runs. A stopped or deleted sandbox,
|
|
122
|
+
* or a process Boat no longer knows, is not running.
|
|
123
|
+
*/
|
|
124
|
+
export const isProgramRunning = (sandboxId: string, state: ProgramState) =>
|
|
125
|
+
boat.getCommandStatus({ sandboxId, processId: state.processId, tailBytes: 0 }).pipe(
|
|
126
|
+
Effect.map((status) => status.running),
|
|
127
|
+
Effect.orElseSucceed(() => false),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const toBase64 = (bytes: Uint8Array) => {
|
|
131
|
+
let binary = "";
|
|
132
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
133
|
+
return btoa(binary);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/** Write one file into the sandbox, through a signed URL when it is too large for one call. */
|
|
137
|
+
const upload = Effect.fn(function* (sandboxId: string, path: string, content: Uint8Array) {
|
|
138
|
+
if (content.byteLength <= MAX_INLINE_BYTES) {
|
|
139
|
+
yield* boat.writeSandboxFile({
|
|
140
|
+
sandboxId,
|
|
141
|
+
path,
|
|
142
|
+
content: toBase64(content),
|
|
143
|
+
encoding: "base64",
|
|
144
|
+
});
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const signed = yield* boat.createUploadUrl({ sandboxId, path, ttlSeconds: 600 });
|
|
148
|
+
const client = HttpClient.filterStatusOk(yield* HttpClient.HttpClient);
|
|
149
|
+
yield* client.execute(
|
|
150
|
+
HttpClientRequest.put(signed.url).pipe(HttpClientRequest.bodyUint8Array(content)),
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Upload the bundle and (re)start the process, unless the same bundle and
|
|
156
|
+
* environment already run. Returns the program's state.
|
|
157
|
+
*/
|
|
158
|
+
export const deployProgram = Effect.fn(function* (input: {
|
|
159
|
+
readonly id: string;
|
|
160
|
+
readonly sandboxId: string;
|
|
161
|
+
readonly source: ProgramSource;
|
|
162
|
+
readonly port: number;
|
|
163
|
+
readonly env: Record<string, unknown>;
|
|
164
|
+
readonly previous: ProgramState | undefined;
|
|
165
|
+
readonly note: (message: string) => Effect.Effect<void>;
|
|
166
|
+
}) {
|
|
167
|
+
const { id, sandboxId, port } = input;
|
|
168
|
+
const envFile = renderEnv(input.env);
|
|
169
|
+
const envHash = yield* envHashOf(input.env);
|
|
170
|
+
const { files, hash } = yield* bundleProgram(input.source);
|
|
171
|
+
|
|
172
|
+
const previous = input.previous;
|
|
173
|
+
const running =
|
|
174
|
+
previous !== undefined &&
|
|
175
|
+
previous.codeHash === hash &&
|
|
176
|
+
previous.envHash === envHash &&
|
|
177
|
+
previous.port === port &&
|
|
178
|
+
(yield* isProgramRunning(sandboxId, previous));
|
|
179
|
+
if (running) return previous;
|
|
180
|
+
|
|
181
|
+
const dir = `${DIR}/${hash}`;
|
|
182
|
+
yield* input.note(`Uploading ${id}'s program...`);
|
|
183
|
+
// Rolldown's entry chunk is `index.mjs`; other chunks keep their names.
|
|
184
|
+
yield* Effect.forEach(
|
|
185
|
+
files,
|
|
186
|
+
(file, i) => upload(sandboxId, `${dir}/${i === 0 ? "index.mjs" : file.path}`, file.content),
|
|
187
|
+
{ concurrency: 4, discard: true },
|
|
188
|
+
);
|
|
189
|
+
yield* upload(sandboxId, `${dir}/.env`, new TextEncoder().encode(envFile));
|
|
190
|
+
|
|
191
|
+
// Replace the running process.
|
|
192
|
+
if (previous !== undefined) {
|
|
193
|
+
yield* boat.stopCommand({ sandboxId, processId: previous.processId }).pipe(Effect.ignore);
|
|
194
|
+
}
|
|
195
|
+
// Free the port from any earlier copy Boat lost track of (a crashed deploy).
|
|
196
|
+
yield* boat
|
|
197
|
+
.executeSandboxCommand({ sandboxId, command: `fuser -k ${port}/tcp 2>/dev/null; true` })
|
|
198
|
+
.pipe(Effect.ignore);
|
|
199
|
+
const started = yield* boat.executeSandboxCommand({
|
|
200
|
+
sandboxId,
|
|
201
|
+
command: `chmod 600 ${dir}/.env && cd ${dir} && set -a && . ./.env && set +a && exec node index.mjs`,
|
|
202
|
+
detached: true,
|
|
203
|
+
});
|
|
204
|
+
if (!("processId" in started) || typeof started.processId !== "number") {
|
|
205
|
+
return yield* new ProgramStartFailed({
|
|
206
|
+
message: `Boat started ${id}'s program without a process id`,
|
|
207
|
+
stdout: "",
|
|
208
|
+
stderr: "",
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
const processId = started.processId;
|
|
212
|
+
|
|
213
|
+
// Ready once the port listens; fail with the logs if the process exits.
|
|
214
|
+
yield* input.note(`Starting ${id}'s program on port ${port}...`);
|
|
215
|
+
const ready = yield* Effect.gen(function* () {
|
|
216
|
+
const status = yield* boat.getCommandStatus({ sandboxId, processId, tailBytes: 0 });
|
|
217
|
+
if (!status.running) return "exited" as const;
|
|
218
|
+
const probe = yield* boat.executeSandboxCommand({
|
|
219
|
+
sandboxId,
|
|
220
|
+
command: `(exec 3<>/dev/tcp/127.0.0.1/${port}) 2>/dev/null`,
|
|
221
|
+
timeoutSeconds: 5,
|
|
222
|
+
});
|
|
223
|
+
return "exitCode" in probe && probe.exitCode === 0
|
|
224
|
+
? ("listening" as const)
|
|
225
|
+
: ("starting" as const);
|
|
226
|
+
}).pipe(
|
|
227
|
+
Effect.repeat({
|
|
228
|
+
schedule: Schedule.spaced("1 second"),
|
|
229
|
+
until: (state) => state !== "starting",
|
|
230
|
+
times: 60,
|
|
231
|
+
}),
|
|
232
|
+
);
|
|
233
|
+
if (ready !== "listening") {
|
|
234
|
+
const logs = yield* boat.getCommandStatus({ sandboxId, processId });
|
|
235
|
+
return yield* new ProgramStartFailed({
|
|
236
|
+
message:
|
|
237
|
+
ready === "exited"
|
|
238
|
+
? `${id}'s program exited before listening on port ${port}`
|
|
239
|
+
: `${id}'s program did not listen on port ${port} within 60 seconds`,
|
|
240
|
+
stdout: logs.stdout,
|
|
241
|
+
stderr: logs.stderr,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
// Keep only the running bundle.
|
|
245
|
+
yield* boat.executeSandboxCommand({
|
|
246
|
+
sandboxId,
|
|
247
|
+
command: `find ${DIR} -mindepth 1 -maxdepth 1 ! -name ${hash} -exec rm -rf {} +`,
|
|
248
|
+
});
|
|
249
|
+
return { port, processId, codeHash: hash, envHash } satisfies ProgramState;
|
|
250
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type * as Effect from "effect/Effect";
|
|
2
|
+
import type { Providers } from "../Providers.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A prop that takes another resource, or a stub with the attributes it
|
|
6
|
+
* needs (`{ name }`, `{ volumeId }`). Users pass the declaration itself.
|
|
7
|
+
*/
|
|
8
|
+
export type Ref<T> = T | Effect.Effect<T, never, Providers>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A `Ref` prop as lifecycle operations see it. The engine resolves
|
|
12
|
+
* resource-valued props to their attributes before `diff`, `reconcile`
|
|
13
|
+
* and the other lifecycle operations run, so the value is always plain.
|
|
14
|
+
*/
|
|
15
|
+
export const resolved = <T>(ref: Ref<T>): T => ref as T;
|