1/**
2 * Railway CLI `up` equivalent: POST a gzipped Docker context to
3 * backboard `/project/:id/environment/:id/up`. Railway builds the
4 * generated Dockerfile; Alchemy never pushes an image.
5 */
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Redacted from "effect/Redacted";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientRequest from "effect/http/HttpClientRequest";
11import { RailwayEnvironment } from "./Environment.ts";
12
13export class DeployUploadFailed extends Data.TaggedError(
14 "Railway.DeployUploadFailed",
15)<{
16 status: number;
17 message: string;
18}> {}
19
20export type UpResponse = {
21 readonly deploymentId: string;
22 readonly url: string;
23 readonly logsUrl: string;
24 readonly deploymentDomain: string;
25};
26
27const parseUpResponse = (value: unknown): UpResponse | undefined => {
28 if (value === null || typeof value !== "object") return undefined;
29 const rec = value as Record<string, unknown>;
30 if (typeof rec.deploymentId !== "string" || rec.deploymentId.length === 0) {
31 return undefined;
32 }
33 return {
34 deploymentId: rec.deploymentId,
35 url: typeof rec.url === "string" ? rec.url : "",
36 logsUrl: typeof rec.logsUrl === "string" ? rec.logsUrl : "",
37 deploymentDomain:
38 typeof rec.deploymentDomain === "string" ? rec.deploymentDomain : "",
39 };
40};
41
42const uploadUrl = (input: {
43 apiBaseUrl: string;
44 projectId: string;
45 environmentId: string;
46 serviceId: string;
47 message?: string;
48}): string => {
49 const base = input.apiBaseUrl.replace(/\/+$/, "");
50 const url = new URL(
51 `${base}/project/${input.projectId}/environment/${input.environmentId}/up`,
52 );
53 url.searchParams.set("serviceId", input.serviceId);
54 if (input.message !== undefined && input.message.length > 0) {
55 url.searchParams.set("message", input.message);
56 }
57 return url.toString();
58};
59
60/**
61 * Upload a gzipped tar of a generated Docker context. Railway builds
62 * and deploys it. Same contract as `railway up`.
63 */
64export const uploadDeployTarball = Effect.fn(function* (input: {
65 projectId: string;
66 environmentId: string;
67 serviceId: string;
68 tarball: Uint8Array;
69 message?: string;
70}) {
71 const env = yield* yield* RailwayEnvironment;
72 const http = yield* HttpClient.HttpClient;
73 const token = Redacted.value(env.token);
74 const url = uploadUrl({
75 apiBaseUrl: env.apiBaseUrl,
76 projectId: input.projectId,
77 environmentId: input.environmentId,
78 serviceId: input.serviceId,
79 message: input.message,
80 });
81 let request = HttpClientRequest.post(url).pipe(
82 HttpClientRequest.bodyUint8Array(input.tarball, "application/gzip"),
83 );
84 request =
85 env.tokenKind === "project"
86 ? request.pipe(HttpClientRequest.setHeader("Project-Access-Token", token))
87 : request.pipe(HttpClientRequest.bearerToken(token));
88 const response = yield* http.execute(request);
89 if (response.status !== 200) {
90 const body = yield* response.text.pipe(Effect.orElseSucceed(() => ""));
91 return yield* new DeployUploadFailed({
92 status: response.status,
93 message:
94 body.length > 0
95 ? body
96 : `Failed to upload code with status ${String(response.status)}`,
97 });
98 }
99 const json = yield* response.json;
100 const parsed = parseUpResponse(json);
101 if (parsed === undefined) {
102 return yield* new DeployUploadFailed({
103 status: response.status,
104 message: "Railway /up did not return a deploymentId",
105 });
106 }
107 return parsed;
108});