Skip to content

Commit fd0613b

Browse files
authored
Merge pull request #1776 from constructive-io/feat/site-deploy-library
feat(site-deploy): deploy a static build as an immutable release
2 parents df6b661 + b629170 commit fd0613b

31 files changed

Lines changed: 5275 additions & 7066 deletions

‎.github/workflows/run-tests.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ jobs:
107107
- batch: uploads
108108
packages: 'uploads/mime-bytes uploads/uuid-hash uploads/uuid-stream uploads/etag-hash uploads/etag-stream uploads/stream-to-etag uploads/content-type-stream uploads/upload-names uploads/s3-utils'
109109
- batch: packages-core
110-
packages: 'packages/url-domains packages/coerce packages/csrf packages/oauth packages/12factor-env packages/orm packages/express-context packages/errors packages/llm-env packages/node-type-registry packages/query-spec packages/server-utils postgres/pg-cache postgres/pg-env'
110+
packages: 'packages/url-domains packages/coerce packages/csrf packages/oauth packages/12factor-env packages/orm packages/express-context packages/errors packages/llm-env packages/node-type-registry packages/query-spec packages/server-utils packages/site-deploy examples/site-deploy-ssg postgres/pg-cache postgres/pg-env'
111111
- batch: packages-services
112112
packages: 'packages/postmaster packages/smtppostmaster packages/csv-to-pg packages/cli postgres/pgsql-client postgres/pg-ast'
113113
- batch: graphql
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
dist-site/
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/**
2+
* A mock of the deploy surface — bulk upload, the release row, the site
3+
* pointer and preview refs — so the example's deploy path is exercised with no
4+
* database and no network.
5+
*
6+
* It models the two server behaviours the pipeline leans on: dedupe (a hash the
7+
* bucket already holds comes back `deduplicated: true` with no upload URL) and
8+
* the versioning trigger (every manifest write stamps a fresh commit id).
9+
*/
10+
11+
import type { GraphQLExecutor, PutObject, ReleaseManifest } from '@constructive-io/site-deploy';
12+
13+
interface ReleaseRow {
14+
id: string;
15+
commitId: string;
16+
storeId: string;
17+
manifest: ReleaseManifest;
18+
}
19+
20+
export interface MockServer {
21+
api: GraphQLExecutor;
22+
putObject: PutObject;
23+
/** CAS keys whose bytes the bucket holds. */
24+
storedKeys: string[];
25+
/** Hashes the bucket holds, as dedupe sees them. */
26+
storedHashes: Set<string>;
27+
release: ReleaseRow | null;
28+
activeCommitId: string | null;
29+
previewRefs: Record<string, string>;
30+
/** The manifest of each commit, so a rollback can be checked. */
31+
commits: Record<string, ReleaseManifest>;
32+
}
33+
34+
export function createMockServer(): MockServer {
35+
let commitCount = 0;
36+
const hashes = new Set<string>();
37+
38+
const server: MockServer = {
39+
storedKeys: [],
40+
storedHashes: hashes,
41+
release: null,
42+
activeCommitId: null,
43+
previewRefs: {},
44+
commits: {},
45+
api: async (query, variables) => handle(operationName(query), variables),
46+
putObject: async (url, body, contentType) => {
47+
const hash = url.slice(url.lastIndexOf('/') + 1);
48+
hashes.add(hash);
49+
server.storedKeys.push(`cas/sha256/${hash}`);
50+
void body;
51+
void contentType;
52+
},
53+
};
54+
55+
function handle(operation: string, variables: Record<string, unknown>) {
56+
const input = (variables.input ?? {}) as Record<string, any>;
57+
switch (operation) {
58+
case 'uploadFiles':
59+
return {
60+
uploadFiles: {
61+
files: (input.files as any[]).map((file) => {
62+
const deduplicated = hashes.has(file.contentHash);
63+
return {
64+
fileId: `file-${file.contentHash.slice(0, 8)}`,
65+
key: file.key as string,
66+
deduplicated,
67+
uploadUrl: deduplicated ? null : `https://s3.test/put/${file.contentHash}`,
68+
};
69+
}),
70+
},
71+
};
72+
case 'siteReleases':
73+
return { siteReleases: { nodes: server.release ? [server.release] : [] } };
74+
case 'createSiteRelease': {
75+
server.release = commit('release-1', 'store-1', input.siteRelease.manifest);
76+
return { createSiteRelease: { siteRelease: server.release } };
77+
}
78+
case 'updateSiteRelease': {
79+
server.release = commit(
80+
input.id as string,
81+
server.release?.storeId ?? 'store-1',
82+
input.siteReleasePatch.manifest,
83+
);
84+
return { updateSiteRelease: { siteRelease: server.release } };
85+
}
86+
case 'updateSite':
87+
server.activeCommitId = input.sitePatch.activeCommitId as string;
88+
return { updateSite: { site: { id: input.id, activeCommitId: server.activeCommitId } } };
89+
case 'provisionSitePreview':
90+
server.previewRefs[input.name as string] = input.commitId as string;
91+
return {
92+
provisionSitePreview: {
93+
result: {
94+
id: 'route-1',
95+
previewRef: `preview/${input.name}`,
96+
domain: { hostname: `${input.name}--example.${input.apex}` },
97+
},
98+
},
99+
};
100+
case 'setSitePreview':
101+
server.previewRefs[input.targetName as string] = input.targetCommitId as string;
102+
return { setSitePreview: { result: input.targetCommitId } };
103+
default:
104+
throw new Error(`Unexpected operation: ${operation}`);
105+
}
106+
}
107+
108+
function commit(id: string, storeId: string, manifest: ReleaseManifest): ReleaseRow {
109+
commitCount += 1;
110+
const commitId = `commit-${commitCount}`;
111+
server.commits[commitId] = manifest;
112+
return { id, commitId, storeId, manifest };
113+
}
114+
115+
return server;
116+
}
117+
118+
function operationName(query: string): string {
119+
const match = query.match(/\{\s*(\w+)\(/);
120+
if (!match) throw new Error(`Could not read an operation from: ${query}`);
121+
return match[1];
122+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/**
2+
* The example's golden path, end to end against a mocked deploy surface:
3+
* build → deploy to a `play` preview → publish → edit one page → redeploy →
4+
* roll back.
5+
*/
6+
7+
import { deployNames, deploySite, publishCommit } from '@constructive-io/site-deploy';
8+
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
9+
import { tmpdir } from 'os';
10+
import { join } from 'path';
11+
12+
import { buildSite } from '../src/ssg';
13+
import { createMockServer } from './mock-server';
14+
15+
const CONTENT = join(__dirname, '..', 'content');
16+
const names = deployNames();
17+
18+
let work: string;
19+
let content: string;
20+
let dist: string;
21+
22+
beforeEach(async () => {
23+
work = await mkdtemp(join(tmpdir(), 'ssg-deploy-'));
24+
content = join(work, 'content');
25+
dist = join(work, 'dist');
26+
await copyContent(CONTENT, content);
27+
});
28+
29+
afterEach(async () => {
30+
await rm(work, { recursive: true, force: true });
31+
});
32+
33+
async function copyContent(from: string, to: string): Promise<void> {
34+
const { cp } = await import('fs/promises');
35+
await cp(from, to, { recursive: true });
36+
}
37+
38+
const deployOptions = (server: ReturnType<typeof createMockServer>) => ({
39+
api: server.api,
40+
putObject: server.putObject,
41+
siteId: 'site-1',
42+
databaseId: 'db-1',
43+
bucketKey: 'site-example',
44+
source: dist,
45+
});
46+
47+
test('builds every page, the stylesheet and a 404', async () => {
48+
const files = await buildSite(content, dist);
49+
expect(files).toEqual([
50+
'404.html',
51+
'about.html',
52+
'assets/site.css',
53+
'index.html',
54+
'previews.html',
55+
]);
56+
const index = await readFile(join(dist, 'index.html'), 'utf8');
57+
expect(index).toContain('<title>Constructive SSG example</title>');
58+
// The nav links every page, with index at the site root.
59+
expect(index).toContain('href="/"');
60+
expect(index).toContain('href="/previews.html"');
61+
});
62+
63+
test('deploys the build to a play preview without touching production', async () => {
64+
const server = createMockServer();
65+
await buildSite(content, dist);
66+
67+
const result = await deploySite({
68+
...deployOptions(server),
69+
preview: 'play',
70+
previewApex: 'preview.example.com',
71+
});
72+
73+
expect(result.files).toBe(5);
74+
expect(result.uploaded).toBe(5);
75+
expect(result.skipped).toBe(0);
76+
expect(result.commitId).toBe('commit-1');
77+
expect(result.previewUrl).toBe('https://play--example.preview.example.com');
78+
expect(server.previewRefs).toEqual({ play: 'commit-1' });
79+
// Production is still unpublished: a preview deploy moves no pointer.
80+
expect(result.published).toBe(false);
81+
expect(server.activeCommitId).toBeNull();
82+
83+
// Bytes are addressed by content, and the manifest carries served types.
84+
expect(server.storedKeys).toHaveLength(5);
85+
for (const key of server.storedKeys) expect(key).toMatch(/^cas\/sha256\/[0-9a-f]{64}$/);
86+
expect(result.manifest.files['assets/site.css'].content_type).toBe('text/css; charset=utf-8');
87+
expect(result.manifest.files['index.html'].content_type).toBe('text/html; charset=utf-8');
88+
});
89+
90+
test('publishing the previewed commit is the same pointer move', async () => {
91+
const server = createMockServer();
92+
await buildSite(content, dist);
93+
94+
const first = await deploySite({ ...deployOptions(server), preview: 'play' });
95+
expect(server.activeCommitId).toBeNull();
96+
97+
await publishCommit(server.api, names, 'site-1', first.commitId);
98+
expect(server.activeCommitId).toBe(first.commitId);
99+
});
100+
101+
test('editing one page re-uploads one file and leaves production behind', async () => {
102+
const server = createMockServer();
103+
await buildSite(content, dist);
104+
const first = await deploySite({ ...deployOptions(server), publish: true });
105+
expect(server.activeCommitId).toBe(first.commitId);
106+
107+
await writeFile(join(content, 'about.html'), '# About\n<p>Now with a changelog.</p>\n');
108+
await buildSite(content, dist);
109+
110+
const second = await deploySite({ ...deployOptions(server), preview: 'play' });
111+
112+
expect(second.commitId).not.toBe(first.commitId);
113+
expect(second.uploaded).toBe(1);
114+
expect(second.skipped).toBe(4);
115+
expect(second.manifest.files['about.html'].hash).not.toBe(
116+
first.manifest.files['about.html'].hash,
117+
);
118+
expect(second.manifest.files['index.html'].hash).toBe(first.manifest.files['index.html'].hash);
119+
// The preview moved; production stayed on the first release.
120+
expect(server.previewRefs.play).toBe(second.commitId);
121+
expect(server.activeCommitId).toBe(first.commitId);
122+
123+
// Publishing then rolling back is one mutation each way.
124+
await publishCommit(server.api, names, 'site-1', second.commitId);
125+
expect(server.activeCommitId).toBe(second.commitId);
126+
await publishCommit(server.api, names, 'site-1', first.commitId);
127+
expect(server.activeCommitId).toBe(first.commitId);
128+
});
129+
130+
test('redeploying an unchanged build writes no new release', async () => {
131+
const server = createMockServer();
132+
await buildSite(content, dist);
133+
const first = await deploySite({ ...deployOptions(server), publish: true });
134+
135+
await buildSite(content, dist);
136+
const again = await deploySite({ ...deployOptions(server), skipIfUnchanged: true });
137+
138+
expect(again.unchanged).toBe(true);
139+
expect(again.commitId).toBe(first.commitId);
140+
expect(again.uploaded).toBe(0);
141+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# About
2+
<p>
3+
This example exists to exercise the deploy library end to end: build, hash,
4+
bulk upload, manifest write, preview ref, publish, roll back.
5+
</p>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Constructive SSG example
2+
<p>
3+
A static site built by a 100-line generator and deployed as one immutable
4+
release with <code>@constructive-io/site-deploy</code>.
5+
</p>
6+
<p>
7+
Every file's bytes live in the site bucket at <code>cas/sha256/&lt;hash&gt;</code>;
8+
one release manifest maps served paths to those hashes. Writing the manifest
9+
commits it into the site's merkle store, so the returned commit id <em>is</em>
10+
the release.
11+
</p>
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Previews
2+
<p>
3+
A preview is a named ref — <code>preview/play</code> — pointing at a release
4+
commit. Deploy with <code>preview: 'play'</code> and the release is reachable
5+
without touching production, which stays on <code>site.activeCommitId</code>
6+
until you publish.
7+
</p>
8+
<p>Publishing and rolling back are the same operation: move the pointer.</p>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/** @type {import('ts-jest').JestConfigWithTsJest} */
2+
module.exports = {
3+
preset: 'ts-jest',
4+
testEnvironment: 'node',
5+
transform: {
6+
'^.+\\.tsx?$': [
7+
'ts-jest',
8+
{
9+
babelConfig: false,
10+
tsconfig: 'tsconfig.json',
11+
},
12+
],
13+
},
14+
transformIgnorePatterns: [`/node_modules/*`],
15+
testRegex: '\\.(test|spec)\\.tsx?$',
16+
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
17+
modulePathIgnorePatterns: ['dist/*']
18+
};
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"name": "@constructive-io/examples-site-deploy-ssg",
3+
"version": "0.0.0",
4+
"private": true,
5+
"description": "Example: a tiny static site generator deployed as an immutable Constructive release, with a `play` preview and rollback.",
6+
"homepage": "https://github.com/constructive-io/constructive",
7+
"license": "MIT",
8+
"repository": {
9+
"type": "git",
10+
"url": "https://github.com/constructive-io/constructive"
11+
},
12+
"keywords": [
13+
"static-site",
14+
"ssg",
15+
"deploy",
16+
"preview",
17+
"constructive",
18+
"example"
19+
],
20+
"scripts": {
21+
"build:site": "tsx src/build.ts",
22+
"deploy": "tsx src/deploy.ts",
23+
"lint": "eslint . --fix",
24+
"test": "jest",
25+
"test:watch": "jest --watch"
26+
},
27+
"dependencies": {
28+
"@constructive-io/site-deploy": "workspace:^"
29+
},
30+
"devDependencies": {
31+
"tsx": "^4.20.3",
32+
"typescript": "^5.1.6"
33+
}
34+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/** Builds the example site into `dist-site/`. */
2+
3+
import { join } from 'path';
4+
5+
import { buildSite } from './ssg';
6+
7+
const root = join(__dirname, '..');
8+
9+
buildSite(join(root, 'content'), join(root, 'dist-site'), { banner: process.env.BANNER })
10+
.then((files) => console.log(`built ${files.length} files:\n ${files.join('\n ')}`))
11+
.catch((error) => {
12+
console.error(error);
13+
process.exit(1);
14+
});

0 commit comments

Comments
 (0)