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.
Files changed (120) hide show
  1. package/dist/api/audits/index.d.ts +2 -2
  2. package/dist/api/files/index.d.ts +44 -3
  3. package/dist/api/files/index.d.ts.map +1 -1
  4. package/dist/api/files/index.js +59 -3
  5. package/dist/api/files/index.js.map +1 -1
  6. package/dist/api/parameters/index.d.ts +2 -2
  7. package/dist/api/users/index.d.ts +9 -9
  8. package/dist/api/users/index.d.ts.map +1 -1
  9. package/dist/api/users/index.js +28 -26
  10. package/dist/api/users/index.js.map +1 -1
  11. package/dist/api/verifications/index.browser.js +1 -0
  12. package/dist/api/verifications/index.browser.js.map +1 -1
  13. package/dist/api/verifications/index.d.ts +17 -0
  14. package/dist/api/verifications/index.d.ts.map +1 -1
  15. package/dist/api/verifications/index.js +5 -1
  16. package/dist/api/verifications/index.js.map +1 -1
  17. package/dist/cli/core/index.d.ts +74 -5
  18. package/dist/cli/core/index.d.ts.map +1 -1
  19. package/dist/cli/core/index.js +152 -34
  20. package/dist/cli/core/index.js.map +1 -1
  21. package/dist/cli/platform/index.d.ts +53 -92
  22. package/dist/cli/platform/index.d.ts.map +1 -1
  23. package/dist/cli/platform/index.js +128 -195
  24. package/dist/cli/platform/index.js.map +1 -1
  25. package/dist/containers/core/index.d.ts +238 -0
  26. package/dist/containers/core/index.d.ts.map +1 -0
  27. package/dist/containers/core/index.js +222 -0
  28. package/dist/containers/core/index.js.map +1 -0
  29. package/dist/containers/core/index.workerd.js +177 -0
  30. package/dist/containers/core/index.workerd.js.map +1 -0
  31. package/dist/core/index.browser.js +6 -0
  32. package/dist/core/index.browser.js.map +1 -1
  33. package/dist/core/index.d.ts.map +1 -1
  34. package/dist/core/index.js +6 -0
  35. package/dist/core/index.js.map +1 -1
  36. package/dist/core/index.native.js +6 -0
  37. package/dist/core/index.native.js.map +1 -1
  38. package/dist/core/index.workerd.js +6 -0
  39. package/dist/core/index.workerd.js.map +1 -1
  40. package/dist/email/cloudflare/index.d.ts +90 -0
  41. package/dist/email/cloudflare/index.d.ts.map +1 -0
  42. package/dist/email/cloudflare/index.js +116 -0
  43. package/dist/email/cloudflare/index.js.map +1 -0
  44. package/dist/orm/core/index.bun.js +2 -2
  45. package/dist/orm/core/index.bun.js.map +1 -1
  46. package/dist/orm/core/index.d.ts.map +1 -1
  47. package/dist/orm/core/index.js +2 -2
  48. package/dist/orm/core/index.js.map +1 -1
  49. package/dist/react/form/index.d.ts +36 -1
  50. package/dist/react/form/index.d.ts.map +1 -1
  51. package/dist/react/form/index.js +91 -3
  52. package/dist/react/form/index.js.map +1 -1
  53. package/dist/react/router/index.browser.js +56 -8
  54. package/dist/react/router/index.browser.js.map +1 -1
  55. package/dist/react/router/index.d.ts +5 -0
  56. package/dist/react/router/index.d.ts.map +1 -1
  57. package/dist/react/router/index.js +55 -7
  58. package/dist/react/router/index.js.map +1 -1
  59. package/dist/server/core/index.d.ts.map +1 -1
  60. package/dist/server/core/index.js +1 -1
  61. package/dist/server/core/index.js.map +1 -1
  62. package/package.json +23 -1
  63. package/src/api/files/controllers/FileController.ts +41 -1
  64. package/src/api/files/providers/FileAccessProvider.ts +23 -1
  65. package/src/api/files/services/FileService.ts +5 -3
  66. package/src/api/users/services/CredentialService.ts +37 -26
  67. package/src/api/verifications/entities/verifications.ts +8 -0
  68. package/src/api/verifications/services/VerificationService.ts +14 -0
  69. package/src/cli/core/__tests__/BuildDockerTask.spec.ts +24 -0
  70. package/src/cli/core/__tests__/init.spec.ts +11 -0
  71. package/src/cli/core/atoms/buildOptions.ts +15 -0
  72. package/src/cli/core/commands/build.ts +14 -2
  73. package/src/cli/core/commands/db.ts +4 -0
  74. package/src/cli/core/services/ProjectScaffolder.ts +14 -6
  75. package/src/cli/core/tasks/BuildAssetsTask.ts +3 -0
  76. package/src/cli/core/tasks/BuildClientTask.ts +3 -0
  77. package/src/cli/core/tasks/BuildCloudflareTask.ts +136 -2
  78. package/src/cli/core/tasks/BuildCompressTask.ts +3 -0
  79. package/src/cli/core/tasks/BuildDockerTask.ts +12 -1
  80. package/src/cli/core/tasks/BuildPrerenderTask.ts +3 -0
  81. package/src/cli/core/tasks/BuildPwaTask.ts +3 -0
  82. package/src/cli/core/tasks/BuildServerTask.ts +3 -0
  83. package/src/cli/core/tasks/BuildTask.ts +8 -0
  84. package/src/cli/core/templates/saasAuthLayoutTsx.ts +9 -7
  85. package/src/cli/core/templates/saasRealmProviderTs.ts +24 -18
  86. package/src/cli/platform/__tests__/NamingService.spec.ts +0 -42
  87. package/src/cli/platform/__tests__/PlatformInspector.spec.ts +4 -48
  88. package/src/cli/platform/adapters/CloudflareAdapter.ts +24 -48
  89. package/src/cli/platform/adapters/PlatformAdapter.ts +8 -0
  90. package/src/cli/platform/adapters/VercelAdapter.ts +5 -15
  91. package/src/cli/platform/atoms/platformOptions.ts +0 -5
  92. package/src/cli/platform/commands/platform.ts +90 -93
  93. package/src/cli/platform/index.ts +16 -4
  94. package/src/cli/platform/services/NamingService.ts +11 -12
  95. package/src/cli/platform/services/PlatformInspector.ts +9 -43
  96. package/src/cli/platform/services/PlatformOrchestrator.ts +40 -98
  97. package/src/containers/core/__tests__/$container.spec.ts +83 -0
  98. package/src/containers/core/index.ts +50 -0
  99. package/src/containers/core/index.workerd.ts +37 -0
  100. package/src/containers/core/interfaces/ContainerOptions.ts +69 -0
  101. package/src/containers/core/primitives/$container.ts +100 -0
  102. package/src/containers/core/providers/CloudflareContainerProvider.ts +72 -0
  103. package/src/containers/core/providers/ContainerProvider.ts +78 -0
  104. package/src/containers/core/providers/MockContainerProvider.ts +62 -0
  105. package/src/containers/core/providers/NodeContainerProvider.ts +53 -0
  106. package/src/core/Alepha.ts +15 -0
  107. package/src/email/cloudflare/__tests__/CloudflareEmailProvider.spec.ts +150 -0
  108. package/src/email/cloudflare/index.ts +26 -0
  109. package/src/email/cloudflare/providers/CloudflareEmailProvider.ts +160 -0
  110. package/src/orm/core/services/Repository.ts +11 -2
  111. package/src/react/form/hooks/useFormQuerySync.ts +0 -0
  112. package/src/react/form/index.ts +1 -0
  113. package/src/react/form/services/FormModel.ts +18 -2
  114. package/src/react/router/__tests__/$page.browser.spec.tsx +123 -1
  115. package/src/react/router/components/ErrorViewer.tsx +29 -7
  116. package/src/react/router/providers/ReactBrowserProvider.ts +21 -5
  117. package/src/react/router/providers/ReactPageProvider.ts +81 -3
  118. package/src/server/core/providers/ServerRouterProvider.ts +7 -1
  119. package/src/cli/platform/hooks/PlatformHook.ts +0 -51
  120. 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
- }): Promise<void> {
63
- const { root, env, app: appFilter, apps, run } = options;
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. Filter apps
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
- // 4. Build
96
- for (const a of targetApps) {
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
- // 5. Migrate
99
+ // 4. Migrate
101
100
  await adapter.migrate(ctx, run);
102
101
 
103
- // 6. Deploy
102
+ // 5. Deploy
104
103
  const urls: string[] = [];
105
- for (const a of targetApps) {
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
- // 7. Platform hooks (register external resources: Stripe webhooks, etc.)
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
- const c = this.color;
116
+ return { urls, domain: envConfig.domain };
117
+ }
123
118
 
124
- if (envConfig.domain) {
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 = envConfig.domain.includes("*")
127
- ? `https://${envConfig.domain} (wildcard route)`
128
- : `https://${envConfig.domain}`;
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, app: appFilter, apps, run, confirm } = options;
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: appFilter ? apps.filter((a) => a.name === appFilter) : 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
+ }