Skip to content

Commit d9c9cf9

Browse files
committed
[release] Manage Appwrite Proxy Rules (custom domains) from the CLI
Adds first-class support for the Proxy service that the underlying node-appwrite@23 SDK doesn't expose. Calls the REST endpoints directly with the API key the user already supplies. Declarative: AppwriteFunction and AppwriteSite gain an optional `domains: string[]`. During --deployFunctions, after a function's deploy succeeds, its declared domains are reconciled against the existing proxy rules — missing ones are created, matching ones are kept untouched (idempotent). Pass --pruneDomains to also delete rules whose domain is no longer listed. Override / one-shot: --functionDomains "a.example.com,b.example.com" attaches domains in a single-function deploy without touching config. Standalone commands for ad-hoc rule management run before controller.init() so they work in any cwd with bare credentials: --listRules <resourceId> --functionId <id> | --siteId <id> --createRule --domain X --functionId Y (or --siteId Y) --deleteRule <ruleId> No new dependencies; uses fetch with X-Appwrite-Project / X-Appwrite-Key headers. REST paths verified against @appwrite.io/console SDK source.
1 parent 88ce14c commit d9c9cf9

5 files changed

Lines changed: 376 additions & 0 deletions

File tree

packages/appwrite-utils-cli/src/cli/commands/deployFunctionsFlow.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ export interface DeployFunctionFieldOverrides {
3838
predeployCommands?: string;
3939
deployDir?: string;
4040
ignore?: string;
41+
/** Comma-separated domains for --functionDomains. Reconciled after deploy. */
42+
domains?: string;
4143
}
4244

4345
export interface DeployFunctionsFlowOptions {
@@ -59,6 +61,9 @@ export interface DeployFunctionsFlowOptions {
5961
};
6062
/** Per-field overrides; only valid when exactly one function is targeted. */
6163
overrides?: DeployFunctionFieldOverrides;
64+
/** When true, proxy rules attached to a function but absent from its
65+
* declared `domains` list are deleted after deploy. Default: false. */
66+
pruneDomains?: boolean;
6267
}
6368

6469
function expandTilde(p: string): string {
@@ -202,6 +207,7 @@ function applyOverrides(
202207
}
203208
if (overrides.deployDir !== undefined) next.deployDir = overrides.deployDir;
204209
if (overrides.ignore !== undefined) next.ignore = splitCsv(overrides.ignore);
210+
if (overrides.domains !== undefined) next.domains = splitCsv(overrides.domains);
205211
return next;
206212
}
207213

