@constructive-io/site-deploy 0.1.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.
package/manifest.d.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Manifest construction: the site-specific half of a deploy.
3
+ *
4
+ * The bytes of a build are content-addressed and say nothing about how the
5
+ * site is served; the manifest is what turns a bag of hashes into a site, by
6
+ * mapping each logical path to the hash, content type and size the edge
7
+ * serves for it.
8
+ */
9
+ import type { DeployFile, ReleaseManifest } from './types';
10
+ /** Prefix every file's bytes are stored under, in the site's own bucket. */
11
+ export declare const CAS_PREFIX = "cas/sha256/";
12
+ /** The one legal shape of a CAS key. */
13
+ export declare const CAS_KEY_PATTERN: RegExp;
14
+ /** The content-addressed bucket key holding the bytes with this hash. */
15
+ export declare function casKey(hash: string): string;
16
+ /** SHA-256 of some bytes, hex — the CAS address and the served ETag. */
17
+ export declare function hashBytes(bytes: Uint8Array): string;
18
+ /**
19
+ * Normalize a logical path to the form the gateway looks up.
20
+ *
21
+ * Windows separators become `/` and a leading `./` or `/` is dropped, so the
22
+ * same build deploys identically from any OS. Anything that would escape the
23
+ * site root or collide with the CAS namespace is a hard error — a silently
24
+ * dropped file is a silently broken site.
25
+ */
26
+ export declare function normalizePath(path: string): string;
27
+ /** A hashed file, ready to be uploaded and named in a manifest. */
28
+ export interface HashedFile {
29
+ /** Normalized logical path. */
30
+ path: string;
31
+ /** Content-addressed bucket key for these bytes. */
32
+ key: string;
33
+ hash: string;
34
+ contentType: string;
35
+ size: number;
36
+ bytes: Uint8Array;
37
+ }
38
+ /** Hash one file and resolve its content type. */
39
+ export declare function hashFile(file: DeployFile, contentTypes?: Record<string, string>): HashedFile;
40
+ /**
41
+ * Build the release manifest from hashed files.
42
+ *
43
+ * `file_count`/`total_bytes` are derived here rather than trusted from a
44
+ * caller, so they can never disagree with `files`.
45
+ */
46
+ export declare function buildManifest(files: Iterable<HashedFile>): ReleaseManifest;
47
+ /** Whether two manifests would serve byte-identical sites. */
48
+ export declare function manifestsEqual(a: ReleaseManifest, b: ReleaseManifest): boolean;
package/manifest.js ADDED
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ /**
3
+ * Manifest construction: the site-specific half of a deploy.
4
+ *
5
+ * The bytes of a build are content-addressed and say nothing about how the
6
+ * site is served; the manifest is what turns a bag of hashes into a site, by
7
+ * mapping each logical path to the hash, content type and size the edge
8
+ * serves for it.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.CAS_KEY_PATTERN = exports.CAS_PREFIX = void 0;
12
+ exports.casKey = casKey;
13
+ exports.hashBytes = hashBytes;
14
+ exports.normalizePath = normalizePath;
15
+ exports.hashFile = hashFile;
16
+ exports.buildManifest = buildManifest;
17
+ exports.manifestsEqual = manifestsEqual;
18
+ const sha2_1 = require("@decryption/hashes/sha2");
19
+ const utils_1 = require("@decryption/hashes/utils");
20
+ const content_type_1 = require("./content-type");
21
+ const types_1 = require("./types");
22
+ /** Prefix every file's bytes are stored under, in the site's own bucket. */
23
+ exports.CAS_PREFIX = 'cas/sha256/';
24
+ /** The one legal shape of a CAS key. */
25
+ exports.CAS_KEY_PATTERN = /^cas\/sha256\/[0-9a-f]{64}$/;
26
+ /** The content-addressed bucket key holding the bytes with this hash. */
27
+ function casKey(hash) {
28
+ return `${exports.CAS_PREFIX}${hash}`;
29
+ }
30
+ /** SHA-256 of some bytes, hex — the CAS address and the served ETag. */
31
+ function hashBytes(bytes) {
32
+ return (0, utils_1.bytesToHex)((0, sha2_1.sha256)(bytes));
33
+ }
34
+ /**
35
+ * Normalize a logical path to the form the gateway looks up.
36
+ *
37
+ * Windows separators become `/` and a leading `./` or `/` is dropped, so the
38
+ * same build deploys identically from any OS. Anything that would escape the
39
+ * site root or collide with the CAS namespace is a hard error — a silently
40
+ * dropped file is a silently broken site.
41
+ */
42
+ function normalizePath(path) {
43
+ const normalized = path.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
44
+ if (!normalized) {
45
+ throw new types_1.DeployError('INVALID_PATH', `Empty logical path (from "${path}")`);
46
+ }
47
+ if (normalized.split('/').some((segment) => segment === '..' || segment === '.')) {
48
+ throw new types_1.DeployError('INVALID_PATH', `Logical path must not contain "." or ".." segments: "${path}"`);
49
+ }
50
+ if (normalized.startsWith(exports.CAS_PREFIX)) {
51
+ throw new types_1.DeployError('INVALID_PATH', `Logical path must not start with "${exports.CAS_PREFIX}" (that is the byte namespace): "${path}"`);
52
+ }
53
+ return normalized;
54
+ }
55
+ /** Hash one file and resolve its content type. */
56
+ function hashFile(file, contentTypes) {
57
+ const path = normalizePath(file.path);
58
+ const hash = hashBytes(file.bytes);
59
+ return {
60
+ path,
61
+ key: casKey(hash),
62
+ hash,
63
+ contentType: file.contentType ?? (0, content_type_1.contentTypeFor)(path, contentTypes),
64
+ size: file.bytes.byteLength,
65
+ bytes: file.bytes,
66
+ };
67
+ }
68
+ /**
69
+ * Build the release manifest from hashed files.
70
+ *
71
+ * `file_count`/`total_bytes` are derived here rather than trusted from a
72
+ * caller, so they can never disagree with `files`.
73
+ */
74
+ function buildManifest(files) {
75
+ const entries = {};
76
+ let totalBytes = 0;
77
+ let count = 0;
78
+ for (const file of files) {
79
+ if (entries[file.path]) {
80
+ throw new types_1.DeployError('INVALID_PATH', `Duplicate logical path: "${file.path}"`);
81
+ }
82
+ entries[file.path] = {
83
+ hash: file.hash,
84
+ content_type: file.contentType,
85
+ size: file.size,
86
+ };
87
+ totalBytes += file.size;
88
+ count += 1;
89
+ }
90
+ if (count === 0) {
91
+ throw new types_1.DeployError('EMPTY_SOURCE', 'Refusing to release an empty manifest');
92
+ }
93
+ return { files: entries, file_count: count, total_bytes: totalBytes };
94
+ }
95
+ /** Whether two manifests would serve byte-identical sites. */
96
+ function manifestsEqual(a, b) {
97
+ const aPaths = Object.keys(a.files);
98
+ const bPaths = Object.keys(b.files);
99
+ if (aPaths.length !== bPaths.length)
100
+ return false;
101
+ return aPaths.every((path) => {
102
+ const left = a.files[path];
103
+ const right = b.files[path];
104
+ return (right !== undefined &&
105
+ left.hash === right.hash &&
106
+ left.content_type === right.content_type &&
107
+ left.size === right.size);
108
+ });
109
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@constructive-io/site-deploy",
3
+ "version": "0.1.0",
4
+ "author": "Constructive <developers@constructive.io>",
5
+ "description": "Deploy a static build as an immutable Constructive release: hash, content-addressed upload with dedupe, one manifest commit, publish/rollback and preview refs",
6
+ "main": "index.js",
7
+ "module": "esm/index.js",
8
+ "types": "index.d.ts",
9
+ "homepage": "https://github.com/constructive-io/constructive",
10
+ "license": "MIT",
11
+ "publishConfig": {
12
+ "access": "public",
13
+ "directory": "dist"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/constructive-io/constructive"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/constructive-io/constructive/issues"
21
+ },
22
+ "scripts": {
23
+ "clean": "makage clean",
24
+ "prepack": "npm run build",
25
+ "build": "makage build",
26
+ "build:dev": "makage build --dev",
27
+ "lint": "eslint . --fix",
28
+ "test": "jest",
29
+ "test:watch": "jest --watch"
30
+ },
31
+ "dependencies": {
32
+ "@constructive-io/upload-client": "^0.27.1",
33
+ "@decryption/hashes": "^0.4.0"
34
+ },
35
+ "devDependencies": {
36
+ "makage": "^0.3.0"
37
+ },
38
+ "keywords": [
39
+ "constructive",
40
+ "static-site",
41
+ "deploy",
42
+ "release",
43
+ "preview",
44
+ "content-addressed"
45
+ ],
46
+ "gitHead": "a0b996a60ff2e9b88ade4362ad494cfa7b498ab6"
47
+ }
package/types.d.ts ADDED
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Types for the site deploy pipeline.
3
+ *
4
+ * A deploy is bytes plus one manifest: every file's bytes live in the site's
5
+ * bucket at `cas/sha256/<hash>`, and one `siteRelease` row maps logical paths
6
+ * to those hashes. Writing that row commits it into the site's own merkle
7
+ * store, so the returned commit id *is* the release id.
8
+ */
9
+ /**
10
+ * Executes a GraphQL document and returns its `data`, throwing on GraphQL
11
+ * errors. The only integration point with a GraphQL client — works with the
12
+ * generated SDK, urql, Apollo or plain fetch.
13
+ */
14
+ export type GraphQLExecutor = (query: string, variables: Record<string, unknown>) => Promise<Record<string, unknown>>;
15
+ /** One file of a build, as the caller hands it over. */
16
+ export interface DeployFile {
17
+ /** Logical path served by the site, POSIX-relative (`assets/app.js`). */
18
+ path: string;
19
+ /** The file's bytes. */
20
+ bytes: Uint8Array;
21
+ /** MIME type; inferred from the extension when omitted. */
22
+ contentType?: string;
23
+ }
24
+ /** A build to deploy: a directory path (Node) or the files themselves. */
25
+ export type DeploySource = string | Iterable<DeployFile> | AsyncIterable<DeployFile>;
26
+ /** One manifest entry: where the bytes are and how to serve them. */
27
+ export interface ManifestEntry {
28
+ /** SHA-256 of the bytes, hex — the CAS address and the served ETag. */
29
+ hash: string;
30
+ content_type: string;
31
+ size: number;
32
+ }
33
+ /** The release manifest, exactly as the gateway reads it. */
34
+ export interface ReleaseManifest {
35
+ files: Record<string, ManifestEntry>;
36
+ file_count: number;
37
+ total_bytes: number;
38
+ }
39
+ /**
40
+ * GraphQL field names of one scope's deploy surface.
41
+ *
42
+ * Every name is generated per scope: unprefixed for the tenant (`database`)
43
+ * scope, `platform`-prefixed for the self-hosted lane. Override individual
44
+ * names when a plane's inflection differs.
45
+ */
46
+ export interface SiteDeployNames {
47
+ /** Bulk presigned-upload mutation, e.g. `uploadFiles`. */
48
+ bulkUploadMutation: string;
49
+ /** Site release collection query, e.g. `siteReleases`. */
50
+ releasesQuery: string;
51
+ /** Create mutation for the release row, e.g. `createSiteRelease`. */
52
+ createRelease: string;
53
+ /** Input field holding the new row, e.g. `siteRelease`. */
54
+ releaseInputField: string;
55
+ /** Update mutation for the release row, e.g. `updateSiteRelease`. */
56
+ updateRelease: string;
57
+ /** Patch field on the update input, e.g. `siteReleasePatch`. */
58
+ releasePatchField: string;
59
+ /** Payload field holding the row, e.g. `siteRelease`. */
60
+ releasePayloadField: string;
61
+ /** Site update mutation, e.g. `updateSite`. */
62
+ updateSite: string;
63
+ /** Patch field on the site update input, e.g. `sitePatch`. */
64
+ sitePatchField: string;
65
+ /** Payload field holding the site, e.g. `site`. */
66
+ sitePayloadField: string;
67
+ /** Preview provisioning mutation, e.g. `provisionSitePreview`. */
68
+ provisionPreview: string;
69
+ /** Preview ref move mutation, e.g. `setSitePreview`. */
70
+ setPreview: string;
71
+ }
72
+ /** Progress events, in the order a deploy emits them. */
73
+ export type DeployProgress = {
74
+ type: 'hashed';
75
+ path: string;
76
+ hash: string;
77
+ size: number;
78
+ index: number;
79
+ } | {
80
+ type: 'diffed';
81
+ files: number;
82
+ toUpload: number;
83
+ skipped: number;
84
+ bytesToUpload: number;
85
+ } | {
86
+ type: 'uploaded';
87
+ path: string;
88
+ key: string;
89
+ size: number;
90
+ done: number;
91
+ total: number;
92
+ } | {
93
+ type: 'manifest';
94
+ commitId: string;
95
+ storeId: string;
96
+ created: boolean;
97
+ } | {
98
+ type: 'unchanged';
99
+ commitId: string;
100
+ } | {
101
+ type: 'published';
102
+ commitId: string;
103
+ } | {
104
+ type: 'preview';
105
+ name: string;
106
+ commitId: string;
107
+ url: string | null;
108
+ };
109
+ /** PUT bytes to a presigned URL. Injectable so tests need no network. */
110
+ export type PutObject = (url: string, body: Uint8Array, contentType: string, signal?: AbortSignal) => Promise<void>;
111
+ export interface DeploySiteOptions {
112
+ /** Executor for the tenant API target (releases, sites, previews). */
113
+ api: GraphQLExecutor;
114
+ /** Executor for the storage target. Defaults to `api`. */
115
+ storage?: GraphQLExecutor;
116
+ /** The site to deploy to. */
117
+ siteId: string;
118
+ /**
119
+ * Database that owns the release row. Required on the first deploy of a
120
+ * site at `database` scope, where the column is NOT NULL.
121
+ */
122
+ databaseId?: string;
123
+ /** Bucket key of the site's bucket (`allow_custom_keys` must be on). */
124
+ bucketKey: string;
125
+ /** The build: a directory path, or the files themselves. */
126
+ source: DeploySource;
127
+ /** Which generated surface to talk to. Defaults to `database`. */
128
+ scope?: 'database' | 'platform';
129
+ /** Override individual generated field names. */
130
+ names?: Partial<SiteDeployNames>;
131
+ /** Move `site.activeCommitId` to the new release when it lands. */
132
+ publish?: boolean;
133
+ /** Also point `preview/<name>` at the new release. */
134
+ preview?: string;
135
+ /** Apex for a newly provisioned preview hostname. */
136
+ previewApex?: string;
137
+ /** Parallel PUTs. Defaults to 8. */
138
+ concurrency?: number;
139
+ /** Files per upload mutation. Defaults to 100 (the server's batch cap). */
140
+ batchSize?: number;
141
+ /** Retries per PUT on a network/5xx failure. Defaults to 2. */
142
+ retries?: number;
143
+ /** Return the live release untouched when the tree already matches it. */
144
+ skipIfUnchanged?: boolean;
145
+ /** Hash and diff only: upload nothing, write no manifest. */
146
+ dryRun?: boolean;
147
+ /** Skip paths — receives the logical path. */
148
+ ignore?: (path: string) => boolean;
149
+ /** Extra or overriding extension → MIME mappings (keys without the dot). */
150
+ contentTypes?: Record<string, string>;
151
+ /** Progress callback. */
152
+ onProgress?: (event: DeployProgress) => void;
153
+ /** Cancellation. */
154
+ signal?: AbortSignal;
155
+ /** PUT implementation. Defaults to a presigned fetch PUT. */
156
+ putObject?: PutObject;
157
+ }
158
+ export interface DeploySiteResult {
159
+ /** The release commit — the immutable id of this deploy. */
160
+ commitId: string;
161
+ /** Merkle store holding the site's release history. */
162
+ storeId: string;
163
+ /** The `siteRelease` row id. */
164
+ releaseId: string;
165
+ /** The manifest that was committed. */
166
+ manifest: ReleaseManifest;
167
+ /** Files in the manifest. */
168
+ files: number;
169
+ /** Files whose bytes were PUT. */
170
+ uploaded: number;
171
+ /** Files the server already had. */
172
+ skipped: number;
173
+ /** Bytes actually PUT. */
174
+ bytesUploaded: number;
175
+ /** Whether `site.activeCommitId` now points at this release. */
176
+ published: boolean;
177
+ /** Preview hostname, when a preview was provisioned with an apex. */
178
+ previewUrl: string | null;
179
+ /** True when `skipIfUnchanged` matched and nothing was written. */
180
+ unchanged: boolean;
181
+ }
182
+ export type DeployErrorCode = 'ABORTED' | 'EMPTY_SOURCE' | 'GRAPHQL_ERROR' | 'INVALID_PATH' | 'INVALID_PREVIEW_NAME' | 'KEY_MISMATCH' | 'MISSING_DATABASE_ID' | 'PREVIEW_FAILED' | 'PUBLISH_FAILED' | 'RELEASE_NOT_VERSIONED' | 'UPLOAD_FAILED';
183
+ /**
184
+ * A deploy failure. Never swallowed and never partially reported: if this
185
+ * throws before the manifest write, production is untouched.
186
+ */
187
+ export declare class DeployError extends Error {
188
+ readonly code: DeployErrorCode;
189
+ readonly cause?: unknown;
190
+ constructor(code: DeployErrorCode, message: string, cause?: unknown);
191
+ }
package/types.js ADDED
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ /**
3
+ * Types for the site deploy pipeline.
4
+ *
5
+ * A deploy is bytes plus one manifest: every file's bytes live in the site's
6
+ * bucket at `cas/sha256/<hash>`, and one `siteRelease` row maps logical paths
7
+ * to those hashes. Writing that row commits it into the site's own merkle
8
+ * store, so the returned commit id *is* the release id.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.DeployError = void 0;
12
+ /**
13
+ * A deploy failure. Never swallowed and never partially reported: if this
14
+ * throws before the manifest write, production is untouched.
15
+ */
16
+ class DeployError extends Error {
17
+ code;
18
+ cause;
19
+ constructor(code, message, cause) {
20
+ super(message);
21
+ this.name = 'DeployError';
22
+ this.code = code;
23
+ this.cause = cause;
24
+ }
25
+ }
26
+ exports.DeployError = DeployError;
package/walk.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Reading a build off disk.
3
+ *
4
+ * Node-only and loaded lazily, so the package stays usable in a browser (a UI
5
+ * that already holds the built files in memory passes them directly instead of
6
+ * a directory path).
7
+ */
8
+ import type { DeployFile } from './types';
9
+ /**
10
+ * Yield every file under `dir` with its POSIX path relative to `dir`.
11
+ *
12
+ * Dotfiles are included: a static build legitimately ships `.well-known/…`,
13
+ * and dropping files by a rule the caller cannot see is how a deploy silently
14
+ * loses a route. Use `ignore` to exclude paths explicitly.
15
+ */
16
+ export declare function walkDirectory(dir: string, ignore?: (path: string) => boolean): AsyncGenerator<DeployFile>;
package/walk.js ADDED
@@ -0,0 +1,85 @@
1
+ "use strict";
2
+ /**
3
+ * Reading a build off disk.
4
+ *
5
+ * Node-only and loaded lazily, so the package stays usable in a browser (a UI
6
+ * that already holds the built files in memory passes them directly instead of
7
+ * a directory path).
8
+ */
9
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ var desc = Object.getOwnPropertyDescriptor(m, k);
12
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
13
+ desc = { enumerable: true, get: function() { return m[k]; } };
14
+ }
15
+ Object.defineProperty(o, k2, desc);
16
+ }) : (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ o[k2] = m[k];
19
+ }));
20
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
21
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
22
+ }) : function(o, v) {
23
+ o["default"] = v;
24
+ });
25
+ var __importStar = (this && this.__importStar) || (function () {
26
+ var ownKeys = function(o) {
27
+ ownKeys = Object.getOwnPropertyNames || function (o) {
28
+ var ar = [];
29
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
30
+ return ar;
31
+ };
32
+ return ownKeys(o);
33
+ };
34
+ return function (mod) {
35
+ if (mod && mod.__esModule) return mod;
36
+ var result = {};
37
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
38
+ __setModuleDefault(result, mod);
39
+ return result;
40
+ };
41
+ })();
42
+ Object.defineProperty(exports, "__esModule", { value: true });
43
+ exports.walkDirectory = walkDirectory;
44
+ const types_1 = require("./types");
45
+ /**
46
+ * Yield every file under `dir` with its POSIX path relative to `dir`.
47
+ *
48
+ * Dotfiles are included: a static build legitimately ships `.well-known/…`,
49
+ * and dropping files by a rule the caller cannot see is how a deploy silently
50
+ * loses a route. Use `ignore` to exclude paths explicitly.
51
+ */
52
+ async function* walkDirectory(dir, ignore) {
53
+ const { readdir, readFile } = await loadFs();
54
+ const { join, posix } = await loadPath();
55
+ async function* walk(absolute, prefix) {
56
+ const entries = await readdir(absolute, { withFileTypes: true });
57
+ for (const entry of entries) {
58
+ const logical = prefix ? posix.join(prefix, entry.name) : entry.name;
59
+ if (ignore?.(logical))
60
+ continue;
61
+ const child = join(absolute, entry.name);
62
+ if (entry.isDirectory()) {
63
+ yield* walk(child, logical);
64
+ continue;
65
+ }
66
+ // Symlinks to files are followed by readFile; anything that is neither a
67
+ // file nor a directory (socket, fifo) has no meaning in a static site.
68
+ if (!entry.isFile() && !entry.isSymbolicLink())
69
+ continue;
70
+ yield { path: logical, bytes: new Uint8Array(await readFile(child)) };
71
+ }
72
+ }
73
+ yield* walk(dir, '');
74
+ }
75
+ async function loadFs() {
76
+ try {
77
+ return await Promise.resolve().then(() => __importStar(require('fs/promises')));
78
+ }
79
+ catch (err) {
80
+ throw new types_1.DeployError('INVALID_PATH', 'Deploying from a directory path requires Node; pass the files directly instead', err);
81
+ }
82
+ }
83
+ async function loadPath() {
84
+ return Promise.resolve().then(() => __importStar(require('path')));
85
+ }