UNPKG

@stackbit/cms-contentful

Version:

Stackbit Contentful CMS Interface

261 lines (245 loc) 9.54 kB
import _ from 'lodash'; import { createClient, ContentfulClientApi, Entry, Asset } from 'contentful'; import { AssetProps, EntryProps, ContentTypeProps, KeyValueMap, PlainClientAPI } from 'contentful-management'; import { Logger } from '@stackbit/types'; import { createPlainApiClient, fetchContentTypesUpdatedAfter, fetchAssetsUpdatedAfter, fetchEntriesUpdatedAfter } from './contentful-api-client'; import { getLastUpdatedEntityDate } from './utils'; export type ContentPollerSyncContext = { lastUpdatedEntryDate?: string; lastUpdatedAssetDate?: string; lastUpdatedContentTypeDate?: string; }; export interface ContentPollerSyncResult { entries: EntryProps<any>[]; assets: AssetProps[]; contentTypes: ContentTypeProps[]; deletedEntries: Entry<any>[]; deletedAssets: Asset[]; } export type SyncCallback = (result: ContentPollerSyncResult) => Promise<void>; export class ContentPoller { private readonly logger: Logger; private readonly client: ContentfulClientApi; private readonly managementClient: PlainClientAPI; private readonly notificationCallback: SyncCallback; private readonly pollingIntervalMs: number; private nextSyncToken: null | string; private running: boolean; private pollTimeout: NodeJS.Timeout | null; public readonly pollType: 'sync' | 'date'; private syncContext!: Required<ContentPollerSyncContext>; constructor({ spaceId, environment, previewToken, managementToken, previewHost, managementHost, uploadHost, pollingIntervalMs = 1000, pollType, syncContext, notificationCallback, logger }: { spaceId: string; environment: string; previewToken: string; managementToken: string; previewHost?: string; managementHost?: string; uploadHost?: string; pollingIntervalMs?: number; pollType?: 'sync' | 'date'; syncContext?: ContentPollerSyncContext; notificationCallback: SyncCallback; logger: Logger; }) { this.logger = logger; this.nextSyncToken = null; this.running = false; this.pollTimeout = null; this.pollingIntervalMs = pollingIntervalMs; this.pollType = pollType ?? 'date'; this.notificationCallback = notificationCallback; this.setSyncContext(syncContext); this.client = createClient({ space: spaceId, environment: environment, accessToken: previewToken, host: previewHost ?? 'preview.contentful.com' }); this.managementClient = createPlainApiClient({ accessToken: managementToken, spaceId, environment, managementHost, uploadHost }); this.handleTimeout = this.handleTimeout.bind(this); } start() { this.logger.debug(`start polling contentful for content changes using ${this.pollType}`); if (!this.running) { this.running = true; this.setPollTimeout(); } } stop() { this.logger.debug(`stop polling contentful for content changes`); if (this.pollTimeout) { clearTimeout(this.pollTimeout); this.pollTimeout = null; } this.running = false; } setSyncContext(syncContext?: ContentPollerSyncContext) { const now = new Date().toISOString(); this.syncContext = { lastUpdatedEntryDate: syncContext?.lastUpdatedEntryDate ?? now, lastUpdatedAssetDate: syncContext?.lastUpdatedAssetDate ?? now, lastUpdatedContentTypeDate: syncContext?.lastUpdatedContentTypeDate ?? now }; } private setPollTimeout() { if (this.pollTimeout) { clearTimeout(this.pollTimeout); } this.pollTimeout = setTimeout(this.handleTimeout, this.pollingIntervalMs); } async handleTimeout() { this.pollTimeout = null; try { if (this.pollType === 'sync') { await this.pollSync(); } else { await this.pollDate(); } } catch (error: any) { this.logger.warn('error polling', { error: error.message }); } if (this.running) { this.setPollTimeout(); } } async pollDate(): Promise<any> { const prevSyncContext = this.syncContext; const entries = await fetchEntriesUpdatedAfter(this.managementClient, this.syncContext.lastUpdatedEntryDate); const assets = await fetchAssetsUpdatedAfter(this.managementClient, this.syncContext.lastUpdatedAssetDate); const contentTypes = await fetchContentTypesUpdatedAfter(this.managementClient, this.syncContext.lastUpdatedContentTypeDate); // Check that syncContext wasn't updated externally via setSyncContext() while the content was fetched. // If it was updated (prevSyncContext != this.syncContext), then ignore the dates from the fetched content // and keep the syncContext that was set externally via setSyncContext(). if (_.isEqual(prevSyncContext, this.syncContext)) { this.syncContext = _.defaults( { lastUpdatedEntryDate: getLastUpdatedEntityDate(entries), lastUpdatedAssetDate: getLastUpdatedEntityDate(assets), lastUpdatedContentTypeDate: getLastUpdatedEntityDate(contentTypes) }, this.syncContext ); } if (contentTypes.length || entries.length || assets.length) { this.logger.info( `poll date response: got ` + `${entries.length} changed entries, ` + `${assets.length} changed assets, ` + `${contentTypes.length} changed content types` ); await this.notificationCallback({ contentTypes, entries, assets, deletedEntries: [], deletedAssets: [] }); } } async pollSync(): Promise<any> { const initial = this.nextSyncToken === null; let hasMoreItems = true; let hasItems = false; const result: ContentPollerSyncResult = { entries: [], assets: [], deletedEntries: [], deletedAssets: [], contentTypes: [] }; if (initial) { this.logger.info('Running initial sync...'); } while (hasMoreItems) { const response = await this.client.sync({ initial: this.nextSyncToken === null, nextSyncToken: this.nextSyncToken, resolveLinks: false, limit: 1000 }); this.logger.info(`sync response: ${response.entries.length}`); const isEmptyResponse = _.every(_.pick(response, ['entries', 'assets', 'deletedEntries', 'deletedAssets']), _.isEmpty); if (this.nextSyncToken === response.nextSyncToken || isEmptyResponse) { hasMoreItems = false; } else { if (!initial) { // refetch entries from management api to get full sys data const { entries, assets } = await this.batchRefetchData({ entryIds: response.entries.map((entry) => entry.sys.id), assetIds: response.assets.map((entry) => entry.sys.id) }); hasItems = true; result.entries = result.entries.concat(entries); result.assets = result.assets.concat(assets); result.deletedEntries = result.deletedEntries.concat(response.deletedEntries); result.deletedAssets = result.deletedAssets.concat(response.deletedAssets); } } this.nextSyncToken = response.nextSyncToken; } if (!initial && hasItems) { await this.notificationCallback(result); } if (initial) { this.logger.info('Initial sync done.'); } } private async batchRefetchData({ entryIds, assetIds }: { entryIds: string[]; assetIds: string[]; }): Promise<{ entries: EntryProps<KeyValueMap>[]; assets: AssetProps[] }> { const limit = 300; const entryChunks = _.chunk(entryIds, limit); const entries = _.flatMap( await Promise.all( entryChunks.map((chunk) => this.managementClient.entry.getMany({ query: { limit: limit, 'sys.id[in]': chunk.join(',') } }) ) ), (result) => result.items ); const assetChunks = _.chunk(assetIds, limit); const assets = _.flatMap( await Promise.all( assetChunks.map((chunk) => this.managementClient.asset.getMany({ query: { limit: limit, 'sys.id[in]': chunk.join(',') } }) ) ), (result) => result.items ); return { entries, assets }; } }