@@ -362,5 +368,42 @@ export async function runDeployFunctionsFlow(
362368
const results = await deployFunctionsBatch(client, items, {
363369
buildConcurrency: opts.buildConcurrency,
364370
});
371+
372+
// 8. Reconcile proxy rules ("domains") for every function that declared any
373+
// and whose deploy succeeded. Failures here don't fail the deploy — the
374+
// code is already on the server — but they're logged loudly.
375+
const { reconcileResourceDomains } = await import("../../functions/proxyRules.js");
376+
for (const item of items) {
377+
const declared = (item.functionConfig as AppwriteFunction).domains ?? [];
378+
if (declared.length === 0) continue;
379+
const result = results.find((r) => r.functionId === (item.functionConfig as AppwriteFunction).$id);
380+
if (!result || result.status !== "ready") continue;
381+
try {
382+
const summary = await reconcileResourceDomains(
383+
client,
384+
"function",
385+
(item.functionConfig as AppwriteFunction).$id,
386+
declared,
387+
{ prune: opts.pruneDomains }
388+
);
389+
const parts: string[] = [];
390+
if (summary.created.length) parts.push(`created [${summary.created.join(", ")}]`);
391+
if (summary.kept.length) parts.push(`kept [${summary.kept.join(", ")}]`);
392+
if (summary.deleted.length) parts.push(`deleted [${summary.deleted.join(", ")}]`);
393+
MessageFormatter.success(
394+
`Domains for ${item.functionName}: ${parts.join("; ") || "no changes"}`,
395+
{ prefix: "Functions" }
396+
);
397+
} catch (err) {
398+
MessageFormatter.error(
399+
`Failed to reconcile domains for ${item.functionName}: ${
400+
err instanceof Error ? err.message : String(err)
401+
}`,
402+
undefined,
403+
{ prefix: "Functions" }
404+
);
405+
}
406+
}
407+
365408
return results.filter((r) => r.status === "failed").length + skipped.length;
366409
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/**
2+
* Thin REST wrappers around Appwrite's Proxy service.
3+
*
4+
* The `node-appwrite@23` SDK that the rest of the CLI uses does not expose
5+
* the Proxy service yet (`@appwrite.io/console@12` does, but that's a
6+
* browser SDK with the wrong auth model). The endpoints are stable and
7+
* documented; we just call them with the API key the user already gave us.
8+
*
9+
* Endpoint shapes were verified against
10+
* `node_modules/.bun/@appwrite.io+console@12.0.0/dist/esm/sdk.js`:
11+
* GET /v1/proxy/rules (queries[]=, search=, total=)
12+
* POST /v1/proxy/rules/function ({ domain, functionId, branch? })
13+
* POST /v1/proxy/rules/site ({ domain, siteId, branch? })
14+
* DELETE /v1/proxy/rules/{ruleId}
15+
*/
16+
import type { Client } from "node-appwrite";
17+
18+
export interface ProxyRule {
19+
$id: string;
20+
$createdAt: string;
21+
$updatedAt: string;
22+
domain: string;
23+
resourceType?: string;
24+
resourceId?: string;
25+
deploymentResourceType?: "function" | "site";
26+
deploymentResourceId?: string;
27+
deploymentVcsProviderBranch?: string;
28+
status?: "created" | "verifying" | "verified" | "unverified";
29+
type?: "api" | "deployment" | "redirect";
30+
trigger?: "manual" | "deployment";
31+
redirectUrl?: string;
32+
redirectStatusCode?: number;
33+
logs?: string;
34+
}
35+
36+
export type ProxyResourceType = "function" | "site";
37+
38+
function authHeaders(client: Client): Record<string, string> {
39+
const { project, key } = client.config;
40+
return {
41+
"X-Appwrite-Project": project,
42+
"X-Appwrite-Key": key,
43+
"Content-Type": "application/json",
44+
};
45+
}
46+
47+
function baseUrl(client: Client): string {
48+
// client.config.endpoint already includes /v1; the SDK just appends.
49+
return client.config.endpoint.replace(/\/$/, "");
50+
}
51+
52+
async function request<T>(
53+
method: "GET" | "POST" | "DELETE",
54+
url: string,
55+
init: { headers: Record<string, string>; body?: unknown }
56+
): Promise<T> {
57+
const res = await fetch(url, {
58+
method,
59+
headers: init.headers,
60+
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
61+
});
62+
const text = await res.text();
63+
if (!res.ok) {
64+
// Surface Appwrite's structured error if present, otherwise the raw body.
65+
let detail = text;
66+
try {
67+
const j = JSON.parse(text);
68+
detail = j.message ? `${j.message} (${res.status})` : text;
69+
} catch {}
70+
throw new Error(`Proxy API ${method} ${url} failed: ${detail}`);
71+
}
72+
if (!text) return undefined as unknown as T;
73+
return JSON.parse(text) as T;
74+
}
75+
76+
/**
77+
* List proxy rules attached to a function or site. Uses the same `queries[]`
78+
* filtering wire format as the rest of the Appwrite REST API.
79+
*/
80+
export async function listRulesForResource(
81+
client: Client,
82+
resourceType: ProxyResourceType,
83+
resourceId: string
84+
): Promise<ProxyRule[]> {
85+
const queries = [
86+
JSON.stringify({ method: "equal", attribute: "deploymentResourceType", values: [resourceType] }),
87+
JSON.stringify({ method: "equal", attribute: "deploymentResourceId", values: [resourceId] }),
88+
];
89+
const params = new URLSearchParams();
90+
for (const q of queries) params.append("queries[]", q);
91+
const url = `${baseUrl(client)}/proxy/rules?${params.toString()}`;
92+
const body = await request<{ total: number; rules: ProxyRule[] }>(
93+
"GET",
94+
url,
95+
{ headers: authHeaders(client) }
96+
);
97+
return body.rules ?? [];
98+
}
99+
100+
export async function createFunctionRule(
101+
client: Client,
102+
params: { domain: string; functionId: string; branch?: string }
103+
): Promise<ProxyRule> {
104+
return request<ProxyRule>(
105+
"POST",
106+
`${baseUrl(client)}/proxy/rules/function`,
107+
{ headers: authHeaders(client), body: params }
108+
);
109+
}
110+
111+
export async function createSiteRule(
112+
client: Client,
113+
params: { domain: string; siteId: string; branch?: string }
114+
): Promise<ProxyRule> {
115+
return request<ProxyRule>(
116+
"POST",
117+
`${baseUrl(client)}/proxy/rules/site`,
118+
{ headers: authHeaders(client), body: params }
119+
);
120+
}
121+
122+
export async function deleteRule(client: Client, ruleId: string): Promise<void> {
123+
await request<void>(
124+
"DELETE",
125+
`${baseUrl(client)}/proxy/rules/${encodeURIComponent(ruleId)}`,
126+
{ headers: authHeaders(client) }
127+
);
128+
}
129+
130+
/**
131+
* Reconcile a resource's desired vs existing proxy rules. Always creates
132+
* missing domains; only deletes extras when `prune` is true. Idempotent —
133+
* existing rules with matching domains are left alone.
134+
*
135+
* Returns a summary so the caller can log what changed.
136+
*/
137+
export async function reconcileResourceDomains(
138+
client: Client,
139+
resourceType: ProxyResourceType,
140+
resourceId: string,
141+
desiredDomains: string[],
142+
options: { prune?: boolean; branch?: string } = {}
143+
): Promise<{ created: string[]; deleted: string[]; kept: string[] }> {
144+
const desired = new Set(desiredDomains.map((d) => d.trim()).filter(Boolean));
145+
const existing = await listRulesForResource(client, resourceType, resourceId);
146+
const existingByDomain = new Map(existing.map((r) => [r.domain, r]));
147+
148+
const created: string[] = [];
149+
const deleted: string[] = [];
150+
const kept: string[] = [];
151+
152+
for (const domain of desired) {
153+
if (existingByDomain.has(domain)) {
154+
kept.push(domain);
155+
continue;
156+
}
157+
if (resourceType === "function") {
158+
await createFunctionRule(client, {
159+
domain,
160+
functionId: resourceId,
161+
branch: options.branch,
162+
});
163+
} else {
164+
await createSiteRule(client, {
165+
domain,
166+
siteId: resourceId,
167+
branch: options.branch,
168+
});
169+
}
170+
created.push(domain);
171+
}
172+
173+
if (options.prune) {
174+
for (const rule of existing) {
175+
if (!desired.has(rule.domain)) {
176+
await deleteRule(client, rule.$id);
177+
deleted.push(rule.domain);
178+
}
179+
}
180+
}
181+
182+
return { created, deleted, kept };
183+
}

0 commit comments

Comments
 (0)