Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow integrations to refresh content layer data #11878

Merged
merged 18 commits into from
Sep 3, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .changeset/curvy-walls-kneel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
'astro': patch
---

Adds support for allowing integrations to refresh the content layer

This adds a new object to the `astro:server:setup` hook that allows integrations to refresh the content layer. This can be used for example to register a webhook endpoint during dev, or to open a socket to a CMS to listen for changes.
ascorbic marked this conversation as resolved.
Show resolved Hide resolved

The hook is passed a function called `refreshContent` that can be called to refresh the content layer. It can optionally be passed a `loaders` property, which is an array of loader names. If provided, only collections that use those loaders will be refreshed. If not provided, all loaders will be refreshed. A CMS integration could use this to only refresh its own collections.
ascorbic marked this conversation as resolved.
Show resolved Hide resolved

It can also pass a `context` object, which will be passed to the loaders. This can be used to pass arbitrary data, such as the webhook body or an event from the websocket.
ascorbic marked this conversation as resolved.
Show resolved Hide resolved

```ts
{
name: 'my-integration',
hooks: {
'astro:server:setup': async ({ server, refreshContent }) => {
server.middlewares.use('/_refresh', async (req, res) => {
if(req.method !== 'POST') {
res.statusCode = 405
res.end('Method Not Allowed');
return
}
let body = '';
req.on('data', chunk => {
body += chunk.toString();
});
req.on('end', async () => {
try {
const webhookBody = JSON.parse(body);
await refreshContent({
context: { webhookBody },
loaders: ['my-loader']
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ message: 'Content refreshed successfully' }));
} catch (error) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to refresh content: ' + error.message }));
}
});
});
}
}
}
```

18 changes: 15 additions & 3 deletions packages/astro/src/content/content-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ export class ContentLayer {
this.#unsubscribe?.();
}

dispose() {
this.#queue.kill();
this.#unsubscribe?.();
}

