Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/run-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ jobs:
- batch: uploads
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'
- batch: packages-core
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'
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'
- batch: packages-services
packages: 'packages/postmaster packages/smtppostmaster packages/csv-to-pg packages/cli postgres/pgsql-client postgres/pg-ast'
- batch: graphql
Expand Down
1 change: 1 addition & 0 deletions examples/site-deploy-ssg/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist-site/
122 changes: 122 additions & 0 deletions examples/site-deploy-ssg/__tests__/mock-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* A mock of the deploy surface — bulk upload, the release row, the site
* pointer and preview refs — so the example's deploy path is exercised with no
* database and no network.
*
* It models the two server behaviours the pipeline leans on: dedupe (a hash the
* bucket already holds comes back `deduplicated: true` with no upload URL) and
* the versioning trigger (every manifest write stamps a fresh commit id).
*/

import type { GraphQLExecutor, PutObject, ReleaseManifest } from '@constructive-io/site-deploy';

interface ReleaseRow {
id: string;
commitId: string;
storeId: string;
manifest: ReleaseManifest;
}

export interface MockServer {
api: GraphQLExecutor;
putObject: PutObject;
/** CAS keys whose bytes the bucket holds. */
storedKeys: string[];
/** Hashes the bucket holds, as dedupe sees them. */
storedHashes: Set<string>;
release: ReleaseRow | null;
activeCommitId: string | null;
previewRefs: Record<string, string>;
/** The manifest of each commit, so a rollback can be checked. */
commits: Record<string, ReleaseManifest>;
}

export function createMockServer(): MockServer {
let commitCount = 0;
const hashes = new Set<string>();

const server: MockServer = {
storedKeys: [],
storedHashes: hashes,
release: null,
activeCommitId: null,
previewRefs: {},
commits: {},
api: async (query, variables) => handle(operationName(query), variables),
putObject: async (url, body, contentType) => {
const hash = url.slice(url.lastIndexOf('/') + 1);
hashes.add(hash);
server.storedKeys.push(`cas/sha256/${hash}`);
void body;
void contentType;
},
};

function handle(operation: string, variables: Record<string, unknown>) {
const input = (variables.input ?? {}) as Record<string, any>;
switch (operation) {
case 'uploadFiles':
return {
uploadFiles: {
files: (input.files as any[]).map((file) => {
const deduplicated = hashes.has(file.contentHash);
return {
fileId: `file-${file.contentHash.slice(0, 8)}`,
key: file.key as string,
deduplicated,
uploadUrl: deduplicated ? null : `https://s3.test/put/${file.contentHash}`,
};
}),
},
};
case 'siteReleases':
return { siteReleases: { nodes: server.release ? [server.release] : [] } };
case 'createSiteRelease': {
server.release = commit('release-1', 'store-1', input.siteRelease.manifest);
return { createSiteRelease: { siteRelease: server.release } };
}
case 'updateSiteRelease': {
server.release = commit(
input.id as string,
server.release?.storeId ?? 'store-1',
input.siteReleasePatch.manifest,
);
return { updateSiteRelease: { siteRelease: server.release } };
}
case 'updateSite':
server.activeCommitId = input.sitePatch.activeCommitId as string;
return { updateSite: { site: { id: input.id, activeCommitId: server.activeCommitId } } };
case 'provisionSitePreview':
server.previewRefs[input.name as string] = input.commitId as string;
return {
provisionSitePreview: {
result: {
id: 'route-1',
previewRef: `preview/${input.name}`,
domain: { hostname: `${input.name}--example.${input.apex}` },
},
},
};
case 'setSitePreview':
server.previewRefs[input.targetName as string] = input.targetCommitId as string;
return { setSitePreview: { result: input.targetCommitId } };
default:
throw new Error(`Unexpected operation: ${operation}`);
}
}

function commit(id: string, storeId: string, manifest: ReleaseManifest): ReleaseRow {
commitCount += 1;
const commitId = `commit-${commitCount}`;
server.commits[commitId] = manifest;
return { id, commitId, storeId, manifest };
}

return server;
}

