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: 2 additions & 0 deletions .changeset/tame-hornets-peel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
117 changes: 53 additions & 64 deletions scripts/build-schemas.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,16 @@ function getVersion() {
}

/**
* Find the latest released version directory in dist/schemas/
* Returns null if no released versions exist
* Get all released version directories in dist/schemas/
* Returns array sorted by semver (descending)
*/
function findLatestReleasedVersion() {
function getAllReleasedVersions() {
if (!fs.existsSync(DIST_DIR)) {
return null;
return [];
}

const entries = fs.readdirSync(DIST_DIR, { withFileTypes: true });
const versionDirs = entries
return entries
.filter(e => e.isDirectory() && /^\d+\.\d+\.\d+$/.test(e.name))
.map(e => e.name)
.sort((a, b) => {
Expand All @@ -61,8 +61,34 @@ function findLatestReleasedVersion() {
if (aMinor !== bMinor) return bMinor - aMinor;
return bPatch - aPatch;
});
}

return versionDirs[0] || null;
/**
* Find the latest released version directory in dist/schemas/
* Returns null if no released versions exist
*/
function findLatestReleasedVersion() {
const versions = getAllReleasedVersions();
return versions[0] || null;
}

/**
* Get the latest patch version for each minor version series
* e.g., for [2.6.0, 2.5.1, 2.5.0], returns { '2.6': '2.6.0', '2.5': '2.5.1' }
*/
function getLatestPatchPerMinor() {
const versions = getAllReleasedVersions();
const latestPerMinor = {};

for (const version of versions) {
const minor = getMinorVersion(version);
// Since versions are sorted descending, first one wins
if (!latestPerMinor[minor]) {
latestPerMinor[minor] = version;
}
}

return latestPerMinor;
}

function getMajorVersion(version) {
Expand Down Expand Up @@ -128,17 +154,6 @@ function copyAndTransformSchemas(sourceDir, targetDir, version) {
}
}

function createSymlink(target, linkPath) {
// Remove existing symlink if it exists
if (fs.existsSync(linkPath)) {
fs.unlinkSync(linkPath);
}

// Create relative symlink
const relativePath = path.relative(path.dirname(linkPath), target);
fs.symlinkSync(relativePath, linkPath, 'dir');
}

function updateSourceRegistry(version) {
const registryPath = path.join(SOURCE_DIR, 'index.json');
const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
Expand Down Expand Up @@ -323,15 +338,8 @@ async function main() {
const { successCount, errorCount } = await generateBundledSchemas(SOURCE_DIR, bundledDir, version);
console.log(` ✓ Bundled ${successCount} schemas${errorCount > 0 ? ` (${errorCount} failed)` : ''}`);

// Update major version symlink (v2 -> 2.5.0)
const majorLink = path.join(DIST_DIR, `v${majorVersion}`);
console.log(`🔗 Updating symlink: v${majorVersion} → ${version}`);
createSymlink(versionDir, majorLink);

// Update minor version symlink (v2.5 -> 2.5.0)
const minorLink = path.join(DIST_DIR, `v${minorVersion}`);
console.log(`🔗 Updating symlink: v${minorVersion} → ${version}`);
createSymlink(versionDir, minorLink);
// Note: Version aliases (v2, v2.5, v1, latest) are handled by HTTP middleware
// No symlinks needed - the server rewrites /schemas/v2.5/* to /schemas/2.5.1/*

// Also update latest/ to match the release
const latestDir = path.join(DIST_DIR, 'latest');
Expand All @@ -346,11 +354,6 @@ async function main() {
const latestBundledDir = path.join(latestDir, 'bundled');
await generateBundledSchemas(SOURCE_DIR, latestBundledDir, 'latest');

// Create v1 symlink pointing to latest/ for backward compatibility
const v1Link = path.join(DIST_DIR, 'v1');
console.log(`🔗 Creating symlink: v1 → latest (backward compatibility)`);
createSymlink(latestDir, v1Link);

// Stage the new versioned directory for git commit
// This is needed for the changesets workflow to include it in the version commit
console.log(`📝 Staging dist/schemas/${version}/ for git commit`);
Expand All @@ -361,15 +364,21 @@ async function main() {
console.log(` (git add skipped - not in git context or git not available)`);
}

// Show available paths (aliases are handled by HTTP middleware)
const latestPerMinor = getLatestPatchPerMinor();
console.log('');
console.log('✅ Release build complete!');
console.log('');
console.log('Released paths:');
console.log(` /schemas/${version}/ - Exact version (pin for production)`);
console.log(` /schemas/${version}/bundled/ - Bundled schemas (no $ref)`);
console.log(` /schemas/v${minorVersion}/ - Minor alias → ${version}`);
console.log(` /schemas/v${majorVersion}/ - Major alias → ${version}`);
console.log(` /schemas/latest/ - Development (matches release)`);
console.log('');
console.log('Version aliases (handled by HTTP middleware):');
console.log(` /schemas/v${majorVersion}/ - Major alias → latest ${majorVersion}.x.x`);
for (const [minor, patchVersion] of Object.entries(latestPerMinor)) {
console.log(` /schemas/v${minor}/ - Minor alias → ${patchVersion}`);
}
console.log(` /schemas/v1/ - Backward compatibility → latest`);

} else {
Expand All @@ -392,46 +401,26 @@ async function main() {
const { successCount, errorCount } = await generateBundledSchemas(SOURCE_DIR, bundledDir, 'latest');
console.log(` ✓ Bundled ${successCount} schemas${errorCount > 0 ? ` (${errorCount} failed)` : ''}`);

// Symlinks for v2, v2.5, v1 should point to the latest RELEASED version
// Only create/update these symlinks if we have a released version
if (latestReleasedVersion) {
const releasedVersionDir = path.join(DIST_DIR, latestReleasedVersion);
const releasedMajor = getMajorVersion(latestReleasedVersion);
const releasedMinor = getMinorVersion(latestReleasedVersion);

const majorLink = path.join(DIST_DIR, `v${releasedMajor}`);
if (!fs.existsSync(majorLink)) {
console.log(`🔗 Creating symlink: v${releasedMajor} → ${latestReleasedVersion}`);
createSymlink(releasedVersionDir, majorLink);
}

const minorLink = path.join(DIST_DIR, `v${releasedMinor}`);
if (!fs.existsSync(minorLink)) {
console.log(`🔗 Creating symlink: v${releasedMinor} → ${latestReleasedVersion}`);
createSymlink(releasedVersionDir, minorLink);
}

}

// v1 always points to latest/ (not a released version)
const v1Link = path.join(DIST_DIR, 'v1');
if (!fs.existsSync(v1Link)) {
console.log(`🔗 Creating symlink: v1 → latest`);
createSymlink(latestDir, v1Link);
}
// Note: Version aliases (v2, v2.5, v1) are handled by HTTP middleware
// No symlinks needed - the server rewrites URLs dynamically

// Show available paths
const latestPerMinor = getLatestPatchPerMinor();
console.log('');
console.log('✅ Development build complete!');
console.log('');
console.log('Available paths:');
console.log(` /schemas/latest/ - Development schemas (just rebuilt)`);
console.log(` /schemas/v1/ - Backward compatibility → latest`);
if (latestReleasedVersion) {
const releasedMajor = getMajorVersion(latestReleasedVersion);
const releasedMinor = getMinorVersion(latestReleasedVersion);
console.log(` /schemas/${latestReleasedVersion}/ - Latest release (unchanged)`);
console.log(` /schemas/v${releasedMinor}/ - Minor alias → ${latestReleasedVersion}`);
console.log(` /schemas/v${releasedMajor}/ - Major alias → ${latestReleasedVersion}`);
console.log('');
console.log('Version aliases (handled by HTTP middleware):');
console.log(` /schemas/v${releasedMajor}/ - Major alias → latest ${releasedMajor}.x.x`);
for (const [minor, patchVersion] of Object.entries(latestPerMinor)) {
console.log(` /schemas/v${minor}/ - Minor alias → ${patchVersion}`);
}
console.log(` /schemas/v1/ - Backward compatibility → latest`);
} else {
console.log('');
console.log('⚠️ No released versions found. Run with --release to create one:');
Expand Down
158 changes: 157 additions & 1 deletion server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,163 @@ export class HTTPServer {
const distPath = process.env.NODE_ENV === 'production'
? __dirname
: path.join(__dirname, "../../dist");
this.app.use('/schemas', express.static(path.join(distPath, 'schemas')));
const schemasPath = path.join(distPath, 'schemas');

// Cache for schema version directories (refreshed every 60 seconds)
let versionCache: { versions: string[], timestamp: number } | null = null;
const CACHE_TTL_MS = 60 * 1000;

async function getSchemaVersions(): Promise<string[]> {
const now = Date.now();
if (versionCache && (now - versionCache.timestamp) < CACHE_TTL_MS) {
return versionCache.versions;
}

const entries = await fs.readdir(schemasPath, { withFileTypes: true });
const versions = entries
.filter(e => e.isDirectory() && /^\d+\.\d+\.\d+$/.test(e.name))
.map(e => e.name)
.sort((a, b) => {
// Sort by semver (descending)
const [aMajor, aMinor, aPatch] = a.split('.').map(Number);
const [bMajor, bMinor, bPatch] = b.split('.').map(Number);
if (aMajor !== bMajor) return bMajor - aMajor;
if (aMinor !== bMinor) return bMinor - aMinor;
return bPatch - aPatch;
});

versionCache = { versions, timestamp: now };
return versions;
}

function parseSemver(version: string): { major: number, minor: number, patch: number } {
const [major, minor, patch] = version.split('.').map(Number);
return { major, minor, patch };
}

function findMatchingVersion(versions: string[], requestedMajor: number, requestedMinor?: number): string | undefined {
// Find the latest version that matches the requested major (and optionally minor)
return versions.find(v => {
const { major, minor } = parseSemver(v);
if (major !== requestedMajor) return false;
if (requestedMinor !== undefined && minor !== requestedMinor) return false;
return true;
});
}

// Middleware to resolve version aliases (e.g., v2.5 → 2.5.1)
// This handles cases where symlinks don't exist (e.g., in Docker)
this.app.use('/schemas', async (req, res, next) => {
// Match version alias patterns: /v2/, /v2.5/, /v2.6/, /v1/
const versionMatch = req.path.match(/^\/v(\d+)(?:\.(\d+))?(\/.*)?$/);
if (!versionMatch) {
return next();
}

const requestedMajor = parseInt(versionMatch[1], 10);
const requestedMinor = versionMatch[2] ? parseInt(versionMatch[2], 10) : undefined;
const restOfPath = versionMatch[3] || '/';

// Special case: v1 always points to latest
if (requestedMajor === 1 && requestedMinor === undefined) {
req.url = '/latest' + restOfPath;
return next();
}

try {
const versions = await getSchemaVersions();
const targetVersion = findMatchingVersion(versions, requestedMajor, requestedMinor);

if (targetVersion) {
req.url = '/' + targetVersion + restOfPath;
}
} catch {
// If we can't read the directory, let static middleware handle it
}
next();
});

// Redirect version directory requests to index.json
// e.g., /schemas/2.6.0/ → /schemas/2.6.0/index.json
this.app.use('/schemas', (req, res, next) => {
// Match paths like /2.6.0/ or /latest/ (directory requests)
if (req.path.match(/^\/(\d+\.\d+\.\d+|latest)\/$/)) {
return res.redirect(req.path + 'index.json');
}
next();
});

// Schema discovery endpoint - returns available versions and aliases
this.app.get('/schemas/', async (req, res) => {
try {
const versions = await getSchemaVersions();
const latestPerMinor: Record<string, string> = {};
let latestMajorVersion: string | undefined;

for (const version of versions) {
const { major, minor } = parseSemver(version);
const minorKey = `${major}.${minor}`;

// First version in sorted list is the overall latest
if (!latestMajorVersion) {
latestMajorVersion = version;
}

// Track latest patch for each minor
if (!latestPerMinor[minorKey]) {
latestPerMinor[minorKey] = version;
}
}

// Build aliases list
const aliases: Array<{ alias: string, resolves_to: string, path: string }> = [];

// v1 -> latest (backward compatibility)
aliases.push({
alias: "v1",
resolves_to: "latest",
path: "/schemas/v1/"
});

// Major version aliases (e.g., v2 -> 2.6.0)
if (latestMajorVersion) {
const { major } = parseSemver(latestMajorVersion);
aliases.push({
alias: `v${major}`,
resolves_to: latestMajorVersion,
path: `/schemas/v${major}/`
});
}

// Minor version aliases (e.g., v2.5 -> 2.5.1)
for (const [minorKey, version] of Object.entries(latestPerMinor)) {
aliases.push({
alias: `v${minorKey}`,
resolves_to: version,
path: `/schemas/v${minorKey}/`
});
}

// Sort aliases for consistent output
aliases.sort((a, b) => a.alias.localeCompare(b.alias, undefined, { numeric: true }));

res.json({
versions: versions.map(v => ({
version: v,
path: `/schemas/${v}/`
})),
aliases,
latest: {
path: "/schemas/latest/",
note: "Development version, may differ from released versions"
}
});
} catch (error) {
res.status(500).json({ error: "Failed to list schema versions" });
}
});

this.app.use('/schemas', express.static(schemasPath));

// Serve other static files (robots.txt, images, etc.)
const staticPath = process.env.NODE_ENV === 'production'
Expand Down
Loading