diff --git a/ghost/core/core/boot.js b/ghost/core/core/boot.js index b741f0392d5..112d5370f5b 100644 --- a/ghost/core/core/boot.js +++ b/ghost/core/core/boot.js @@ -322,12 +322,14 @@ async function initServices({ghostServer, config, prometheusClient}) { debug('Begin: Services'); const identityTokens = require('./server/services/identity-tokens'); + const donationService = require('./server/services/donations'); + donationService.init(); const stripe = require('./server/services/stripe'); const members = require('./server/services/members'); const tiers = require('./server/services/tiers'); const permissions = require('./server/services/permissions'); - const indexnow = require('./server/services/indexnow-ping').default; - const slack = require('./server/services/slack-ping').default; + const indexnow = require('./server/services/indexnow-ping'); + const slack = require('./server/services/slack-ping'); const webhooks = require('./server/services/webhooks'); const postScheduling = require('./server/services/post-scheduling').default; const comments = require('./server/services/comments'); @@ -342,14 +344,16 @@ async function initServices({ghostServer, config, prometheusClient}) { const mentionsService = require('./server/services/mentions'); const tagsPublic = require('./server/services/tags-public'); const postsPublic = require('./server/services/posts-public'); + const postsService = require('./server/services/posts'); const slackNotifications = require('./server/services/slack-notifications'); const mediaInliner = require('./server/services/media-inliner'); - const donationService = require('./server/services/donations'); + const announcementBarService = require('./server/services/announcement-bar-service'); const giftService = require('./server/services/gifts'); const machinePaymentsService = require('./server/services/machine-payments'); const recommendationsService = require('./server/services/recommendations'); const emailAddressService = require('./server/services/email-address'); const statsService = require('./server/services/stats'); + const tinybird = require('./server/services/tinybird'); const explorePingService = require('./server/services/explore-ping'); const domainEvents = require('@tryghost/domain-events'); const automations = require('./server/services/automations'); @@ -404,10 +408,10 @@ async function initServices({ghostServer, config, prometheusClient}) { emailSuppressionList.init(), slackNotifications.init(), mediaInliner.init(), - donationService.init(), + announcementBarService.init(), recommendationsService.init(), + tinybird.init(), statsService.init(), - explorePingService.init(), giftService.init({ apiUrl, schedulerAdapter, @@ -423,6 +427,9 @@ async function initServices({ghostServer, config, prometheusClient}) { }) ]); + postsService.init(); + await explorePingService.init(); + if (schedulerAdapter.rescheduleOnBoot) { await postScheduling.rescheduleAll(); } diff --git a/ghost/core/core/server/api/endpoints/announcements.js b/ghost/core/core/server/api/endpoints/announcements.js index 2a6bbdfd85a..14576b66b17 100644 --- a/ghost/core/core/server/api/endpoints/announcements.js +++ b/ghost/core/core/server/api/endpoints/announcements.js @@ -1,4 +1,4 @@ -const announcementBarSettings = require('../../services/announcement-bar-service'); +const announcementBarSettings = require('../../services/announcement-bar-service').service; /** @type {import('@tryghost/api-framework').Controller} */ const controller = { diff --git a/ghost/core/core/server/api/endpoints/gift-links.ts b/ghost/core/core/server/api/endpoints/gift-links.ts index 59d42571f22..237086b1574 100644 --- a/ghost/core/core/server/api/endpoints/gift-links.ts +++ b/ghost/core/core/server/api/endpoints/gift-links.ts @@ -40,7 +40,7 @@ const controller = { return assertCanEditAndGift(frame); }, query(frame: Frame) { - return service!.getPost(frame.options.id); + return service.getPost(frame.options.id); } }, @@ -53,7 +53,7 @@ const controller = { return assertCanEditAndGift(frame); }, query(frame: Frame) { - return service!.ensure(requestContextFromFrame(frame), frame.options.id); + return service.ensure(requestContextFromFrame(frame), frame.options.id); } }, @@ -66,7 +66,7 @@ const controller = { return assertCanEditAndGift(frame); }, query(frame: Frame) { - return service!.create(requestContextFromFrame(frame), frame.options.id); + return service.create(requestContextFromFrame(frame), frame.options.id); } }, @@ -77,7 +77,7 @@ const controller = { return permissionsService.canThis(frame.options.context).removeAll.gift_link(); }, async query(frame: Frame) { - const count = await service!.removeAll(requestContextFromFrame(frame)); + const count = await service.removeAll(requestContextFromFrame(frame)); return {count}; } } diff --git a/ghost/core/core/server/api/endpoints/pages.js b/ghost/core/core/server/api/endpoints/pages.js index 2428d24112a..ca7937d8bb1 100644 --- a/ghost/core/core/server/api/endpoints/pages.js +++ b/ghost/core/core/server/api/endpoints/pages.js @@ -1,7 +1,7 @@ const models = require('../../models'); const tpl = require('@tryghost/tpl'); const errors = require('@tryghost/errors'); -const getPostServiceInstance = require('../../services/posts/posts-service-instance'); +const postsService = require('../../services/posts').service; const {rejectAdminApiRestrictedFieldsTransformer} = require('./utils/api-filter-utils'); const ALLOWED_INCLUDES = ['tags', 'authors', 'authors.roles', 'tiers', 'count.signups', 'count.paid_conversions', 'post_revisions', 'post_revisions.author']; const UNSAFE_ATTRS = ['status', 'authors', 'visibility']; @@ -10,7 +10,6 @@ const messages = { pageNotFound: 'Page not found.' }; -const postsService = getPostServiceInstance(); /** @type {import('@tryghost/api-framework').Controller} */ const controller = { diff --git a/ghost/core/core/server/api/endpoints/posts-public.js b/ghost/core/core/server/api/endpoints/posts-public.js index 96129307e2c..adbaddb6d26 100644 --- a/ghost/core/core/server/api/endpoints/posts-public.js +++ b/ghost/core/core/server/api/endpoints/posts-public.js @@ -2,8 +2,7 @@ const models = require('../../models'); const tpl = require('@tryghost/tpl'); const errors = require('@tryghost/errors'); const postsPublicService = require('../../services/posts-public'); -const getPostServiceInstance = require('../../services/posts/posts-service-instance'); -const postsService = getPostServiceInstance(); +const postsService = require('../../services/posts').service; const {rejectContentApiRestrictedFieldsTransformer} = require('./utils/api-filter-utils'); const {generateGiftKeyData, applyGiftAccess} = require('./utils/gift-link-access'); const {generateOptionsData, generateAuthData} = require('./utils/public-cache-keys'); diff --git a/ghost/core/core/server/api/endpoints/posts.js b/ghost/core/core/server/api/endpoints/posts.js index c943413c1ab..581d29bd9e9 100644 --- a/ghost/core/core/server/api/endpoints/posts.js +++ b/ghost/core/core/server/api/endpoints/posts.js @@ -1,7 +1,7 @@ const urlUtils = require('../../../shared/url-utils').default; const models = require('../../models'); const {getCSVExportFileName} = require('./utils/csv-export-filename'); -const getPostServiceInstance = require('../../services/posts/posts-service-instance'); +const postsService = require('../../services/posts').service; const {rejectAdminApiRestrictedFieldsTransformer} = require('./utils/api-filter-utils'); const allowedIncludes = [ 'tags', @@ -22,7 +22,6 @@ const allowedIncludes = [ ]; const unsafeAttrs = ['status', 'authors', 'visibility']; -const postsService = getPostServiceInstance(); /** * @param {string} event diff --git a/ghost/core/core/server/api/endpoints/search-index-public.js b/ghost/core/core/server/api/endpoints/search-index-public.js index e4801750120..6e2561c9e8a 100644 --- a/ghost/core/core/server/api/endpoints/search-index-public.js +++ b/ghost/core/core/server/api/endpoints/search-index-public.js @@ -1,8 +1,7 @@ const models = require('../../models'); const urlService = require('../../services/url'); const {requiredUrlColumns} = require('./utils/serializers/input/utils/url'); -const getPostServiceInstance = require('../../services/posts/posts-service-instance'); -const postsService = getPostServiceInstance(); +const postsService = require('../../services/posts').service; const urlRelationsForRouting = () => { const withRelated = urlService.getRequiredRelations(); diff --git a/ghost/core/core/server/api/endpoints/search-index.js b/ghost/core/core/server/api/endpoints/search-index.js index 3bee7aa0053..6b112e60750 100644 --- a/ghost/core/core/server/api/endpoints/search-index.js +++ b/ghost/core/core/server/api/endpoints/search-index.js @@ -1,8 +1,7 @@ const models = require('../../models'); const urlService = require('../../services/url'); const {requiredUrlColumns} = require('./utils/serializers/input/utils/url'); -const getPostServiceInstance = require('../../services/posts/posts-service-instance'); -const postsService = getPostServiceInstance(); +const postsService = require('../../services/posts').service; const urlRelationsForRouting = () => { const withRelated = urlService.getRequiredRelations(); diff --git a/ghost/core/core/server/api/endpoints/tinybird.js b/ghost/core/core/server/api/endpoints/tinybird.js index 4404c4a1fd7..3d108872bd2 100644 --- a/ghost/core/core/server/api/endpoints/tinybird.js +++ b/ghost/core/core/server/api/endpoints/tinybird.js @@ -1,4 +1,4 @@ -const TinybirdServiceWrapper = require('../../services/tinybird'); +const tinybird = require('../../services/tinybird'); /** @type {import('@tryghost/api-framework').Controller} */ const controller = { @@ -13,8 +13,7 @@ const controller = { method: 'browse' }, async query() { - TinybirdServiceWrapper.init(); - const tokenData = TinybirdServiceWrapper.instance?.getToken() ?? null; + const tokenData = tinybird.service.getToken(); if (tokenData?.exp) { return { diff --git a/ghost/core/core/server/api/endpoints/utils/gift-link-access.ts b/ghost/core/core/server/api/endpoints/utils/gift-link-access.ts index 1b9cfa80910..c7a0a7450e9 100644 --- a/ghost/core/core/server/api/endpoints/utils/gift-link-access.ts +++ b/ghost/core/core/server/api/endpoints/utils/gift-link-access.ts @@ -38,7 +38,7 @@ export async function generateGiftKeyData(frame: Frame): Promise<{present: true; return undefined; } - const post = await giftLinksService!.getPostByToken(token); + const post = await giftLinksService.getPostByToken(token); const postId = post ? post.id : null; frame.giftLinkPostId = postId; diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js index 1ddbf1a56a1..32f711393e5 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js @@ -15,8 +15,7 @@ const utils = require('../../../index'); const postsMetaSchema = require('../../../../../../data/schema').tables.posts_meta; -const getPostServiceInstance = require('../../../../../../services/posts/posts-service-instance'); -const postsService = getPostServiceInstance(); +const postsService = require('../../../../../../services/posts').service; const commentsService = require('../../../../../../services/comments'); const memberAttribution = require('../../../../../../services/member-attribution'); diff --git a/ghost/core/core/server/lib/lexical.js b/ghost/core/core/server/lib/lexical.js index e6bde308b44..717e93e5483 100644 --- a/ghost/core/core/server/lib/lexical.js +++ b/ghost/core/core/server/lib/lexical.js @@ -31,8 +31,7 @@ function createLexicalHtmlRenderer(onError) { function buildRenderOptions(userOptions) { if (!postsService) { - const getPostServiceInstance = require('../services/posts/posts-service-instance'); - postsService = getPostServiceInstance(); + postsService = require('../services/posts').service; } if (!serializePosts) { serializePosts = require('../api/endpoints/utils/serializers/output/posts').all; diff --git a/ghost/core/core/server/services/announcement-bar-service/index.js b/ghost/core/core/server/services/announcement-bar-service/index.js index 76c1bdf3ea9..0b192c8e758 100644 --- a/ghost/core/core/server/services/announcement-bar-service/index.js +++ b/ghost/core/core/server/services/announcement-bar-service/index.js @@ -1,12 +1,27 @@ -const settingsCache = require('../../../shared/settings-cache'); const AnnouncementBarSettings = require('./announcement-bar-settings'); +const {lazySingleton} = require('../../../shared/lazy-singleton'); -const announcementBarService = new AnnouncementBarSettings({ - getAnnouncementSettings: () => ({ - announcement: settingsCache.get('announcement_content'), - announcement_background: settingsCache.get('announcement_background'), - announcement_visibility: settingsCache.get('announcement_visibility') - }) -}); +let instance; -module.exports = announcementBarService; +const service = lazySingleton('AnnouncementBarSettings', () => instance); + +function init() { + if (instance) { + return; + } + + const settingsCache = require('../../../shared/settings-cache'); + + instance = new AnnouncementBarSettings({ + getAnnouncementSettings: () => ({ + announcement: settingsCache.get('announcement_content'), + announcement_background: settingsCache.get('announcement_background'), + announcement_visibility: settingsCache.get('announcement_visibility') + }) + }); +} + +module.exports = { + init, + service +}; diff --git a/ghost/core/core/server/services/donations/donation-service-wrapper.js b/ghost/core/core/server/services/donations/donation-service-wrapper.js deleted file mode 100644 index c2480d8dfaf..00000000000 --- a/ghost/core/core/server/services/donations/donation-service-wrapper.js +++ /dev/null @@ -1,19 +0,0 @@ -const {DonationPaymentEvent: DonationPaymentEventModel} = require('../../models'); - -class DonationServiceWrapper { - repository; - - init() { - if (this.repository) { - return; - } - - const {DonationBookshelfRepository} = require('./donation-bookshelf-repository'); - - this.repository = new DonationBookshelfRepository({ - DonationPaymentEventModel - }); - } -} - -module.exports = DonationServiceWrapper; diff --git a/ghost/core/core/server/services/donations/index.js b/ghost/core/core/server/services/donations/index.js index 35f99f93e3f..33d527671ab 100644 --- a/ghost/core/core/server/services/donations/index.js +++ b/ghost/core/core/server/services/donations/index.js @@ -1,3 +1,23 @@ -const DonationServiceWrapper = require('./donation-service-wrapper'); +const {lazySingleton} = require('../../../shared/lazy-singleton'); -module.exports = new DonationServiceWrapper(); +let repository; + +const service = lazySingleton('DonationRepository', () => repository); + +function init() { + if (repository) { + return; + } + + const {DonationPaymentEvent: DonationPaymentEventModel} = require('../../models'); + const {DonationBookshelfRepository} = require('./donation-bookshelf-repository'); + + repository = new DonationBookshelfRepository({ + DonationPaymentEventModel + }); +} + +module.exports = { + init, + service +}; diff --git a/ghost/core/core/server/services/explore-ping/index.ts b/ghost/core/core/server/services/explore-ping/index.ts index 53a146b5347..a243dfd1d0d 100644 --- a/ghost/core/core/server/services/explore-ping/index.ts +++ b/ghost/core/core/server/services/explore-ping/index.ts @@ -1,40 +1,42 @@ import {ExplorePingService} from './explore-ping-service'; +import {lazySingleton} from '../../../shared/lazy-singleton'; -const config = require('../../../shared/config'); -const logging = require('@tryghost/logging'); -const ghostVersion = require('@tryghost/version'); -const request = require('@tryghost/request'); -const settingsCache = require('../../../shared/settings-cache'); -const posts = require('../posts/posts-service-instance'); -const members = require('../members'); -const statsService = require('../stats'); +let instance: ExplorePingService | undefined; -// Export the creation function for testing -export function createService(): ExplorePingService { - return new ExplorePingService({ +export const service = lazySingleton('ExplorePingService', () => instance); + +export async function init(): Promise { + if (instance) { + return; + } + + const config = require('../../../shared/config'); + + const logging = require('@tryghost/logging'); + const ghostVersion = require('@tryghost/version'); + const request = require('@tryghost/request'); + const settingsCache = require('../../../shared/settings-cache'); + const posts = require('../posts').service; + const members = require('../members'); + const statsService = require('../stats'); + + instance = new ExplorePingService({ settingsCache, config, logging, ghostVersion, request, - posts: posts(), + posts, members, statsService }); -} -export async function init(): Promise { - // The explore ping is a background "phone home" request. It should not run - // in the test environment (cf. the update-check service, which gates on the - // same environments), where there is no explore URL configured. - if (!config.isProductionOrDevelopment()) { - return; + // The explore ping is a background "phone home" request. Construct the + // service in every environment so init() always fulfils the service + // contract, but only trigger the request in production or development. + if (config.isProductionOrDevelopment()) { + // The final intention is to have this run on a schedule. For the + // initial version, ping when the server starts without awaiting it. + instance.ping(); } - - const explorePingService = createService(); - - // The final intention is to have this run on a schedule - // For the initial version, we'll just ping when the server starts - // Without waiting for the response - explorePingService.ping(); } diff --git a/ghost/core/core/server/services/gift-links/index.ts b/ghost/core/core/server/services/gift-links/index.ts index a0e1c059cae..02d8645d419 100644 --- a/ghost/core/core/server/services/gift-links/index.ts +++ b/ghost/core/core/server/services/gift-links/index.ts @@ -1,13 +1,16 @@ import {GiftLinksService} from './service'; import {recordGiftLinkAction, type RecordGiftLinkAction} from './actions'; +import {lazySingleton} from '../../../shared/lazy-singleton'; export type {RequestContext} from './actions'; // Constructed by init() at boot, not at import: knex is only available once the DB has connected. -export let service: GiftLinksService | undefined; +let instance: GiftLinksService | undefined; + +export const service = lazySingleton('GiftLinksService', () => instance); export function init(): void { - if (service) { + if (instance) { return; } @@ -16,5 +19,5 @@ export function init(): void { const recordAction: RecordGiftLinkAction = ({context, verb, subject}) => recordGiftLinkAction({Action: models.Action, context, verb, subject}); - service = new GiftLinksService({knex, recordAction}); + instance = new GiftLinksService({knex, recordAction}); } diff --git a/ghost/core/core/server/services/indexnow-ping/index.ts b/ghost/core/core/server/services/indexnow-ping/index.ts index 092f9d8132e..4b112559e74 100644 --- a/ghost/core/core/server/services/indexnow-ping/index.ts +++ b/ghost/core/core/server/services/indexnow-ping/index.ts @@ -1,35 +1,32 @@ import {IndexNowPingService} from './indexnow-ping-service'; +import {lazySingleton} from '../../../shared/lazy-singleton'; -class IndexNowPingServiceWrapper { - service?: IndexNowPingService; +let instance: IndexNowPingService | undefined; - init(): void { - if (this.service) { - // Already done - return; - } +export const service = lazySingleton('IndexNowPingService', () => instance); - // Wire up all the dependencies - const settingsCache = require('../../../shared/settings-cache'); - const config = require('../../../shared/config'); - const urlService = require('../url'); - const urlUtils = require('../../../shared/url-utils').default; - const request = require('@tryghost/request'); - const logging = require('@tryghost/logging'); - const events = require('../../lib/common/events'); +export function init(): void { + if (instance) { + return; + } - this.service = new IndexNowPingService({ - settingsCache, - config, - urlService, - urlUtils, - request, - logging, - events - }); + const settingsCache = require('../../../shared/settings-cache'); + const config = require('../../../shared/config'); + const urlService = require('../url'); + const urlUtils = require('../../../shared/url-utils').default; + const request = require('@tryghost/request'); + const logging = require('@tryghost/logging'); + const events = require('../../lib/common/events'); - this.service.subscribeEvents(); - } -} + instance = new IndexNowPingService({ + settingsCache, + config, + urlService, + urlUtils, + request, + logging, + events + }); -export default new IndexNowPingServiceWrapper(); + instance.subscribeEvents(); +} diff --git a/ghost/core/core/server/services/posts/posts-service-instance.js b/ghost/core/core/server/services/posts/index.js similarity index 82% rename from ghost/core/core/server/services/posts/posts-service-instance.js rename to ghost/core/core/server/services/posts/index.js index 09ef216d7b2..a5fa80e2192 100644 --- a/ghost/core/core/server/services/posts/posts-service-instance.js +++ b/ghost/core/core/server/services/posts/index.js @@ -1,11 +1,17 @@ const PostsService = require('./posts-service'); const PostsExporter = require('./posts-exporter'); const url = require('../../../server/api/endpoints/utils/serializers/output/utils/url'); +const {lazySingleton} = require('../../../shared/lazy-singleton'); + +let instance; + +const service = lazySingleton('PostsService', () => instance); + +function init() { + if (instance) { + return; + } -/** - * @returns {InstanceType} instance of the PostsService - */ -const getPostServiceInstance = () => { const urlUtils = require('../../../shared/url-utils').default; const labs = require('../../../shared/labs'); const models = require('../../models'); @@ -32,7 +38,7 @@ const getPostServiceInstance = () => { settingsHelpers }); - return new PostsService({ + instance = new PostsService({ urlUtils: urlUtils, models: models, isSet: flag => labs.isSet(flag), // don't use bind, that breaks test subbing of labs @@ -40,8 +46,9 @@ const getPostServiceInstance = () => { emailService: emailService.service, postsExporter }); -}; +} -module.exports = getPostServiceInstance; -// exposed for testing purposes only -module.exports.PostsService = PostsService; +module.exports = { + init, + service +}; diff --git a/ghost/core/core/server/services/slack-ping/index.ts b/ghost/core/core/server/services/slack-ping/index.ts index 1ec1176b6f9..bfa9b627674 100644 --- a/ghost/core/core/server/services/slack-ping/index.ts +++ b/ghost/core/core/server/services/slack-ping/index.ts @@ -1,35 +1,32 @@ import {SlackPingService} from './slack-ping-service'; +import {lazySingleton} from '../../../shared/lazy-singleton'; -class SlackPingServiceWrapper { - service?: SlackPingService; +let instance: SlackPingService | undefined; - init(): void { - if (this.service) { - // Already done - return; - } +export const service = lazySingleton('SlackPingService', () => instance); - // Wire up all the dependencies - const {blogIcon} = require('../../lib/image'); - const events = require('../../lib/common/events'); - const logging = require('@tryghost/logging'); - const request = require('../../lib/request-external'); - const settingsCache = require('../../../shared/settings-cache'); - const urlService = require('../url'); - const urlUtils = require('../../../shared/url-utils').default; +export function init(): void { + if (instance) { + return; + } - this.service = new SlackPingService({ - blogIcon, - events, - logging, - request, - settingsCache, - urlService, - urlUtils - }); + const {blogIcon} = require('../../lib/image'); + const events = require('../../lib/common/events'); + const logging = require('@tryghost/logging'); + const request = require('../../lib/request-external'); + const settingsCache = require('../../../shared/settings-cache'); + const urlService = require('../url'); + const urlUtils = require('../../../shared/url-utils').default; - this.service.subscribeEvents(); - } -} + instance = new SlackPingService({ + blogIcon, + events, + logging, + request, + settingsCache, + urlService, + urlUtils + }); -export default new SlackPingServiceWrapper(); + instance.subscribeEvents(); +} diff --git a/ghost/core/core/server/services/stats/stats-service.js b/ghost/core/core/server/services/stats/stats-service.js index 3161df729a3..3697fc74be3 100644 --- a/ghost/core/core/server/services/stats/stats-service.js +++ b/ghost/core/core/server/services/stats/stats-service.js @@ -248,13 +248,13 @@ class StatsService { if (settingsCache.get('web_analytics_enabled')) { // TODO: move the tinybird client to the tinybird service - const TinybirdServiceWrapper = require('../tinybird'); - TinybirdServiceWrapper.init(); + const tinybird = require('../tinybird'); + tinybird.init(); tinybirdClient = require('./utils/tinybird').create({ config, request, settingsCache, - tinybirdService: TinybirdServiceWrapper.instance + tinybirdService: tinybird.service }); } diff --git a/ghost/core/core/server/services/stripe/service.js b/ghost/core/core/server/services/stripe/service.js index a7093447cf4..3d4459ffdc1 100644 --- a/ghost/core/core/server/services/stripe/service.js +++ b/ghost/core/core/server/services/stripe/service.js @@ -9,7 +9,7 @@ const events = require('../../lib/common/events'); const models = require('../../models'); const {getConfig} = require('./config'); const settingsHelpers = require('../settings-helpers'); -const donationService = require('../donations'); +const donationRepository = require('../donations').service; const giftService = require('../gifts'); const staffService = require('../staff'); const labs = require('../../../shared/labs'); @@ -60,7 +60,7 @@ module.exports = new StripeService({ }]); } }, - donationService, + donationRepository, giftService, staffService, settingsCache diff --git a/ghost/core/core/server/services/stripe/stripe-service.js b/ghost/core/core/server/services/stripe/stripe-service.js index 2314554d6cc..ec1f0921639 100644 --- a/ghost/core/core/server/services/stripe/stripe-service.js +++ b/ghost/core/core/server/services/stripe/stripe-service.js @@ -37,7 +37,7 @@ module.exports = class StripeService { * @param {object} deps * @param {*} deps.labs * @param {*} deps.membersService - * @param {*} deps.donationService + * @param {*} deps.donationRepository * @param {*} deps.giftService * @param {*} deps.staffService * @param {import('./webhook-manager').StripeWebhook} deps.StripeWebhook @@ -54,7 +54,7 @@ module.exports = class StripeService { constructor({ labs, membersService, - donationService, + donationRepository, giftService, staffService, StripeWebhook, @@ -111,7 +111,7 @@ module.exports = class StripeService { return membersService.api.events; }, get donationRepository(){ - return donationService.repository; + return donationRepository; }, get giftService(){ return giftService.service; diff --git a/ghost/core/core/server/services/tinybird/index.js b/ghost/core/core/server/services/tinybird/index.js index ff110775777..109e519c7c3 100644 --- a/ghost/core/core/server/services/tinybird/index.js +++ b/ghost/core/core/server/services/tinybird/index.js @@ -1 +1,34 @@ -module.exports = require('./tinybird-service-wrapper'); +const TinybirdService = require('./tinybird-service'); +const {lazySingleton} = require('../../../shared/lazy-singleton'); + +let instance; + +const service = lazySingleton('TinybirdService', () => instance); + +function init() { + if (instance) { + return; + } + + const config = require('../../../shared/config'); + const settingsCache = require('../../../shared/settings-cache'); + const logging = require('@tryghost/logging'); + + const tinybirdConfig = config.get('tinybird'); + const siteUuid = settingsCache.get('site_uuid'); + + if (!tinybirdConfig || !siteUuid) { + logging.warn('Tinybird service not configured'); + } + + instance = new TinybirdService({ + tinybirdConfig, + getTinybirdConfig: () => config.get('tinybird'), + siteUuid + }); +} + +module.exports = { + init, + service +}; diff --git a/ghost/core/core/server/services/tinybird/tinybird-service-wrapper.js b/ghost/core/core/server/services/tinybird/tinybird-service-wrapper.js deleted file mode 100644 index 3dc76b93ec3..00000000000 --- a/ghost/core/core/server/services/tinybird/tinybird-service-wrapper.js +++ /dev/null @@ -1,32 +0,0 @@ -const TinybirdService = require('./tinybird-service'); - -module.exports = class TinybirdServiceWrapper { - /** @type TinybirdService */ - static instance; - - static init() { - const config = require('../../../shared/config'); - const settingsCache = require('../../../shared/settings-cache'); - const logging = require('@tryghost/logging'); - - const tinybirdConfig = config.get('tinybird'); - const siteUuid = settingsCache.get('site_uuid'); - - if (!tinybirdConfig || !siteUuid) { - logging.warn('Tinybird service not configured'); - TinybirdServiceWrapper.instance = null; - return; - } - - // Create instance with valid config - TinybirdServiceWrapper.instance = new TinybirdService({ - tinybirdConfig, - siteUuid - }); - } - - // Reset the instance for testing - static reset() { - TinybirdServiceWrapper.instance = null; - } -}; diff --git a/ghost/core/core/server/services/tinybird/tinybird-service.js b/ghost/core/core/server/services/tinybird/tinybird-service.js index f9316e8b6af..9ca98dad5d1 100644 --- a/ghost/core/core/server/services/tinybird/tinybird-service.js +++ b/ghost/core/core/server/services/tinybird/tinybird-service.js @@ -19,6 +19,7 @@ const jwt = require('jsonwebtoken'); /** * @typedef {Object} TinybirdConstructorOptions * @property {TinybirdConfig} tinybirdConfig - Tinybird configuration object + * @property {() => TinybirdConfig} [getTinybirdConfig] - Returns the current Tinybird configuration * @property {string} siteUuid - Unique identifier for the site */ @@ -81,9 +82,15 @@ class TinybirdService { * Creates a new TinybirdService instance * @param {TinybirdConstructorOptions} options - Configuration options */ - constructor({tinybirdConfig, siteUuid}) { + constructor({tinybirdConfig, getTinybirdConfig = () => tinybirdConfig, siteUuid}) { + this.getTinybirdConfig = getTinybirdConfig; + this.defaultSiteUuid = siteUuid; + this._configure(tinybirdConfig); + } + + _configure(tinybirdConfig) { this.tinybirdConfig = tinybirdConfig; - this.siteUuid = tinybirdConfig?.stats?.id || siteUuid; + this.siteUuid = tinybirdConfig?.stats?.id || this.defaultSiteUuid; // Flags for determining which token to use // We should aim to simplify this in the future @@ -100,7 +107,14 @@ class TinybirdService { * For now we need to remain backwards compatible with the old stats token * @returns {{token: string, exp?: number}|null} Object with token and optional exp, or null if generation fails */ - getToken({name = `tinybird-jwt-${this.siteUuid}`, expiresInMinutes = 180} = {}) { + getToken({name, expiresInMinutes = 180} = {}) { + const tinybirdConfig = this.getTinybirdConfig(); + if (tinybirdConfig !== this.tinybirdConfig) { + this._configure(tinybirdConfig); + } + + name ??= `tinybird-jwt-${this.siteUuid}`; + // Prefer JWT tokens if enabled if (this.isJwtEnabled) { // Generate a new JWT token if it doesn't exist or is expired diff --git a/ghost/core/core/shared/lazy-singleton.ts b/ghost/core/core/shared/lazy-singleton.ts new file mode 100644 index 00000000000..054a2af7dee --- /dev/null +++ b/ghost/core/core/shared/lazy-singleton.ts @@ -0,0 +1,46 @@ +import {InternalServerError} from '@tryghost/errors'; + +/** + * Exposes a stable facade for a service that is constructed during boot. + * + * The facade resolves the current service instance for every property access, + * allowing require-shaped consumers to import it before the service is + * initialized without exposing an optional value. + */ +export function lazySingleton(name: string, getInstance: () => T | undefined): T { + const resolve = (): T => { + const instance = getInstance(); + + if (!instance) { + throw new InternalServerError({ + message: `${name} must be initialized before use` + }); + } + + return instance; + }; + + return new Proxy({} as T, { + get(_target, property) { + const instance = resolve(); + const value = Reflect.get(instance, property, instance); + + return typeof value === 'function' ? value.bind(instance) : value; + }, + set(_target, property, value) { + return Reflect.set(resolve(), property, value); + }, + has(_target, property) { + return Reflect.has(resolve(), property); + }, + defineProperty(_target, property, attributes) { + return Reflect.defineProperty(resolve(), property, attributes); + }, + deleteProperty(_target, property) { + return Reflect.deleteProperty(resolve(), property); + }, + getPrototypeOf() { + return Reflect.getPrototypeOf(resolve()); + } + }); +} diff --git a/ghost/core/test/unit/api/endpoints/search-index-public.test.js b/ghost/core/test/unit/api/endpoints/search-index-public.test.js index 6541a5f838d..fc221a9896f 100644 --- a/ghost/core/test/unit/api/endpoints/search-index-public.test.js +++ b/ghost/core/test/unit/api/endpoints/search-index-public.test.js @@ -2,14 +2,15 @@ const assert = require('node:assert/strict'); const sinon = require('sinon'); const models = require('../../../../core/server/models'); const urlService = require('../../../../core/server/services/url'); -const {PostsService} = require('../../../../core/server/services/posts/posts-service-instance'); +const posts = require('../../../../core/server/services/posts'); +const PostsService = require('../../../../core/server/services/posts/posts-service'); const searchIndexController = require('../../../../core/server/api/endpoints/search-index-public'); describe('Search index public controller', function () { let browsePostsStub; beforeEach(function () { - // the controller constructs its own PostsService instance + posts.init(); browsePostsStub = sinon.stub(PostsService.prototype, 'browsePosts').resolves({data: []}); sinon.stub(models.Tag, 'findPage').resolves({data: []}); sinon.stub(models.Author, 'findPage').resolves({data: []}); diff --git a/ghost/core/test/unit/api/endpoints/search-index.test.js b/ghost/core/test/unit/api/endpoints/search-index.test.js index f43d0584226..9d7ede79101 100644 --- a/ghost/core/test/unit/api/endpoints/search-index.test.js +++ b/ghost/core/test/unit/api/endpoints/search-index.test.js @@ -2,14 +2,15 @@ const assert = require('node:assert/strict'); const sinon = require('sinon'); const models = require('../../../../core/server/models'); const urlService = require('../../../../core/server/services/url'); -const {PostsService} = require('../../../../core/server/services/posts/posts-service-instance'); +const posts = require('../../../../core/server/services/posts'); +const PostsService = require('../../../../core/server/services/posts/posts-service'); const searchIndexController = require('../../../../core/server/api/endpoints/search-index'); describe('Search index controller', function () { let browsePostsStub; beforeEach(function () { - // the controller constructs its own PostsService instance + posts.init(); browsePostsStub = sinon.stub(PostsService.prototype, 'browsePosts').resolves({data: []}); sinon.stub(models.Tag, 'findPage').resolves({data: []}); sinon.stub(models.User, 'findPage').resolves({data: []}); diff --git a/ghost/core/test/unit/api/endpoints/utils/gift-link-access.test.ts b/ghost/core/test/unit/api/endpoints/utils/gift-link-access.test.ts index cc8f6e793b3..d7af1b92247 100644 --- a/ghost/core/test/unit/api/endpoints/utils/gift-link-access.test.ts +++ b/ghost/core/test/unit/api/endpoints/utils/gift-link-access.test.ts @@ -22,7 +22,7 @@ describe('Gift link access', function () { // The service singleton is normally wired at boot; the stub replaces // its only query so no DB is touched. giftLinksService.init(); - getPostByTokenStub = sinon.stub(giftLinksService.service!, 'getPostByToken'); + getPostByTokenStub = sinon.stub(giftLinksService.service, 'getPostByToken'); sinon.stub(Product, 'findAll').resolves([{ get: sinon.stub().returns('silver') diff --git a/ghost/core/test/unit/server/services/announcement-bar/index.test.js b/ghost/core/test/unit/server/services/announcement-bar/index.test.js new file mode 100644 index 00000000000..0ba42dfab5b --- /dev/null +++ b/ghost/core/test/unit/server/services/announcement-bar/index.test.js @@ -0,0 +1,28 @@ +const assert = require('node:assert/strict'); + +describe('Announcement bar composition root', function () { + let announcementBar; + + beforeEach(function () { + const modulePath = require.resolve('../../../../../core/server/services/announcement-bar-service'); + delete require.cache[modulePath]; + announcementBar = require(modulePath); + }); + + it('fails loudly when the service is used before initialization', function () { + assert.throws( + () => announcementBar.service.getAnnouncementSettings(), + /AnnouncementBarSettings must be initialized before use/ + ); + }); + + it('initializes idempotently', function () { + announcementBar.init(); + const firstService = announcementBar.service; + + announcementBar.init(); + + assert.equal(announcementBar.service, firstService); + assert.doesNotThrow(() => announcementBar.service.getAnnouncementSettings()); + }); +}); diff --git a/ghost/core/test/unit/server/services/donations/index.test.js b/ghost/core/test/unit/server/services/donations/index.test.js new file mode 100644 index 00000000000..595b5b39e6c --- /dev/null +++ b/ghost/core/test/unit/server/services/donations/index.test.js @@ -0,0 +1,26 @@ +const assert = require('node:assert/strict'); + +describe('Donations composition root', function () { + let donations; + + beforeEach(function () { + const modulePath = require.resolve('../../../../../core/server/services/donations'); + delete require.cache[modulePath]; + donations = require(modulePath); + }); + + it('fails loudly when the repository is used before initialization', function () { + assert.throws( + () => donations.service.create({}), + /DonationRepository must be initialized before use/ + ); + }); + + it('initializes idempotently', function () { + donations.init(); + const prototype = Object.getPrototypeOf(donations.service); + + assert.equal(donations.init(), undefined); + assert.equal(Object.getPrototypeOf(donations.service), prototype); + }); +}); diff --git a/ghost/core/test/unit/server/services/posts/index.test.js b/ghost/core/test/unit/server/services/posts/index.test.js new file mode 100644 index 00000000000..3c547418ba4 --- /dev/null +++ b/ghost/core/test/unit/server/services/posts/index.test.js @@ -0,0 +1,26 @@ +const assert = require('node:assert/strict'); + +describe('Posts service composition root', function () { + let posts; + + beforeEach(function () { + const modulePath = require.resolve('../../../../../core/server/services/posts'); + delete require.cache[modulePath]; + posts = require(modulePath); + }); + + it('fails loudly when the service is used before initialization', function () { + assert.throws( + () => posts.service.browsePosts({}), + /PostsService must be initialized before use/ + ); + }); + + it('initializes idempotently', function () { + posts.init(); + const prototype = Object.getPrototypeOf(posts.service); + + assert.equal(posts.init(), undefined); + assert.equal(Object.getPrototypeOf(posts.service), prototype); + }); +}); diff --git a/ghost/core/test/unit/server/services/tinybird/index.test.js b/ghost/core/test/unit/server/services/tinybird/index.test.js new file mode 100644 index 00000000000..f506c66490b --- /dev/null +++ b/ghost/core/test/unit/server/services/tinybird/index.test.js @@ -0,0 +1,25 @@ +const assert = require('node:assert/strict'); + +describe('Tinybird service composition root', function () { + let tinybird; + + beforeEach(function () { + const modulePath = require.resolve('../../../../../core/server/services/tinybird'); + delete require.cache[modulePath]; + tinybird = require(modulePath); + }); + + it('fails loudly when the service is used before initialization', function () { + assert.throws( + () => tinybird.service.getToken(), + /TinybirdService must be initialized before use/ + ); + }); + + it('is idempotent and returns null after unconfigured initialization', function () { + tinybird.init(); + tinybird.init(); + + assert.equal(tinybird.service.getToken(), null); + }); +}); diff --git a/ghost/core/test/unit/server/services/tinybird/tinybird-service.test.js b/ghost/core/test/unit/server/services/tinybird/tinybird-service.test.js index a19a2172e3d..ebc2b28b9cd 100644 --- a/ghost/core/test/unit/server/services/tinybird/tinybird-service.test.js +++ b/ghost/core/test/unit/server/services/tinybird/tinybird-service.test.js @@ -172,5 +172,24 @@ describe('TinybirdService', function () { assert.equal(result.token, 'stats-token'); assert.equal(result.exp, undefined); }); + + it('should use updated configuration without creating another service instance', function () { + let currentConfig = null; + tinybirdService = new TinybirdService({ + tinybirdConfig: currentConfig, + getTinybirdConfig: () => currentConfig, + siteUuid + }); + + assert.equal(tinybirdService.getToken(), null); + + currentConfig = { + stats: { + token: 'updated-stats-token' + } + }; + + assert.deepEqual(tinybirdService.getToken(), {token: 'updated-stats-token'}); + }); }); }); diff --git a/ghost/core/test/unit/shared/lazy-singleton.test.ts b/ghost/core/test/unit/shared/lazy-singleton.test.ts new file mode 100644 index 00000000000..630e36c72ca --- /dev/null +++ b/ghost/core/test/unit/shared/lazy-singleton.test.ts @@ -0,0 +1,55 @@ +import {describe, expect, it} from 'vitest'; +import {lazySingleton} from '../../../core/shared/lazy-singleton'; + +describe('lazySingleton', function () { + it('returns a stable facade', function () { + const facade = lazySingleton('ExampleService', () => ({value: 1})); + + expect(facade).toBe(facade); + }); + + it('throws an actionable internal error when read before initialization', function () { + const facade = lazySingleton<{value: number}>('ExampleService', () => undefined); + + expect(() => facade.value).toThrow('ExampleService must be initialized before use'); + }); + + it('forwards property reads and writes to the current instance', function () { + let instance = {value: 1}; + const facade = lazySingleton('ExampleService', () => instance); + + expect(facade.value).toBe(1); + + facade.value = 2; + expect(instance.value).toBe(2); + + instance = {value: 3}; + expect(facade.value).toBe(3); + }); + + it('preserves method binding when a method is extracted', function () { + class ExampleService { + value = 1; + + increment() { + this.value += 1; + return this.value; + } + } + + const instance = new ExampleService(); + const facade = lazySingleton('ExampleService', () => instance); + + const increment = facade.increment; + + expect(increment()).toBe(2); + expect(instance.value).toBe(2); + expect(facade).toBeInstanceOf(ExampleService); + }); + + it('forwards null values returned by an initialized service', function () { + const facade = lazySingleton('ExampleService', () => ({value: null as null | string})); + + expect(facade.value).toBeNull(); + }); +});