astro:cache
This commit is contained in:
Vendored
+139
@@ -0,0 +1,139 @@
|
||||
import type { AstroFactoryReturnValue } from 'astro/runtime/server/render/astro/factory.js';
|
||||
import { rootDebug } from './debug.js';
|
||||
import { type MaybePromise, type Thunk } from './utils.js';
|
||||
import { type PersistedMetadata, RenderFileStore } from './renderFileStore.js';
|
||||
import { inMemoryCacheHit, inMemoryCacheMiss } from './metrics.js';
|
||||
import { MemoryCache } from './inMemoryLRU.js';
|
||||
import { FactoryValueClone } from './factoryValueClone.ts';
|
||||
|
||||
const debug = rootDebug.extend('cache');
|
||||
|
||||
type ValueThunk = Thunk<AstroFactoryReturnValue>;
|
||||
|
||||
export class Cache {
|
||||
private readonly valueCache = new MemoryCache<Thunk<AstroFactoryReturnValue> | null>();
|
||||
|
||||
private readonly metadataCache = new MemoryCache<PersistedMetadata | null>();
|
||||
|
||||
private readonly persisted: RenderFileStore;
|
||||
|
||||
public constructor(cacheDir: string) {
|
||||
this.persisted = new RenderFileStore(cacheDir);
|
||||
}
|
||||
|
||||
public initialize(): Promise<void> {
|
||||
return this.persisted.initialize();
|
||||
}
|
||||
|
||||
public async flush(): Promise<void> {
|
||||
await this.persisted.flush();
|
||||
|
||||
this.valueCache.clear();
|
||||
this.metadataCache.clear();
|
||||
|
||||
const self = this as any;
|
||||
delete self.valueCache;
|
||||
delete self.metadataCache;
|
||||
}
|
||||
|
||||
public saveRenderValue({
|
||||
key,
|
||||
factoryValue,
|
||||
...options
|
||||
}: {
|
||||
key: string;
|
||||
factoryValue: AstroFactoryReturnValue;
|
||||
persist: boolean;
|
||||
skipInMemory: boolean;
|
||||
}): Promise<ValueThunk> {
|
||||
const promise = options.persist
|
||||
? this.persisted.saveRenderValue(key, factoryValue)
|
||||
: FactoryValueClone.makeResultClone(factoryValue);
|
||||
if (!options.skipInMemory) this.valueCache.storeLoading(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
public async getRenderValue({
|
||||
key,
|
||||
loadFresh,
|
||||
...options
|
||||
}: {
|
||||
key: string;
|
||||
loadFresh: Thunk<MaybePromise<AstroFactoryReturnValue>>;
|
||||
persist: boolean;
|
||||
force: boolean;
|
||||
skipInMemory: boolean;
|
||||
}): Promise<{ cached: boolean; value: ValueThunk }> {
|
||||
const value = await this.getStoredRenderValue(key, options.force, options.skipInMemory);
|
||||
|
||||
if (value) return { cached: true, value };
|
||||
|
||||
return {
|
||||
cached: false,
|
||||
value: await this.saveRenderValue({
|
||||
...options,
|
||||
key,
|
||||
factoryValue: await loadFresh(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
public saveMetadata({
|
||||
key,
|
||||
metadata,
|
||||
persist,
|
||||
skipInMemory,
|
||||
}: {
|
||||
key: string;
|
||||
metadata: PersistedMetadata;
|
||||
persist: boolean;
|
||||
skipInMemory: boolean;
|
||||
}): void {
|
||||
if (!skipInMemory) this.metadataCache.storeSync(key, metadata);
|
||||
if (persist) this.persisted.saveMetadata(key, metadata);
|
||||
}
|
||||
|
||||
public async getMetadata({
|
||||
key,
|
||||
skipInMemory,
|
||||
}: {
|
||||
key: string;
|
||||
skipInMemory: boolean;
|
||||
}): Promise<PersistedMetadata | null> {
|
||||
const fromMemory = this.metadataCache.get(key);
|
||||
if (fromMemory) {
|
||||
debug(`Retrieve metadata for "${key}" from memory`);
|
||||
inMemoryCacheHit();
|
||||
return fromMemory;
|
||||
}
|
||||
|
||||
inMemoryCacheMiss();
|
||||
|
||||
const newPromise = this.persisted.loadMetadata(key);
|
||||
if (!skipInMemory) this.metadataCache.storeLoading(key, newPromise);
|
||||
|
||||
return newPromise;
|
||||
}
|
||||
|
||||
private getStoredRenderValue(
|
||||
key: string,
|
||||
force: boolean,
|
||||
skipInMemory: boolean
|
||||
): MaybePromise<ValueThunk | null> {
|
||||
const fromMemory = this.valueCache.get(key);
|
||||
if (fromMemory) {
|
||||
debug(`Retrieve renderer for "${key}" from memory`);
|
||||
inMemoryCacheHit();
|
||||
return fromMemory;
|
||||
}
|
||||
|
||||
inMemoryCacheMiss();
|
||||
|
||||
if (force) return null;
|
||||
|
||||
const newPromise = this.persisted.loadRenderer(key);
|
||||
if (!skipInMemory) this.valueCache.storeLoading(key, newPromise);
|
||||
|
||||
return newPromise;
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import { runtime } from './utils.js';
|
||||
import type { getImage } from 'astro:assets';
|
||||
import { rootDebug } from './debug.js';
|
||||
import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import type { renderEntry } from 'astro/content/runtime';
|
||||
import type { UnresolvedImageTransform } from 'astro';
|
||||
import { getSystemErrorName, types } from 'node:util';
|
||||
import { createResolver } from 'astro-integration-kit';
|
||||
import type { Cache } from './cache.js';
|
||||
|
||||
export type ContextTracking = {
|
||||
assetServiceCalls: Array<{
|
||||
options: UnresolvedImageTransform;
|
||||
resultingAttributes: Record<string, any>;
|
||||
}>;
|
||||
renderEntryCalls: Array<{
|
||||
id: string;
|
||||
filePath: string;
|
||||
hash: string;
|
||||
}>;
|
||||
nestedComponents: Record<string, string>;
|
||||
doNotCache: boolean;
|
||||
renderingEntry: boolean;
|
||||
};
|
||||
|
||||
const debug = rootDebug.extend('context-tracking');
|
||||
const contextTracking = new AsyncLocalStorage<ContextTracking>();
|
||||
|
||||
let cachingOptions: {
|
||||
cache: Cache;
|
||||
root: string;
|
||||
routeEntrypoints: string[];
|
||||
componentHashes: Map<string, string>;
|
||||
cacheComponents: false | 'in-memory' | 'persistent';
|
||||
cachePages: boolean;
|
||||
componentsHaveSharedState: boolean;
|
||||
resolver: ReturnType<typeof createResolver>['resolve'];
|
||||
};
|
||||
|
||||
export function setCachingOptions(options: Omit<typeof cachingOptions, 'resolver'>) {
|
||||
cachingOptions = {
|
||||
...options,
|
||||
resolver: createResolver(options.root).resolve,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCachingOptions(): typeof cachingOptions {
|
||||
return cachingOptions;
|
||||
}
|
||||
|
||||
export function makeContextTracking(): {
|
||||
runIn: <T>(fn: () => T) => T;
|
||||
collect: () => ContextTracking;
|
||||
} {
|
||||
debug('Initializing asset collector');
|
||||
const parent = contextTracking.getStore();
|
||||
const context: ContextTracking = {
|
||||
assetServiceCalls: [],
|
||||
renderEntryCalls: [],
|
||||
nestedComponents: {},
|
||||
doNotCache: false,
|
||||
renderingEntry: false,
|
||||
};
|
||||
|
||||
return {
|
||||
runIn: <T>(fn: () => T): T => {
|
||||
return contextTracking.run(context, fn);
|
||||
},
|
||||
collect: () => {
|
||||
debug('Retrieving collected context', {
|
||||
assetCalls: context.assetServiceCalls.length,
|
||||
ccRenderCalls: context.renderEntryCalls.length,
|
||||
nestedComponents: Object.keys(context.nestedComponents),
|
||||
});
|
||||
|
||||
if (parent) {
|
||||
parent.assetServiceCalls.push(...context.assetServiceCalls);
|
||||
parent.renderEntryCalls.push(...context.renderEntryCalls);
|
||||
Object.assign(parent.nestedComponents, context.nestedComponents);
|
||||
parent.doNotCache ||= context.doNotCache;
|
||||
}
|
||||
return context;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getCurrentContext(): ContextTracking | undefined {
|
||||
return contextTracking.getStore();
|
||||
}
|
||||
|
||||
const assetTrackingSym = Symbol.for('@domain-expansion:astro-asset-tracking');
|
||||
(globalThis as any)[assetTrackingSym] = (original: typeof getImage): typeof getImage => {
|
||||
debug('Assigning original getImage, skipping wrapper');
|
||||
debugger
|
||||
runtime.getImage = original;
|
||||
return original;
|
||||
};
|
||||
|
||||
export async function computeEntryHash(filePath: string): Promise<string> {
|
||||
try {
|
||||
return createHash('sha1')
|
||||
.update(await fs.promises.readFile(cachingOptions.resolver(filePath)))
|
||||
.digest()
|
||||
.toString('hex');
|
||||
} catch (err) {
|
||||
if (
|
||||
types.isNativeError(err) &&
|
||||
'errno' in err &&
|
||||
typeof err.errno === 'number' &&
|
||||
getSystemErrorName(err.errno) === 'ENOENT'
|
||||
) {
|
||||
// Placeholder hash for entries attempting to render a missing file
|
||||
return '__NO_FILE__';
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const ccRenderTrackingSym = Symbol.for('@domain-expansion:astro-cc-render-tracking');
|
||||
(globalThis as any)[ccRenderTrackingSym] = (original: typeof renderEntry): typeof renderEntry => {
|
||||
debug('Wrapping renderEntry');
|
||||
return (runtime.renderEntry = async (entry) => {
|
||||
const context = contextTracking.getStore();
|
||||
if (!context) return original(entry);
|
||||
|
||||
if (!('id' in entry && entry.filePath)) {
|
||||
context.doNotCache = true;
|
||||
return original(entry);
|
||||
}
|
||||
|
||||
const hash = await computeEntryHash(entry.filePath);
|
||||
|
||||
const val: ContextTracking['renderEntryCalls'][number] = {
|
||||
id: entry.id,
|
||||
filePath: entry.filePath,
|
||||
hash,
|
||||
};
|
||||
debug('Collected renderEntry call', val);
|
||||
context.renderEntryCalls.push(val);
|
||||
|
||||
context.renderingEntry = true;
|
||||
const result = await original(entry);
|
||||
context.renderingEntry = false;
|
||||
|
||||
return result;
|
||||
});
|
||||
};
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
import debugC from 'debug';
|
||||
|
||||
export const rootDebug = debugC('domain-expansion');
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
import type { RenderTemplateResult } from 'astro/runtime/server/render/astro/render-template.js';
|
||||
import { runtime, type Thunk } from './utils.ts';
|
||||
import type {
|
||||
RenderDestination,
|
||||
RenderDestinationChunk,
|
||||
} from 'astro/runtime/server/render/common.js';
|
||||
import type { HeadAndContent, ThinHead } from 'astro/runtime/server/render/astro/head-and-content.js';
|
||||
import type { AstroFactoryReturnValue } from 'astro/runtime/server/render/astro/factory.js';
|
||||
|
||||
export namespace FactoryValueClone {
|
||||
export function makeResultClone(
|
||||
value: AstroFactoryReturnValue
|
||||
): Promise<Thunk<AstroFactoryReturnValue>> {
|
||||
if (value instanceof Response) {
|
||||
return makeResponseClone(value);
|
||||
}
|
||||
|
||||
if (runtime.isHeadAndContent(value)) {
|
||||
return makeHeadAndContentClone(value);
|
||||
}
|
||||
|
||||
if (runtime.isRenderTemplateResult(value)) {
|
||||
return makeRenderTemplateClone(value);
|
||||
}
|
||||
|
||||
// ThinHead - no content to clone
|
||||
return makeThinHeadClone(value as ThinHead);
|
||||
}
|
||||
|
||||
export async function makeThinHeadClone(value: ThinHead): Promise<Thunk<ThinHead>> {
|
||||
return () => value;
|
||||
}
|
||||
|
||||
export async function makeResponseClone(value: Response): Promise<Thunk<Response>> {
|
||||
const body = await value.arrayBuffer();
|
||||
return () => new Response(body, value);
|
||||
}
|
||||
|
||||
export async function makeRenderTemplateClone(
|
||||
value: RenderTemplateResult
|
||||
): Promise<Thunk<RenderTemplateResult>> {
|
||||
const chunks = await renderTemplateToChunks(value);
|
||||
return () => renderTemplateFromChunks(chunks);
|
||||
}
|
||||
|
||||
export async function makeHeadAndContentClone(
|
||||
value: HeadAndContent
|
||||
): Promise<Thunk<HeadAndContent>> {
|
||||
const chunks = await renderTemplateToChunks(value.content);
|
||||
return () => runtime.createHeadAndContent(value.head, renderTemplateFromChunks(chunks));
|
||||
}
|
||||
|
||||
export function renderTemplateFromChunks(chunks: RenderDestinationChunk[]): RenderTemplateResult {
|
||||
const template = runtime.renderTemplate(Object.assign([], { raw: [] }));
|
||||
|
||||
return Object.assign(template, {
|
||||
render: (destination: RenderDestination) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
setImmediate(() => {
|
||||
for (const chunk of chunks) {
|
||||
destination.write(chunk);
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function renderTemplateToChunks(
|
||||
value: RenderTemplateResult
|
||||
): Promise<RenderDestinationChunk[]> {
|
||||
const chunks: RenderDestinationChunk[] = [];
|
||||
|
||||
const cachedDestination: RenderDestination = {
|
||||
write(chunk) {
|
||||
// Drop empty chunks
|
||||
if (chunk) chunks.push(chunk);
|
||||
},
|
||||
};
|
||||
|
||||
await value.render(cachedDestination);
|
||||
|
||||
return chunks;
|
||||
}
|
||||
}
|
||||
Vendored
+92
@@ -0,0 +1,92 @@
|
||||
import { types } from 'node:util';
|
||||
import { rootDebug } from './debug.js';
|
||||
import { Either, type MaybePromise } from './utils.js';
|
||||
|
||||
// Arbitrary limit for now
|
||||
const CACHE_LIMIT = 4096;
|
||||
|
||||
const debug = rootDebug.extend('lru-cache');
|
||||
|
||||
export class MemoryCache<T> {
|
||||
readonly #cacheLimit: number;
|
||||
readonly #cache = new Map<string, Either<T, Promise<T>>>();
|
||||
|
||||
public constructor(cacheLimit: number = CACHE_LIMIT) {
|
||||
this.#cacheLimit = cacheLimit;
|
||||
}
|
||||
|
||||
public async load(key: string, loader: () => MaybePromise<T>): Promise<T> {
|
||||
const cached = await this.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const fresh = loader();
|
||||
|
||||
if (types.isPromise(fresh)) {
|
||||
return this.storeLoading(key, fresh);
|
||||
}
|
||||
|
||||
this.storeSync(key, fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
public async getAll(): Promise<Record<string, T>> {
|
||||
return Object.fromEntries(
|
||||
await Promise.all(
|
||||
Array.from(this.#cache.entries()).map(([k, v]) =>
|
||||
Either.isLeft(v) ? [k, v.value] : v.value.then((value) => [k, value])
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public get(key: string): MaybePromise<T> | null {
|
||||
const cached = this.#cache.get(key);
|
||||
|
||||
if (!cached) return null;
|
||||
|
||||
this.#cache.delete(key);
|
||||
this.#cache.set(key, cached);
|
||||
|
||||
while (this.#cache.size > this.#cacheLimit) {
|
||||
const { value } = this.#cache.keys().next();
|
||||
this.#cache.delete(value!);
|
||||
}
|
||||
|
||||
if (Either.isLeft(cached)) return cached.value;
|
||||
|
||||
return cached.value;
|
||||
}
|
||||
|
||||
public storeSync(key: string, value: T): void {
|
||||
this.#cache.set(key, Either.left(value));
|
||||
}
|
||||
|
||||
public storeLoading(key: string, promise: Promise<T>): Promise<T> {
|
||||
// Use a 3-stage cache with a loading stage holding the promises
|
||||
// to avoid duplicate reading from not caching the promise
|
||||
// and memory leaks to only caching the promises.
|
||||
|
||||
const stored = Either.right(promise);
|
||||
this.#cache.set(key, stored);
|
||||
|
||||
return promise
|
||||
.then((result) => {
|
||||
const cached = this.#cache.get(key);
|
||||
if (!Object.is(cached, stored)) return cached!.value;
|
||||
debug(`Storing cached render for "${key}"`);
|
||||
this.#cache.set(key, Either.left(result));
|
||||
|
||||
return result;
|
||||
})
|
||||
.finally(() => {
|
||||
const cached = this.#cache.get(key);
|
||||
if (!Object.is(cached, stored)) return;
|
||||
debug(`Clearing loading state for "${key}"`);
|
||||
this.#cache.delete(key);
|
||||
});
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.#cache.clear();
|
||||
}
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
import { integration } from './integration.js';
|
||||
|
||||
export default integration;
|
||||
Vendored
+142
@@ -0,0 +1,142 @@
|
||||
import { addIntegration, defineIntegration } from 'astro-integration-kit';
|
||||
import { interceptorPlugin } from './interceptor.js';
|
||||
import { clearMetrics, collectMetrics } from './metrics.js';
|
||||
import chalk from 'chalk';
|
||||
import humanFormat from 'human-format';
|
||||
import { z } from 'astro/zod';
|
||||
|
||||
function getDefaultCacheComponents(): false | 'in-memory' | 'persistent' {
|
||||
const env = process.env.DOMAIN_EXPANSION_CACHE_COMPONENT;
|
||||
|
||||
switch (env) {
|
||||
case 'false':
|
||||
return false;
|
||||
case 'in-memory':
|
||||
return 'in-memory';
|
||||
case 'persistent':
|
||||
return 'persistent';
|
||||
case '':
|
||||
case undefined:
|
||||
return false;
|
||||
default:
|
||||
console.warn(
|
||||
chalk.bold.redBright(`Invalid environment variable value for component cache: ${env}`)
|
||||
);
|
||||
console.warn(chalk.italic.yellow('Assuming "in-memory" as default.'));
|
||||
return 'in-memory';
|
||||
}
|
||||
}
|
||||
|
||||
export const INTEGRATION_NAME = '@domain-expansion/astro';
|
||||
|
||||
export const integration = defineIntegration({
|
||||
name: INTEGRATION_NAME,
|
||||
optionsSchema: z
|
||||
.object({
|
||||
/**
|
||||
* Whether non-page components should be cached.
|
||||
*
|
||||
* - `false` (default) means not caching at all
|
||||
* - `in-memory` means deduplicating repeated uses of components
|
||||
* without persisting them to disk
|
||||
* - `persistent` means persisting all uses of components to disk
|
||||
* just like pages. Changes to other segments of a page will use
|
||||
* the cached result of all unchanged components
|
||||
*
|
||||
* Components receiving slots are never cached.
|
||||
* If your component relies on state provided through Astro.locals
|
||||
* or any other means (like Starlight), you should also enable
|
||||
* `componentHasSharedState` to make sure the component is only
|
||||
* reused when the shared state is not expected to change.
|
||||
*/
|
||||
cacheComponents: z
|
||||
.enum(['in-memory', 'persistent'])
|
||||
.or(z.literal(false))
|
||||
.default(getDefaultCacheComponents()),
|
||||
componentsHaveSharedState: z
|
||||
.boolean()
|
||||
.default(process.env.DOMAIN_EXPANSION_STATEFUL_COMPONENTS === 'true'),
|
||||
cachePages: z
|
||||
.boolean()
|
||||
.default((process.env.DOMAIN_EXPANSION_CACHE_PAGES || 'true') === 'true'),
|
||||
/**
|
||||
* Cache prefix used to store independent cache data across multiple runs.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
cachePrefix: z.string().optional().default(''),
|
||||
})
|
||||
.default({}),
|
||||
setup({ options }) {
|
||||
const routeEntrypoints: string[] = [];
|
||||
let cleanup: undefined | (() => Promise<void>);
|
||||
|
||||
return {
|
||||
hooks: {
|
||||
'astro:routes:resolved': (params) => {
|
||||
routeEntrypoints.length = 0;
|
||||
routeEntrypoints.push(...params.routes.map((route) => route.entrypoint));
|
||||
},
|
||||
'astro:build:setup': ({ updateConfig, target }) => {
|
||||
if (target === 'server') {
|
||||
const interceptor = interceptorPlugin({
|
||||
...options,
|
||||
routeEntrypoints,
|
||||
});
|
||||
cleanup = interceptor.cleanup;
|
||||
updateConfig({
|
||||
plugins: [interceptor.plugin],
|
||||
});
|
||||
}
|
||||
},
|
||||
'astro:build:done': async () => {
|
||||
await cleanup?.();
|
||||
},
|
||||
'astro:config:setup': (params) => {
|
||||
if (params.command !== 'build') return;
|
||||
|
||||
clearMetrics();
|
||||
|
||||
addIntegration(params, {
|
||||
ensureUnique: true,
|
||||
integration: {
|
||||
name: '@domain-expansion/astro:reporting',
|
||||
hooks: {
|
||||
'astro:build:done': ({ logger }) => {
|
||||
if (!['debug', 'info'].includes(logger.options.level)) return;
|
||||
|
||||
const metrics = collectMetrics();
|
||||
|
||||
const fsCacheTotal = metrics['fs-cache-hit'] + metrics['fs-cache-miss'];
|
||||
const fsHitRatio = (100 * metrics['fs-cache-hit']) / fsCacheTotal;
|
||||
|
||||
const inMemoryCacheTotal =
|
||||
metrics['in-memory-cache-hit'] + metrics['in-memory-cache-miss'];
|
||||
const inMemoryHitRatio =
|
||||
(100 * metrics['in-memory-cache-hit']) / inMemoryCacheTotal;
|
||||
|
||||
// TODO: Add metrics for rollup time
|
||||
|
||||
console.log(`
|
||||
${chalk.bold.cyan('[Domain Expansion report]')}
|
||||
${chalk.bold.green('FS hit ratio:')} ${fsHitRatio.toFixed(2)}%
|
||||
${chalk.bold.green('FS hit total:')} ${humanFormat(metrics['fs-cache-hit'])}
|
||||
${chalk.bold.green('FS miss total:')} ${humanFormat(metrics['fs-cache-miss'])}
|
||||
${chalk.bold.green('In-Memory hit ratio:')} ${inMemoryHitRatio.toFixed(2)}%
|
||||
${chalk.bold.green('In-Memory hit total:')} ${humanFormat(metrics['in-memory-cache-hit'])}
|
||||
${chalk.bold.green('In-Memory miss total:')} ${humanFormat(metrics['in-memory-cache-miss'])}
|
||||
|
||||
${chalk.bold.green('Stored data in FS:')} ${humanFormat.bytes(metrics['stored-compressed-size'])}
|
||||
${chalk.bold.green('Loaded data from FS:')} ${humanFormat.bytes(metrics['loaded-compressed-size'])}
|
||||
${chalk.bold.green('Stored data uncompressed:')} ${humanFormat.bytes(metrics['stored-data-size'])}
|
||||
${chalk.bold.green('Loaded data uncompressed:')} ${humanFormat.bytes(metrics['loaded-data-size'])}
|
||||
`);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
Vendored
+286
@@ -0,0 +1,286 @@
|
||||
import type { Plugin } from 'vite';
|
||||
import type { AstNode, TransformPluginContext } from 'rollup';
|
||||
import { walk, type Node as ETreeNode } from 'estree-walker';
|
||||
import { rootDebug } from './debug.js';
|
||||
import { AstroError } from 'astro/errors';
|
||||
import { setCachingOptions } from './contextTracking.js';
|
||||
import { Cache } from './cache.js';
|
||||
import { createResolver } from 'astro-integration-kit';
|
||||
import MagicString, { type SourceMap } from 'magic-string';
|
||||
import hash_sum from 'hash-sum';
|
||||
import assert from 'node:assert';
|
||||
import './renderCaching.js';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
const debug = rootDebug.extend('interceptor-plugin');
|
||||
|
||||
const MODULE_ID = 'virtual:domain-expansion';
|
||||
const RESOLVED_MODULE_ID = '\x00virtual:domain-expansion';
|
||||
|
||||
const EXCLUDED_MODULE_IDS: string[] = [RESOLVED_MODULE_ID, '\0astro:content', '\0astro:assets'];
|
||||
|
||||
type ParseNode = ETreeNode & AstNode;
|
||||
|
||||
export const interceptorPlugin = (options: {
|
||||
cacheComponents: false | 'in-memory' | 'persistent';
|
||||
cachePages: boolean;
|
||||
componentsHaveSharedState: boolean;
|
||||
routeEntrypoints: string[];
|
||||
cachePrefix: string;
|
||||
}): { plugin: Plugin; cleanup: () => Promise<void> } => {
|
||||
const componentHashes = new Map<string, string>();
|
||||
|
||||
let cache: Cache;
|
||||
|
||||
const plugin: Plugin = {
|
||||
name: '@domain-expansion/interceptor',
|
||||
enforce: 'post',
|
||||
async configResolved(config) {
|
||||
const { resolve: resolver } = createResolver(config.root);
|
||||
|
||||
cache = new Cache(resolver(`node_modules/.domain-expansion/${options.cachePrefix}`));
|
||||
|
||||
await cache.initialize();
|
||||
|
||||
setCachingOptions({
|
||||
...options,
|
||||
cache,
|
||||
root: config.root,
|
||||
routeEntrypoints: options.routeEntrypoints.map((entrypoint) => resolver(entrypoint)),
|
||||
componentHashes,
|
||||
});
|
||||
},
|
||||
resolveId(id) {
|
||||
if (id === MODULE_ID) return RESOLVED_MODULE_ID;
|
||||
|
||||
return null;
|
||||
},
|
||||
load(id, { ssr } = {}) {
|
||||
if (id !== RESOLVED_MODULE_ID) return;
|
||||
if (!ssr) throw new AstroError("Client domain can't be expanded.");
|
||||
|
||||
// Return unchanged functions when not in a shared context with the build pipeline
|
||||
// AKA. During server rendering
|
||||
const code = `
|
||||
import { HTMLBytes, HTMLString } from "astro/runtime/server/index.js";
|
||||
import { SlotString } from "astro/runtime/server/render/slot.js";
|
||||
import { createHeadAndContent, isHeadAndContent } from "astro/runtime/server/render/astro/head-and-content.js";
|
||||
import { isRenderTemplateResult, renderTemplate } from "astro/runtime/server/render/astro/render-template.js";
|
||||
import { createRenderInstruction } from "astro/runtime/server/render/instruction.js";
|
||||
|
||||
Object.assign(globalThis[Symbol.for('@domain-expansion:astro-runtime-instances')] ?? {}, {
|
||||
HTMLBytes,
|
||||
HTMLString,
|
||||
SlotString,
|
||||
createHeadAndContent,
|
||||
isHeadAndContent,
|
||||
renderTemplate,
|
||||
isRenderTemplateResult,
|
||||
createRenderInstruction,
|
||||
});
|
||||
|
||||
const compCacheSym = Symbol.for('@domain-expansion:astro-component-caching');
|
||||
export const domainExpansionComponents = globalThis[compCacheSym] ?? ((fn) => fn);
|
||||
|
||||
const assetTrackingSym = Symbol.for('@domain-expansion:astro-asset-tracking');
|
||||
export const domainExpansionAssets = globalThis[assetTrackingSym] ?? ((fn) => fn);
|
||||
|
||||
const ccRenderTrackingSym = Symbol.for('@domain-expansion:astro-cc-render-tracking');
|
||||
export const domainExpansionRenderEntry = globalThis[ccRenderTrackingSym] ?? ((fn) => fn);
|
||||
`;
|
||||
|
||||
if (process.env.TEST)
|
||||
return code + `domainExpansionAssets(${JSON.stringify(randomBytes(8).toString('hex'))});`;
|
||||
|
||||
return code;
|
||||
},
|
||||
async transform(code, id, { ssr } = {}) {
|
||||
if (!ssr) return;
|
||||
|
||||
const transformers: Transformer[] = [
|
||||
createComponentTransformer,
|
||||
getImageAssetTransformer,
|
||||
renderCCEntryTransformer,
|
||||
];
|
||||
|
||||
for (const transformer of transformers) {
|
||||
const result = transformer(this, code, id);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
return;
|
||||
},
|
||||
async generateBundle() {
|
||||
for (const rootName of this.getModuleIds()) {
|
||||
if (!rootName.endsWith('.astro')) continue;
|
||||
|
||||
const processedImports: string[] = [];
|
||||
const hashParts: string[] = [];
|
||||
const importQueue = [rootName];
|
||||
|
||||
while (importQueue.length) {
|
||||
const modName = importQueue.pop()!;
|
||||
const modInfo = this.getModuleInfo(modName)!;
|
||||
if (modInfo.isExternal || !modInfo.code) continue;
|
||||
|
||||
processedImports.push(modName);
|
||||
hashParts.push(modInfo.code);
|
||||
importQueue.push(
|
||||
...modInfo.importedIdResolutions
|
||||
.map((resolution) => resolution.id)
|
||||
.filter(
|
||||
(importId) =>
|
||||
!importId.endsWith('.astro') &&
|
||||
!EXCLUDED_MODULE_IDS.includes(importId) &&
|
||||
!processedImports.includes(importId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
componentHashes.set(rootName, hash_sum(hashParts));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
plugin,
|
||||
cleanup: () => cache.flush(),
|
||||
};
|
||||
};
|
||||
|
||||
type Transformer = (
|
||||
ctx: TransformPluginContext,
|
||||
code: string,
|
||||
id: string
|
||||
) => TransformResult | null;
|
||||
|
||||
type TransformResult = {
|
||||
code: string;
|
||||
map: SourceMap;
|
||||
};
|
||||
|
||||
const createComponentTransformer: Transformer = (ctx, code, id) => {
|
||||
if (!code.includes('function createComponent(')) return null;
|
||||
|
||||
if (!/node_modules\/astro\/dist\/runtime\/[\w\/.-]+\.js/.test(id)) {
|
||||
debug('"createComponent" declaration outside of expected module', { id });
|
||||
return null;
|
||||
}
|
||||
|
||||
const ms = new MagicString(code);
|
||||
const ast = ctx.parse(code);
|
||||
|
||||
walk(ast, {
|
||||
leave(estreeNode, parent) {
|
||||
const node = estreeNode as ParseNode;
|
||||
if (node.type !== 'FunctionDeclaration') return;
|
||||
if (node.id.name !== 'createComponent') return;
|
||||
if (parent?.type !== 'Program') {
|
||||
throw new Error(
|
||||
'Astro core has changed its runtime, "@domain-expansion/astro" is not compatible with the currently installed Astro version.'
|
||||
);
|
||||
}
|
||||
|
||||
ms.prependLeft(
|
||||
node.start,
|
||||
[
|
||||
`import {domainExpansionComponents as $$domainExpansion} from ${JSON.stringify(MODULE_ID)};`,
|
||||
'const createComponent = $$domainExpansion(',
|
||||
].join('\n')
|
||||
);
|
||||
ms.appendRight(node.end, ');');
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
code: ms.toString(),
|
||||
map: ms.generateMap(),
|
||||
};
|
||||
};
|
||||
|
||||
const getImageAssetTransformer: Transformer = (ctx, code, id) => {
|
||||
if (id !== '\0astro:assets') return null;
|
||||
|
||||
const ms = new MagicString(code);
|
||||
const ast = ctx.parse(code);
|
||||
|
||||
const path: ParseNode[] = [];
|
||||
|
||||
walk(ast, {
|
||||
enter(estreeNode) {
|
||||
const node = estreeNode as ParseNode;
|
||||
path.push(node);
|
||||
if (
|
||||
node.type !== 'VariableDeclarator' ||
|
||||
node.id.type !== 'Identifier' ||
|
||||
node.id.name !== 'getImage'
|
||||
)
|
||||
return;
|
||||
const exportDeclaration = path.at(-3);
|
||||
assert.ok(isParseNode(node.init));
|
||||
assert.ok(
|
||||
exportDeclaration?.type === 'ExportNamedDeclaration',
|
||||
'Astro core has changed its runtime, "@domain-expansion/astro" is not compatible with the currently installed Astro version.'
|
||||
);
|
||||
|
||||
ms.prependLeft(
|
||||
exportDeclaration.start,
|
||||
`import {domainExpansionAssets as $$domainExpansion} from ${JSON.stringify(MODULE_ID)};\n`
|
||||
);
|
||||
ms.prependLeft(node.init.start, '$$domainExpansion(');
|
||||
ms.appendRight(node.init.end, ')');
|
||||
},
|
||||
leave(estreeNode) {
|
||||
const lastNode = path.pop();
|
||||
assert.ok(Object.is(lastNode, estreeNode), 'Stack tracking broke');
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
code: ms.toString(),
|
||||
map: ms.generateMap(),
|
||||
};
|
||||
};
|
||||
|
||||
const renderCCEntryTransformer: Transformer = (ctx, code, id) => {
|
||||
if (!code.includes('function renderEntry(')) return null;
|
||||
|
||||
if (!/node_modules\/astro\/dist\/content\/[\w\/.-]+\.js/.test(id)) {
|
||||
debug('"renderEntry" declaration outside of expected module', { id });
|
||||
return null;
|
||||
}
|
||||
|
||||
const ms = new MagicString(code);
|
||||
const ast = ctx.parse(code);
|
||||
|
||||
walk(ast, {
|
||||
enter(estreeNode, parent) {
|
||||
const node = estreeNode as ParseNode;
|
||||
if (node.type !== 'FunctionDeclaration') return;
|
||||
if (node.id.name !== 'renderEntry') return;
|
||||
if (parent?.type !== 'Program') {
|
||||
throw new Error(
|
||||
'Astro core has changed its runtime, "@domain-expansion/astro" is not compatible with the currently installed Astro version.'
|
||||
);
|
||||
}
|
||||
|
||||
ms.prependLeft(
|
||||
node.start,
|
||||
[
|
||||
`import {domainExpansionRenderEntry as $$domainExpansion} from ${JSON.stringify(MODULE_ID)};\n`,
|
||||
'const renderEntry = $$domainExpansion(',
|
||||
].join('\n')
|
||||
);
|
||||
ms.appendRight(node.end, ');');
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
code: ms.toString(),
|
||||
map: ms.generateMap(),
|
||||
};
|
||||
};
|
||||
|
||||
function isParseNode(node?: ETreeNode | null): node is ParseNode {
|
||||
return node != null && 'start' in node && typeof node.start === 'number';
|
||||
}
|
||||
Vendored
+37
@@ -0,0 +1,37 @@
|
||||
type Metrics =
|
||||
| 'in-memory-cache-hit'
|
||||
| 'in-memory-cache-miss'
|
||||
| 'fs-cache-hit'
|
||||
| 'fs-cache-miss'
|
||||
| 'loaded-data-size'
|
||||
| 'stored-data-size'
|
||||
| 'loaded-compressed-size'
|
||||
| 'stored-compressed-size';
|
||||
|
||||
const metricState: Record<string, number> = {};
|
||||
|
||||
function makeTracker(name: Metrics): (n?: number) => void {
|
||||
metricState[name] = 0;
|
||||
return (n = 1) => {
|
||||
metricState[name]! += n;
|
||||
};
|
||||
}
|
||||
|
||||
export const inMemoryCacheHit = makeTracker('in-memory-cache-hit');
|
||||
export const inMemoryCacheMiss = makeTracker('in-memory-cache-miss');
|
||||
export const fsCacheHit = makeTracker('fs-cache-hit');
|
||||
export const fsCacheMiss = makeTracker('fs-cache-miss');
|
||||
export const trackLoadedData = makeTracker('loaded-data-size');
|
||||
export const trackStoredData = makeTracker('stored-data-size');
|
||||
export const trackLoadedCompressedData = makeTracker('loaded-compressed-size');
|
||||
export const trackStoredCompressedData = makeTracker('stored-compressed-size');
|
||||
|
||||
export type CollectedMetrics = Record<Metrics, number>;
|
||||
|
||||
export const collectMetrics = (): CollectedMetrics => ({ ...metricState }) as CollectedMetrics;
|
||||
|
||||
export const clearMetrics = (): void => {
|
||||
for (const key in metricState) {
|
||||
metricState[key] = 0;
|
||||
}
|
||||
};
|
||||
Vendored
+283
@@ -0,0 +1,283 @@
|
||||
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;
|
||||
};
|
||||
}
|
||||
+502
@@ -0,0 +1,502 @@
|
||||
import { createResolver } from 'astro-integration-kit';
|
||||
import type { RenderDestinationChunk } from 'astro/runtime/server/render/common.js';
|
||||
import { rootDebug } from './debug.js';
|
||||
import * as fs from 'node:fs';
|
||||
import type { AstroFactoryReturnValue } from 'astro/runtime/server/render/astro/factory.js';
|
||||
import { Either, runtime, type Thunk } from './utils.js';
|
||||
import type { SSRMetadata } from 'astro';
|
||||
import type { RenderInstruction } from 'astro/runtime/server/render/instruction.js';
|
||||
import * as zlib from 'node:zlib';
|
||||
import { promisify } from 'node:util';
|
||||
import {
|
||||
fsCacheHit,
|
||||
fsCacheMiss,
|
||||
trackLoadedCompressedData,
|
||||
trackLoadedData,
|
||||
trackStoredCompressedData,
|
||||
trackStoredData,
|
||||
} from './metrics.js';
|
||||
import type { ContextTracking } from './contextTracking.js';
|
||||
import { MemoryCache } from './inMemoryLRU.js';
|
||||
import murmurHash from 'murmurhash-native';
|
||||
import { FactoryValueClone } from './factoryValueClone.ts';
|
||||
|
||||
const gzip = promisify(zlib.gzip),
|
||||
gunzip = promisify(zlib.gunzip);
|
||||
|
||||
const NON_SERIALIZABLE_RENDER_INSTRUCTIONS = ['renderer-hydration-script'] satisfies Array<
|
||||
RenderInstruction['type']
|
||||
>;
|
||||
|
||||
type SerializableRenderInstruction = Exclude<
|
||||
RenderInstruction,
|
||||
{
|
||||
type: (typeof NON_SERIALIZABLE_RENDER_INSTRUCTIONS)[number];
|
||||
}
|
||||
>;
|
||||
|
||||
type ChunkSerializationMap = {
|
||||
primitive: { value: string | number | boolean };
|
||||
htmlString: { value: string };
|
||||
htmlBytes: { value: string };
|
||||
slotString: {
|
||||
value: string;
|
||||
renderInstructions: Array<SerializableRenderInstruction> | undefined;
|
||||
};
|
||||
renderInstruction: {
|
||||
instruction: SerializableRenderInstruction;
|
||||
};
|
||||
arrayBufferView: {
|
||||
value: string;
|
||||
};
|
||||
response: {
|
||||
body: string;
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
};
|
||||
|
||||
type SerializedChunk<K extends keyof ChunkSerializationMap = keyof ChunkSerializationMap> = {
|
||||
[T in K]: ChunkSerializationMap[T] & { type: T };
|
||||
}[K];
|
||||
|
||||
type ValueSerializationMap = {
|
||||
headAndContent: {
|
||||
head: string;
|
||||
chunks: SerializedChunk[];
|
||||
};
|
||||
templateResult: {
|
||||
chunks: SerializedChunk[];
|
||||
};
|
||||
response: ChunkSerializationMap['response'];
|
||||
};
|
||||
|
||||
type SerializedValue<K extends keyof ValueSerializationMap = keyof ValueSerializationMap> = {
|
||||
[T in K]: ValueSerializationMap[T] & { type: T };
|
||||
}[K];
|
||||
|
||||
export type PersistedMetadata = Omit<ContextTracking, 'doNotCache' | 'renderingEntry'> & {
|
||||
metadata: Omit<SSRMetadata, 'propagators'>;
|
||||
};
|
||||
|
||||
export type SerializedMetadata = Omit<ContextTracking, 'doNotCache' | 'renderingEntry'> & {
|
||||
metadata: {
|
||||
hasHydrationScript: boolean;
|
||||
rendererSpecificHydrationScripts: Array<string>;
|
||||
renderedScripts: Array<string>;
|
||||
hasDirectives: Array<string>;
|
||||
hasRenderedHead: boolean;
|
||||
hasRenderedServerIslandRuntime?: boolean;
|
||||
headInTree: boolean;
|
||||
extraHead: string[];
|
||||
extraStyleHashes?: string[];
|
||||
extraScriptHashes?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
const debug = rootDebug.extend('file-store');
|
||||
|
||||
type ValueThunk = Thunk<AstroFactoryReturnValue>;
|
||||
|
||||
type DenormalizationResult<D, N> = {
|
||||
denormalized?: D;
|
||||
clone: Thunk<N>;
|
||||
};
|
||||
|
||||
export class RenderFileStore {
|
||||
private readonly gzippedCache = new MemoryCache<Buffer>(Number.POSITIVE_INFINITY);
|
||||
|
||||
private readonly resolver: ReturnType<typeof createResolver>['resolve'];
|
||||
|
||||
private readonly pending: Array<Promise<void>> = [];
|
||||
|
||||
private knownFiles!: string[];
|
||||
|
||||
public constructor(private readonly cacheDir: string) {
|
||||
this.resolver = createResolver(this.cacheDir).resolve;
|
||||
fs.mkdirSync(this.cacheDir, { recursive: true });
|
||||
}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
if (fs.existsSync(this.cacheDir)) {
|
||||
this.knownFiles = await fs.promises.readdir(this.cacheDir, {
|
||||
recursive: true,
|
||||
withFileTypes: false,
|
||||
});
|
||||
} else {
|
||||
this.knownFiles = [];
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
this.knownFiles.map(async (hash) => {
|
||||
try {
|
||||
const stored = await fs.promises.readFile(this.resolver(hash));
|
||||
this.gzippedCache.storeSync(hash, stored);
|
||||
} catch {}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public async flush(): Promise<void> {
|
||||
while (this.pending.length) {
|
||||
await Promise.all(this.pending);
|
||||
}
|
||||
|
||||
this.gzippedCache.clear();
|
||||
this.knownFiles = [];
|
||||
}
|
||||
|
||||
public async saveRenderValue(key: string, value: AstroFactoryReturnValue): Promise<ValueThunk> {
|
||||
debug('Persisting renderer for ', key);
|
||||
const { denormalized, clone } = await RenderFileStore.denormalizeValue(value);
|
||||
|
||||
if (denormalized) {
|
||||
this.store(key + ':renderer', denormalized);
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
public async loadRenderer(key: string): Promise<ValueThunk | null> {
|
||||
try {
|
||||
const serializedValue: SerializedValue | null = await this.load(key + ':renderer');
|
||||
|
||||
if (!serializedValue) {
|
||||
debug('Renderer cache miss', key);
|
||||
fsCacheMiss();
|
||||
return null;
|
||||
}
|
||||
|
||||
debug('Renderer cache hit', key);
|
||||
fsCacheHit();
|
||||
|
||||
return RenderFileStore.normalizeValue(serializedValue);
|
||||
} catch {
|
||||
debug('Renderer cache miss', key);
|
||||
fsCacheMiss();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public saveMetadata(key: string, metadata: PersistedMetadata): void {
|
||||
debug('Persisting metadata for ', key);
|
||||
|
||||
const serialized: SerializedMetadata = {
|
||||
...metadata,
|
||||
metadata: {
|
||||
...metadata.metadata,
|
||||
hasDirectives: Array.from(metadata.metadata.hasDirectives),
|
||||
renderedScripts: Array.from(metadata.metadata.renderedScripts),
|
||||
rendererSpecificHydrationScripts: Array.from(
|
||||
metadata.metadata.rendererSpecificHydrationScripts
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
this.store(key + ':metadata', serialized);
|
||||
}
|
||||
|
||||
public async loadMetadata(key: string): Promise<PersistedMetadata | null> {
|
||||
try {
|
||||
const serializedValue: SerializedMetadata | null = await this.load(key + ':metadata');
|
||||
if (!serializedValue) {
|
||||
debug('Metadata cache miss', key);
|
||||
fsCacheMiss();
|
||||
return null;
|
||||
}
|
||||
|
||||
debug('Metadata cache hit', key);
|
||||
fsCacheHit();
|
||||
|
||||
return {
|
||||
...serializedValue,
|
||||
metadata: {
|
||||
...serializedValue.metadata,
|
||||
hasDirectives: new Set(serializedValue.metadata.hasDirectives),
|
||||
renderedScripts: new Set(serializedValue.metadata.renderedScripts),
|
||||
rendererSpecificHydrationScripts: new Set(
|
||||
serializedValue.metadata.rendererSpecificHydrationScripts
|
||||
),
|
||||
hasRenderedServerIslandRuntime: serializedValue.metadata.hasRenderedServerIslandRuntime ?? false,
|
||||
extraStyleHashes: serializedValue.metadata.extraStyleHashes ?? [],
|
||||
extraScriptHashes: serializedValue.metadata.extraScriptHashes ?? [],
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
debug('Metadata cache miss', key);
|
||||
fsCacheMiss();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public store(cacheKey: string, data: any): void {
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const serializedData = Buffer.isBuffer(data)
|
||||
? data
|
||||
: Buffer.from(JSON.stringify(data), 'utf-8');
|
||||
trackStoredData(serializedData.byteLength);
|
||||
const compressedData = await gzip(serializedData, { level: 9 });
|
||||
trackStoredCompressedData(compressedData.byteLength);
|
||||
|
||||
const hash = murmurHash.murmurHash64(cacheKey);
|
||||
this.gzippedCache.storeSync(hash, compressedData);
|
||||
await fs.promises.writeFile(this.resolver(hash), compressedData);
|
||||
} catch (err) {
|
||||
debug('Failed to persist data', err);
|
||||
} finally {
|
||||
resolve();
|
||||
this.pending.splice(this.pending.indexOf(promise), 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.pending.push(promise);
|
||||
}
|
||||
|
||||
public async load(cacheKey: string, parse = true): Promise<any> {
|
||||
const hash = murmurHash.murmurHash64(cacheKey);
|
||||
if (!this.knownFiles.includes(hash)) return null;
|
||||
|
||||
const storedData = await this.gzippedCache.load(
|
||||
hash,
|
||||
async () => await fs.promises.readFile(this.resolver(hash))
|
||||
);
|
||||
trackLoadedCompressedData(storedData.byteLength);
|
||||
const uncompressedData = await gunzip(storedData);
|
||||
trackLoadedData(uncompressedData.byteLength);
|
||||
|
||||
return parse ? JSON.parse(uncompressedData.toString('utf-8')) : uncompressedData;
|
||||
}
|
||||
|
||||
public static async denormalizeValue(
|
||||
value: AstroFactoryReturnValue
|
||||
): Promise<DenormalizationResult<SerializedValue, AstroFactoryReturnValue>> {
|
||||
if (value instanceof Response) {
|
||||
return RenderFileStore.denormalizeResponse(value);
|
||||
}
|
||||
|
||||
if (runtime.isHeadAndContent(value)) {
|
||||
const chunks = await FactoryValueClone.renderTemplateToChunks(value.content);
|
||||
const seminormalChunks = await Promise.all(chunks.map(RenderFileStore.tryDenormalizeChunk));
|
||||
const clone = () =>
|
||||
runtime.createHeadAndContent(
|
||||
value.head,
|
||||
FactoryValueClone.renderTemplateFromChunks(chunks)
|
||||
);
|
||||
|
||||
return seminormalChunks.every(Either.isRight)
|
||||
? {
|
||||
clone,
|
||||
denormalized: {
|
||||
type: 'headAndContent',
|
||||
head: value.head.toString(),
|
||||
chunks: seminormalChunks.map((right) => right.value),
|
||||
},
|
||||
}
|
||||
: { clone };
|
||||
}
|
||||
|
||||
// Handle ThinHead - no content to denormalize
|
||||
if (!runtime.isRenderTemplateResult(value)) {
|
||||
return { clone: () => value };
|
||||
}
|
||||
|
||||
const chunks = await FactoryValueClone.renderTemplateToChunks(value);
|
||||
const seminormalChunks = await Promise.all(chunks.map(RenderFileStore.tryDenormalizeChunk));
|
||||
const clone = () => FactoryValueClone.renderTemplateFromChunks(chunks);
|
||||
|
||||
return seminormalChunks.every(Either.isRight)
|
||||
? {
|
||||
clone,
|
||||
denormalized: {
|
||||
type: 'templateResult',
|
||||
chunks: seminormalChunks.map((right) => right.value),
|
||||
},
|
||||
}
|
||||
: { clone };
|
||||
}
|
||||
|
||||
private static normalizeValue(value: SerializedValue): ValueThunk {
|
||||
switch (value.type) {
|
||||
case 'headAndContent': {
|
||||
const normalChunks = value.chunks.map(RenderFileStore.normalizeChunk);
|
||||
return () =>
|
||||
runtime.createHeadAndContent(
|
||||
// SAFETY: Astro core is wrong
|
||||
new runtime.HTMLString(value.head) as unknown as string,
|
||||
FactoryValueClone.renderTemplateFromChunks(normalChunks)
|
||||
);
|
||||
}
|
||||
case 'templateResult': {
|
||||
const normalChunks = value.chunks.map(RenderFileStore.normalizeChunk);
|
||||
return () => FactoryValueClone.renderTemplateFromChunks(normalChunks);
|
||||
}
|
||||
case 'response':
|
||||
return () => RenderFileStore.normalizeResponse(value);
|
||||
}
|
||||
}
|
||||
|
||||
private static async tryDenormalizeChunk(
|
||||
chunk: RenderDestinationChunk
|
||||
): Promise<Either<RenderDestinationChunk, SerializedChunk>> {
|
||||
const deno = await RenderFileStore.denormalizeChunk(chunk);
|
||||
return deno === null ? Either.left(chunk) : Either.right(deno);
|
||||
}
|
||||
|
||||
private static async denormalizeChunk(
|
||||
chunk: RenderDestinationChunk
|
||||
): Promise<SerializedChunk | null> {
|
||||
switch (typeof chunk) {
|
||||
case 'string':
|
||||
case 'number':
|
||||
case 'boolean':
|
||||
return {
|
||||
type: 'primitive',
|
||||
value: chunk,
|
||||
};
|
||||
case 'object':
|
||||
break;
|
||||
default:
|
||||
debug('Unexpected chunk type', chunk);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (chunk instanceof runtime.HTMLBytes)
|
||||
return {
|
||||
type: 'htmlBytes',
|
||||
value: Buffer.from(chunk).toString('base64'),
|
||||
};
|
||||
|
||||
if (chunk instanceof runtime.SlotString) {
|
||||
const instructions = chunk.instructions?.filter(
|
||||
RenderFileStore.isSerializableRenderInstruction
|
||||
);
|
||||
|
||||
// Some instruction was not serializable
|
||||
if (instructions?.length !== chunk.instructions?.length) return null;
|
||||
|
||||
return {
|
||||
type: 'slotString',
|
||||
value: chunk.toString(),
|
||||
renderInstructions: instructions,
|
||||
};
|
||||
}
|
||||
|
||||
if (chunk instanceof runtime.HTMLString)
|
||||
return {
|
||||
type: 'htmlString',
|
||||
value: chunk.toString(),
|
||||
};
|
||||
|
||||
if (chunk instanceof Response) {
|
||||
const { denormalized } = await RenderFileStore.denormalizeResponse(chunk);
|
||||
return denormalized!;
|
||||
}
|
||||
|
||||
if ('buffer' in chunk)
|
||||
return {
|
||||
type: 'arrayBufferView',
|
||||
value: Buffer.from(
|
||||
chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength)
|
||||
).toString('base64'),
|
||||
};
|
||||
|
||||
if (RenderFileStore.isSerializableRenderInstruction(chunk))
|
||||
return {
|
||||
type: 'renderInstruction',
|
||||
instruction: chunk,
|
||||
};
|
||||
|
||||
debug('Unexpected chunk type', chunk);
|
||||
return null;
|
||||
}
|
||||
|
||||
private static normalizeChunk(chunk: SerializedChunk): RenderDestinationChunk {
|
||||
/*
|
||||
export type RenderDestinationChunk = string | HTMLBytes | HTMLString | SlotString | ArrayBufferView | RenderInstruction | Response;
|
||||
*/
|
||||
|
||||
switch (chunk.type) {
|
||||
case 'primitive': {
|
||||
if (chunk.value === undefined) {
|
||||
throw new Error('Undefined chunk value');
|
||||
}
|
||||
return chunk.value as string;
|
||||
}
|
||||
case 'htmlString':
|
||||
return new runtime.HTMLString(chunk.value);
|
||||
case 'htmlBytes':
|
||||
return new runtime.HTMLBytes(Buffer.from(chunk.value, 'base64'));
|
||||
case 'slotString':
|
||||
return new runtime.SlotString(
|
||||
chunk.value,
|
||||
chunk.renderInstructions?.map(RenderFileStore.normalizeRenderInstruction) ?? null
|
||||
);
|
||||
case 'renderInstruction':
|
||||
return RenderFileStore.normalizeRenderInstruction(chunk.instruction);
|
||||
case 'arrayBufferView': {
|
||||
const buffer = Buffer.from(chunk.value, 'base64');
|
||||
return {
|
||||
buffer: buffer.buffer,
|
||||
byteLength: buffer.length,
|
||||
byteOffset: 0,
|
||||
};
|
||||
}
|
||||
case 'response':
|
||||
return new Response(chunk.body, {
|
||||
headers: chunk.headers,
|
||||
});
|
||||
default:
|
||||
throw new Error(`Unknown chunk type: ${(chunk as any).type}`);
|
||||
}
|
||||
}
|
||||
|
||||
private static normalizeRenderInstruction(
|
||||
instruction: SerializableRenderInstruction
|
||||
): RenderInstruction {
|
||||
// SAFETY: `createRenderInstruction` uses an overload to handle the types of each render instruction
|
||||
// individually. This breaks when the given instruction can be any of them as no overload
|
||||
// accepts them indistinctivelly. Each individual type matches the output so the following
|
||||
// is valid.
|
||||
return runtime.createRenderInstruction(instruction as any);
|
||||
}
|
||||
|
||||
private static async denormalizeResponse(
|
||||
value: Response
|
||||
): Promise<
|
||||
DenormalizationResult<SerializedValue<'response'> & SerializedChunk<'response'>, Response>
|
||||
> {
|
||||
const body = await value.arrayBuffer();
|
||||
return {
|
||||
denormalized: {
|
||||
type: 'response',
|
||||
body: Buffer.from(body).toString('base64'),
|
||||
status: value.status,
|
||||
statusText: value.statusText,
|
||||
headers: Object.fromEntries(value.headers.entries()),
|
||||
},
|
||||
clone: () => new Response(body, value),
|
||||
};
|
||||
}
|
||||
|
||||
private static normalizeResponse(
|
||||
value: SerializedValue<'response'> | SerializedChunk<'response'>
|
||||
): Response {
|
||||
return new Response(value.body, {
|
||||
headers: value.headers,
|
||||
status: value.status,
|
||||
statusText: value.statusText,
|
||||
});
|
||||
}
|
||||
|
||||
private static isSerializableRenderInstruction(
|
||||
instruction: RenderInstruction
|
||||
): instruction is SerializableRenderInstruction {
|
||||
return !(NON_SERIALIZABLE_RENDER_INSTRUCTIONS as Array<RenderInstruction['type']>).includes(
|
||||
instruction.type
|
||||
);
|
||||
}
|
||||
}
|
||||
Vendored
+57
@@ -0,0 +1,57 @@
|
||||
import type { HTMLBytes, HTMLString } from 'astro/runtime/server/index.js';
|
||||
import type { SlotString } from 'astro/runtime/server/render/slot.js';
|
||||
import type {
|
||||
createHeadAndContent,
|
||||
isHeadAndContent,
|
||||
} from 'astro/runtime/server/render/astro/head-and-content.js';
|
||||
import type {
|
||||
isRenderTemplateResult,
|
||||
renderTemplate,
|
||||
} from 'astro/runtime/server/render/astro/render-template.js';
|
||||
import type { createRenderInstruction } from 'astro/runtime/server/render/instruction.js';
|
||||
import type { getImage } from 'astro:assets';
|
||||
import type { renderEntry } from 'astro/content/runtime';
|
||||
|
||||
type RuntimeInstances = {
|
||||
HTMLBytes: typeof HTMLBytes;
|
||||
HTMLString: typeof HTMLString;
|
||||
SlotString: typeof SlotString;
|
||||
createHeadAndContent: typeof createHeadAndContent;
|
||||
isHeadAndContent: typeof isHeadAndContent;
|
||||
renderTemplate: typeof renderTemplate;
|
||||
isRenderTemplateResult: typeof isRenderTemplateResult;
|
||||
createRenderInstruction: typeof createRenderInstruction;
|
||||
getImage: typeof getImage;
|
||||
renderEntry: typeof renderEntry;
|
||||
};
|
||||
|
||||
export const runtime: RuntimeInstances = ((globalThis as any)[
|
||||
Symbol.for('@domain-expansion:astro-runtime-instances')
|
||||
] = {} as RuntimeInstances);
|
||||
|
||||
export type MaybePromise<T> = Promise<T> | T;
|
||||
|
||||
type Left<T> = { variant: 'left'; value: T };
|
||||
type Right<T> = { variant: 'right'; value: T };
|
||||
|
||||
export type Either<L, R> = Left<L> | Right<R>;
|
||||
|
||||
export namespace Either {
|
||||
export function left<T>(value: T): Left<T> {
|
||||
return { variant: 'left', value };
|
||||
}
|
||||
|
||||
export function right<T>(value: T): Right<T> {
|
||||
return { variant: 'right', value };
|
||||
}
|
||||
|
||||
export function isLeft<L, R>(either: Either<L, R>): either is Left<L> {
|
||||
return either.variant === 'left';
|
||||
}
|
||||
|
||||
export function isRight<L, R>(either: Either<L, R>): either is Right<R> {
|
||||
return either.variant === 'right';
|
||||
}
|
||||
}
|
||||
|
||||
export type Thunk<T> = () => T;
|
||||
Reference in New Issue
Block a user