astro:cache

This commit is contained in:
babayaga
2025-12-31 12:21:55 +01:00
parent 2ec586123d
commit 73a12c95e3
90 changed files with 14164 additions and 1730 deletions
+139
View File
@@ -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
View File
@@ -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;
});
};
+3
View File
@@ -0,0 +1,3 @@
import debugC from 'debug';
export const rootDebug = debugC('domain-expansion');
+87
View File
@@ -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;
}
}
+92
View File
@@ -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();
}
}
+3
View File
@@ -0,0 +1,3 @@
import { integration } from './integration.js';
export default integration;
+142
View File
@@ -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'])}
`);
},
},
},
});
},
},
};
},
});
+286
View File
@@ -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';
}
+37
View File
@@ -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;
}
};
+283
View File
@@ -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
View File
@@ -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
);
}
}
+57
View File
@@ -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;