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
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ setting only to the vnext build, not the live build. If vnext builds a different
source branch, set the intended label explicitly instead. Changing the badge on
a deployed static site requires rebuilding its frontend assets.

## Incremental API builds

API reference cache keys include the deployment commit and repository URL emitted
by the shared page head. Changing either value must regenerate cached API pages:
mixing old and new `git-commit-id` metadata makes the deployment guard correctly
fall back to full-page navigation. Builds with unchanged data and deployment
metadata can still reuse their API pages. Keep the head metadata and API cache
keys in sync when changing build identity handling.

## 🆘 Getting help

- **Issues**: Report bugs or request features via [GitHub Issues](https://github.com/microsoft/aspire.dev/issues)
Expand Down
8 changes: 2 additions & 6 deletions src/frontend/src/components/starlight/Head.astro
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,13 @@ import AccessibleCodeButtons from '../AccessibleCodeButtons.astro';
import SiteTour from '../SiteTour.astro';
import { getStructuredData } from '@utils/structured-data';
import { getOgMetadata } from '@utils/page-metadata';
import { deploymentMetadata } from '@utils/deployment-metadata';

// The Poppins weights that text in the first viewport renders with. The same files back the
// @font-face rules in site.css, so these resolve to the already-emitted assets.
const preloadedFonts = [poppinsRegular, poppinsSemiBold, poppinsBold];

const repoUrl = (
import.meta.env.PUBLIC_REPO_URL ||
import.meta.env.REPO_URL ||
'https://github.com/microsoft/aspire.dev'
).replace(/\/$/, '');
const commit = import.meta.env.PUBLIC_GIT_COMMIT_ID || import.meta.env.GIT_COMMIT_ID || '';
const { commit, repoUrl } = deploymentMetadata;

const route = Astro.locals?.starlightRoute;
const entryId = route?.entry?.id;
Expand Down
7 changes: 7 additions & 0 deletions src/frontend/src/env.d.ts
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
declare const __ASPIRE_REDIRECT_PATHS__: readonly string[];

interface ImportMetaEnv {
readonly PUBLIC_GIT_COMMIT_ID?: string;
readonly GIT_COMMIT_ID?: string;
readonly PUBLIC_REPO_URL?: string;
readonly REPO_URL?: string;
}
10 changes: 7 additions & 3 deletions src/frontend/src/utils/api-cache-key.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createHash } from 'node:crypto';
import { deploymentMetadata } from './deployment-metadata';

const digests = new WeakMap<object, string>();

Expand All @@ -7,13 +8,16 @@ const digests = new WeakMap<object, string>();
*
* Every page generated from a package or module document returns this value
* from `getStaticPaths()`, so Astro can reuse a page from the previous build
* as long as the document it was rendered from (and the template code) is
* unchanged. The digest is memoized per document object.
* as long as its document, deployment metadata and template code are unchanged.
* The digest is memoized per document object.
*/
export function apiDocumentCacheKey(document: object): string {
let digest = digests.get(document);
if (!digest) {
digest = createHash('sha1').update(JSON.stringify(document)).digest('base64url');
// Bun reads import.meta.env at render time, outside Astro's dependency hash.
digest = createHash('sha1')
.update(JSON.stringify([document, deploymentMetadata]))
.digest('base64url');
digests.set(document, digest);
}
return digest;
Expand Down
8 changes: 8 additions & 0 deletions src/frontend/src/utils/deployment-metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const deploymentMetadata = {
commit: import.meta.env.PUBLIC_GIT_COMMIT_ID || import.meta.env.GIT_COMMIT_ID || '',
repoUrl: (
import.meta.env.PUBLIC_REPO_URL ||
import.meta.env.REPO_URL ||
'https://github.com/microsoft/aspire.dev'
).replace(/\/$/, ''),
};
69 changes: 69 additions & 0 deletions src/frontend/tests/unit/api-cache-key.vitest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { afterEach, describe, expect, test, vi } from 'vitest';

afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});

async function loadCacheKeys(commit: string, repoUrl = 'https://github.com/microsoft/aspire.dev') {
vi.stubEnv('PUBLIC_GIT_COMMIT_ID', commit);
vi.stubEnv('PUBLIC_REPO_URL', repoUrl);
vi.resetModules();
return import('../../src/utils/api-cache-key');
}

describe('API incremental cache deployment identity', () => {
const document = { package: { name: 'Aspire.Hosting.Redis' }, types: [] };

test('retains the same key for unchanged data and deployment', async () => {
const first = await loadCacheKeys('build-a');
const key = first.apiDocumentCacheKey(document);
expect(first.apiDocumentCacheKey(document)).toBe(key);
expect(first.apiDocumentCacheKey(structuredClone(document))).toBe(key);
const second = await loadCacheKeys('build-a');
expect(second.apiDocumentCacheKey(document)).toBe(key);
expect(second.apiDocumentCacheKey({ ...document, types: ['RedisResource'] })).not.toBe(key);
});

test('invalidates unchanged API data when the deployment changes', async () => {
const first = await loadCacheKeys('build-a');
const oldKey = first.apiDocumentCacheKey(document);
const second = await loadCacheKeys('build-b');
expect(second.apiDocumentCacheKey(document)).not.toBe(oldKey);
});

test('invalidates source links when the repository changes, ignoring a trailing slash', async () => {
const first = await loadCacheKeys('build-a');
const oldKey = first.apiDocumentCacheKey(document);
const equivalent = await loadCacheKeys('build-a', 'https://github.com/microsoft/aspire.dev/');
expect(equivalent.apiDocumentCacheKey(document)).toBe(oldKey);
const changed = await loadCacheKeys('build-a', 'https://github.com/example/docs');
expect(changed.apiDocumentCacheKey(document)).not.toBe(oldKey);
});

test('applies deployment-aware keys to every path for the owning package', async () => {
const { apiDocumentCacheKey, withApiCacheKeys } = await loadCacheKeys('build-a');
const paths = ['type', 'member'].map((name) => ({
params: { name },
props: { packageName: document.package.name },
}));
const result = withApiCacheKeys(paths, [{ data: document }]);
expect(result).toEqual(paths.map((path) => ({
...path,
cacheKey: apiDocumentCacheKey(document),
})));
});

test('shares the head metadata fallback values', async () => {
await loadCacheKeys('');
vi.stubEnv('GIT_COMMIT_ID', 'fallback-build');
vi.stubEnv('PUBLIC_REPO_URL', '');
vi.stubEnv('REPO_URL', 'https://github.com/example/docs/');
vi.resetModules();
const { deploymentMetadata } = await import('../../src/utils/deployment-metadata');
expect(deploymentMetadata).toEqual({
commit: 'fallback-build',
repoUrl: 'https://github.com/example/docs',
});
});
});
Loading