vyft 0.2.0-alpha → 0.4.0-alpha

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 (56) hide show
  1. package/README.md +5 -16
  2. package/dist/build.d.ts +1 -0
  3. package/dist/build.js +9 -4
  4. package/dist/cli.js +648 -43
  5. package/dist/context.d.ts +39 -0
  6. package/dist/context.js +101 -0
  7. package/dist/docker.d.ts +24 -12
  8. package/dist/docker.js +299 -389
  9. package/dist/exec.d.ts +1 -1
  10. package/dist/exec.js +2 -2
  11. package/dist/index.d.ts +4 -1
  12. package/dist/index.js +5 -1
  13. package/dist/init.js +19 -2
  14. package/dist/interpolate.d.ts +11 -0
  15. package/dist/interpolate.js +11 -0
  16. package/dist/local/dev.d.ts +31 -0
  17. package/dist/local/dev.js +109 -0
  18. package/dist/local/index.d.ts +2 -0
  19. package/dist/local/index.js +2 -0
  20. package/dist/local/runtime.d.ts +61 -0
  21. package/dist/local/runtime.js +391 -0
  22. package/dist/proxy.d.ts +16 -0
  23. package/dist/proxy.js +0 -0
  24. package/dist/resource.d.ts +104 -1
  25. package/dist/resource.js +11 -1
  26. package/dist/runtime.d.ts +11 -1
  27. package/dist/services/index.d.ts +26 -0
  28. package/dist/services/index.js +35 -0
  29. package/dist/services/minio.d.ts +36 -0
  30. package/dist/services/minio.js +53 -0
  31. package/dist/services/mongo.d.ts +28 -0
  32. package/dist/services/mongo.js +45 -0
  33. package/dist/services/mysql.d.ts +28 -0
  34. package/dist/services/mysql.js +44 -0
  35. package/dist/services/nats.d.ts +26 -0
  36. package/dist/services/nats.js +38 -0
  37. package/dist/services/postgres.d.ts +28 -0
  38. package/dist/services/postgres.js +45 -0
  39. package/dist/services/rabbitmq.d.ts +28 -0
  40. package/dist/services/rabbitmq.js +44 -0
  41. package/dist/services/redis.d.ts +28 -0
  42. package/dist/services/redis.js +49 -0
  43. package/dist/services/storage.d.ts +39 -0
  44. package/dist/services/storage.js +94 -0
  45. package/dist/swarm/factories.d.ts +9 -2
  46. package/dist/swarm/factories.js +9 -32
  47. package/dist/swarm/index.d.ts +11 -2
  48. package/dist/swarm/proxy.d.ts +24 -0
  49. package/dist/swarm/proxy.js +339 -0
  50. package/dist/swarm/types.d.ts +11 -21
  51. package/dist/symbols.d.ts +7 -0
  52. package/dist/symbols.js +3 -0
  53. package/package.json +4 -5
  54. package/templates/fullstack/package.json +2 -6
  55. package/templates/fullstack/vyft.config.ts +13 -28
  56. package/templates/fullstack/compose.yaml +0 -14
