run402 1.42.0 → 1.44.0

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.
@@ -0,0 +1,62 @@
1
+ /**
2
+ * RFC 8785 (JCS) canonical JSON for the v1.32 deploy-plan manifest digest.
3
+ *
4
+ * MUST stay byte-for-byte identical to the gateway's
5
+ * `services/deploy-plans.ts:canonicalizeJson`. A digest mismatch breaks
6
+ * idempotency: the SDK's hash won't match the gateway's, so retrying a
7
+ * plan creates a NEW plan instead of finding the existing one.
8
+ *
9
+ * Spec for our value domain:
10
+ * - object keys sorted ASCII-ascending
11
+ * - arrays comma-separated, no whitespace
12
+ * - strings/numbers via JSON.stringify (matches RFC 8785 for ASCII paths,
13
+ * hex sha256, integer sizes, ASCII content_types)
14
+ */
15
+ export function canonicalizeJson(value) {
16
+ if (value === null)
17
+ return "null";
18
+ if (typeof value === "boolean")
19
+ return value ? "true" : "false";
20
+ if (typeof value === "number")
21
+ return JSON.stringify(value);
22
+ if (typeof value === "string")
23
+ return JSON.stringify(value);
24
+ if (Array.isArray(value))
25
+ return "[" + value.map(canonicalizeJson).join(",") + "]";
26
+ if (typeof value === "object") {
27
+ const obj = value;
28
+ const keys = Object.keys(obj).sort();
29
+ const pairs = keys.map((k) => JSON.stringify(k) + ":" + canonicalizeJson(obj[k]));
30
+ return "{" + pairs.join(",") + "}";
31
+ }
32
+ throw new Error("canonicalizeJson: unsupported value type");
33
+ }
34
+ /**
35
+ * Build the canonical manifest object the gateway hashes: `{ files: [...] }`
36
+ * with entries sorted by path and only the four expected keys per entry.
37
+ * `content_type` defaults to `"application/octet-stream"` when absent.
38
+ */
39
+ export function buildCanonicalManifest(entries) {
40
+ const files = entries
41
+ .map((e) => ({
42
+ path: e.path,
43
+ sha256: e.sha256,
44
+ size: e.size,
45
+ content_type: e.content_type ?? "application/octet-stream",
46
+ }))
47
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
48
+ return { files };
49
+ }
50
+ /**
51
+ * Compute the hex SHA-256 of the canonical JSON encoding of a manifest.
52
+ * Matches the gateway's `computeManifestDigest`.
53
+ */
54
+ export async function computeManifestDigest(manifest) {
55
+ const canonical = canonicalizeJson(manifest);
56
+ const bytes = new TextEncoder().encode(canonical);
57
+ const hash = await crypto.subtle.digest("SHA-256", bytes);
58
+ return Array.from(new Uint8Array(hash))
59
+ .map((b) => b.toString(16).padStart(2, "0"))
60
+ .join("");
61
+ }
62
+ //# sourceMappingURL=canonicalize.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"canonicalize.js","sourceRoot":"","sources":["../../src/node/canonicalize.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAaH,MAAM,UAAU,gBAAgB,CAAC,KAAc;IAC7C,IAAI,KAAK,KAAK,IAAI;QAAE,OAAO,MAAM,CAAC;IAClC,IAAI,OAAO,KAAK,KAAK,SAAS;QAAE,OAAO,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC;IAChE,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC;IAC5D,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC;IAC5D,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,GAAG,GAAG,KAAK,CAAC,GAAG,CAAC,gBAAgB,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC;IACnF,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAC9B,MAAM,GAAG,GAAG,KAAgC,CAAC;QAC7C,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC;QACrC,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,GAAG,GAAG,GAAG,gBAAgB,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QAClF,OAAO,GAAG,GAAG,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC;IACrC,CAAC;IACD,MAAM,IAAI,KAAK,CAAC,0CAA0C,CAAC,CAAC;AAC9D,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,sBAAsB,CACpC,OAAqF;IAErF,MAAM,KAAK,GAAoB,OAAO;SACnC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;QACX,IAAI,EAAE,CAAC,CAAC,IAAI;QACZ,MAAM,EAAE,CAAC,CAAC,MAAM;QAChB,IAAI,EAAE,CAAC,CAAC,IAAI;QACZ,YAAY,EAAE,CAAC,CAAC,YAAY,IAAI,0BAA0B;KAC3D,CAAC,CAAC;SACF,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IACpE,OAAO,EAAE,KAAK,EAAE,CAAC;AACnB,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,qBAAqB,CAAC,QAAkB;IAC5D,MAAM,SAAS,GAAG,gBAAgB,CAAC,QAAQ,CAAC,CAAC;IAC7C,MAAM,KAAK,GAAG,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;IAClD,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,SAAS,EAAE,KAAgC,CAAC,CAAC;IACrF,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,UAAU,CAAC,IAAI,CAAC,CAAC;SACpC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;SAC3C,IAAI,CAAC,EAAE,CAAC,CAAC;AACd,CAAC"}
@@ -5,15 +5,22 @@
5
5
  * - default API base from `RUN402_API_BASE` (via core/config)
