Skip to content

Commit 9f9da19

Browse files
Merge pull request #5020 from Shopify/fetch-notifications-in-background
Fetch notifications in background
2 parents 255f7ea + 2375a3f commit 9f9da19

File tree

6 files changed

+126
-32
lines changed

6 files changed

+126
-32
lines changed

.changeset/violet-carrots-argue.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/cli-kit': patch
3+
---
4+
5+
Fetch notifications in background

packages/cli-kit/src/public/node/hooks/postrun.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import {reportAnalyticsEvent} from '../analytics.js'
33
import {outputDebug} from '../../../public/node/output.js'
44
import BaseCommand from '../base-command.js'
55
import * as metadata from '../../../public/node/metadata.js'
6+
import {fetchNotificationsInBackground} from '../notifications-system.js'
67
import {Command, Hook} from '@oclif/core'
78

89
// This hook is called after each successful command run. More info: https://oclif.io/docs/hooks
910
export const hook: Hook.Postrun = async ({config, Command}) => {
1011
await detectStopCommand(Command as unknown as typeof Command)
1112
await reportAnalyticsEvent({config, exitMode: 'ok'})
13+
if (!Command.hidden) fetchNotificationsInBackground(Command.id)
1214
deprecationsHook(Command)
1315

1416
const command = Command.id.replace(/:/g, ' ')
@@ -26,10 +28,10 @@ async function detectStopCommand(commandClass: Command.Class | typeof BaseComman
2628
const stopCommand = (commandClass as typeof BaseCommand).analyticsStopCommand()
2729
if (stopCommand) {
2830
const {commandStartOptions} = metadata.getAllSensitiveMetadata()
31+
if (!commandStartOptions) return
2932
await metadata.addSensitiveMetadata(() => ({
3033
commandStartOptions: {
31-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
32-
...commandStartOptions!,
34+
...commandStartOptions,
3335
startTime: currentTime,
3436
startCommand: stopCommand,
3537
},

packages/cli-kit/src/public/node/notifications-system.test.ts

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
1-
import {Notification, filterNotifications, showNotificationsIfNeeded} from './notifications-system.js'
1+
import {
2+
Notification,
3+
fetchNotificationsInBackground,
4+
filterNotifications,
5+
showNotificationsIfNeeded,
6+
} from './notifications-system.js'
27
import {renderError, renderInfo, renderWarning} from './ui.js'
38
import {sniffForJson} from './path.js'
4-
import {cacheRetrieve, cacheRetrieveOrRepopulate} from '../../private/node/conf-store.js'
9+
import {exec} from './system.js'
10+
import {cacheRetrieve} from '../../private/node/conf-store.js'
511
import {afterEach, describe, expect, test, vi} from 'vitest'
612

713
vi.mock('./ui.js')
814
vi.mock('../../private/node/conf-store.js')
915
vi.mock('./path.js')
16+
vi.mock('./system.js')
1017

1118
const betweenVersins1and2: Notification = {
1219
id: 'betweenVersins1and2',
@@ -333,7 +340,7 @@ describe('showNotificationsIfNeeded', () => {
333340
test('an info notification triggers a renderInfo call', async () => {
334341
// Given
335342
const notifications = [infoNotification]
336-
vi.mocked(cacheRetrieveOrRepopulate).mockResolvedValue(JSON.stringify({notifications}))
343+
vi.mocked(cacheRetrieve).mockReturnValue({value: JSON.stringify({notifications}), timestamp: 0})
337344

338345
// When
339346
await showNotificationsIfNeeded(undefined, {SHOPIFY_UNIT_TEST: 'false'})
@@ -345,7 +352,7 @@ describe('showNotificationsIfNeeded', () => {
345352
test('a warning notification triggers a renderWarning call', async () => {
346353
// Given
347354
const notifications = [warningNotification]
348-
vi.mocked(cacheRetrieveOrRepopulate).mockResolvedValue(JSON.stringify({notifications}))
355+
vi.mocked(cacheRetrieve).mockReturnValue({value: JSON.stringify({notifications}), timestamp: 0})
349356

350357
// When
351358
await showNotificationsIfNeeded(undefined, {SHOPIFY_UNIT_TEST: 'false'})
@@ -357,7 +364,7 @@ describe('showNotificationsIfNeeded', () => {
357364
test('an error notification triggers a renderError call and throws an error', async () => {
358365
// Given
359366
const notifications = [errorNotification]
360-
vi.mocked(cacheRetrieveOrRepopulate).mockResolvedValue(JSON.stringify({notifications}))
367+
vi.mocked(cacheRetrieve).mockReturnValue({value: JSON.stringify({notifications}), timestamp: 0})
361368

362369
// When
363370
await expect(showNotificationsIfNeeded(undefined, {SHOPIFY_UNIT_TEST: 'false'})).rejects.toThrowError()
@@ -369,7 +376,7 @@ describe('showNotificationsIfNeeded', () => {
369376
test('notifications are skipped on CI', async () => {
370377
// Given
371378
const notifications = [infoNotification]
372-
vi.mocked(cacheRetrieveOrRepopulate).mockResolvedValue(JSON.stringify({notifications}))
379+
vi.mocked(cacheRetrieve).mockReturnValue({value: JSON.stringify({notifications}), timestamp: 0})
373380

374381
// When
375382
await showNotificationsIfNeeded(undefined, {SHOPIFY_UNIT_TEST: 'false', CI: 'true'})
@@ -381,7 +388,7 @@ describe('showNotificationsIfNeeded', () => {
381388
test('notifications are skipped on tests', async () => {
382389
// Given
383390
const notifications = [infoNotification]
384-
vi.mocked(cacheRetrieveOrRepopulate).mockResolvedValue(JSON.stringify({notifications}))
391+
vi.mocked(cacheRetrieve).mockReturnValue({value: JSON.stringify({notifications}), timestamp: 0})
385392

386393
// When
387394
await showNotificationsIfNeeded(undefined, {SHOPIFY_UNIT_TEST: 'true'})
@@ -393,7 +400,7 @@ describe('showNotificationsIfNeeded', () => {
393400
test('notifications are skipped when using --json flag', async () => {
394401
// Given
395402
const notifications = [infoNotification]
396-
vi.mocked(cacheRetrieveOrRepopulate).mockResolvedValue(JSON.stringify({notifications}))
403+
vi.mocked(cacheRetrieve).mockReturnValue({value: JSON.stringify({notifications}), timestamp: 0})
397404
vi.mocked(sniffForJson).mockReturnValue(true)
398405

399406
// When
@@ -406,7 +413,7 @@ describe('showNotificationsIfNeeded', () => {
406413
test('notifications are skipped when using SHOPIFY_FLAG_JSON', async () => {
407414
// Given
408415
const notifications = [infoNotification]
409-
vi.mocked(cacheRetrieveOrRepopulate).mockResolvedValue(JSON.stringify({notifications}))
416+
vi.mocked(cacheRetrieve).mockReturnValue({value: JSON.stringify({notifications}), timestamp: 0})
410417

411418
// When
412419
await showNotificationsIfNeeded(undefined, {SHOPIFY_UNIT_TEST: 'false', SHOPIFY_FLAG_JSON: 'true'})
@@ -415,3 +422,33 @@ describe('showNotificationsIfNeeded', () => {
415422
expect(renderInfo).not.toHaveBeenCalled()
416423
})
417424
})
425+
426+
describe('fetchNotificationsInBackground', () => {
427+
test('calls the expected Shopify binary for global installation', async () => {
428+
// Given / When
429+
fetchNotificationsInBackground('theme:init', ['shopify', 'theme', 'init'], {SHOPIFY_UNIT_TEST: 'false'})
430+
431+
// Then
432+
expect(exec).toHaveBeenCalledWith('shopify', ['notifications', 'list'], expect.anything())
433+
})
434+
435+
test('calls the expected Shopify binary for local installation', async () => {
436+
// Given / When
437+
fetchNotificationsInBackground('theme:init', ['npm', 'run', 'shopify', 'theme', 'init'], {
438+
SHOPIFY_UNIT_TEST: 'false',
439+
})
440+
441+
// Then
442+
expect(exec).toHaveBeenCalledWith('npm', ['run', 'shopify', 'notifications', 'list'], expect.anything())
443+
})
444+
445+
test('calls the expected Shopify binary for dev environment', async () => {
446+
// Given / When
447+
fetchNotificationsInBackground('theme:init', ['node', 'packages/cli/bin/dev.js', 'theme', 'init'], {
448+
SHOPIFY_UNIT_TEST: 'false',
449+
})
450+
451+
// Then
452+
expect(exec).toHaveBeenCalledWith('node', ['packages/cli/bin/dev.js', 'notifications', 'list'], expect.anything())
453+
})
454+
})

packages/cli-kit/src/public/node/notifications-system.ts

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,14 @@ import {outputDebug} from './output.js'
55
import {zod} from './schema.js'
66
import {AbortSilentError} from './error.js'
77
import {isTruthy} from './context/utilities.js'
8+
import {exec} from './system.js'
89
import {jsonOutputEnabled} from './environment.js'
910
import {CLI_KIT_VERSION} from '../common/version.js'
10-
import {
11-
NotificationKey,
12-
NotificationsKey,
13-
cacheRetrieve,
14-
cacheRetrieveOrRepopulate,
15-
cacheStore,
16-
} from '../../private/node/conf-store.js'
11+
import {NotificationKey, NotificationsKey, cacheRetrieve, cacheStore} from '../../private/node/conf-store.js'
1712
import {fetch} from '@shopify/cli-kit/node/http'
1813

1914
const URL = 'https://cdn.shopify.com/static/cli/notifications.json'
20-
const CACHE_DURATION_IN_MS = 3600 * 1000
15+
const EMPTY_CACHE_MESSAGE = 'Cache is empty'
2116

2217
function url(): string {
2318
return process.env.SHOPIFY_CLI_NOTIFICATIONS_URL ?? URL
@@ -60,7 +55,7 @@ export async function showNotificationsIfNeeded(
6055
environment: NodeJS.ProcessEnv = process.env,
6156
): Promise<void> {
6257
try {
63-
if (skipNotifications(environment)) return
58+
if (skipNotifications(environment) || jsonOutputEnabled(environment)) return
6459

6560
const notifications = await getNotifications()
6661
const commandId = getCurrentCommandId()
@@ -72,14 +67,15 @@ export async function showNotificationsIfNeeded(
7267
if (error.message === 'abort') throw new AbortSilentError()
7368
const errorMessage = `Error retrieving notifications: ${error.message}`
7469
outputDebug(errorMessage)
70+
if (error.message === EMPTY_CACHE_MESSAGE) return
7571
// This is very prone to becoming a circular dependency, so we import it dynamically
7672
const {sendErrorToBugsnag} = await import('./error-handler.js')
7773
await sendErrorToBugsnag(errorMessage, 'unexpected_error')
7874
}
7975
}
8076

81-
function skipNotifications(environment: NodeJS.ProcessEnv): boolean {
82-
return isTruthy(environment.CI) || isTruthy(environment.SHOPIFY_UNIT_TEST) || jsonOutputEnabled(environment)
77+
function skipNotifications(environment: NodeJS.ProcessEnv = process.env): boolean {
78+
return isTruthy(environment.CI) || isTruthy(environment.SHOPIFY_UNIT_TEST)
8379
}
8480

8581
/**
@@ -113,25 +109,70 @@ async function renderNotifications(notifications: Notification[]) {
113109
}
114110

115111
/**
116-
* Get notifications list from cache (refreshed every hour) or fetch it if not present.
112+
* Get notifications list from cache, that is updated in the background from bin/fetch-notifications.json.
117113
*
118114
* @returns A Notifications object.
119115
*/
120116
export async function getNotifications(): Promise<Notifications> {
121117
const cacheKey: NotificationsKey = `notifications-${url()}`
122-
const rawNotifications = await cacheRetrieveOrRepopulate(cacheKey, fetchNotifications, CACHE_DURATION_IN_MS)
118+
const rawNotifications = cacheRetrieve(cacheKey)?.value as unknown as string
119+
if (!rawNotifications) throw new Error(EMPTY_CACHE_MESSAGE)
123120
const notifications: object = JSON.parse(rawNotifications)
124121
return NotificationsSchema.parse(notifications)
125122
}
126123

127124
/**
128-
* Fetch notifications from GitHub.
125+
* Fetch notifications from the CDN and chache them.
126+
*
127+
* @returns A string with the notifications.
129128
*/
130-
async function fetchNotifications(): Promise<string> {
131-
outputDebug(`No cached notifications found. Fetching them...`)
129+
export async function fetchNotifications(): Promise<Notifications> {
130+
outputDebug(`Fetching notifications...`)
132131
const response = await fetch(url(), {signal: AbortSignal.timeout(3 * 1000)})
133132
if (response.status !== 200) throw new Error(`Failed to fetch notifications: ${response.statusText}`)
134-
return response.text() as unknown as string
133+
const rawNotifications = await response.text()
134+
const notifications: object = JSON.parse(rawNotifications)
135+
const result = NotificationsSchema.parse(notifications)
136+
await cacheNotifications(rawNotifications)
137+
return result
138+
}
139+
140+
/**
141+
* Store the notifications in the cache.
142+
*
143+
* @param notifications - String with the notifications to cache.
144+
* @returns A Notifications object.
145+
*/
146+
async function cacheNotifications(notifications: string): Promise<void> {
147+
cacheStore(`notifications-${url()}`, notifications)
148+
outputDebug(`Notifications from ${url()} stored in the cache`)
149+
}
150+
151+
/**
152+
* Fetch notifications in background as a detached process.
153+
*
154+
* @param currentCommand - The current Shopify command being run.
155+
* @param argv - The arguments passed to the current process.
156+
* @param environment - Process environment variables.
157+
*/
158+
export function fetchNotificationsInBackground(
159+
currentCommand: string,
160+
argv = process.argv,
161+
environment: NodeJS.ProcessEnv = process.env,
162+
): void {
163+
if (skipNotifications(environment)) return
164+
165+
let command = 'shopify'
166+
const args = ['notifications', 'list']
167+
// Run the Shopify command the same way as the current execution when it's not the global installation
168+
if (argv[0] && argv[0] !== 'shopify') {
169+
command = argv[0]
170+
const indexValue = currentCommand.split(':')[0] ?? ''
171+
const index = argv.indexOf(indexValue)
172+
if (index > 0) args.unshift(...argv.slice(1, index))
173+
}
174+
// eslint-disable-next-line no-void
175+
void exec(command, args, {background: true, env: {...process.env, SHOPIFY_CLI_NO_ANALYTICS: '1'}})
135176
}
136177

137178
/**

packages/cli-kit/src/public/node/system.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export interface ExecOptions {
2121
signal?: AbortSignal
2222
// Custom handler if process exits with a non-zero code
2323
externalErrorHandler?: (error: unknown) => Promise<void>
24+
background?: boolean
2425
}
2526

2627
/**
@@ -55,6 +56,11 @@ export async function captureOutput(command: string, args: string[], options?: E
5556
*/
5657
export async function exec(command: string, args: string[], options?: ExecOptions): Promise<void> {
5758
const commandProcess = buildExec(command, args, options)
59+
60+
if (options?.background) {
61+
commandProcess.unref()
62+
}
63+
5864
if (options?.stderr && options.stderr !== 'inherit') {
5965
commandProcess.stderr?.pipe(options.stderr, {end: false})
6066
}
@@ -106,16 +112,18 @@ function buildExec(command: string, args: string[], options?: ExecOptions): Exec
106112
env,
107113
cwd: executionCwd,
108114
input: options?.input,
109-
stdio: options?.stdio,
115+
stdio: options?.background ? 'ignore' : options?.stdio,
110116
stdin: options?.stdin,
111117
stdout: options?.stdout === 'inherit' ? 'inherit' : undefined,
112118
stderr: options?.stderr === 'inherit' ? 'inherit' : undefined,
113119
// Setting this to false makes it possible to kill the main process
114120
// and all its sub-processes with Ctrl+C on Windows
115121
windowsHide: false,
122+
detached: options?.background,
123+
cleanup: !options?.background,
116124
})
117125
outputDebug(`
118-
Running system process:
126+
Running system process${options?.background ? ' in background' : ''}:
119127
· Command: ${command} ${args.join(' ')}
120128
· Working directory: ${executionCwd}
121129
`)

packages/cli/src/cli/services/commands/notifications.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
Notification,
77
stringifyFilters,
88
getNotifications,
9+
fetchNotifications,
910
} from '@shopify/cli-kit/node/notifications-system'
1011
import {outputInfo} from '@shopify/cli-kit/node/output'
1112
import {renderSelectPrompt, renderTextPrompt, renderSuccess, renderTable, TableColumn} from '@shopify/cli-kit/node/ui'
@@ -95,7 +96,7 @@ export async function generate() {
9596
}
9697

9798
export async function list() {
98-
const notifications: Notifications = await getNotifications()
99+
const notifications = await fetchNotifications()
99100

100101
const columns: TableColumn<{type: string; title: string; message: string; filters: string}> = {
101102
type: {header: 'Type', color: 'dim'},
@@ -107,7 +108,7 @@ export async function list() {
107108
const rows = notifications.notifications.map((notification: Notification) => {
108109
return {
109110
type: notification.type,
110-
title: notification.title || '',
111+
title: notification.title ?? '',
111112
message: notification.message,
112113
filters: stringifyFilters(notification),
113114
}

0 commit comments

Comments
 (0)