@@ -0,0 +1,391 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { PassThrough } from "node:stream";
3
+ import Docker from "dockerode";
4
+ import { logger as defaultLogger } from "../logger.js";
5
+ import { isInterpolation, isReference } from "../resource.js";
6
+ /**
7
+ * Docker runtime for local development.
8
+ *
9
+ * Runs managed infrastructure (databases, caches, etc.) as plain Docker
10
+ * containers with ports published to localhost. Secrets are generated
11
+ * in-memory and injected as environment variables.
12
+ */
13
+ export class LocalRuntime {
14
+ docker;
15
+ project;
16
+ log;
17
+ secretValues = new Map();
18
+ containers = [];
19
+ networkName;
20
+ constructor(project, opts) {
21
+ this.docker = new Docker({ socketPath: "/var/run/docker.sock" });
22
+ this.project = project;
23
+ this.networkName = `${project}-dev`;
24
+ this.log = (opts?.parentLogger ?? defaultLogger).child({
25
+ component: "local-runtime",
26
+ project,
27
+ });
28
+ }
29
+ /** Create bridge network for inter-container DNS. */
30
+ async ensureInfrastructure() {
31
+ const networks = await this.docker.listNetworks({
32
+ filters: JSON.stringify({ name: [this.networkName] }),
33
+ });
34
+ const exists = networks.some((n) => n.Name === this.networkName);
35
+ if (!exists) {
36
+ this.log.debug({ network: this.networkName }, "creating bridge network");
37
+ await this.docker.createNetwork({
38
+ Name: this.networkName,
39
+ Driver: "bridge",
40
+ Labels: {
41
+ "vyft.dev": "true",
42
+ "vyft.project": this.project,
43
+ },
44
+ });
45
+ }
46
+ }
47
+ /** Generate a random secret value and store it in memory. */
48
+ createSecret(secret) {
49
+ const length = secret.config.length || 32;
50
+ const value = randomBytes(length).toString("base64url");
51
+ this.secretValues.set(secret.id, value);
52
+ this.log.debug({ secretName: secret.id }, "secret generated");
53
+ }
54
+ /** Get the generated value for a secret. */
55
+ getSecretValue(id) {
56
+ return this.secretValues.get(id);
57
+ }
58
+ /** Create a Docker volume for persistent data. */
59
+ async createVolume(volume) {
60
+ const name = volume.id;
61
+ try {
62
+ await this.docker.getVolume(name).inspect();
63
+ this.log.debug({ volumeName: name }, "volume already exists");
64
+ return;
65
+ }
66
+ catch {
67
+ // Volume doesn't exist, create it
68
+ }
69
+ await this.docker.createVolume({
70
+ Name: name,
71
+ Labels: {
72
+ "vyft.dev": "true",
73
+ "vyft.project": this.project,
74
+ },
75
+ });
76
+ this.log.debug({ volumeName: name }, "volume created");
77
+ }
78
+ /**
79
+ * Resolve env vars for a managed service container.
80
+ *
81
+ * Secrets become plain `KEY=<value>` env vars.
82
+ * Interpolations are fully resolved with generated secret values.
83
+ */
84
+ resolveEnv(env) {
85
+ const result = [];
86
+ for (const [key, value] of Object.entries(env)) {
87
+ if (typeof value === "string") {
88
+ result.push(`${key}=${value}`);
89
+ }
90
+ else if (isReference(value)) {
91
+ const secretValue = this.secretValues.get(value.id);
92
+ if (!secretValue) {
93
+ throw new Error(`Secret ${value.id} not yet generated`);
94
+ }
95
+ result.push(`${key}=${secretValue}`);
96
+ }
97
+ else if (isInterpolation(value)) {
98
+ const parts = [];
99
+ for (let i = 0; i < value.strings.length; i++) {
100
+ parts.push(value.strings[i]);
101
+ if (i < value.values.length) {
102
+ const v = value.values[i];
103
+ if (isReference(v)) {
104
+ const secretValue = this.secretValues.get(v.id);
105
+ if (!secretValue) {
106
+ throw new Error(`Secret ${v.id} not yet generated`);
107
+ }
108
+ parts.push(secretValue);
109
+ }
110
+ else {
111
+ parts.push(v);
112
+ }
113
+ }
114
+ }
115
+ result.push(`${key}=${parts.join("")}`);
116
+ }
117
+ }
118
+ return result;
119
+ }
120
+ /**
121
+ * Build an entrypoint wrapper that writes secret values to /run/secrets/
122
+ * before exec'ing the original entrypoint.
123
+ *
124
+ * This is needed because some factory images (e.g. redis) read passwords
125
+ * from `/run/secrets/*-password` via shell glob.
126
+ */
127
+ buildSecretInit(secrets, originalEntrypoint, originalCmd) {
128
+ if (secrets.length === 0) {
129
+ return { entrypoint: originalEntrypoint, cmd: originalCmd };
130
+ }
131
+ const writeCommands = secrets
132
+ .map((s) => `printf '%s' "$${s.envVar}" > /run/secrets/${s.name}`)
133
+ .join(" && ");
134
+ // Build the original command to exec
135
+ const original = [...originalEntrypoint, ...originalCmd];
136
+ const execCmd = original.length > 0 ? `exec ${original.map(shellEscape).join(" ")}` : "";
137
+ const script = `mkdir -p /run/secrets && ${writeCommands}${execCmd ? ` && ${execCmd}` : ""}`;
138
+ return {
139
+ entrypoint: ["sh", "-c", script],
140
+ cmd: [],
141
+ };
142
+ }
143
+ /** Pull, create, and start a container for a managed service. */
144
+ async createService(service) {
145
+ const config = service.config;
146
+ const image = typeof config.image === "string" ? config.image : `${service.id}:latest`;
147
+ const port = config.port || 3000;
148
+ // Stop and remove existing container if present
149
+ await this.removeContainer(service.id);
150
+ // Pull image
151
+ this.log.debug({ image }, "pulling image");
152
+ const pullStream = await this.docker.pull(image);
153
+ await new Promise((resolve, reject) => {
154
+ this.docker.modem.followProgress(pullStream, (err) => err ? reject(err) : resolve());
155
+ });
156
+ // Resolve env
157
+ const envList = config.env ? this.resolveEnv(config.env) : [];
158
+ // Collect secrets that need /run/secrets/ files
159
+ const secretMappings = [];
160
+ if (config.env) {
161
+ let idx = 0;
162
+ for (const [, value] of Object.entries(config.env)) {
163
+ if (isReference(value)) {
164
+ const envVar = `__VYFT_S${idx}`;
165
+ secretMappings.push({
166
+ name: value.id.replace(/^.*-/, ""), // short name e.g. "db-password" -> "password"
167
+ envVar,
168
+ });
169
+ // The full id as a file too, for glob patterns like *-password
170
+ secretMappings.push({
171
+ name: value.id,
172
+ envVar,
173
+ });
174
+ const secretVal = this.secretValues.get(value.id);
175
+ if (secretVal) {
176
+ envList.push(`${envVar}=${secretVal}`);
177
+ }
178
+ idx++;
179
+ }
180
+ }
181
+ }
182
+ // Inspect image for default entrypoint/cmd
183
+ const imageInfo = await this.docker.getImage(image).inspect();
184
+ const rawEntrypoint = imageInfo.Config?.Entrypoint ?? [];
185
+ const defaultEntrypoint = Array.isArray(rawEntrypoint)
186
+ ? rawEntrypoint
187
+ : [rawEntrypoint];
188
+ const rawCmd = imageInfo.Config?.Cmd ?? [];
189
+ const defaultCmd = Array.isArray(rawCmd) ? rawCmd : [rawCmd];
190
+ // Use config.command if specified, otherwise image defaults
191
+ const userCmd = config.command ?? defaultCmd;
192
+ const { entrypoint, cmd } = this.buildSecretInit(secretMappings, defaultEntrypoint, userCmd);
193
+ // Build volume mounts
194
+ const mounts = config.volumes?.map(({ volume, mount }) => ({
195
+ Type: "volume",
196
+ Source: volume.id,
197
+ Target: mount,
198
+ }));
199
+ // Health check
200
+ const healthCheck = config.healthCheck
201
+ ? {
202
+ Test: ["CMD", ...config.healthCheck.command],
203
+ Interval: config.healthCheck.interval
204
+ ? parseDurationNs(config.healthCheck.interval)
205
+ : undefined,
206
+ Timeout: config.healthCheck.timeout
207
+ ? parseDurationNs(config.healthCheck.timeout)
208
+ : undefined,
209
+ Retries: config.healthCheck.retries,
210
+ StartPeriod: config.healthCheck.startPeriod
211
+ ? parseDurationNs(config.healthCheck.startPeriod)
212
+ : undefined,
213
+ }
214
+ : undefined;
215
+ const containerName = `${service.id}-dev`;
216
+ this.log.debug({ containerName, image, port }, "creating container");
217
+ const container = await this.docker.createContainer({
218
+ name: containerName,
219
+ Image: image,
220
+ Entrypoint: entrypoint.length > 0 ? entrypoint : undefined,
221
+ Cmd: cmd.length > 0 ? cmd : undefined,
222
+ Env: envList,
223
+ ExposedPorts: { [`${port}/tcp`]: {} },
224
+ Healthcheck: healthCheck,
225
+ Labels: {
226
+ "vyft.dev": "true",
227
+ "vyft.project": this.project,
228
+ "vyft.service": service.id,
229
+ },
230
+ HostConfig: {
231
+ PortBindings: {
232
+ [`${port}/tcp`]: [{ HostIp: "127.0.0.1", HostPort: `${port}` }],
233
+ },
234
+ NetworkMode: this.networkName,
235
+ Mounts: mounts,
236
+ },
237
+ });
238
+ await container.start();
239
+ this.containers.push({
240
+ id: service.id,
241
+ name: containerName,
242
+ containerId: container.id,
243
+ });
244
+ this.log.debug({ containerName, port }, "container started");
245
+ return port;
246
+ }
247
+ /** Wait for a container to pass its health check. */
248
+ async waitForHealthy(id, timeoutMs = 120000) {
249
+ const info = this.containers.find((c) => c.id === id);
250
+ if (!info)
251
+ return;
252
+ const start = performance.now();
253
+ const interval = 1000;
254
+ this.log.debug({ resourceId: id }, "waiting for healthy");
255
+ while (performance.now() - start < timeoutMs) {
256
+ const container = this.docker.getContainer(info.containerId);
257
+ const data = await container.inspect();
258
+ const health = data.State?.Health?.Status;
259
+ if (health === "healthy") {
260
+ this.log.debug({ resourceId: id }, "container is healthy");
261
+ return;
262
+ }
263
+ if (health === undefined) {
264
+ // No health check configured, just check if running
265
+ if (data.State?.Running) {
266
+ this.log.debug({ resourceId: id }, "container running (no health check)");
267
+ return;
268
+ }
269
+ }
270
+ await new Promise((r) => setTimeout(r, interval));
271
+ }
272
+ throw new Error(`Container ${id} did not become healthy within ${timeoutMs}ms`);
273
+ }
274
+ /** Stream logs from a container. */
275
+ async *containerLogs(id) {
276
+ const info = this.containers.find((c) => c.id === id);
277
+ if (!info)
278
+ return;
279
+ const container = this.docker.getContainer(info.containerId);
280
+ const stream = await container.logs({
281
+ stdout: true,
282
+ stderr: true,
283
+ follow: true,
284
+ tail: 0,
285
+ });
286
+ const buffer = [];
287
+ let notify = null;
288
+ let done = false;
289
+ const push = (entry) => {
290
+ buffer.push(entry);
291
+ if (notify) {
292
+ const n = notify;
293
+ notify = null;
294
+ n();
295
+ }
296
+ };
297
+ const stdoutPT = new PassThrough();
298
+ const stderrPT = new PassThrough();
299
+ stdoutPT.on("data", (chunk) => {
300
+ for (const line of chunk.toString().split("\n")) {
301
+ if (line)
302
+ push({ stream: "stdout", text: line });
303
+ }
304
+ });
305
+ stderrPT.on("data", (chunk) => {
306
+ for (const line of chunk.toString().split("\n")) {
307
+ if (line)
308
+ push({ stream: "stderr", text: line });
309
+ }
310
+ });
311
+ this.docker.modem.demuxStream(stream, stdoutPT, stderrPT);
312
+ stream.on("end", () => {
313
+ done = true;
314
+ if (notify) {
315
+ const n = notify;
316
+ notify = null;
317
+ n();
318
+ }
319
+ });
320
+ while (!done || buffer.length > 0) {
321
+ if (buffer.length === 0) {
322
+ await new Promise((r) => {
323
+ notify = r;
324
+ });
325
+ }
326
+ while (buffer.length > 0) {
327
+ yield buffer.shift();
328
+ }
329
+ }
330
+ }
331
+ /** Stop and remove all managed containers (keep volumes). */
332
+ async stop() {
333
+ for (const info of this.containers) {
334
+ try {
335
+ const container = this.docker.getContainer(info.containerId);
336
+ await container.stop({ t: 5 });
337
+ await container.remove();
338
+ this.log.debug({ containerName: info.name }, "container stopped");
339
+ }
340
+ catch {
341
+ this.log.debug({ containerName: info.name }, "container already stopped");
342
+ }
343
+ }
344
+ this.containers = [];
345
+ }
346
+ /** List running dev containers for this project. */
347
+ async listContainers() {
348
+ const containers = await this.docker.listContainers({
349
+ filters: JSON.stringify({
350
+ label: ["vyft.dev=true", `vyft.project=${this.project}`],
351
+ }),
352
+ });
353
+ return containers.map((c) => ({
354
+ id: c.Labels["vyft.service"] || "",
355
+ name: c.Names[0]?.replace(/^\//, "") || "",
356
+ containerId: c.Id,
357
+ }));
358
+ }
359
+ async removeContainer(serviceId) {
360
+ const containerName = `${serviceId}-dev`;
361
+ try {
362
+ const container = this.docker.getContainer(containerName);
363
+ await container.stop({ t: 2 });
364
+ await container.remove();
365
+ this.log.debug({ containerName }, "removed existing container");
366
+ }
367
+ catch {
368
+ // Container didn't exist
369
+ }
370
+ }
371
+ }
372
+ function parseDurationNs(duration) {
373
+ const match = duration.match(/^(\d+)(ms|s|m|h)$/);
374
+ if (!match?.[1] || !match[2]) {
375
+ throw new Error(`Invalid duration format: ${duration}`);
376
+ }
377
+ const value = parseInt(match[1], 10);
378
+ const unit = match[2];
379
+ const multipliers = {
380
+ ms: 1_000_000,
381
+ s: 1_000_000_000,
382
+ m: 60_000_000_000,
383
+ h: 3_600_000_000_000,
384
+ };
385
+ return value * multipliers[unit];
386
+ }
387
+ function shellEscape(s) {
388
+ if (/^[a-zA-Z0-9_./:=-]+$/.test(s))
389
+ return s;
390
+ return `'${s.replace(/'/g, "'\\''")}'`;
391
+ }
@@ -0,0 +1,16 @@
1
+ export interface ReverseProxy {
2
+ /** Ensure the proxy service is running and configured. */
3
+ ensure(): Promise<void>;
4
+ /** Register or update a route for a resource. */
5
+ addRoute(resourceId: string, route: string, target: {
6
+ host: string;
7
+ port: number;
8
+ }): Promise<void>;
9
+ /** Remove a route for a resource. No-op if it doesn't exist. */
10
+ removeRoute(resourceId: string): Promise<void>;
11
+ /** Stream proxy access logs. */
12
+ logs(options: {
13
+ follow?: boolean;
14
+ tail?: number;
15
+ }): Promise<void>;
16
+ }
package/dist/proxy.js ADDED
File without changes
@@ -1,78 +1,181 @@
1
1
  import type { RuntimeRef } from "./symbols.js";
2
+ /** Discriminant for the four resource kinds. */
2
3
  export type ResourceType = "volume" | "secret" | "service" | "site";
3
- export type VolumeConfig = {};
4
+ /** Configuration for a persistent volume. */
5
+ export interface VolumeConfig {
6
+ /** Human-readable size hint (e.g. `"10GB"`). Informational only. */
7
+ size?: string;
8
+ }
9
+ /** Configuration for an auto-generated secret. */
4
10
  export interface SecretConfig {
11
+ /** Length of the generated random value in bytes. */
5
12
  length?: number;
6
13
  }
14
+ /** Container health-check configuration. */
7
15
  export interface HealthCheckConfig {
16
+ /** Command to run inside the container (e.g. `["pg_isready", "-U", "postgres"]`). */
8
17
  command: string[];
18
+ /** Time between checks (e.g. `"5s"`, `"1m"`). */
9
19
  interval?: string;
20
+ /** Maximum time a single check may take. */
10
21
  timeout?: string;
22
+ /** Consecutive failures required to mark unhealthy. */
11
23
  retries?: number;
24
+ /** Grace period before the first check runs. */
12
25
  startPeriod?: string;
13
26
  }
27
+ /** CPU and memory limits for a service. */
14
28
  export interface ResourceLimits {
29
+ /** Memory ceiling (e.g. `"512MB"`, `"2GB"`). */
15
30
  memory?: string;
31
+ /** CPU core limit (e.g. `0.5`, `2`). */
16
32
  cpus?: number;
17
33
  }
34
+ /** A persistent storage volume. */
18
35
  export interface Volume extends RuntimeRef {
19
36
  type: "volume";
20
37
  id: string;
21
38
  config: VolumeConfig;
22
39
  }
40
+ /** An auto-generated secret value, injected at deploy time. */
23
41
  export interface Secret extends RuntimeRef {
24
42
  type: "secret";
25
43
  id: string;
26
44
  config: SecretConfig;
27
45
  }
46
+ /**
47
+ * A long-running service (container).
48
+ *
49
+ * Use `host` and `port` to reference this service from other services
50
+ * in the same project, or `url` for the full address.
51
+ */
28
52
  export interface Service extends RuntimeRef {
29
53
  type: "service";
30
54
  id: string;
31
55
  config: ServiceConfig;
56
+ /** Internal hostname reachable by other services. */
32
57
  host: string;
58
+ /** Port the service listens on. */
33
59
  port: number;
60
+ /** Full URL — `https://<route>` if routed, otherwise `http://<host>:<port>`. */
34
61
  url: string;
35
62
  }
63
+ /** A static site served via Caddy. */
36
64
  export interface Site extends RuntimeRef {
37
65
  type: "site";
38
66
  id: string;
39
67
  config: SiteConfig;
68
+ /** Public URL derived from the route (e.g. `https://example.com`). */
40
69
  url: string;
41
70
  }
71
+ /** Union of all deployable resource types. */
42
72
  export type Resource = Volume | Secret | Service | Site;
73
+ /**
74
+ * A deferred reference to a value that isn't known until deploy time.
75
+ *
76
+ * Currently only {@link Secret} produces deferred values. Expand this
77
+ * union as more resources gain deferred outputs.
78
+ */
43
79
  export type Reference = Secret;
80
+ /**
81
+ * A tagged-template interpolation that mixes literal strings with
82
+ * {@link Reference} values. Created via {@link interpolate}.
83
+ *
84
+ * @example
85
+ * ```ts
86
+ * const connStr = interpolate`postgres://user:${dbPassword}@${db.host}:5432/app`;
87
+ * ```
88
+ */
44
89
  export interface Interpolation {
45
90
  type: "interpolation";
46
91
  strings: TemplateStringsArray;
47
92
  values: Array<Reference | string>;
48
93
  }
94
+ /**
95
+ * A value that can appear in a service's `env` map.
96
+ *
97
+ * - Plain `string` — set verbatim.
98
+ * - {@link Reference} — resolved at deploy time (e.g. a secret).
99
+ * - {@link Interpolation} — a template mixing strings and references.
100
+ */
49
101
  export type EnvValue = string | Reference | Interpolation;
102
+ /** Type-guard: returns `true` if `value` is a {@link Reference}. */
50
103
  export declare function isReference(value: unknown): value is Reference;
104
+ /** Type-guard: returns `true` if `value` is a {@link Secret}. */
51
105
  export declare function isSecret(value: unknown): value is Secret;
106
+ /** Type-guard: returns `true` if `value` is an {@link Interpolation}. */
52
107
  export declare function isInterpolation(value: unknown): value is Interpolation;
108
+ /** Configuration for a long-running service. */
53
109
  export interface ServiceConfig {
110
+ /**
111
+ * Container image to run.
112
+ *
113
+ * - `string` — a registry image (e.g. `"node:22"`).
114
+ * - `{ context, dockerfile }` — build from a local Dockerfile.
115
+ */
54
116
  image: string | {
55
117
  context?: string;
56
118
  dockerfile?: string;
57
119
  };
120
+ /** Public route to expose (e.g. `"api.example.com"` or `"example.com/api"`). */
58
121
  route?: string;
122
+ /** Port the container listens on. Defaults to `3000`. */
59
123
  port?: number;
124
+ /** Environment variables injected into the container. */
60
125
  env?: Record<string, EnvValue>;
126
+ /** Override the container's default command. */
61
127
  command?: string[];
128
+ /** Volumes to mount into the container. */
62
129
  volumes?: Array<{
63
130
  volume: Volume;
64
131
  mount: string;
65
132
  }>;
133
+ /** Services that must be healthy before this one starts. */
134
+ dependsOn?: Service[];
135
+ /** Container health check. */
136
+ healthCheck?: HealthCheckConfig;
137
+ /**
138
+ * Restart behaviour on failure.
139
+ * @defaultValue `"any"`
140
+ */
141
+ restartPolicy?: "none" | "on-failure" | "any";
142
+ /** Local development configuration. Used by `vyft dev`. */
143
+ dev?: {
144
+ /** Shell command to run (e.g. `"bun run dev"`). */
145
+ command: string;
146
+ /** Working directory relative to project root. */
147
+ cwd?: string;
148
+ };
66
149
  }
150
+ /** Configuration for a static site. */
67
151
  export interface SiteConfig {
152
+ /** Domain (and optional path) to serve the site on. */
68
153
  route: string;
154
+ /**
155
+ * Enable single-page application mode. When `true`, all requests that
156
+ * don't match a static file are rewritten to `/index.html`.
157
+ * @defaultValue `true`
158
+ */
69
159
  spa?: boolean;
160
+ /** Build settings for the static site. */
70
161
  build: {
162
+ /** Working directory containing the source (relative to project root). */
71
163
  cwd: string;
164
+ /** Directory containing the built output (relative to `cwd`). Defaults to `"dist"`. */
72
165
  output?: string;
166
+ /** Build command to run (e.g. `"npm run build"`). */
73
167
  command?: string;
168
+ /** Environment variables passed to the build command. */
74
169
  env?: Record<string, string>;
75
170
  };
76
171
  }
172
+ /**
173
+ * Validates a resource ID.
174
+ * @throws If the ID is empty, too long, or contains invalid characters.
175
+ */
77
176
  export declare function validateId(id: string): void;
177
+ /**
178
+ * Validates a route string.
179
+ * @throws If the route is empty or not a valid domain with optional path.
180
+ */
78
181
  export declare function validateRoute(route: string): void;
package/dist/resource.js CHANGED
@@ -1,19 +1,25 @@
1
+ /** Type-guard: returns `true` if `value` is a {@link Reference}. */
1
2
  export function isReference(value) {
2
3
  return isSecret(value);
3
4
  }
5
+ /** Type-guard: returns `true` if `value` is a {@link Secret}. */
4
6
  export function isSecret(value) {
5
7
  return (typeof value === "object" &&
6
8
  value !== null &&
7
9
  value.type === "secret");
8
10
  }
11
+ /** Type-guard: returns `true` if `value` is an {@link Interpolation}. */
9
12
  export function isInterpolation(value) {
10
13
  return (typeof value === "object" &&
11
14
  value !== null &&
12
15
  value.type === "interpolation");
13
16
  }
14
- // Validation
15
17
  const ID_PATTERN = /^[a-z][a-z0-9-]*[a-z0-9]$|^[a-z]$/;
16
18
  const ROUTE_PATTERN = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*(\/.+)?$/;
19
+ /**
20
+ * Validates a resource ID.
21
+ * @throws If the ID is empty, too long, or contains invalid characters.
22
+ */
17
23
  export function validateId(id) {
18
24
  if (!id || id.length < 1) {
19
25
  throw new Error("Resource ID cannot be empty");
@@ -25,6 +31,10 @@ export function validateId(id) {
25
31
  throw new Error(`Invalid resource ID "${id}": must start with a letter, contain only lowercase letters, numbers, and hyphens, and end with a letter or number`);
26
32
  }
27
33
  }
34
+ /**
35
+ * Validates a route string.
36
+ * @throws If the route is empty or not a valid domain with optional path.
37
+ */
28
38
  export function validateRoute(route) {
29
39
  if (!route) {
30
40
  throw new Error("Route cannot be empty");
package/dist/runtime.d.ts CHANGED
@@ -1,6 +1,16 @@
1
1
  import type { Resource } from "./resource.js";
2
+ /** Structured result returned by {@link Runtime.create}. */
3
+ export interface CreateResult {
4
+ action: "created" | "updated";
5
+ route?: string;
6
+ duration: number;
7
+ }
8
+ /** Backend that can deploy, query, and tear down resources. */
2
9
  export interface Runtime {
3
- create(resource: Resource): Promise<void>;
10
+ /** Deploy or update a resource. */
11
+ create(resource: Resource): Promise<CreateResult>;
12
+ /** Check whether a resource currently exists. */
4
13
  exists(resource: Resource): Promise<boolean>;
14
+ /** Tear down a resource. */
5
15
  remove(resource: Resource): Promise<void>;
6
16
  }
@@ -0,0 +1,26 @@
1
+ import type { Secret, SecretConfig, Service, ServiceConfig, Volume, VolumeConfig } from "../resource.js";
2
+ export interface Primitives {
3
+ volume(id: string, config?: VolumeConfig): Volume;
4
+ service(id: string, config: ServiceConfig): Service;
5
+ secret(id: string, config?: SecretConfig): Secret;
6
+ }
7
+ /** Returns `true` if a resource was created by a built-in factory. */
8
+ export declare function isManaged(resource: unknown): boolean;
9
+ export declare function createServices(p: Primitives): {
10
+ postgres: (id: string, config?: import("./postgres.js").PostgresConfig) => import("./postgres.js").Postgres;
11
+ redis: (id: string, config?: import("./redis.js").RedisConfig) => import("./redis.js").Redis;
12
+ rabbitmq: (id: string, config?: import("./rabbitmq.js").RabbitmqConfig) => import("./rabbitmq.js").Rabbitmq;
13
+ nats: (id: string, config?: import("./nats.js").NatsConfig) => import("./nats.js").Nats;
14
+ mysql: (id: string, config?: import("./mysql.js").MysqlConfig) => import("./mysql.js").Mysql;
15
+ mongo: (id: string, config?: import("./mongo.js").MongoConfig) => import("./mongo.js").Mongo;
16
+ minio: (id: string, config?: import("./minio.js").MinioConfig) => import("./minio.js").Minio;
17
+ storage: (id: string, config?: import("./storage.js").StorageConfig) => import("./storage.js").Storage;
18
+ };
19
+ export type { Bucket, Minio, MinioConfig } from "./minio.js";
20
+ export type { Mongo, MongoConfig } from "./mongo.js";
21
+ export type { Mysql, MysqlConfig } from "./mysql.js";
22
+ export type { Nats, NatsConfig } from "./nats.js";
23
+ export type { Postgres, PostgresConfig } from "./postgres.js";
24
+ export type { Rabbitmq, RabbitmqConfig } from "./rabbitmq.js";
25
+ export type { Redis, RedisConfig } from "./redis.js";
26
+ export type { BackupConfig, Storage, StorageConfig } from "./storage.js";