alepha 0.21.0 → 0.21.2
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/dist/api/audits/index.d.ts +2 -2
- package/dist/api/files/index.d.ts +44 -3
- package/dist/api/files/index.d.ts.map +1 -1
- package/dist/api/files/index.js +59 -3
- package/dist/api/files/index.js.map +1 -1
- package/dist/api/parameters/index.d.ts +2 -2
- package/dist/api/users/index.d.ts +9 -9
- package/dist/api/users/index.d.ts.map +1 -1
- package/dist/api/users/index.js +28 -26
- package/dist/api/users/index.js.map +1 -1
- package/dist/api/verifications/index.browser.js +1 -0
- package/dist/api/verifications/index.browser.js.map +1 -1
- package/dist/api/verifications/index.d.ts +17 -0
- package/dist/api/verifications/index.d.ts.map +1 -1
- package/dist/api/verifications/index.js +5 -1
- package/dist/api/verifications/index.js.map +1 -1
- package/dist/cli/core/index.d.ts +74 -5
- package/dist/cli/core/index.d.ts.map +1 -1
- package/dist/cli/core/index.js +152 -34
- package/dist/cli/core/index.js.map +1 -1
- package/dist/cli/platform/index.d.ts +53 -92
- package/dist/cli/platform/index.d.ts.map +1 -1
- package/dist/cli/platform/index.js +128 -195
- package/dist/cli/platform/index.js.map +1 -1
- package/dist/containers/core/index.d.ts +238 -0
- package/dist/containers/core/index.d.ts.map +1 -0
- package/dist/containers/core/index.js +222 -0
- package/dist/containers/core/index.js.map +1 -0
- package/dist/containers/core/index.workerd.js +177 -0
- package/dist/containers/core/index.workerd.js.map +1 -0
- package/dist/core/index.browser.js +6 -0
- package/dist/core/index.browser.js.map +1 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +6 -0
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.native.js +6 -0
- package/dist/core/index.native.js.map +1 -1
- package/dist/core/index.workerd.js +6 -0
- package/dist/core/index.workerd.js.map +1 -1
- package/dist/email/cloudflare/index.d.ts +90 -0
- package/dist/email/cloudflare/index.d.ts.map +1 -0
- package/dist/email/cloudflare/index.js +116 -0
- package/dist/email/cloudflare/index.js.map +1 -0
- package/dist/orm/core/index.bun.js +2 -2
- package/dist/orm/core/index.bun.js.map +1 -1
- package/dist/orm/core/index.d.ts.map +1 -1
- package/dist/orm/core/index.js +2 -2
- package/dist/orm/core/index.js.map +1 -1
- package/dist/react/form/index.d.ts +36 -1
- package/dist/react/form/index.d.ts.map +1 -1
- package/dist/react/form/index.js +91 -3
- package/dist/react/form/index.js.map +1 -1
- package/dist/react/router/index.browser.js +56 -8
- package/dist/react/router/index.browser.js.map +1 -1
- package/dist/react/router/index.d.ts +5 -0
- package/dist/react/router/index.d.ts.map +1 -1
- package/dist/react/router/index.js +55 -7
- package/dist/react/router/index.js.map +1 -1
- package/dist/server/core/index.d.ts.map +1 -1
- package/dist/server/core/index.js +1 -1
- package/dist/server/core/index.js.map +1 -1
- package/package.json +23 -1
- package/src/api/files/controllers/FileController.ts +41 -1
- package/src/api/files/providers/FileAccessProvider.ts +23 -1
- package/src/api/files/services/FileService.ts +5 -3
- package/src/api/users/services/CredentialService.ts +37 -26
- package/src/api/verifications/entities/verifications.ts +8 -0
- package/src/api/verifications/services/VerificationService.ts +14 -0
- package/src/cli/core/__tests__/BuildDockerTask.spec.ts +24 -0
- package/src/cli/core/__tests__/init.spec.ts +11 -0
- package/src/cli/core/atoms/buildOptions.ts +15 -0
- package/src/cli/core/commands/build.ts +14 -2
- package/src/cli/core/commands/db.ts +4 -0
- package/src/cli/core/services/ProjectScaffolder.ts +14 -6
- package/src/cli/core/tasks/BuildAssetsTask.ts +3 -0
- package/src/cli/core/tasks/BuildClientTask.ts +3 -0
- package/src/cli/core/tasks/BuildCloudflareTask.ts +136 -2
- package/src/cli/core/tasks/BuildCompressTask.ts +3 -0
- package/src/cli/core/tasks/BuildDockerTask.ts +12 -1
- package/src/cli/core/tasks/BuildPrerenderTask.ts +3 -0
- package/src/cli/core/tasks/BuildPwaTask.ts +3 -0
- package/src/cli/core/tasks/BuildServerTask.ts +3 -0
- package/src/cli/core/tasks/BuildTask.ts +8 -0
- package/src/cli/core/templates/saasAuthLayoutTsx.ts +9 -7
- package/src/cli/core/templates/saasRealmProviderTs.ts +24 -18
- package/src/cli/platform/__tests__/NamingService.spec.ts +0 -42
- package/src/cli/platform/__tests__/PlatformInspector.spec.ts +4 -48
- package/src/cli/platform/adapters/CloudflareAdapter.ts +24 -48
- package/src/cli/platform/adapters/PlatformAdapter.ts +8 -0
- package/src/cli/platform/adapters/VercelAdapter.ts +5 -15
- package/src/cli/platform/atoms/platformOptions.ts +0 -5
- package/src/cli/platform/commands/platform.ts +90 -93
- package/src/cli/platform/index.ts +16 -4
- package/src/cli/platform/services/NamingService.ts +11 -12
- package/src/cli/platform/services/PlatformInspector.ts +9 -43
- package/src/cli/platform/services/PlatformOrchestrator.ts +40 -98
- package/src/containers/core/__tests__/$container.spec.ts +83 -0
- package/src/containers/core/index.ts +50 -0
- package/src/containers/core/index.workerd.ts +37 -0
- package/src/containers/core/interfaces/ContainerOptions.ts +69 -0
- package/src/containers/core/primitives/$container.ts +100 -0
- package/src/containers/core/providers/CloudflareContainerProvider.ts +72 -0
- package/src/containers/core/providers/ContainerProvider.ts +78 -0
- package/src/containers/core/providers/MockContainerProvider.ts +62 -0
- package/src/containers/core/providers/NodeContainerProvider.ts +53 -0
- package/src/core/Alepha.ts +15 -0
- package/src/email/cloudflare/__tests__/CloudflareEmailProvider.spec.ts +150 -0
- package/src/email/cloudflare/index.ts +26 -0
- package/src/email/cloudflare/providers/CloudflareEmailProvider.ts +160 -0
- package/src/orm/core/services/Repository.ts +11 -2
- package/src/react/form/hooks/useFormQuerySync.ts +0 -0
- package/src/react/form/index.ts +1 -0
- package/src/react/form/services/FormModel.ts +18 -2
- package/src/react/router/__tests__/$page.browser.spec.tsx +123 -1
- package/src/react/router/components/ErrorViewer.tsx +29 -7
- package/src/react/router/providers/ReactBrowserProvider.ts +21 -5
- package/src/react/router/providers/ReactPageProvider.ts +81 -3
- package/src/server/core/providers/ServerRouterProvider.ts +7 -1
- package/src/cli/platform/hooks/PlatformHook.ts +0 -51
- package/src/orm/REFACTORING.md +0 -330
|
@@ -9,10 +9,6 @@ import type {
|
|
|
9
9
|
PlatformState,
|
|
10
10
|
} from "../adapters/PlatformAdapter.ts";
|
|
11
11
|
import { VercelAdapter } from "../adapters/VercelAdapter.ts";
|
|
12
|
-
import {
|
|
13
|
-
PlatformHook,
|
|
14
|
-
type PlatformHookContext,
|
|
15
|
-
} from "../hooks/PlatformHook.ts";
|
|
16
12
|
import { type NamingContext, NamingService } from "./NamingService.ts";
|
|
17
13
|
import {
|
|
18
14
|
PlatformInspector,
|
|
@@ -56,11 +52,22 @@ export class PlatformOrchestrator {
|
|
|
56
52
|
public async up(options: {
|
|
57
53
|
root: string;
|
|
58
54
|
env: string;
|
|
59
|
-
app?: string;
|
|
60
55
|
apps: AppDefinition[];
|
|
61
56
|
run: RunnerMethod;
|
|
62
|
-
|
|
63
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Pre-built mode — the artifact's `dist/` is already produced.
|
|
59
|
+
*
|
|
60
|
+
* Still runs auth → provision → build → migrate → deploy → secrets,
|
|
61
|
+
* but the `build` step shells out to `alepha build --prebuilt` which
|
|
62
|
+
* only regenerates the target-specific deploy config (e.g.
|
|
63
|
+
* `wrangler.jsonc`) and skips the Vite client + server builds.
|
|
64
|
+
* Used by external orchestrators (Rocket) that ship a pre-built
|
|
65
|
+
* `dist/` and just need the wrangler config refreshed for
|
|
66
|
+
* per-tenant overrides on every deploy.
|
|
67
|
+
*/
|
|
68
|
+
prebuilt?: boolean;
|
|
69
|
+
}): Promise<{ urls: string[]; domain?: string }> {
|
|
70
|
+
const { root, env, apps, run, prebuilt } = options;
|
|
64
71
|
const envConfig = await this.inspector.resolveEnvironment(root, env);
|
|
65
72
|
const config = await this.inspector.resolveConfig(root);
|
|
66
73
|
const adapter = this.resolveAdapter(envConfig.adapter);
|
|
@@ -73,63 +80,58 @@ export class PlatformOrchestrator {
|
|
|
73
80
|
apps,
|
|
74
81
|
root,
|
|
75
82
|
naming: namingCtx,
|
|
83
|
+
prebuilt,
|
|
76
84
|
};
|
|
77
85
|
|
|
78
86
|
// 1. Auth
|
|
79
87
|
await adapter.authenticate(ctx, run);
|
|
80
88
|
|
|
81
|
-
// 2.
|
|
82
|
-
const targetApps = appFilter
|
|
83
|
-
? apps.filter((a) => a.name === appFilter)
|
|
84
|
-
: apps;
|
|
85
|
-
|
|
86
|
-
if (targetApps.length === 0 && appFilter) {
|
|
87
|
-
throw new AlephaError(
|
|
88
|
-
`App "${appFilter}" not found. Available: ${apps.map((a) => a.name).join(", ")}`,
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// 3. Provision (before build so resource IDs are available for wrangler config)
|
|
89
|
+
// 2. Provision (before build so resource IDs are available for wrangler config)
|
|
93
90
|
await adapter.provision(ctx, run);
|
|
94
91
|
|
|
95
|
-
//
|
|
96
|
-
|
|
92
|
+
// 3. Build
|
|
93
|
+
// Always runs — the adapter checks `ctx.prebuilt` to decide whether
|
|
94
|
+
// to do a full bundle build or just regenerate deploy config.
|
|
95
|
+
for (const a of apps) {
|
|
97
96
|
await adapter.build({ ...ctx, app: a }, run);
|
|
98
97
|
}
|
|
99
98
|
|
|
100
|
-
//
|
|
99
|
+
// 4. Migrate
|
|
101
100
|
await adapter.migrate(ctx, run);
|
|
102
101
|
|
|
103
|
-
//
|
|
102
|
+
// 5. Deploy
|
|
104
103
|
const urls: string[] = [];
|
|
105
|
-
for (const a of
|
|
104
|
+
for (const a of apps) {
|
|
106
105
|
const url = await adapter.deploy({ ...ctx, app: a }, run);
|
|
107
106
|
if (url) {
|
|
108
107
|
urls.push(url);
|
|
109
108
|
}
|
|
110
109
|
}
|
|
111
110
|
|
|
112
|
-
//
|
|
113
|
-
// Run before secrets() so any secret a hook writes to .env.<env>
|
|
114
|
-
// gets pushed to the deployed worker in the same up cycle.
|
|
115
|
-
await this.runHooks("register", ctx, urls, run);
|
|
116
|
-
|
|
117
|
-
// 8. Secrets (push .env.{env} secrets to deployed workers)
|
|
111
|
+
// 6. Secrets (push .env.{env} secrets to deployed workers)
|
|
118
112
|
await adapter.secrets(ctx, run);
|
|
119
113
|
|
|
120
114
|
run.end();
|
|
121
115
|
|
|
122
|
-
|
|
116
|
+
return { urls, domain: envConfig.domain };
|
|
117
|
+
}
|
|
123
118
|
|
|
124
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Pretty-print the `up()` result to stdout. Matches the formatting the
|
|
121
|
+
* orchestrator used to emit inline; split out so callers that want
|
|
122
|
+
* JSON output can skip this branch.
|
|
123
|
+
*/
|
|
124
|
+
public printUpSummary(result: { urls: string[]; domain?: string }): void {
|
|
125
|
+
const c = this.color;
|
|
126
|
+
if (result.domain) {
|
|
125
127
|
this.log.info("");
|
|
126
|
-
const display =
|
|
127
|
-
? `https://${
|
|
128
|
-
: `https://${
|
|
128
|
+
const display = result.domain.includes("*")
|
|
129
|
+
? `https://${result.domain} (wildcard route)`
|
|
130
|
+
: `https://${result.domain}`;
|
|
129
131
|
this.log.info(` ${c.set("GREEN", "\u2192")} ${c.set("CYAN", display)}`);
|
|
130
132
|
this.log.info("");
|
|
131
133
|
} else {
|
|
132
|
-
for (const url of urls) {
|
|
134
|
+
for (const url of result.urls) {
|
|
133
135
|
this.log.info("");
|
|
134
136
|
this.log.info(` ${c.set("GREEN", "\u2192")} ${c.set("CYAN", url)}`);
|
|
135
137
|
this.log.info("");
|
|
@@ -144,12 +146,11 @@ export class PlatformOrchestrator {
|
|
|
144
146
|
public async down(options: {
|
|
145
147
|
root: string;
|
|
146
148
|
env: string;
|
|
147
|
-
app?: string;
|
|
148
149
|
apps: AppDefinition[];
|
|
149
150
|
run: RunnerMethod;
|
|
150
151
|
confirm: (prompt: string) => Promise<string>;
|
|
151
152
|
}): Promise<boolean> {
|
|
152
|
-
const { root, env,
|
|
153
|
+
const { root, env, apps, run, confirm } = options;
|
|
153
154
|
const envConfig = await this.inspector.resolveEnvironment(root, env);
|
|
154
155
|
const config = await this.inspector.resolveConfig(root);
|
|
155
156
|
const adapter = this.resolveAdapter(envConfig.adapter);
|
|
@@ -159,7 +160,7 @@ export class PlatformOrchestrator {
|
|
|
159
160
|
project: config.project,
|
|
160
161
|
env,
|
|
161
162
|
envConfig,
|
|
162
|
-
apps
|
|
163
|
+
apps,
|
|
163
164
|
root,
|
|
164
165
|
naming: namingCtx,
|
|
165
166
|
};
|
|
@@ -177,9 +178,6 @@ export class PlatformOrchestrator {
|
|
|
177
178
|
// Auth
|
|
178
179
|
await adapter.authenticate(ctx, run);
|
|
179
180
|
|
|
180
|
-
// Platform hooks (tear down external resources first, while creds still valid)
|
|
181
|
-
await this.runHooks("unregister", ctx, [], run);
|
|
182
|
-
|
|
183
181
|
// Teardown
|
|
184
182
|
await adapter.teardown(ctx, run);
|
|
185
183
|
run.end();
|
|
@@ -187,62 +185,6 @@ export class PlatformOrchestrator {
|
|
|
187
185
|
return true;
|
|
188
186
|
}
|
|
189
187
|
|
|
190
|
-
// -------------------------------------------------------------------------
|
|
191
|
-
// Platform hooks
|
|
192
|
-
// -------------------------------------------------------------------------
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Run all registered PlatformHook instances.
|
|
196
|
-
*
|
|
197
|
-
* Discovered dynamically via `alepha.services(PlatformHook)`: any plugin
|
|
198
|
-
* that registers a PlatformHook subclass in its `$module.services`
|
|
199
|
-
* participates automatically, without the core knowing about it.
|
|
200
|
-
*/
|
|
201
|
-
protected async runHooks(
|
|
202
|
-
phase: "register" | "unregister",
|
|
203
|
-
ctx: PlatformContext,
|
|
204
|
-
deployUrls: string[],
|
|
205
|
-
run: RunnerMethod,
|
|
206
|
-
): Promise<void> {
|
|
207
|
-
const hooks = this.alepha.services(PlatformHook);
|
|
208
|
-
if (hooks.length === 0) return;
|
|
209
|
-
|
|
210
|
-
// Wildcard domains aren't a concrete URL — fall back to the worker URL so
|
|
211
|
-
// hooks (Stripe webhooks, etc.) receive a usable endpoint.
|
|
212
|
-
const hasUsableDomain =
|
|
213
|
-
ctx.envConfig.domain && !ctx.envConfig.domain.includes("*");
|
|
214
|
-
const baseUrl = hasUsableDomain
|
|
215
|
-
? `https://${ctx.envConfig.domain}`
|
|
216
|
-
: deployUrls[0];
|
|
217
|
-
|
|
218
|
-
if (!baseUrl) {
|
|
219
|
-
this.log.debug("Skipping platform hooks: no base URL available");
|
|
220
|
-
return;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
const hookCtx: PlatformHookContext = { ...ctx, baseUrl, run };
|
|
224
|
-
|
|
225
|
-
for (const hook of hooks) {
|
|
226
|
-
this.log.debug(`Platform hook: ${hook.name} (${phase})`);
|
|
227
|
-
try {
|
|
228
|
-
if (phase === "register") {
|
|
229
|
-
await hook.register(hookCtx);
|
|
230
|
-
} else {
|
|
231
|
-
await hook.unregister(hookCtx);
|
|
232
|
-
}
|
|
233
|
-
} catch (err) {
|
|
234
|
-
// unregister must never block teardown
|
|
235
|
-
if (phase === "unregister") {
|
|
236
|
-
this.log.debug(
|
|
237
|
-
`Platform hook ${hook.name} failed to unregister: ${(err as Error).message}`,
|
|
238
|
-
);
|
|
239
|
-
} else {
|
|
240
|
-
throw err;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
188
|
// -------------------------------------------------------------------------
|
|
247
189
|
// plan
|
|
248
190
|
// -------------------------------------------------------------------------
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Alepha, t } from "alepha";
|
|
2
|
+
import { $action, AlephaServer } from "alepha/server";
|
|
3
|
+
import { AlephaServerLinks } from "alepha/server/links";
|
|
4
|
+
import { describe, it } from "vitest";
|
|
5
|
+
import { AlephaContainers } from "../index.ts";
|
|
6
|
+
import { $container } from "../primitives/$container.ts";
|
|
7
|
+
import { ContainerProvider } from "../providers/ContainerProvider.ts";
|
|
8
|
+
import { NodeContainerProvider } from "../providers/NodeContainerProvider.ts";
|
|
9
|
+
|
|
10
|
+
describe("$container", () => {
|
|
11
|
+
it("routes proxy calls through the active provider", async ({ expect }) => {
|
|
12
|
+
class RocketController {
|
|
13
|
+
createJob = $action({
|
|
14
|
+
schema: {
|
|
15
|
+
body: t.object({ op: t.text() }),
|
|
16
|
+
response: t.object({ jobId: t.text(), status: t.text() }),
|
|
17
|
+
},
|
|
18
|
+
handler: async ({ body }) => ({
|
|
19
|
+
jobId: `job-${body.op}`,
|
|
20
|
+
status: "queued",
|
|
21
|
+
}),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class DeployService {
|
|
26
|
+
rocket = $container<RocketController>({
|
|
27
|
+
image: "alepha/rocket:latest",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// In test mode AlephaContainers binds the Mock provider by default,
|
|
32
|
+
// which routes the proxy's `.createJob(...)` call back through
|
|
33
|
+
// LinkProvider — so RocketController must live on the same Alepha.
|
|
34
|
+
const alepha = Alepha.create({ env: { LOG_LEVEL: "warn" } })
|
|
35
|
+
.with(AlephaServer)
|
|
36
|
+
.with(AlephaServerLinks)
|
|
37
|
+
.with(AlephaContainers)
|
|
38
|
+
.with(RocketController)
|
|
39
|
+
.with(DeployService);
|
|
40
|
+
|
|
41
|
+
await alepha.start();
|
|
42
|
+
|
|
43
|
+
const service = alepha.inject(DeployService);
|
|
44
|
+
const result = await alepha.context.run(() =>
|
|
45
|
+
(service.rocket as any).createJob({ body: { op: "up" } }),
|
|
46
|
+
);
|
|
47
|
+
expect(result).toStrictEqual({ jobId: "job-up", status: "queued" });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("exposes the underlying primitive via instanceof + name", ({ expect }) => {
|
|
51
|
+
class App {
|
|
52
|
+
rocket = $container({
|
|
53
|
+
image: "alepha/rocket:latest",
|
|
54
|
+
name: "rocket",
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const alepha = Alepha.create({ env: {} }).with(AlephaContainers).with(App);
|
|
59
|
+
|
|
60
|
+
// Force App instantiation so the primitive registers.
|
|
61
|
+
const app = alepha.inject(App);
|
|
62
|
+
expect((app.rocket as any).name).toBe("rocket");
|
|
63
|
+
expect((app.rocket as any).options.image).toBe("alepha/rocket:latest");
|
|
64
|
+
expect(alepha.primitives($container).length).toBe(1);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("throws when a Node container has no URL", async ({ expect }) => {
|
|
68
|
+
class App {
|
|
69
|
+
rocket = $container({ image: "alepha/rocket:latest" });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const alepha = Alepha.create({ env: {} })
|
|
73
|
+
.with({ provide: ContainerProvider, use: NodeContainerProvider })
|
|
74
|
+
.with(AlephaContainers)
|
|
75
|
+
.with(App);
|
|
76
|
+
await alepha.start();
|
|
77
|
+
|
|
78
|
+
const app = alepha.inject(App);
|
|
79
|
+
await expect(
|
|
80
|
+
alepha.context.run(() => (app.rocket as any).deploy({ body: {} })),
|
|
81
|
+
).rejects.toThrow(/no 'url' configured/i);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { $module } from "alepha";
|
|
2
|
+
import { $container } from "./primitives/$container.ts";
|
|
3
|
+
import { ContainerProvider } from "./providers/ContainerProvider.ts";
|
|
4
|
+
import { MockContainerProvider } from "./providers/MockContainerProvider.ts";
|
|
5
|
+
import { NodeContainerProvider } from "./providers/NodeContainerProvider.ts";
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
export * from "./interfaces/ContainerOptions.ts";
|
|
10
|
+
export * from "./primitives/$container.ts";
|
|
11
|
+
export * from "./providers/ContainerProvider.ts";
|
|
12
|
+
export * from "./providers/MockContainerProvider.ts";
|
|
13
|
+
export * from "./providers/NodeContainerProvider.ts";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Type-safe RPC clients for ephemeral containerized Alepha apps.
|
|
19
|
+
*
|
|
20
|
+
* **Features:**
|
|
21
|
+
* - `$container<T>()` directive returns a typed Proxy onto a remote
|
|
22
|
+
* Alepha controller's `$action` endpoints — the wire format is the
|
|
23
|
+
* same `POST /api/<method>` shape served by every Alepha server.
|
|
24
|
+
* - Pluggable transport: Node (plain `fetch` against a configured URL),
|
|
25
|
+
* Mock (in-process call via `LinkProvider.follow` for tests),
|
|
26
|
+
* Cloudflare workerd (`getContainer().fetch()` through the Containers
|
|
27
|
+
* binding — see `alepha/containers` workerd entry).
|
|
28
|
+
* - Build-time integration: `BuildCloudflareTask.enhanceContainers`
|
|
29
|
+
* emits the matching wrangler bindings and Durable Object class
|
|
30
|
+
* declarations.
|
|
31
|
+
*
|
|
32
|
+
* The Node binding is the "temporary, not perfect" path documented in
|
|
33
|
+
* the spec — apps need to provide an explicit `url` per primitive
|
|
34
|
+
* until a `target=docker` adapter ships.
|
|
35
|
+
*
|
|
36
|
+
* @module alepha.containers
|
|
37
|
+
*/
|
|
38
|
+
export const AlephaContainers = $module({
|
|
39
|
+
name: "alepha.containers",
|
|
40
|
+
primitives: [$container],
|
|
41
|
+
services: [ContainerProvider],
|
|
42
|
+
variants: [MockContainerProvider, NodeContainerProvider],
|
|
43
|
+
register: (alepha) => {
|
|
44
|
+
alepha.with({
|
|
45
|
+
optional: true,
|
|
46
|
+
provide: ContainerProvider,
|
|
47
|
+
use: alepha.isTest() ? MockContainerProvider : NodeContainerProvider,
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { $module } from "alepha";
|
|
2
|
+
import { $container } from "./primitives/$container.ts";
|
|
3
|
+
import { CloudflareContainerProvider } from "./providers/CloudflareContainerProvider.ts";
|
|
4
|
+
import { ContainerProvider } from "./providers/ContainerProvider.ts";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
export * from "./interfaces/ContainerOptions.ts";
|
|
9
|
+
export * from "./primitives/$container.ts";
|
|
10
|
+
export * from "./providers/CloudflareContainerProvider.ts";
|
|
11
|
+
export * from "./providers/ContainerProvider.ts";
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Type-safe RPC clients for ephemeral containerized Alepha apps,
|
|
17
|
+
* Cloudflare workerd build.
|
|
18
|
+
*
|
|
19
|
+
* Auto-binds `CloudflareContainerProvider` so `$container()` calls
|
|
20
|
+
* route through `env.<NAME>.getContainer(...).fetch()`. Pair with
|
|
21
|
+
* `BuildCloudflareTask.enhanceContainers` (in `alepha/cli/core`) which
|
|
22
|
+
* emits the wrangler.jsonc bindings and DO class declarations.
|
|
23
|
+
*
|
|
24
|
+
* @module alepha.containers
|
|
25
|
+
*/
|
|
26
|
+
export const AlephaContainers = $module({
|
|
27
|
+
name: "alepha.containers",
|
|
28
|
+
primitives: [$container],
|
|
29
|
+
services: [ContainerProvider, CloudflareContainerProvider],
|
|
30
|
+
register: (alepha) => {
|
|
31
|
+
alepha.with({
|
|
32
|
+
optional: true,
|
|
33
|
+
provide: ContainerProvider,
|
|
34
|
+
use: CloudflareContainerProvider,
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { DurationLike } from "alepha/datetime";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Options for the `$container` primitive.
|
|
5
|
+
*
|
|
6
|
+
* Describes a remote Alepha app running in an ephemeral container. The
|
|
7
|
+
* primitive returns a typed Proxy that calls the container's `$action`
|
|
8
|
+
* endpoints through whatever transport the active provider chooses
|
|
9
|
+
* (Cloudflare Containers binding on `target=cloudflare`, plain HTTP on
|
|
10
|
+
* Node when `url` is set).
|
|
11
|
+
*/
|
|
12
|
+
export interface ContainerPrimitiveOptions {
|
|
13
|
+
/**
|
|
14
|
+
* Logical container name. Defaults to the property key.
|
|
15
|
+
*
|
|
16
|
+
* Cloudflare provider uppercases this to look up the binding
|
|
17
|
+
* (`env.<NAME>`), so prefer lowercase here.
|
|
18
|
+
*/
|
|
19
|
+
name?: string;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Docker image the container runs. Required.
|
|
23
|
+
*
|
|
24
|
+
* The build task uses this verbatim in `wrangler.jsonc`'s
|
|
25
|
+
* `containers[].image` entry.
|
|
26
|
+
*/
|
|
27
|
+
image: string;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Explicit URL the Node provider should call. When set, `$container`
|
|
31
|
+
* routes through `LinkProvider` exactly like `$remote({ url })`.
|
|
32
|
+
*
|
|
33
|
+
* On `target=cloudflare`, this is ignored — the Containers binding is
|
|
34
|
+
* used instead.
|
|
35
|
+
*/
|
|
36
|
+
url?: string | (() => string);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Port the container app listens on. Defaults to 3000 (Alepha
|
|
40
|
+
* convention — NOT Cloudflare's 8080). Used in build-time codegen.
|
|
41
|
+
*/
|
|
42
|
+
port?: number;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Idle window before the platform stops the container instance.
|
|
46
|
+
*
|
|
47
|
+
* @default "15m"
|
|
48
|
+
*/
|
|
49
|
+
sleepAfter?: DurationLike;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Environment variables injected into the container at runtime.
|
|
53
|
+
*/
|
|
54
|
+
envVars?: Record<string, string>;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Cloudflare-specific instance class. Ignored on other targets.
|
|
58
|
+
*
|
|
59
|
+
* @default "dev"
|
|
60
|
+
*/
|
|
61
|
+
instanceType?: "dev" | "basic" | "standard";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Maximum concurrent container instances.
|
|
65
|
+
*
|
|
66
|
+
* @default 5
|
|
67
|
+
*/
|
|
68
|
+
maxInstances?: number;
|
|
69
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { $inject, AlephaError, createPrimitive, KIND, Primitive } from "alepha";
|
|
2
|
+
import type { ContainerPrimitiveOptions } from "../interfaces/ContainerOptions.ts";
|
|
3
|
+
import { ContainerProvider } from "../providers/ContainerProvider.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `$container` — typed RPC client to a containerized Alepha app.
|
|
7
|
+
*
|
|
8
|
+
* Returns a typed Proxy whose method calls map 1:1 to the target
|
|
9
|
+
* controller's `$action` endpoints. The wire format mirrors what a
|
|
10
|
+
* normal Alepha server exposes (`POST /api/<method>` with a JSON body),
|
|
11
|
+
* so the container is literally a tiny Alepha worker.
|
|
12
|
+
*
|
|
13
|
+
* Transport is owned by the active {@link ContainerProvider}:
|
|
14
|
+
* - `target=cloudflare` → `CloudflareContainerProvider` routes through
|
|
15
|
+
* the Containers binding (`env.<NAME>.getContainer(...).fetch()`).
|
|
16
|
+
* - Node (with `url` set) → `NodeContainerProvider` uses plain
|
|
17
|
+
* `fetch()` against the configured URL.
|
|
18
|
+
*
|
|
19
|
+
* Build-time, `BuildCloudflareTask.enhanceContainers` walks
|
|
20
|
+
* `alepha.primitives($container)` to emit the matching `wrangler.jsonc`
|
|
21
|
+
* entries and Durable Object class declarations into
|
|
22
|
+
* `main.cloudflare.js`.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```ts
|
|
26
|
+
* import { $container } from "alepha/containers";
|
|
27
|
+
* import type { RocketController } from "@alepha/rocket";
|
|
28
|
+
*
|
|
29
|
+
* class DeployService {
|
|
30
|
+
* rocket = $container<RocketController>({
|
|
31
|
+
* image: "alepha/rocket:latest",
|
|
32
|
+
* port: 3000,
|
|
33
|
+
* sleepAfter: "15m",
|
|
34
|
+
* });
|
|
35
|
+
*
|
|
36
|
+
* async deploy() {
|
|
37
|
+
* return this.rocket.createJob({ body: { op: "up" } });
|
|
38
|
+
* }
|
|
39
|
+
* }
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export const $container = <T extends object = any>(
|
|
43
|
+
options: ContainerPrimitiveOptions,
|
|
44
|
+
): ContainerProxy<T> => {
|
|
45
|
+
if (!options.image) {
|
|
46
|
+
throw new AlephaError("$container requires an 'image' option");
|
|
47
|
+
}
|
|
48
|
+
const instance = createPrimitive(ContainerPrimitive, options);
|
|
49
|
+
return instance.proxy() as ContainerProxy<T>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Typed proxy returned by `$container<T>()`. Each `$action` member of
|
|
54
|
+
* `T` becomes a callable returning the action's response body.
|
|
55
|
+
*
|
|
56
|
+
* The runtime shape is a `Proxy(primitive)` — own-property reads hit the
|
|
57
|
+
* underlying `ContainerPrimitive` (so `alepha.primitives($container)`
|
|
58
|
+
* still finds it via the `instanceof Primitive` check inside
|
|
59
|
+
* `Alepha.new()`), and anything else is treated as an action name and
|
|
60
|
+
* routed through the active `ContainerProvider`.
|
|
61
|
+
*/
|
|
62
|
+
export type ContainerProxy<T extends object> = {
|
|
63
|
+
[K in keyof T]: T[K] extends (...args: infer A) => infer R
|
|
64
|
+
? (...args: A) => Promise<Awaited<R>>
|
|
65
|
+
: (config?: {
|
|
66
|
+
body?: unknown;
|
|
67
|
+
query?: unknown;
|
|
68
|
+
params?: unknown;
|
|
69
|
+
}) => Promise<any>;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export class ContainerPrimitive extends Primitive<ContainerPrimitiveOptions> {
|
|
73
|
+
protected readonly provider = $inject(ContainerProvider);
|
|
74
|
+
|
|
75
|
+
public get name(): string {
|
|
76
|
+
return this.options.name ?? this.config.propertyKey;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build the typed Proxy. The target is the primitive itself so
|
|
81
|
+
* `instanceof Primitive` keeps working (required by Alepha's
|
|
82
|
+
* primitive registration in `Alepha.new`). Property reads that match
|
|
83
|
+
* an own/inherited member return that member; anything else is
|
|
84
|
+
* treated as an action name and routed through the provider.
|
|
85
|
+
*/
|
|
86
|
+
public proxy<T extends object = any>(): ContainerProxy<T> {
|
|
87
|
+
const primitive = this;
|
|
88
|
+
return new Proxy(primitive as any, {
|
|
89
|
+
get(target: any, prop: string | symbol, receiver: any) {
|
|
90
|
+
if (typeof prop === "symbol" || prop in target) {
|
|
91
|
+
return Reflect.get(target, prop, receiver);
|
|
92
|
+
}
|
|
93
|
+
return (config: any = {}) =>
|
|
94
|
+
primitive.provider.invoke(primitive, prop as string, config);
|
|
95
|
+
},
|
|
96
|
+
}) as ContainerProxy<T>;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
$container[KIND] = ContainerPrimitive;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { $inject, Alepha, AlephaError } from "alepha";
|
|
2
|
+
import type { ContainerPrimitive } from "../primitives/$container.ts";
|
|
3
|
+
import {
|
|
4
|
+
type ContainerInvokeConfig,
|
|
5
|
+
ContainerProvider,
|
|
6
|
+
} from "./ContainerProvider.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Cloudflare Workers (workerd) container provider.
|
|
10
|
+
*
|
|
11
|
+
* Routes proxy calls through the Cloudflare Containers binding bound on
|
|
12
|
+
* `env.<NAME>`, using a single shared instance per container
|
|
13
|
+
* (`getContainer(env.NAME, "shared")`). The binding is generated by
|
|
14
|
+
* `BuildCloudflareTask.enhanceContainers` from the `$container`
|
|
15
|
+
* primitives discovered in the app.
|
|
16
|
+
*
|
|
17
|
+
* The runtime `env` object is pulled from the Alepha store key
|
|
18
|
+
* `cloudflare.env` — the worker entry point publishes it there on every
|
|
19
|
+
* fetch (`__alepha.set("cloudflare.env", env)`).
|
|
20
|
+
*/
|
|
21
|
+
export class CloudflareContainerProvider extends ContainerProvider {
|
|
22
|
+
protected readonly alepha = $inject(Alepha);
|
|
23
|
+
|
|
24
|
+
public override async invoke(
|
|
25
|
+
container: ContainerPrimitive,
|
|
26
|
+
action: string,
|
|
27
|
+
config: ContainerInvokeConfig,
|
|
28
|
+
): Promise<unknown> {
|
|
29
|
+
const env = this.alepha.store.get("cloudflare.env") as
|
|
30
|
+
| Record<string, unknown>
|
|
31
|
+
| undefined;
|
|
32
|
+
if (!env) {
|
|
33
|
+
throw new AlephaError(
|
|
34
|
+
`CloudflareContainerProvider could not resolve 'cloudflare.env' from the store — is the app running on workerd?`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const binding = env[container.name.toUpperCase()] as
|
|
39
|
+
| {
|
|
40
|
+
getContainer?: (id: string) => {
|
|
41
|
+
fetch(req: Request): Promise<Response>;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
| undefined;
|
|
45
|
+
|
|
46
|
+
if (!binding?.getContainer) {
|
|
47
|
+
throw new AlephaError(
|
|
48
|
+
`Cloudflare Containers binding '${container.name.toUpperCase()}' not found on env — check wrangler.jsonc.`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const instance = binding.getContainer("shared");
|
|
53
|
+
const { path, init } = this.buildRequest(action, config);
|
|
54
|
+
const request = new Request(`http://container${path}`, init);
|
|
55
|
+
const response = await instance.fetch(request);
|
|
56
|
+
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
const text = await response.text().catch(() => "");
|
|
59
|
+
throw new AlephaError(
|
|
60
|
+
`Container '${container.name}' action '${action}' failed: ${response.status} ${response.statusText}${
|
|
61
|
+
text ? ` — ${text}` : ""
|
|
62
|
+
}`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
67
|
+
if (contentType.includes("application/json")) {
|
|
68
|
+
return await response.json();
|
|
69
|
+
}
|
|
70
|
+
return await response.text();
|
|
71
|
+
}
|
|
72
|
+
}
|