polymech-astro/packages/cache/src/renderCaching.ts
2025-12-31 12:21:55 +01:00

284 lines
9.0 KiB
TypeScript

import type * as Runtime from 'astro/compiler-runtime';
import hashSum from 'hash-sum';
import { rootDebug } from './debug.js';
import type { AstroComponentFactory } from 'astro/runtime/server/index.js';
import type { SSRMetadata, SSRResult } from 'astro';
import { runtime } from './utils.js';
import type { RenderDestination } from 'astro/runtime/server/render/common.js';
import type { PersistedMetadata } from './renderFileStore.js';
import { isDeepStrictEqual, types } from 'node:util';
import {
computeEntryHash,
getCachingOptions,
getCurrentContext,
makeContextTracking,
} from './contextTracking.js';
const debug = rootDebug.extend('render-caching');
const ASSET_SERVICE_CALLS = Symbol('@domain-expansion:astro-assets-service-calls');
interface ExtendedSSRResult extends SSRResult {
[ASSET_SERVICE_CALLS]: PersistedMetadata['assetServiceCalls'];
}
(globalThis as any)[Symbol.for('@domain-expansion:astro-component-caching')] = (
originalFn: typeof Runtime.createComponent
): typeof Runtime.createComponent => {
return function cachedCreateComponent(factoryOrOptions, moduleId, propagation) {
const options =
typeof factoryOrOptions === 'function'
? ({ factory: factoryOrOptions, moduleId, propagation } as Exclude<
typeof factoryOrOptions,
Function
>)
: factoryOrOptions;
const context = getCurrentContext();
let cacheScope = options.moduleId || '';
const { componentHashes } = getCachingOptions();
if (!options.moduleId || !componentHashes.has(options.moduleId)) {
if (!context) return originalFn(options);
delete options.moduleId;
if (!context.renderingEntry) {
context.doNotCache = true;
return originalFn(options);
}
const ccRenderCall = context.renderEntryCalls.at(-1)!;
cacheScope = `ccEntry:${ccRenderCall.id}:${ccRenderCall.hash}`;
} else {
const hash = componentHashes.get(options.moduleId)!;
cacheScope = hash;
}
return originalFn(
cacheFn(cacheScope, options.factory, options.moduleId),
options.moduleId,
options.propagation
);
};
function cacheFn(
cacheScope: string,
factory: AstroComponentFactory,
moduleId?: string
): AstroComponentFactory {
const { cache, routeEntrypoints, componentHashes, componentsHaveSharedState, ...cacheOptions } =
getCachingOptions();
const isEntrypoint = routeEntrypoints.includes(moduleId!);
const cacheParams: Record<'persist' | 'skipInMemory', boolean> = {
persist:
(isEntrypoint && cacheOptions.cachePages) ||
(!isEntrypoint && cacheOptions.cacheComponents === 'persistent'),
skipInMemory: isEntrypoint || cacheOptions.cacheComponents === false,
};
debug('Creating cached component', {
cacheScope,
moduleId,
isEntrypoint,
cacheParams,
});
return async (result: ExtendedSSRResult, props, slots) => {
const context = getCurrentContext();
if (context) {
if (moduleId) {
context.nestedComponents[moduleId] = componentHashes.get(moduleId)!;
}
}
if (!cacheParams.persist && cacheParams.skipInMemory) return factory(result, props, slots);
if (slots !== undefined && Object.keys(slots).length > 0) {
debug('Skip caching of component instance with children', { moduleId });
return factory(result, props, slots);
}
// TODO: Handle edge-cases involving Object.defineProperty
const resolvedProps = Object.fromEntries(
(
await Promise.all(
Object.entries(props).map(async ([key, value]) => [
key,
types.isProxy(value) ? undefined : await value,
])
)
).filter(([_key, value]) => !!value)
);
// We need to delete this because otherwise scopes from outside of a component can be globally
// restricted to the inside of a child component through a slot and to support that the component
// has to depend on its parent. Don't do that.
//
// This is required because this block in Astro doesn't return the `transformResult.scope`:
// https://github.com/withastro/astro/blob/799c8676dfba0d281faf2a3f2d9513518b57593b/packages/astro/src/vite-plugin-astro/index.ts?plain=1#L246-L257
// TODO: This might no longer be necessary, try removing it
const scopeProp = Object.keys(resolvedProps).find((prop) =>
prop.startsWith('data-astro-cid-')
);
if (scopeProp !== undefined) {
delete resolvedProps[scopeProp];
}
const url = new URL(result.request.url);
const hash = hashSum(
isEntrypoint || componentsHaveSharedState
? [moduleId, result.compressHTML, result.params, url.pathname, resolvedProps]
: [moduleId, result.compressHTML, resolvedProps]
);
const cacheKey = `${cacheScope}:${hash}`;
const { runIn: enterTrackingScope, collect: collectTracking } = makeContextTracking();
return enterTrackingScope(async () => {
const cachedMetadata = await getValidMetadata(cacheKey);
const cachedValue = await cache.getRenderValue({
key: cacheKey,
loadFresh: () => factory(result, props, slots),
force: !cachedMetadata,
...cacheParams,
});
const resultValue = cachedValue.value();
if (resultValue instanceof Response) return resultValue;
const templateResult = runtime.isRenderTemplateResult(resultValue)
? resultValue
: runtime.isHeadAndContent(resultValue)
? resultValue.content
: null;
if (!templateResult) return resultValue; // ThinHead case
const originalRender = templateResult.render;
if (cachedMetadata && cachedValue.cached) {
const { metadata } = cachedMetadata;
Object.assign(templateResult, {
render: async (destination: RenderDestination) => {
const newMetadata: SSRMetadata = {
...metadata,
extraHead: result._metadata.extraHead.concat(metadata.extraHead),
renderedScripts: new Set([
...result._metadata.renderedScripts.values(),
...metadata.renderedScripts.values(),
]),
hasDirectives: new Set([
...result._metadata.hasDirectives.values(),
...metadata.hasDirectives.values(),
]),
rendererSpecificHydrationScripts: new Set([
...result._metadata.rendererSpecificHydrationScripts.values(),
...metadata.rendererSpecificHydrationScripts.values(),
]),
propagators: result._metadata.propagators,
};
Object.assign(result._metadata, newMetadata);
return originalRender.call(templateResult, destination);
},
});
return resultValue;
}
const previousExtraHeadLength = result._metadata.extraHead.length;
const renderedScriptsDiff = delayedSetDifference(result._metadata.renderedScripts);
const hasDirectivedDiff = delayedSetDifference(result._metadata.hasDirectives);
const rendererSpecificHydrationScriptsDiff = delayedSetDifference(
result._metadata.rendererSpecificHydrationScripts
);
Object.assign(templateResult, {
render: (destination: RenderDestination) =>
enterTrackingScope(async () => {
// Renderer was not cached, so we need to cache the metadata as well
const context = collectTracking();
cache.saveMetadata({
key: cacheKey,
metadata: {
...context,
metadata: {
...result._metadata,
extraHead: result._metadata.extraHead.slice(previousExtraHeadLength),
renderedScripts: renderedScriptsDiff(result._metadata.renderedScripts),
hasDirectives: hasDirectivedDiff(result._metadata.hasDirectives),
rendererSpecificHydrationScripts: rendererSpecificHydrationScriptsDiff(
result._metadata.rendererSpecificHydrationScripts
),
},
},
...cacheParams,
});
return originalRender.call(templateResult, destination);
}),
});
return resultValue;
});
};
async function getValidMetadata(cacheKey: string): Promise<PersistedMetadata | null> {
const cachedMetadata = await cache.getMetadata({
key: cacheKey,
...cacheParams,
});
if (!cachedMetadata) return null;
for (const [component, hash] of Object.entries(cachedMetadata.nestedComponents)) {
const currentHash = componentHashes.get(component);
if (currentHash !== hash) return null;
}
for (const entry of cachedMetadata.renderEntryCalls) {
const currentHash = await computeEntryHash(entry.filePath);
if (currentHash !== entry.hash) return null;
}
for (const { options, resultingAttributes } of cachedMetadata.assetServiceCalls) {
try {
debug('Replaying getImage call', { options });
const result = await runtime.getImage(options);
if (!isDeepStrictEqual(result.attributes, resultingAttributes)) {
debug('Image call mismatch, bailing out of cache');
return null;
}
} catch (error) {
debug('Error replaying getImage call', { options, error });
return null;
}
}
return cachedMetadata;
}
}
};
function delayedSetDifference(previous: Set<string>): (next: Set<string>) => Set<string> {
const storedPrevious = new Set(previous);
return (next) => {
const newSet = new Set(next);
for (const k of storedPrevious.values()) {
newSet.delete(k);
}
return newSet;
};
}