@theholocron/netlify-client 1.36.0 → 1.37.1

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/README.md CHANGED
@@ -32,12 +32,17 @@ await netlify.sites.update("site-id", { custom_domain: "example.com" });
32
32
  // Trigger a build from a git-linked site's default branch
33
33
  const { deploy_id } = await netlify.sites.triggerBuild("site-id");
34
34
 
35
- // Create a deployment directly from source files — zipped in-memory, no
36
- // dependency. Netlify runs no build step for a raw zip upload, unlike a
37
- // Git-linked deploy — resolve dependencies into the file set yourself first.
38
- const deploy = await netlify.deploys.createFromZip("site-id", {
39
- "api/webhook.mjs": "export default () => new Response('ok');",
40
- "package.json": '{"name":"fn"}',
35
+ // Create a deploy — static files (plain text) and/or functions (pre-built
36
+ // zips). This is the digest-based flow: real, invokable functions — not
37
+ // the single-whole-zip method, which only deploys static assets.
38
+ import { buildZip } from "@theholocron/netlify-client";
39
+
40
+ const functionZip = buildZip({
41
+ "webhook.mjs": "exports.handler = async () => ({ statusCode: 200, body: 'ok' });",
42
+ });
43
+ const deploy = await netlify.deploys.create("site-id", {
44
+ files: { "index.html": "<!doctype html><title>status</title>" },
45
+ functions: [{ name: "webhook", zip: functionZip }],
41
46
  });
42
47
 
43
48
  // Get a deploy by id
@@ -53,15 +58,39 @@ await netlify.env.set("account-id", "site-id", "API_URL", "production", "https:/
53
58
 
54
59
  ## Notes
55
60
 
56
- - **No build step on a raw zip upload.** `deploys.createFromZip()` deploys
57
- exactly the file tree you give it — Netlify doesn't run `npm install` or
58
- any configured build command the way a Git-linked deploy (or `netlify
59
- deploy` run locally via the CLI) does. If your files need dependencies
60
- installed, resolve them into the file set before calling this.
61
+ - **Functions need the digest flow — the single-zip method doesn't deploy
62
+ functions at all.** Confirmed live: a function file placed inside a
63
+ single whole-site zip (`POST .../deploys` with `Content-Type:
64
+ application/zip`) is served as an inert static asset, never wired up
65
+ as a Lambda. `deploys.create()`'s `functions` field uses the real flow
66
+ (SHA1 file manifest + SHA256 function manifest, then
67
+ `PUT .../functions/{name}?runtime=js`) — this is the only way to ship
68
+ an actually-invokable function through this API.
69
+ - **A function's zip entry file basename must exactly match the function
70
+ name.** Confirmed live: naming the function `webhook` but the zip's
71
+ entry file `handler.mjs` fails at invoke time with
72
+ `Runtime.ImportModuleError: Cannot find module 'webhook'`. No nested
73
+ paths either (unlike Vercel's `api/webhook.mjs`) — the file needs to
74
+ sit at the zip root as `<name>.mjs` (or `.js`).
75
+ - **`runtime: "js"` expects a classic CommonJS/Lambda handler, not the
76
+ modern `(Request) => Response` style.** Confirmed live: `export default
77
+ (req) => new Response(...)` deploys without error but fails at invoke
78
+ time with `Runtime.HandlerNotFound: <name>.handler is undefined or not
79
+ exported`. Use `exports.handler = async (event, context) => ({
80
+ statusCode, headers, body })` (or the ESM equivalent, `export const
81
+ handler = ...`) instead.
82
+ - **No build step on a function upload.** Netlify runs no `npm install`
83
+ or configured build command for a raw API-uploaded function, unlike a
84
+ Git-linked deploy (or `netlify deploy` run locally via the CLI). If the
85
+ function imports anything beyond Node builtins, its `zip` needs a real
86
+ `node_modules` included — resolve dependencies into the zip yourself
87
+ before calling `create()`.
61
88
  - **Sites are account-slug-scoped to create, account-id-scoped for env
62
89
  vars.** Netlify's own API is inconsistent here — `POST /{account_slug}/sites`
63
90
  but `GET/POST/PUT /accounts/{account_id}/env`. Not something this client
64
- can paper over; pass whichever one each method asks for.
91
+ can paper over; pass whichever one each method asks for. The slug is
92
+ also mutable (an account/team can rename it in settings) — don't treat
93
+ a previously-observed slug as permanent.
65
94
  - **`state` on a deploy is a free-form string**, not a published enum.
66
95
  Known values seen in practice: `new`, `building`, `uploading`,
67
96
  `uploaded`, `preparing`, `prepared`, `processing`, `ready`, `error`,
package/dist/index.d.mts CHANGED
@@ -52,7 +52,36 @@ interface NetlifyDeploy {
52
52
  error_message?: string | null;
53
53
  created_at: string;
54
54
  }
55
- /** Raw transport details `createFromZip` needs beyond the generic `RestClient` — it sends a binary body, which `RestClient.request()` can't carry (always JSON-encodes a given body). */
55
+ /**
56
+ * One function to deploy. `zip` is a pre-built archive — the function's
57
+ * own code plus, if it imports anything beyond Node builtins, a real
58
+ * `node_modules` (Netlify's raw deploy API runs no build/install step,
59
+ * unlike a Git-linked deploy — `src/zip.ts`'s `buildZip()` builds this
60
+ * from `{ path: content }` entries, text or binary).
61
+ *
62
+ * Netlify's classic function runtime (`runtime: "js"`) expects a
63
+ * CommonJS-style `exports.handler` or ESM `export const handler` entry
64
+ * point — confirmed live: a V2-style `export default (req) => Response`
65
+ * handler deploys without error but fails at invoke time with
66
+ * `Runtime.HandlerNotFound`. The entry file's basename (minus extension)
67
+ * inside the zip should match `name`.
68
+ */
69
+ interface NetlifyDeployFunctionSpec {
70
+ /** Becomes invokable at `/.netlify/functions/<name>`. */
71
+ name: string;
72
+ /** Lambda runtime: `"js"` (Node.js, zipped) or `"go"` (zipped binary). */
73
+ runtime?: "js" | "go";
74
+ zip: Uint8Array;
75
+ }
76
+ interface NetlifyCreateDeployInput {
77
+ /** Static files, plain text — served as-is, no build step. */
78
+ files?: Record<string, string>;
79
+ /** Functions to deploy alongside (or instead of) static files. */
80
+ functions?: NetlifyDeployFunctionSpec[];
81
+ /** Process normally but don't make this the published deploy. */
82
+ draft?: boolean;
83
+ }
84
+ /** Raw transport details `create()` needs beyond the generic `RestClient` — file/function uploads send a binary body, which `RestClient.request()` can't carry (always JSON-encodes a given body). */
56
85
  interface NetlifyRawTransport {
57
86
  token: string;
58
87
  baseUrl?: string;
@@ -95,8 +124,13 @@ interface NetlifyUser {
95
124
  * real timestamp would only make otherwise-identical deploys look
96
125
  * different in diffs/caches for no benefit.
97
126
  */
98
- /** Builds a STORED-method ZIP archive from `{ path: utf8-content }` entries. */
99
- declare function buildZip(files: Record<string, string>): Uint8Array;
127
+ /**
128
+ * Builds a STORED-method ZIP archive from `{ path: content }` entries.
129
+ * `content` may be a utf8 string (text files) or raw bytes (binary —
130
+ * native addons, wasm, anything under a real `node_modules` tree a
131
+ * function's own dependencies need).
132
+ */
133
+ declare function buildZip(files: Record<string, string | Uint8Array>): Uint8Array;
100
134
  //#endregion
101
135
  //#region ../../../../../src/index.d.ts
102
136
  declare function createNetlifyClient(opts: NetlifyClientOptions): {
@@ -111,9 +145,7 @@ declare function createNetlifyClient(opts: NetlifyClientOptions): {
111
145
  };
112
146
  deploys: {
113
147
  get: (deployId: string) => Promise<NetlifyDeploy>;
114
- createFromZip(siteId: string, files: Record<string, string>, opts?: {
115
- draft?: boolean;
116
- }): Promise<NetlifyDeploy>;
148
+ create(siteId: string, input?: NetlifyCreateDeployInput): Promise<NetlifyDeploy>;
117
149
  };
118
150
  env: {
119
151
  list: (accountId: string, siteId: string) => Promise<NetlifyEnvVar[]>;
@@ -125,4 +157,4 @@ declare function createNetlifyClient(opts: NetlifyClientOptions): {
125
157
  };
126
158
  type NetlifyClient = ReturnType<typeof createNetlifyClient>;
127
159
  //#endregion
128
- export { NetlifyClient, type NetlifyClientOptions, type NetlifyCreateSiteInput, type NetlifyDeploy, type NetlifyEnvContext, type NetlifyEnvVar, type NetlifyEnvVarValue, type NetlifyRawTransport, type NetlifySite, type NetlifyUpdateSiteInput, type NetlifyUser, buildZip, createNetlifyClient };
160
+ export { NetlifyClient, type NetlifyClientOptions, type NetlifyCreateDeployInput, type NetlifyCreateSiteInput, type NetlifyDeploy, type NetlifyDeployFunctionSpec, type NetlifyEnvContext, type NetlifyEnvVar, type NetlifyEnvVarValue, type NetlifyRawTransport, type NetlifySite, type NetlifyUpdateSiteInput, type NetlifyUser, buildZip, createNetlifyClient };
package/dist/index.mjs CHANGED
@@ -1,4 +1,166 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { ProviderApiError, createRestClient } from "@theholocron/http-client";
3
+ //#region ../../../../../src/deploys/deploys.ts
4
+ function sha1Hex(bytes) {
5
+ return createHash("sha1").update(bytes).digest("hex");
6
+ }
7
+ function sha256Hex(bytes) {
8
+ return createHash("sha256").update(bytes).digest("hex");
9
+ }
10
+ function deploys(rest, raw) {
11
+ async function uploadRaw(path, body) {
12
+ const fetchImpl = raw.fetch ?? globalThis.fetch;
13
+ let baseUrl = raw.baseUrl ?? "https://api.netlify.com/api/v1";
14
+ while (baseUrl.endsWith("/")) baseUrl = baseUrl.slice(0, -1);
15
+ const tag = `Netlify PUT ${path}`;
16
+ let res;
17
+ try {
18
+ res = await fetchImpl(`${baseUrl}${path}`, {
19
+ method: "PUT",
20
+ headers: {
21
+ authorization: `Bearer ${raw.token}`,
22
+ accept: "application/json",
23
+ "content-type": "application/octet-stream"
24
+ },
25
+ body
26
+ });
27
+ } catch (err) {
28
+ const detail = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
29
+ throw new ProviderApiError(`${tag} failed: ${detail}`, 0, void 0);
30
+ }
31
+ if (!res.ok) {
32
+ const resBody = await res.text().catch(() => "");
33
+ throw new ProviderApiError(`${tag} → ${res.status}`, res.status, resBody);
34
+ }
35
+ }
36
+ return {
37
+ get: (deployId) => rest.request(`/deploys/${encodeURIComponent(deployId)}`),
38
+ /**
39
+ * The digest-based deploy flow: send a manifest of file/function
40
+ * hashes, then upload only the content Netlify doesn't already have
41
+ * cached (`required`/`required_functions` in the response — in
42
+ * practice, for a fresh deploy that's everything). This is the
43
+ * *only* flow that deploys real, invokable functions — the
44
+ * single-request whole-zip method (`POST .../deploys` with
45
+ * `Content-Type: application/zip`) only deploys static assets; a
46
+ * function file placed in that zip is served as an inert static
47
+ * file, never wired up as a Lambda (confirmed live).
48
+ */
49
+ async create(siteId, input = {}) {
50
+ const files = input.files ?? {};
51
+ const functions = input.functions ?? [];
52
+ const fileHashes = Object.fromEntries(Object.entries(files).map(([path, content]) => [path, sha1Hex(new TextEncoder().encode(content))]));
53
+ const functionHashes = Object.fromEntries(functions.map((f) => [f.name, sha256Hex(f.zip)]));
54
+ const deploy = await rest.request(`/sites/${encodeURIComponent(siteId)}/deploys`, {
55
+ method: "POST",
56
+ body: {
57
+ files: fileHashes,
58
+ ...functions.length > 0 ? { functions: functionHashes } : {},
59
+ ...input.draft ? { draft: true } : {}
60
+ }
61
+ });
62
+ const requiredFiles = new Set(deploy.required ?? []);
63
+ for (const [path, hash] of Object.entries(fileHashes)) {
64
+ if (!requiredFiles.has(hash)) continue;
65
+ await uploadRaw(`/deploys/${deploy.id}/files/${path.replace(/^\//, "")}`, new TextEncoder().encode(files[path]));
66
+ }
67
+ const requiredFunctions = new Set(deploy.required_functions ?? []);
68
+ for (const fn of functions) {
69
+ if (!requiredFunctions.has(functionHashes[fn.name])) continue;
70
+ await uploadRaw(`/deploys/${deploy.id}/functions/${encodeURIComponent(fn.name)}?runtime=${fn.runtime ?? "js"}`, fn.zip);
71
+ }
72
+ return deploy;
73
+ }
74
+ };
75
+ }
76
+ //#endregion
77
+ //#region ../../../../../src/env/env.ts
78
+ function env(rest) {
79
+ const list = (accountId, siteId) => rest.request(`/accounts/${encodeURIComponent(accountId)}/env`, { query: { site_id: siteId } });
80
+ return {
81
+ list,
82
+ /**
83
+ * Upsert one value for one context on one key. Netlify's env API has
84
+ * no single "upsert a value" endpoint — `PUT /env/{key}` replaces the
85
+ * entire `values` array, and there's no documented "get one key"
86
+ * endpoint either (only list + PUT/DELETE by key) — so a genuine
87
+ * upsert needs the key's current full value list first.
88
+ */
89
+ async set(accountId, siteId, key, context, value) {
90
+ const query = { site_id: siteId };
91
+ const existing = (await list(accountId, siteId)).find((v) => v.key === key);
92
+ if (!existing) {
93
+ await rest.request(`/accounts/${encodeURIComponent(accountId)}/env`, {
94
+ method: "POST",
95
+ query,
96
+ body: {
97
+ key,
98
+ scopes: ["functions", "runtime"],
99
+ values: [{
100
+ value,
101
+ context
102
+ }]
103
+ }
104
+ });
105
+ return;
106
+ }
107
+ const values = existing.values.filter((v) => v.context !== context);
108
+ values.push({
109
+ value,
110
+ context
111
+ });
112
+ await rest.request(`/accounts/${encodeURIComponent(accountId)}/env/${encodeURIComponent(key)}`, {
113
+ method: "PUT",
114
+ query,
115
+ body: {
116
+ key,
117
+ scopes: existing.scopes ?? ["functions", "runtime"],
118
+ values
119
+ }
120
+ });
121
+ }
122
+ };
123
+ }
124
+ //#endregion
125
+ //#region ../../../../../src/sites/sites.ts
126
+ function sites(rest) {
127
+ return {
128
+ list: () => rest.request("/sites"),
129
+ get: (siteId) => rest.request(`/sites/${encodeURIComponent(siteId)}`),
130
+ /** Scoped to `/{account_slug}/sites` — Netlify has no account-id-based create path. */
131
+ create: (accountSlug, input) => rest.request(`/${encodeURIComponent(accountSlug)}/sites`, {
132
+ method: "POST",
133
+ body: input
134
+ }),
135
+ update: (siteId, input) => rest.request(`/sites/${encodeURIComponent(siteId)}`, {
136
+ method: "PATCH",
137
+ body: input
138
+ }),
139
+ /** Triggers a new build from the latest commit on a git-linked site's default branch. */
140
+ triggerBuild: (siteId) => rest.request(`/sites/${encodeURIComponent(siteId)}/builds`, {
141
+ method: "POST",
142
+ body: { clear_cache: false }
143
+ })
144
+ };
145
+ }
146
+ //#endregion
147
+ //#region ../../../../../src/user/user.ts
148
+ function user(rest) {
149
+ return { get: () => rest.request("/user") };
150
+ }
151
+ //#endregion
152
+ //#region ../../../../../src/utils.ts
153
+ function createNetlifyRestClient(opts) {
154
+ return createRestClient({
155
+ baseUrl: opts.baseUrl ?? "https://api.netlify.com/api/v1",
156
+ token: opts.token,
157
+ vendor: "Netlify",
158
+ fetch: opts.fetch,
159
+ logger: opts.logger,
160
+ errors: opts.errors
161
+ });
162
+ }
163
+ //#endregion
2
164
  //#region ../../../../../src/zip.ts
3
165
  /**
4
166
  * Minimal ZIP archive builder — STORED (uncompressed) entries only, no
@@ -42,11 +204,16 @@ function crc32(data) {
42
204
  for (const byte of data) crc = CRC_TABLE[(crc ^ byte) & 255] ^ crc >>> 8;
43
205
  return (crc ^ 4294967295) >>> 0;
44
206
  }
45
- /** Builds a STORED-method ZIP archive from `{ path: utf8-content }` entries. */
207
+ /**
208
+ * Builds a STORED-method ZIP archive from `{ path: content }` entries.
209
+ * `content` may be a utf8 string (text files) or raw bytes (binary —
210
+ * native addons, wasm, anything under a real `node_modules` tree a
211
+ * function's own dependencies need).
212
+ */
46
213
  function buildZip(files) {
47
214
  const entries = Object.entries(files).map(([path, content]) => ({
48
215
  path: path.startsWith("/") ? path.slice(1) : path,
49
- content: new TextEncoder().encode(content)
216
+ content: typeof content === "string" ? new TextEncoder().encode(content) : content
50
217
  }));
51
218
  const localParts = [];
52
219
  const centralParts = [];
@@ -113,136 +280,6 @@ function buildZip(files) {
113
280
  return total;
114
281
  }
115
282
  //#endregion
116
- //#region ../../../../../src/deploys/deploys.ts
117
- function deploys(rest, raw) {
118
- return {
119
- get: (deployId) => rest.request(`/deploys/${encodeURIComponent(deployId)}`),
120
- /**
121
- * Zips `files` (plain text) and uploads as a one-shot deploy via
122
- * `POST /sites/{id}/deploys` with `Content-Type: application/zip`.
123
- * Netlify runs no build step for a raw zip upload (unlike a
124
- * Git-linked deploy) — the caller is responsible for having already
125
- * resolved dependencies into the file set.
126
- */
127
- async createFromZip(siteId, files, opts = {}) {
128
- const fetchImpl = raw.fetch ?? globalThis.fetch;
129
- let baseUrl = raw.baseUrl ?? "https://api.netlify.com/api/v1";
130
- while (baseUrl.endsWith("/")) baseUrl = baseUrl.slice(0, -1);
131
- const url = new URL(`${baseUrl}/sites/${encodeURIComponent(siteId)}/deploys`);
132
- if (opts.draft) url.searchParams.set("draft", "true");
133
- const zip = buildZip(files);
134
- const tag = `Netlify POST /sites/${siteId}/deploys`;
135
- let res;
136
- try {
137
- res = await fetchImpl(url.toString(), {
138
- method: "POST",
139
- headers: {
140
- authorization: `Bearer ${raw.token}`,
141
- accept: "application/json",
142
- "content-type": "application/zip"
143
- },
144
- body: zip
145
- });
146
- } catch (err) {
147
- const detail = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
148
- throw new ProviderApiError(`${tag} failed: ${detail}`, 0, void 0);
149
- }
150
- if (!res.ok) {
151
- const body = await res.text().catch(() => "");
152
- throw new ProviderApiError(`${tag} → ${res.status}`, res.status, body);
153
- }
154
- return await res.json();
155
- }
156
- };
157
- }
158
- //#endregion
159
- //#region ../../../../../src/env/env.ts
160
- function env(rest) {
161
- const list = (accountId, siteId) => rest.request(`/accounts/${encodeURIComponent(accountId)}/env`, { query: { site_id: siteId } });
162
- return {
163
- list,
164
- /**
165
- * Upsert one value for one context on one key. Netlify's env API has
166
- * no single "upsert a value" endpoint — `PUT /env/{key}` replaces the
167
- * entire `values` array, and there's no documented "get one key"
168
- * endpoint either (only list + PUT/DELETE by key) — so a genuine
169
- * upsert needs the key's current full value list first.
170
- */
171
- async set(accountId, siteId, key, context, value) {
172
- const query = { site_id: siteId };
173
- const existing = (await list(accountId, siteId)).find((v) => v.key === key);
174
- if (!existing) {
175
- await rest.request(`/accounts/${encodeURIComponent(accountId)}/env`, {
176
- method: "POST",
177
- query,
178
- body: {
179
- key,
180
- scopes: ["functions", "runtime"],
181
- values: [{
182
- value,
183
- context
184
- }]
185
- }
186
- });
187
- return;
188
- }
189
- const values = existing.values.filter((v) => v.context !== context);
190
- values.push({
191
- value,
192
- context
193
- });
194
- await rest.request(`/accounts/${encodeURIComponent(accountId)}/env/${encodeURIComponent(key)}`, {
195
- method: "PUT",
196
- query,
197
- body: {
198
- key,
199
- scopes: existing.scopes ?? ["functions", "runtime"],
200
- values
201
- }
202
- });
203
- }
204
- };
205
- }
206
- //#endregion
207
- //#region ../../../../../src/sites/sites.ts
208
- function sites(rest) {
209
- return {
210
- list: () => rest.request("/sites"),
211
- get: (siteId) => rest.request(`/sites/${encodeURIComponent(siteId)}`),
212
- /** Scoped to `/{account_slug}/sites` — Netlify has no account-id-based create path. */
213
- create: (accountSlug, input) => rest.request(`/${encodeURIComponent(accountSlug)}/sites`, {
214
- method: "POST",
215
- body: input
216
- }),
217
- update: (siteId, input) => rest.request(`/sites/${encodeURIComponent(siteId)}`, {
218
- method: "PATCH",
219
- body: input
220
- }),
221
- /** Triggers a new build from the latest commit on a git-linked site's default branch. */
222
- triggerBuild: (siteId) => rest.request(`/sites/${encodeURIComponent(siteId)}/builds`, {
223
- method: "POST",
224
- body: { clear_cache: false }
225
- })
226
- };
227
- }
228
- //#endregion
229
- //#region ../../../../../src/user/user.ts
230
- function user(rest) {
231
- return { get: () => rest.request("/user") };
232
- }
233
- //#endregion
234
- //#region ../../../../../src/utils.ts
235
- function createNetlifyRestClient(opts) {
236
- return createRestClient({
237
- baseUrl: opts.baseUrl ?? "https://api.netlify.com/api/v1",
238
- token: opts.token,
239
- vendor: "Netlify",
240
- fetch: opts.fetch,
241
- logger: opts.logger,
242
- errors: opts.errors
243
- });
244
- }
245
- //#endregion
246
283
  //#region ../../../../../src/index.ts
247
284
  function createNetlifyClient(opts) {
248
285
  const rest = createNetlifyRestClient(opts);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theholocron/netlify-client",
3
- "version": "1.36.0",
3
+ "version": "1.37.1",
4
4
  "description": "A TypeScript client for the Netlify API",
5
5
  "keywords": [
6
6
  "netlify",
@@ -33,7 +33,7 @@
33
33
  ],
34
34
  "dependencies": {
35
35
  "@theholocron/observability": "^0.3.0",
36
- "@theholocron/http-client": "^1.36.0"
36
+ "@theholocron/http-client": "^1.37.1"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@theholocron/cli": "5.0.0-alpha.79",