async #getGenerateDigest() {
if (this.#generateDigest) {
return this.#generateDigest;
Expand Down Expand Up @@ -236,7 +241,7 @@ export class ContentLayer {
if (!existsSync(this.#settings.config.cacheDir)) {
await fs.mkdir(this.#settings.config.cacheDir, { recursive: true });
}
const cacheFile = new URL(DATA_STORE_FILE, this.#settings.config.cacheDir);
const cacheFile = getDataStoreFile(this.#settings);
await this.#store.writeToDisk(cacheFile);
if (!existsSync(this.#settings.dotAstroDir)) {
await fs.mkdir(this.#settings.dotAstroDir, { recursive: true });
Expand Down Expand Up @@ -297,17 +302,24 @@ export async function simpleLoader<TData extends { id: string }>(
}
}

export function getDataStoreFile(settings: AstroSettings) {
return new URL(
DATA_STORE_FILE,
process?.env.NODE_ENV === 'development' ? settings.dotAstroDir : settings.config.cacheDir,
);
}

function contentLayerSingleton() {
let instance: ContentLayer | null = null;
return {
init: (options: ContentLayerOptions) => {
instance?.unwatchContentConfig();
instance?.dispose();
instance = new ContentLayer(options);
return instance;
},
get: () => instance,
dispose: () => {
instance?.unwatchContentConfig();
instance?.dispose();
instance = null;
},
};
Expand Down
3 changes: 3 additions & 0 deletions packages/astro/src/content/data-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ export class DataStore {
try {
// @ts-expect-error - this is a virtual module
const data = await import('astro:data-layer-content');
if (data.default instanceof Map) {
return DataStore.fromMap(data.default);
}
const map = devalue.unflatten(data.default);
return DataStore.fromMap(map);
} catch {}
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/content/loaders/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface LoaderContext {
/** When running in dev, this is a filesystem watcher that can be used to trigger updates */
watcher?: FSWatcher;

/** If the loader has been triggered by an integration, this may optionally contain extra data set by that integration */
refreshContextData?: Record<string, unknown>;
entryTypes: Map<string, ContentEntryType>;
}
Expand Down
42 changes: 26 additions & 16 deletions packages/astro/src/content/vite-plugin-content-virtual-mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,16 @@ export function astroContentVirtualModPlugin({
}: AstroContentVirtualModPluginParams): Plugin {
let IS_DEV = false;
const IS_SERVER = isServerLikeOutput(settings.config);
const dataStoreFile = new URL(DATA_STORE_FILE, settings.config.cacheDir);
let dataStoreFile: URL;
return {
name: 'astro-content-virtual-mod-plugin',
enforce: 'pre',
configResolved(config) {
IS_DEV = config.mode === 'development';
dataStoreFile = new URL(
DATA_STORE_FILE,
IS_DEV ? settings.dotAstroDir : settings.config.cacheDir,
);
},
async resolveId(id) {
if (id === VIRTUAL_MODULE_ID) {
Expand Down Expand Up @@ -180,25 +184,31 @@ export function astroContentVirtualModPlugin({

configureServer(server) {
const dataStorePath = fileURLToPath(dataStoreFile);
// Watch for changes to the data store file
if (Array.isArray(server.watcher.options.ignored)) {
// The data store file is in node_modules, so is ignored by default,
// so we need to un-ignore it.
server.watcher.options.ignored.push(`!${dataStorePath}`);
}

server.watcher.add(dataStorePath);

function invalidateDataStore() {
const module = server.moduleGraph.getModuleById(RESOLVED_DATA_STORE_VIRTUAL_ID);
if (module) {
server.moduleGraph.invalidateModule(module);
}
server.ws.send({
type: 'full-reload',
path: '*',
});
}

// If the datastore file changes, invalidate the virtual module

server.watcher.on('add', (addedPath) => {
if (addedPath === dataStorePath) {
invalidateDataStore();
}
});

server.watcher.on('change', (changedPath) => {
// If the datastore file changes, invalidate the virtual module
if (changedPath === dataStorePath) {
const module = server.moduleGraph.getModuleById(RESOLVED_DATA_STORE_VIRTUAL_ID);
if (module) {
server.moduleGraph.invalidateModule(module);
}
server.ws.send({
type: 'full-reload',
path: '*',
});
invalidateDataStore();
}
});
},
Expand Down
7 changes: 4 additions & 3 deletions packages/astro/src/core/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ export default async function build(
const logger = createNodeLogger(inlineConfig);
const { userConfig, astroConfig } = await resolveConfig(inlineConfig, 'build');
telemetry.record(eventCliSession('build', userConfig));

const settings = await createSettings(astroConfig, fileURLToPath(astroConfig.root));

if (inlineConfig.force) {
if (astroConfig.experimental.contentCollectionCache) {
const contentCacheDir = new URL('./content/', astroConfig.cacheDir);
Expand All @@ -65,11 +68,9 @@ export default async function build(
logger.warn('content', 'content cache cleared (force)');
}
}
await clearContentLayerCache({ astroConfig, logger, fs });
await clearContentLayerCache({ settings, logger, fs });
}

const settings = await createSettings(astroConfig, fileURLToPath(astroConfig.root));

const builder = new AstroBuilder(settings, {
...options,
logger,
Expand Down
5 changes: 2 additions & 3 deletions packages/astro/src/core/dev/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ import { performance } from 'node:perf_hooks';
import { green } from 'kleur/colors';
import { gt, major, minor, patch } from 'semver';
import type * as vite from 'vite';
import { DATA_STORE_FILE } from '../../content/consts.js';
import { globalContentLayer } from '../../content/content-layer.js';
import { getDataStoreFile, globalContentLayer } from '../../content/content-layer.js';
import { attachContentServerListeners } from '../../content/index.js';
import { MutableDataStore } from '../../content/mutable-data-store.js';
import { globalContentConfigObserver } from '../../content/utils.js';
Expand Down Expand Up @@ -108,7 +107,7 @@ export default async function dev(inlineConfig: AstroInlineConfig): Promise<DevS

let store: MutableDataStore | undefined;
try {
const dataStoreFile = new URL(DATA_STORE_FILE, restart.container.settings.config.cacheDir);
const dataStoreFile = getDataStoreFile(restart.container.settings);
if (existsSync(dataStoreFile)) {
store = await MutableDataStore.fromFile(dataStoreFile);
}
Expand Down
16 changes: 8 additions & 8 deletions packages/astro/src/core/sync/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { performance } from 'node:perf_hooks';
import { fileURLToPath } from 'node:url';
import { dim } from 'kleur/colors';
import { type HMRPayload, createServer } from 'vite';
import { CONTENT_TYPES_FILE, DATA_STORE_FILE } from '../../content/consts.js';
import { globalContentLayer } from '../../content/content-layer.js';
import { CONTENT_TYPES_FILE } from '../../content/consts.js';
import { getDataStoreFile, globalContentLayer } from '../../content/content-layer.js';
import { createContentTypesGenerator } from '../../content/index.js';
import { MutableDataStore } from '../../content/mutable-data-store.js';
import { getContentPaths, globalContentConfigObserver } from '../../content/utils.js';
Expand All @@ -13,7 +13,7 @@ import { telemetry } from '../../events/index.js';
import { eventCliSession } from '../../events/session.js';
import { runHookConfigDone, runHookConfigSetup } from '../../integrations/hooks.js';
import type { AstroSettings } from '../../types/astro.js';
import type { AstroConfig, AstroInlineConfig } from '../../types/public/config.js';
import type { AstroInlineConfig } from '../../types/public/config.js';
import { getTimeStat } from '../build/util.js';
import { resolveConfig } from '../config/config.js';
import { createNodeLogger } from '../config/logging.js';
Expand Down Expand Up @@ -70,11 +70,11 @@ export default async function sync(
* Clears the content layer and content collection cache, forcing a full rebuild.
*/
export async function clearContentLayerCache({
astroConfig,
settings,
logger,
fs = fsMod,
}: { astroConfig: AstroConfig; logger: Logger; fs?: typeof fsMod }) {
const dataStore = new URL(DATA_STORE_FILE, astroConfig.cacheDir);
}: { settings: AstroSettings; logger: Logger; fs?: typeof fsMod }) {
const dataStore = getDataStoreFile(settings);
if (fs.existsSync(dataStore)) {
logger.debug('content', 'clearing data store');
await fs.promises.rm(dataStore, { force: true });
Expand All @@ -96,7 +96,7 @@ export async function syncInternal({
force,
}: SyncOptions): Promise<void> {
if (force) {
await clearContentLayerCache({ astroConfig: settings.config, logger, fs });
await clearContentLayerCache({ settings, logger, fs });
}

const timerStart = performance.now();
Expand All @@ -107,7 +107,7 @@ export async function syncInternal({
settings.timer.start('Sync content layer');
let store: MutableDataStore | undefined;
try {
const dataStoreFile = new URL(DATA_STORE_FILE, settings.config.cacheDir);
const dataStoreFile = getDataStoreFile(settings);
if (existsSync(dataStoreFile)) {
store = await MutableDataStore.fromFile(dataStoreFile);
}
Expand Down
28 changes: 27 additions & 1 deletion packages/astro/src/integrations/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { bold } from 'kleur/colors';
import type { InlineConfig, ViteDevServer } from 'vite';
import astroIntegrationActionsRouteHandler from '../actions/integration.js';
import { isActionsFilePresent } from '../actions/utils.js';
import { CONTENT_LAYER_TYPE } from '../content/consts.js';
import { globalContentLayer } from '../content/content-layer.js';
import { globalContentConfigObserver } from '../content/utils.js';
import type { SerializedSSRManifest } from '../core/app/types.js';
import type { PageBuildData } from '../core/build/types.js';
import { buildClientDirectiveEntrypoint } from '../core/client-directive/index.js';
Expand All @@ -13,7 +16,11 @@ import type { AstroIntegrationLogger, Logger } from '../core/logger/core.js';
import { isServerLikeOutput } from '../core/util.js';
import type { AstroSettings } from '../types/astro.js';
import type { AstroConfig } from '../types/public/config.js';
import type { ContentEntryType, DataEntryType } from '../types/public/content.js';
import type {
ContentEntryType,
DataEntryType,
RefreshContentOptions,
} from '../types/public/content.js';
import type {
AstroIntegration,
AstroRenderer,
Expand Down Expand Up @@ -367,6 +374,24 @@ export async function runHookServerSetup({
server: ViteDevServer;
logger: Logger;
}) {
let refreshContent: undefined | ((options: RefreshContentOptions) => Promise<void>);
if (config.experimental?.contentLayer) {
refreshContent = async (options: RefreshContentOptions) => {
const contentConfig = globalContentConfigObserver.get();
if (
contentConfig.status !== 'loaded' ||
!Object.values(contentConfig.config.collections).some(
(collection) => collection.type === CONTENT_LAYER_TYPE,
)
) {
return;
}

const contentLayer = await globalContentLayer.get();
await contentLayer?.sync(options);
};
}

for (const integration of config.integrations) {
if (integration?.hooks?.['astro:server:setup']) {
await withTakingALongTimeMsg({
Expand All @@ -376,6 +401,7 @@ export async function runHookServerSetup({
server,
logger: getLogger(integration, logger),
toolbar: getToolbarServerCommunicationHelpers(server),
refreshContent,
}),
logger,
});
Expand Down
2 changes: 2 additions & 0 deletions packages/astro/src/types/public/integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { AstroIntegrationLogger } from '../../core/logger/core.js';
import type { getToolbarServerCommunicationHelpers } from '../../integrations/hooks.js';
import type { DeepPartial } from '../../type-utils.js';
import type { AstroConfig } from './config.js';
import type { RefreshContentOptions } from './content.js';
import type { RouteData } from './internal.js';
import type { DevToolbarAppEntry } from './toolbar.js';

Expand Down Expand Up @@ -187,6 +188,7 @@ export interface BaseIntegrationHooks {
server: ViteDevServer;
logger: AstroIntegrationLogger;
toolbar: ReturnType<typeof getToolbarServerCommunicationHelpers>;
refreshContent?: (options: RefreshContentOptions) => Promise<void>;
}) => void | Promise<void>;
'astro:server:start': (options: {
address: AddressInfo;
Expand Down
16 changes: 16 additions & 0 deletions packages/astro/test/content-layer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -290,5 +290,21 @@ describe('Content Layer', () => {
assert.ok(updated.fileLoader[0].data.temperament.includes('Bouncy'));
await fixture.resetAllFiles();
});

it('reloads data when an integration triggers a content refresh', async () => {
const rawJsonResponse = await fixture.fetch('/collections.json');
const initialJson = devalue.parse(await rawJsonResponse.text());
assert.equal(initialJson.increment.data.lastValue, 1);

const refreshResponse = await fixture.fetch('/_refresh', {
method: 'POST',
body: JSON.stringify({}),
});
const refreshData = await refreshResponse.json();
assert.equal(refreshData.message, 'Content refreshed successfully');
const updatedJsonResponse = await fixture.fetch('/collections.json');
const updated = devalue.parse(await updatedJsonResponse.text());
assert.equal(updated.increment.data.lastValue, 2);
});
});
});
Loading
Loading