function operationName(query: string): string {
const match = query.match(/\{\s*(\w+)\(/);
if (!match) throw new Error(`Could not read an operation from: ${query}`);
return match[1];
}
141 changes: 141 additions & 0 deletions examples/site-deploy-ssg/__tests__/ssg-deploy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* The example's golden path, end to end against a mocked deploy surface:
* build → deploy to a `play` preview → publish → edit one page → redeploy →
* roll back.
*/

import { deployNames, deploySite, publishCommit } from '@constructive-io/site-deploy';
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';

import { buildSite } from '../src/ssg';
import { createMockServer } from './mock-server';

const CONTENT = join(__dirname, '..', 'content');
const names = deployNames();

let work: string;
let content: string;
let dist: string;

beforeEach(async () => {
work = await mkdtemp(join(tmpdir(), 'ssg-deploy-'));
content = join(work, 'content');
dist = join(work, 'dist');
await copyContent(CONTENT, content);
});

afterEach(async () => {
await rm(work, { recursive: true, force: true });
});

async function copyContent(from: string, to: string): Promise<void> {
const { cp } = await import('fs/promises');
await cp(from, to, { recursive: true });
}

const deployOptions = (server: ReturnType<typeof createMockServer>) => ({
api: server.api,
putObject: server.putObject,
siteId: 'site-1',
databaseId: 'db-1',
bucketKey: 'site-example',
source: dist,
});

test('builds every page, the stylesheet and a 404', async () => {
const files = await buildSite(content, dist);
expect(files).toEqual([
'404.html',
'about.html',
'assets/site.css',
'index.html',
'previews.html',
]);
const index = await readFile(join(dist, 'index.html'), 'utf8');
expect(index).toContain('<title>Constructive SSG example</title>');
// The nav links every page, with index at the site root.
expect(index).toContain('href="/"');
expect(index).toContain('href="/previews.html"');
});

test('deploys the build to a play preview without touching production', async () => {
const server = createMockServer();
await buildSite(content, dist);

const result = await deploySite({
...deployOptions(server),
preview: 'play',
previewApex: 'preview.example.com',
});

expect(result.files).toBe(5);
expect(result.uploaded).toBe(5);
expect(result.skipped).toBe(0);
expect(result.commitId).toBe('commit-1');
expect(result.previewUrl).toBe('https://play--example.preview.example.com');
expect(server.previewRefs).toEqual({ play: 'commit-1' });
// Production is still unpublished: a preview deploy moves no pointer.
expect(result.published).toBe(false);
expect(server.activeCommitId).toBeNull();

// Bytes are addressed by content, and the manifest carries served types.
expect(server.storedKeys).toHaveLength(5);
for (const key of server.storedKeys) expect(key).toMatch(/^cas\/sha256\/[0-9a-f]{64}$/);
expect(result.manifest.files['assets/site.css'].content_type).toBe('text/css; charset=utf-8');
expect(result.manifest.files['index.html'].content_type).toBe('text/html; charset=utf-8');
});

test('publishing the previewed commit is the same pointer move', async () => {
const server = createMockServer();
await buildSite(content, dist);

const first = await deploySite({ ...deployOptions(server), preview: 'play' });
expect(server.activeCommitId).toBeNull();

await publishCommit(server.api, names, 'site-1', first.commitId);
expect(server.activeCommitId).toBe(first.commitId);
});

test('editing one page re-uploads one file and leaves production behind', async () => {
const server = createMockServer();
await buildSite(content, dist);
const first = await deploySite({ ...deployOptions(server), publish: true });
expect(server.activeCommitId).toBe(first.commitId);

await writeFile(join(content, 'about.html'), '# About\n<p>Now with a changelog.</p>\n');
await buildSite(content, dist);

const second = await deploySite({ ...deployOptions(server), preview: 'play' });

expect(second.commitId).not.toBe(first.commitId);
expect(second.uploaded).toBe(1);
expect(second.skipped).toBe(4);
expect(second.manifest.files['about.html'].hash).not.toBe(
first.manifest.files['about.html'].hash,
);
expect(second.manifest.files['index.html'].hash).toBe(first.manifest.files['index.html'].hash);
// The preview moved; production stayed on the first release.
expect(server.previewRefs.play).toBe(second.commitId);
expect(server.activeCommitId).toBe(first.commitId);

// Publishing then rolling back is one mutation each way.
await publishCommit(server.api, names, 'site-1', second.commitId);
expect(server.activeCommitId).toBe(second.commitId);
await publishCommit(server.api, names, 'site-1', first.commitId);
expect(server.activeCommitId).toBe(first.commitId);
});

test('redeploying an unchanged build writes no new release', async () => {
const server = createMockServer();
await buildSite(content, dist);
const first = await deploySite({ ...deployOptions(server), publish: true });

await buildSite(content, dist);
const again = await deploySite({ ...deployOptions(server), skipIfUnchanged: true });

expect(again.unchanged).toBe(true);
expect(again.commitId).toBe(first.commitId);
expect(again.uploaded).toBe(0);
});
5 changes: 5 additions & 0 deletions examples/site-deploy-ssg/content/about.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# About
<p>
This example exists to exercise the deploy library end to end: build, hash,
bulk upload, manifest write, preview ref, publish, roll back.
</p>
11 changes: 11 additions & 0 deletions examples/site-deploy-ssg/content/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Constructive SSG example
<p>
A static site built by a 100-line generator and deployed as one immutable
release with <code>@constructive-io/site-deploy</code>.
</p>
<p>
Every file's bytes live in the site bucket at <code>cas/sha256/&lt;hash&gt;</code>;
one release manifest maps served paths to those hashes. Writing the manifest
commits it into the site's merkle store, so the returned commit id <em>is</em>
the release.
</p>
8 changes: 8 additions & 0 deletions examples/site-deploy-ssg/content/previews.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Previews
<p>
A preview is a named ref — <code>preview/play</code> — pointing at a release
commit. Deploy with <code>preview: 'play'</code> and the release is reachable
without touching production, which stays on <code>site.activeCommitId</code>
until you publish.
</p>
<p>Publishing and rolling back are the same operation: move the pointer.</p>
18 changes: 18 additions & 0 deletions examples/site-deploy-ssg/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
babelConfig: false,
tsconfig: 'tsconfig.json',
},
],
},
transformIgnorePatterns: [`/node_modules/*`],
testRegex: '\\.(test|spec)\\.tsx?$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
modulePathIgnorePatterns: ['dist/*']
};
34 changes: 34 additions & 0 deletions examples/site-deploy-ssg/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"name": "@constructive-io/examples-site-deploy-ssg",
"version": "0.0.0",
"private": true,
"description": "Example: a tiny static site generator deployed as an immutable Constructive release, with a `play` preview and rollback.",
"homepage": "https://github.com/constructive-io/constructive",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/constructive-io/constructive"
},
"keywords": [
"static-site",
"ssg",
"deploy",
"preview",
"constructive",
"example"
],
"scripts": {
"build:site": "tsx src/build.ts",
"deploy": "tsx src/deploy.ts",
"lint": "eslint . --fix",
"test": "jest",
"test:watch": "jest --watch"
},
"dependencies": {
"@constructive-io/site-deploy": "workspace:^"
},
"devDependencies": {
"tsx": "^4.20.3",
"typescript": "^5.1.6"
}
}
14 changes: 14 additions & 0 deletions examples/site-deploy-ssg/src/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/** Builds the example site into `dist-site/`. */

import { join } from 'path';

import { buildSite } from './ssg';

const root = join(__dirname, '..');

buildSite(join(root, 'content'), join(root, 'dist-site'), { banner: process.env.BANNER })
.then((files) => console.log(`built ${files.length} files:\n ${files.join('\n ')}`))
.catch((error) => {
console.error(error);
process.exit(1);
});
Loading
Loading