6
6
  * - {@link NodeCredentialsProvider} backed by the local keystore + allowance
7
7
  * - an x402-wrapped fetch built lazily on first request
8
+ * - {@link NodeSites}: the `sites` namespace enriched with `deployDir(dir)`
8
9
  *
9
10
  * Usage:
10
11
  * ```ts
11
12
  * import { run402 } from "@run402/sdk/node";
12
13
  * const r = run402();
13
14
  * const project = await r.projects.provision({ tier: "prototype" });
15
+ * await r.sites.deployDir({ project: project.project_id, dir: "./my-site" });
14
16
  * ```
17
+ *
18
+ * `deployDir` uses the v1.32 plan/commit transport and only uploads the
19
+ * bytes the gateway doesn't already have. Re-deploying an unchanged tree
20
+ * issues no S3 PUTs.
15
21
  */
16
22
  import { Run402 } from "../index.js";
23
+ import { NodeSites } from "./sites-node.js";
17
24
  export interface NodeRun402Options {
18
25
  /** Override the API base URL. Defaults to `getApiBase()` (env var or production URL). */
19
26
  apiBase?: string;
@@ -29,16 +36,26 @@ export interface NodeRun402Options {
29
36
  /** Fully custom fetch implementation. Takes precedence over `disablePaidFetch`. */
30
37
  fetch?: typeof globalThis.fetch;
31
38
  }
39
+ /** Run402 instance with the Node-only `sites.deployDir` helper wired in. */
40
+ export type NodeRun402 = Omit<Run402, "sites"> & {
41
+ sites: NodeSites;
42
+ };
32
43
  /**
33
44
  * Construct a Run402 client wired with Node defaults.
34
45
  *
35
46
  * Behavior matches today's `run402-mcp` / `run402` CLI: reads keystore and
36
47
  * allowance from disk, signs SIWX headers, and retries 402 responses via
37
48
  * `@x402/fetch` when the allowance wallet has USDC balance.
49
+ *
50
+ * The returned instance's `sites` namespace is a {@link NodeSites}, which
51
+ * adds a `deployDir({ dir })` helper on top of the isomorphic `deploy()`
52
+ * and `getDeployment()` methods.
38
53
  */
39
- export declare function run402(opts?: NodeRun402Options): Run402;
54
+ export declare function run402(opts?: NodeRun402Options): NodeRun402;
55
+ export { NodeSites } from "./sites-node.js";
56
+ export type { DeployDirOptions } from "./sites-node.js";
40
57
  export { NodeCredentialsProvider } from "./credentials.js";
41
58
  export { setupPaidFetch, createLazyPaidFetch } from "./paid-fetch.js";
42
- export { Run402, Run402Error, PaymentRequired, ProjectNotFound, Unauthorized, ApiError, NetworkError, } from "../index.js";
59
+ export { Run402, Run402Error, PaymentRequired, ProjectNotFound, Unauthorized, ApiError, NetworkError, LocalError, } from "../index.js";
43
60
  export type { Run402Options, CredentialsProvider, ProjectKeys, RequestOptions, Client, } from "../index.js";
44
61
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/node/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAGH,OAAO,EAAE,MAAM,EAAsB,MAAM,aAAa,CAAC;AAIzD,MAAM,WAAW,iBAAiB;IAChC,yFAAyF;IACzF,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,0EAA0E;IAC1E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,2EAA2E;IAC3E,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,mFAAmF;IACnF,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;CACjC;AAED;;;;;;GAMG;AACH,wBAAgB,MAAM,CAAC,IAAI,GAAE,iBAAsB,GAAG,MAAM,CAY3D;AAED,OAAO,EAAE,uBAAuB,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AAEtE,OAAO,EACL,MAAM,EACN,WAAW,EACX,eAAe,EACf,eAAe,EACf,YAAY,EACZ,QAAQ,EACR,YAAY,GACb,MAAM,aAAa,CAAC;AACrB,YAAY,EACV,aAAa,EACb,mBAAmB,EACnB,WAAW,EACX,cAAc,EACd,MAAM,GACP,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/node/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAGH,OAAO,EAAE,MAAM,EAAsB,MAAM,aAAa,CAAC;AAIzD,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAE5C,MAAM,WAAW,iBAAiB;IAChC,yFAAyF;IACzF,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,0EAA0E;IAC1E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,2EAA2E;IAC3E,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,mFAAmF;IACnF,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;CACjC;AAED,4EAA4E;AAC5E,MAAM,MAAM,UAAU,GAAG,IAAI,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG;IAAE,KAAK,EAAE,SAAS,CAAA;CAAE,CAAC;AAEtE;;;;;;;;;;GAUG;AACH,wBAAgB,MAAM,CAAC,IAAI,GAAE,iBAAsB,GAAG,UAAU,CAqB/D;AAED,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,YAAY,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACxD,OAAO,EAAE,uBAAuB,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AAEtE,OAAO,EACL,MAAM,EACN,WAAW,EACX,eAAe,EACf,eAAe,EACf,YAAY,EACZ,QAAQ,EACR,YAAY,EACZ,UAAU,GACX,MAAM,aAAa,CAAC;AACrB,YAAY,EACV,aAAa,EACb,mBAAmB,EACnB,WAAW,EACX,cAAc,EACd,MAAM,GACP,MAAM,aAAa,CAAC"}
@@ -5,24 +5,35 @@
5
5
  * - default API base from `RUN402_API_BASE` (via core/config)
6
6
  * - {@link NodeCredentialsProvider} backed by the local keystore + allowance
7
7
  * - an x402-wrapped fetch built lazily on first request
8
+ * - {@link NodeSites}: the `sites` namespace enriched with `deployDir(dir)`
8
9
  *
9
10
  * Usage:
10
11
  * ```ts
11
12
  * import { run402 } from "@run402/sdk/node";
12
13
  * const r = run402();
13
14
  * const project = await r.projects.provision({ tier: "prototype" });
15
+ * await r.sites.deployDir({ project: project.project_id, dir: "./my-site" });
14
16
  * ```
17
+ *
18
+ * `deployDir` uses the v1.32 plan/commit transport and only uploads the
19
+ * bytes the gateway doesn't already have. Re-deploying an unchanged tree
20
+ * issues no S3 PUTs.
15
21
  */
16
22
  import { getApiBase } from "../../core-dist/config.js";
17
23
  import { Run402 } from "../index.js";
18
24
  import { NodeCredentialsProvider } from "./credentials.js";
19
25
  import { createLazyPaidFetch } from "./paid-fetch.js";
26
+ import { NodeSites } from "./sites-node.js";
20
27
  /**
21
28
  * Construct a Run402 client wired with Node defaults.
22
29
  *
23
30
  * Behavior matches today's `run402-mcp` / `run402` CLI: reads keystore and
24
31
  * allowance from disk, signs SIWX headers, and retries 402 responses via
25
32
  * `@x402/fetch` when the allowance wallet has USDC balance.
33
+ *
34
+ * The returned instance's `sites` namespace is a {@link NodeSites}, which
35
+ * adds a `deployDir({ dir })` helper on top of the isomorphic `deploy()`
36
+ * and `getDeployment()` methods.
26
37
  */
27
38
  export function run402(opts = {}) {
28
39
  const runOpts = {
@@ -34,10 +45,18 @@ export function run402(opts = {}) {
34
45
  fetch: opts.fetch ??
35
46
  (opts.disablePaidFetch ? globalThis.fetch.bind(globalThis) : createLazyPaidFetch()),
36
47
  };
37
- return new Run402(runOpts);
48
+ const base = new Run402(runOpts);
49
+ // Upgrade `sites` to the Node-aware variant, sharing the kernel `Client`
50
+ // that the isomorphic Sites was constructed with. Access to `client` goes
51
+ // through a cast because it is `private` on `Sites` — runtime still exposes
52
+ // the field; this keeps a single Client per instance (no divergent state).
53
+ const client = base.sites.client;
54
+ base.sites = new NodeSites(client);
55
+ return base;
38
56
  }
57
+ export { NodeSites } from "./sites-node.js";
39
58
  export { NodeCredentialsProvider } from "./credentials.js";
40
59
  export { setupPaidFetch, createLazyPaidFetch } from "./paid-fetch.js";
41
60
  // Re-export the isomorphic surface so Node consumers don't need two imports.
42
- export { Run402, Run402Error, PaymentRequired, ProjectNotFound, Unauthorized, ApiError, NetworkError, } from "../index.js";
61
+ export { Run402, Run402Error, PaymentRequired, ProjectNotFound, Unauthorized, ApiError, NetworkError, LocalError, } from "../index.js";
43
62
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/node/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AACvD,OAAO,EAAE,MAAM,EAAsB,MAAM,aAAa,CAAC;AACzD,OAAO,EAAE,uBAAuB,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AAkBtD;;;;;;GAMG;AACH,MAAM,UAAU,MAAM,CAAC,OAA0B,EAAE;IACjD,MAAM,OAAO,GAAkB;QAC7B,OAAO,EAAE,IAAI,CAAC,OAAO,IAAI,UAAU,EAAE;QACrC,WAAW,EAAE,IAAI,uBAAuB,CAAC;YACvC,aAAa,EAAE,IAAI,CAAC,aAAa;YACjC,YAAY,EAAE,IAAI,CAAC,YAAY;SAChC,CAAC;QACF,KAAK,EACH,IAAI,CAAC,KAAK;YACV,CAAC,IAAI,CAAC,gBAAgB,CAAC,CAAC,CAAC,UAAU,CAAC,KAAK,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,mBAAmB,EAAE,CAAC;KACtF,CAAC;IACF,OAAO,IAAI,MAAM,CAAC,OAAO,CAAC,CAAC;AAC7B,CAAC;AAED,OAAO,EAAE,uBAAuB,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AACtE,6EAA6E;AAC7E,OAAO,EACL,MAAM,EACN,WAAW,EACX,eAAe,EACf,eAAe,EACf,YAAY,EACZ,QAAQ,EACR,YAAY,GACb,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/node/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AACvD,OAAO,EAAE,MAAM,EAAsB,MAAM,aAAa,CAAC;AAEzD,OAAO,EAAE,uBAAuB,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AACtD,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAqB5C;;;;;;;;;;GAUG;AACH,MAAM,UAAU,MAAM,CAAC,OAA0B,EAAE;IACjD,MAAM,OAAO,GAAkB;QAC7B,OAAO,EAAE,IAAI,CAAC,OAAO,IAAI,UAAU,EAAE;QACrC,WAAW,EAAE,IAAI,uBAAuB,CAAC;YACvC,aAAa,EAAE,IAAI,CAAC,aAAa;YACjC,YAAY,EAAE,IAAI,CAAC,YAAY;SAChC,CAAC;QACF,KAAK,EACH,IAAI,CAAC,KAAK;YACV,CAAC,IAAI,CAAC,gBAAgB,CAAC,CAAC,CAAC,UAAU,CAAC,KAAK,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,mBAAmB,EAAE,CAAC;KACtF,CAAC;IACF,MAAM,IAAI,GAAG,IAAI,MAAM,CAAC,OAAO,CAAC,CAAC;IAEjC,yEAAyE;IACzE,0EAA0E;IAC1E,4EAA4E;IAC5E,2EAA2E;IAC3E,MAAM,MAAM,GAAI,IAAI,CAAC,KAAuC,CAAC,MAAM,CAAC;IACnE,IAAwC,CAAC,KAAK,GAAG,IAAI,SAAS,CAAC,MAAM,CAAC,CAAC;IAExE,OAAO,IAA6B,CAAC;AACvC,CAAC;AAED,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAE5C,OAAO,EAAE,uBAAuB,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AACtE,6EAA6E;AAC7E,OAAO,EACL,MAAM,EACN,WAAW,EACX,eAAe,EACf,eAAe,EACf,YAAY,EACZ,QAAQ,EACR,YAAY,EACZ,UAAU,GACX,MAAM,aAAa,CAAC"}
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Node-only augmentation of the `sites` namespace.
3
+ *
4
+ * `deployDir(dir)` walks a directory, computes per-file SHA-256 + size,
5
+ * builds a canonical manifest, and ships it via the v1.32 plan/commit
6
+ * transport:
7
+ *
8
+ * 1. POST /deploy/v1/plan { manifest_digest, manifest } — returns per-file
9
+ * state (`present`, `satisfied_by_plan`, or `missing` + presigned URLs).
10
+ * 2. PUT each `missing` file's bytes to its presigned S3 URL(s),
11
+ * sending `x-amz-checksum-sha256` to satisfy the signed checksum
12
+ * algorithm. Single-PUT covers small files; multipart covers large
13
+ * files (the gateway picks per-part sizing).
14
+ * 3. POST /deploy/v1/commit { plan_id } — Stage 1 (DB) returns
15
+ * synchronously with `applied`, `noop`, `copying`, or `failed`.
16
+ * `copying` triggers a poll loop on `GET /deployments/v1/:id` until
17
+ * `ready` (or `failed`).
18
+ *
19
+ * The canonicalize used to compute `manifest_digest` MUST match the
20
+ * gateway's byte-for-byte (see `./canonicalize.ts`).
21
+ *
22
+ * Imports `node:fs/promises` and so cannot run in a V8 isolate. Wired into
23
+ * the SDK via the `@run402/sdk/node` entry point only.
24
+ */
25
+ import { Sites, type SiteDeployResult } from "../namespaces/sites.js";
26
+ import { type ManifestEntry } from "./canonicalize.js";
27
+ export interface DeployDirOptions {
28
+ /** Project ID the deployment is linked to. */
29
+ project: string;
30
+ /** Local directory to walk. Paths in the manifest are relative to this root. */
31
+ dir: string;
32
+ /** Deployment target label, e.g. `"production"`. */
33
+ target?: string;
34
+ }
35
+ /** One walked file plus everything we need to hash, plan, and upload it. */
36
+ interface WalkedFile {
37
+ path: string;
38
+ size: number;
39
+ sha256: string;
40
+ content_type: string;
41
+ bytes: Buffer;
42
+ }
43
+ interface PlanFilePresent {
44
+ sha256: string;
45
+ present: true;
46
+ size: number;
47
+ content_type: string;
48
+ }
49
+ interface PlanFileSatisfied {
50
+ sha256: string;
51
+ satisfied_by_plan: true;
52
+ size: number;
53
+ content_type: string;
54
+ }
55
+ interface PlanFileMissing {
56
+ sha256: string;
57
+ missing: true;
58
+ upload_id: string;
59
+ mode: "single" | "multipart";
60
+ key: string;
61
+ staging_key: string;
62
+ part_size_bytes: number;
63
+ part_count: number;
64
+ parts: Array<{
65
+ part_number: number;
66
+ url: string;
67
+ byte_start: number;
68
+ byte_end: number;
69
+ }>;
70
+ expires_at: string;
71
+ }
72
+ type PlanFileResponse = PlanFilePresent | PlanFileSatisfied | PlanFileMissing;
73
+ interface PlanResponse {
74
+ plan_id: string;
75
+ files: PlanFileResponse[];
76
+ }
77
+ interface CommitResponse {
78
+ deployment_id: string;
79
+ url: string;
80
+ status: "applied" | "noop" | "copying" | "failed";
81
+ bytes_total?: number;
82
+ bytes_uploaded?: number;
83
+ }
84
+ /**
85
+ * Sites namespace enriched with the Node-only `deployDir` convenience.
86
+ * All existing `Sites` methods are inherited unchanged.
87
+ */
88
+ export declare class NodeSites extends Sites {
89
+ /**
90
+ * Deploy every file under `dir` as a static site. Walks the tree, hashes
91
+ * each file, plans the deploy with the gateway (which dedupes against
92
+ * already-uploaded content), uploads only the missing bytes, then
93
+ * commits.
94
+ *
95
+ * Files named `.git`, `node_modules`, or `.DS_Store` are skipped at every
96
+ * depth. Symlinks cause a {@link LocalError} — they are not followed.
97
+ */
98
+ deployDir(opts: DeployDirOptions): Promise<SiteDeployResult>;
99
+ }
100
+ /**
101
+ * Normalize a relative path to POSIX forward slashes. Exposed for tests;
102
+ * not part of the public SDK API.
103
+ */
104
+ export declare function normalizeRelPath(rel: string): string;
105
+ /** @internal — exposed for tests, not part of the public SDK API. */
106
+ export declare function _collectFilesForTest(root: string): Promise<WalkedFile[]>;
107
+ /** @internal — exposed for tests, not part of the public SDK API. */
108
+ export type { WalkedFile, PlanResponse, CommitResponse, ManifestEntry };
109
+ //# sourceMappingURL=sites-node.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sites-node.d.ts","sourceRoot":"","sources":["../../src/node/sites-node.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAKH,OAAO,EAAE,KAAK,EAAE,KAAK,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAEtE,OAAO,EAIL,KAAK,aAAa,EACnB,MAAM,mBAAmB,CAAC;AAa3B,MAAM,WAAW,gBAAgB;IAC/B,8CAA8C;IAC9C,OAAO,EAAE,MAAM,CAAC;IAChB,gFAAgF;IAChF,GAAG,EAAE,MAAM,CAAC;IACZ,oDAAoD;IACpD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,4EAA4E;AAC5E,UAAU,UAAU;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,UAAU,eAAe;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,IAAI,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;CACtB;AACD,UAAU,iBAAiB;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,iBAAiB,EAAE,IAAI,CAAC;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;CACtB;AACD,UAAU,eAAe;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,IAAI,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,QAAQ,GAAG,WAAW,CAAC;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,KAAK,CAAC;QAAE,WAAW,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACzF,UAAU,EAAE,MAAM,CAAC;CACpB;AACD,KAAK,gBAAgB,GAAG,eAAe,GAAG,iBAAiB,GAAG,eAAe,CAAC;AAE9E,UAAU,YAAY;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,gBAAgB,EAAE,CAAC;CAC3B;AAED,UAAU,cAAc;IACtB,aAAa,EAAE,MAAM,CAAC;IACtB,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,SAAS,GAAG,MAAM,GAAG,SAAS,GAAG,QAAQ,CAAC;IAClD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;GAGG;AACH,qBAAa,SAAU,SAAQ,KAAK;IAClC;;;;;;;;OAQG;IACG,SAAS,CAAC,IAAI,EAAE,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,CAAC;CAwFnE;AAoOD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEpD;AA2DD,qEAAqE;AACrE,wBAAsB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC,CAE9E;AAED,qEAAqE;AACrE,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,CAAC"}
@@ -0,0 +1,347 @@
1
+ /**
2
+ * Node-only augmentation of the `sites` namespace.
3
+ *
4
+ * `deployDir(dir)` walks a directory, computes per-file SHA-256 + size,
5
+ * builds a canonical manifest, and ships it via the v1.32 plan/commit
6
+ * transport:
7
+ *
8
+ * 1. POST /deploy/v1/plan { manifest_digest, manifest } — returns per-file
9
+ * state (`present`, `satisfied_by_plan`, or `missing` + presigned URLs).
10
+ * 2. PUT each `missing` file's bytes to its presigned S3 URL(s),
11
+ * sending `x-amz-checksum-sha256` to satisfy the signed checksum
12
+ * algorithm. Single-PUT covers small files; multipart covers large
13
+ * files (the gateway picks per-part sizing).
14
+ * 3. POST /deploy/v1/commit { plan_id } — Stage 1 (DB) returns
15
+ * synchronously with `applied`, `noop`, `copying`, or `failed`.
16
+ * `copying` triggers a poll loop on `GET /deployments/v1/:id` until
17
+ * `ready` (or `failed`).
18
+ *
19
+ * The canonicalize used to compute `manifest_digest` MUST match the
20
+ * gateway's byte-for-byte (see `./canonicalize.ts`).
21
+ *
22
+ * Imports `node:fs/promises` and so cannot run in a V8 isolate. Wired into
23
+ * the SDK via the `@run402/sdk/node` entry point only.
24
+ */
25
+ import { readdir, readFile, lstat } from "node:fs/promises";
26
+ import { join, relative, sep } from "node:path";
27
+ import { Sites } from "../namespaces/sites.js";
28
+ import { ApiError, LocalError, Run402Error } from "../errors.js";
29
+ import { buildCanonicalManifest, computeManifestDigest, } from "./canonicalize.js";
30
+ const DEFAULT_IGNORE = new Set([".git", "node_modules", ".DS_Store"]);
31
+ const CONTEXT = "deploying directory";
32
+ /** Cap on total time spent waiting for Stage 2 copy to drain. */
33
+ const COPY_POLL_TIMEOUT_MS = 10 * 60 * 1000;
34
+ /** Initial poll interval; we hold this for ~30 s, then back off. */
35
+ const COPY_POLL_INITIAL_MS = 1_000;
36
+ const COPY_POLL_MAX_MS = 30_000;
37
+ /** Time after which we re-call /deploy/v1/plan to refresh presigned URLs (1 h TTL). */
38
+ const URL_REFRESH_AT_MS = 50 * 60 * 1000;
39
+ /**
40
+ * Sites namespace enriched with the Node-only `deployDir` convenience.
41
+ * All existing `Sites` methods are inherited unchanged.
42
+ */
43
+ export class NodeSites extends Sites {
44
+ /**
45
+ * Deploy every file under `dir` as a static site. Walks the tree, hashes
46
+ * each file, plans the deploy with the gateway (which dedupes against
47
+ * already-uploaded content), uploads only the missing bytes, then
48
+ * commits.
49
+ *
50
+ * Files named `.git`, `node_modules`, or `.DS_Store` are skipped at every
51
+ * depth. Symlinks cause a {@link LocalError} — they are not followed.
52
+ */
53
+ async deployDir(opts) {
54
+ const files = await collectFiles(opts.dir);
55
+ if (files.length === 0) {
56
+ throw new LocalError(`directory ${opts.dir} contains no deployable files`, CONTEXT);
57
+ }
58
+ const manifest = buildCanonicalManifest(files.map((f) => ({
59
+ path: f.path,
60
+ sha256: f.sha256,
61
+ size: f.size,
62
+ content_type: f.content_type,
63
+ })));
64
+ const manifestDigest = await computeManifestDigest(manifest);
65
+ // Map sha → bytes so we can satisfy the plan response without re-walking.
66
+ // Multiple paths may share the same sha (identical files); any copy works.
67
+ const bytesBySha = new Map();
68
+ for (const f of files)
69
+ bytesBySha.set(f.sha256, f.bytes);
70
+ // Reach through to the kernel client. `Sites.client` is `private` in TS
71
+ // but enumerable at runtime; the cast bypasses the visibility check.
72
+ const client = this.client;
73
+ const planClient = new ClientFromBase(client);
74
+ const plan = await planClient.requestPlan(opts.project, manifest, manifestDigest);
75
+ const planAt = Date.now();
76
+ let activePlan = plan;
77
+ let activePlanAt = planAt;
78
+ for (const entry of activePlan.files) {
79
+ if (!isMissing(entry))
80
+ continue;
81
+ // Refresh the plan if the URL TTL window is about to close.
82
+ if (Date.now() - activePlanAt > URL_REFRESH_AT_MS) {
83
+ activePlan = await planClient.requestPlan(opts.project, manifest, manifestDigest);
84
+ activePlanAt = Date.now();
85
+ }
86
+ const refreshed = activePlan.files.find((e) => e.sha256 === entry.sha256);
87
+ const target = refreshed && isMissing(refreshed) ? refreshed : entry;
88
+ const bytes = bytesBySha.get(target.sha256);
89
+ if (!bytes) {
90
+ throw new LocalError(`internal: no local bytes for sha ${target.sha256.slice(0, 12)}…`, CONTEXT);
91
+ }
92
+ try {
93
+ await uploadOne(client.fetch, target, bytes);
94
+ }
95
+ catch (err) {
96
+ // S3 returns 403 when the presigned URL has expired. Refresh once.
97
+ if (err instanceof ApiError && err.status === 403) {
98
+ activePlan = await planClient.requestPlan(opts.project, manifest, manifestDigest);
99
+ activePlanAt = Date.now();
100
+ const fresh = activePlan.files.find((e) => e.sha256 === target.sha256);
101
+ if (fresh && isMissing(fresh)) {
102
+ await uploadOne(client.fetch, fresh, bytes);
103
+ }
104
+ else {
105
+ throw err;
106
+ }
107
+ }
108
+ else {
109
+ throw err;
110
+ }
111
+ }
112
+ }
113
+ const commit = await planClient.commit(opts.project, activePlan.plan_id);
114
+ if (commit.status === "applied" || commit.status === "noop") {
115
+ return shapeResult(commit);
116
+ }
117
+ if (commit.status === "copying") {
118
+ const final = await pollUntilReady(this, commit.deployment_id);
119
+ return { deployment_id: commit.deployment_id, url: commit.url, ...final };
120
+ }
121
+ // status === "failed": stage 2 exhausted retries. Surface as ApiError so
122
+ // callers can decide whether to re-call deployDir (which re-commits).
123
+ throw new ApiError(`Deploy commit failed for plan ${activePlan.plan_id} after copy retries`, 500, commit, "committing deploy");
124
+ }
125
+ }
126
+ class ClientFromBase {
127
+ client;
128
+ constructor(client) {
129
+ this.client = client;
130
+ }
131
+ async requestPlan(projectId, manifest, manifestDigest) {
132
+ return this.client.request("/deploy/v1/plan", {
133
+ method: "POST",
134
+ body: { project: projectId, manifest_digest: manifestDigest, manifest },
135
+ context: "planning deploy",
136
+ });
137
+ }
138
+ async commit(projectId, planId) {
139
+ return this.client.request("/deploy/v1/commit", {
140
+ method: "POST",
141
+ body: { project: projectId, plan_id: planId },
142
+ context: "committing deploy",
143
+ });
144
+ }
145
+ }
146
+ function isMissing(entry) {
147
+ return entry.missing === true;
148
+ }
149
+ function shapeResult(commit) {
150
+ const out = { deployment_id: commit.deployment_id, url: commit.url };
151
+ if (commit.bytes_total !== undefined)
152
+ out.bytes_total = commit.bytes_total;
153
+ if (commit.bytes_uploaded !== undefined)
154
+ out.bytes_uploaded = commit.bytes_uploaded;
155
+ return out;
156
+ }
157
+ // ─── Upload ──────────────────────────────────────────────────────────────────
158
+ async function uploadOne(fetchFn, entry, bytes) {
159
+ if (entry.mode === "single") {
160
+ if (entry.parts.length !== 1) {
161
+ throw new LocalError(`internal: single-mode upload for ${entry.sha256.slice(0, 12)}… returned ${entry.parts.length} parts`, CONTEXT);
162
+ }
163
+ const part = entry.parts[0];
164
+ const slice = bytes.subarray(part.byte_start, part.byte_end + 1);
165
+ // For single-PUT, the URL pre-commits the whole-object SHA — we send
166
+ // the same value (in base64, not hex) on the PUT.
167
+ const checksum = base64FromHex(entry.sha256);
168
+ await putToS3(fetchFn, part.url, slice, checksum, part.part_number);
169
+ return;
170
+ }
171
+ // multipart: each part PUT carries its own per-part SHA-256 base64.
172
+ for (const part of entry.parts) {
173
+ const slice = bytes.subarray(part.byte_start, part.byte_end + 1);
174
+ const checksum = await sha256Base64(slice);
175
+ await putToS3(fetchFn, part.url, slice, checksum, part.part_number);
176
+ }
177
+ }
178
+ async function putToS3(fetchFn, url, body, checksumBase64, partNumber) {
179
+ let res;
180
+ try {
181
+ res = await fetchFn(url, {
182
+ method: "PUT",
183
+ headers: { "x-amz-checksum-sha256": checksumBase64 },
184
+ body: body,
185
+ });
186
+ }
187
+ catch (err) {
188
+ throw new ApiError(`S3 PUT failed for part ${partNumber}: ${err.message}`, 0, null, "uploading deploy bytes");
189
+ }
190
+ if (!res.ok) {
191
+ const text = await res.text().catch(() => "");
192
+ throw new ApiError(`S3 PUT failed for part ${partNumber} (HTTP ${res.status})${text ? ": " + text.slice(0, 200) : ""}`, res.status, text, "uploading deploy bytes");
193
+ }
194
+ }
195
+ async function pollUntilReady(sites, deploymentId) {
196
+ const start = Date.now();
197
+ let interval = COPY_POLL_INITIAL_MS;
198
+ while (Date.now() - start < COPY_POLL_TIMEOUT_MS) {
199
+ await sleep(interval);
200
+ let info;
201
+ try {
202
+ info = await sites.getDeployment(deploymentId);
203
+ }
204
+ catch (err) {
205
+ // Transient lookup failure: keep polling unless we're a hard error.
206
+ if (err instanceof Run402Error && err.status !== null && err.status >= 500) {
207
+ continue;
208
+ }
209
+ throw err;
210
+ }
211
+ if (info.status === "ready" || info.status === "applied") {
212
+ return { status: info.status, url: info.url };
213
+ }
214
+ if (info.status === "failed") {
215
+ throw new ApiError(`Deployment ${deploymentId} entered failed state during copy`, 500, info, "polling deploy");
216
+ }
217
+ // Hold the initial cadence for the first 30 s, then back off to a max.
218
+ if (Date.now() - start > 30_000) {
219
+ interval = Math.min(Math.floor(interval * 1.5), COPY_POLL_MAX_MS);
220
+ }
221
+ }
222
+ throw new ApiError(`Timed out waiting for deployment ${deploymentId} to reach ready (${COPY_POLL_TIMEOUT_MS / 60_000} min)`, 504, null, "polling deploy");
223
+ }
224
+ function sleep(ms) {
225
+ return new Promise((resolve) => setTimeout(resolve, ms));
226
+ }
227
+ // ─── Filesystem walk + hashing ───────────────────────────────────────────────
228
+ async function collectFiles(root) {
229
+ let rootStat;
230
+ try {
231
+ rootStat = await lstat(root);
232
+ }
233
+ catch (err) {
234
+ throw new LocalError(`cannot read directory ${root}: ${err.message}`, CONTEXT, err);
235
+ }
236
+ if (rootStat.isSymbolicLink()) {
237
+ throw new LocalError(`symlink found at ${root} (following symlinks is not supported)`, CONTEXT);
238
+ }
239
+ if (!rootStat.isDirectory()) {
240
+ throw new LocalError(`path ${root} is not a directory`, CONTEXT);
241
+ }
242
+ const out = [];
243
+ await walkInto(root, root, out);
244
+ return out;
245
+ }
246
+ async function walkInto(root, current, out) {
247
+ let entries;
248
+ try {
249
+ entries = await readdir(current, { withFileTypes: true });
250
+ }
251
+ catch (err) {
252
+ throw new LocalError(`cannot read directory ${current}: ${err.message}`, CONTEXT, err);
253
+ }
254
+ for (const entry of entries) {
255
+ if (DEFAULT_IGNORE.has(entry.name))
256
+ continue;
257
+ const fullPath = join(current, entry.name);
258
+ if (entry.isSymbolicLink()) {
259
+ throw new LocalError(`symlink found at ${fullPath} (following symlinks is not supported)`, CONTEXT);
260
+ }
261
+ if (entry.isDirectory()) {
262
+ await walkInto(root, fullPath, out);
263
+ continue;
264
+ }
265
+ if (entry.isFile()) {
266
+ let bytes;
267
+ try {
268
+ bytes = await readFile(fullPath);
269
+ }
270
+ catch (err) {
271
+ throw new LocalError(`cannot read file ${fullPath}: ${err.message}`, CONTEXT, err);
272
+ }
273
+ const rel = normalizeRelPath(relative(root, fullPath));
274
+ out.push({
275
+ path: rel,
276
+ size: bytes.byteLength,
277
+ sha256: await sha256Hex(bytes),
278
+ content_type: guessContentType(rel),
279
+ bytes,
280
+ });
281
+ }
282
+ }
283
+ }
284
+ /**
285
+ * Normalize a relative path to POSIX forward slashes. Exposed for tests;
286
+ * not part of the public SDK API.
287
+ */
288
+ export function normalizeRelPath(rel) {
289
+ return sep === "/" ? rel : rel.split(sep).join("/");
290
+ }
291
+ async function sha256Hex(bytes) {
292
+ const hash = await crypto.subtle.digest("SHA-256", bytes);
293
+ return Array.from(new Uint8Array(hash))
294
+ .map((b) => b.toString(16).padStart(2, "0"))
295
+ .join("");
296
+ }
297
+ async function sha256Base64(bytes) {
298
+ const hash = await crypto.subtle.digest("SHA-256", bytes);
299
+ return Buffer.from(hash).toString("base64");
300
+ }
301
+ function base64FromHex(hex) {
302
+ if (!/^[0-9a-f]*$/i.test(hex) || hex.length % 2 !== 0) {
303
+ throw new LocalError(`invalid hex sha256: ${hex}`, CONTEXT);
304
+ }
305
+ return Buffer.from(hex, "hex").toString("base64");
306
+ }
307
+ const CONTENT_TYPES = {
308
+ html: "text/html; charset=utf-8",
309
+ htm: "text/html; charset=utf-8",
310
+ css: "text/css; charset=utf-8",
311
+ js: "application/javascript; charset=utf-8",
312
+ mjs: "application/javascript; charset=utf-8",
313
+ json: "application/json; charset=utf-8",
314
+ svg: "image/svg+xml",
315
+ png: "image/png",
316
+ jpg: "image/jpeg",
317
+ jpeg: "image/jpeg",
318
+ gif: "image/gif",
319
+ webp: "image/webp",
320
+ ico: "image/x-icon",
321
+ woff: "font/woff",
322
+ woff2: "font/woff2",
323
+ ttf: "font/ttf",
324
+ eot: "application/vnd.ms-fontobject",
325
+ otf: "font/otf",
326
+ txt: "text/plain; charset=utf-8",
327
+ md: "text/markdown; charset=utf-8",
328
+ xml: "application/xml",
329
+ pdf: "application/pdf",
330
+ wasm: "application/wasm",
331
+ map: "application/json; charset=utf-8",
332
+ };
333
+ function guessContentType(path) {
334
+ const dot = path.lastIndexOf(".");
335
+ if (dot === -1)
336
+ return "application/octet-stream";
337
+ const ext = path.slice(dot + 1).toLowerCase();
338
+ return CONTENT_TYPES[ext] ?? "application/octet-stream";
339
+ }
340
+ // ─── Test-only exports ───────────────────────────────────────────────────────
341
+ // Kept exported so the unit tests can drive the walk + hash logic without
342
+ // having to spin up an HTTP mock for every concern.
343
+ /** @internal — exposed for tests, not part of the public SDK API. */
344
+ export async function _collectFilesForTest(root) {
345
+ return collectFiles(root);
346
+ }
347
+ //# sourceMappingURL=sites-node.js.map