diff --git a/packages/workshop-app/node-version-check.d.ts b/packages/workshop-app/node-version-check.d.ts new file mode 100644 index 00000000..a3eff174 --- /dev/null +++ b/packages/workshop-app/node-version-check.d.ts @@ -0,0 +1,15 @@ +export function loadSemver( + importer?: (specifier: string) => Promise, +): Promise boolean +}> + +export function checkNodeVersion(options: { + semver: null | { satisfies: (version: string, range: string) => boolean } + currentNodeVersion: string + requiredVersions?: string + skip?: boolean +}): + | { ok: true; skipped: true } + | { ok: true; skipped: false } + | { ok: false; skipped: false } diff --git a/packages/workshop-app/node-version-check.js b/packages/workshop-app/node-version-check.js new file mode 100644 index 00000000..de5a896b --- /dev/null +++ b/packages/workshop-app/node-version-check.js @@ -0,0 +1,57 @@ +/** + * Soft Node.js engines check used at workshop-app startup. + * semver is loaded dynamically so a corrupt/incomplete install cannot take + * down the whole process before the server boots. + */ + +/** + * @param {(specifier: string) => Promise} [importer] + * @returns {Promise boolean }>} + */ +export async function loadSemver(importer = (specifier) => import(specifier)) { + try { + const mod = await importer('semver') + const semver = + mod && typeof mod === 'object' && 'default' in mod ? mod.default : mod + if ( + !semver || + typeof semver !== 'object' || + typeof semver.satisfies !== 'function' + ) { + return null + } + return semver + } catch (error) { + console.warn( + 'Failed to import semver:', + error instanceof Error ? error.message : String(error), + '- Node.js version check will be skipped. If startup fails for other reasons, reinstall dependencies.', + ) + return null + } +} + +/** + * @param {{ + * semver: null | { satisfies: (version: string, range: string) => boolean } + * currentNodeVersion: string + * requiredVersions?: string + * skip?: boolean + * }} options + * @returns {{ ok: true, skipped: true } | { ok: true, skipped: false } | { ok: false, skipped: false }} + */ +export function checkNodeVersion({ + semver, + currentNodeVersion, + requiredVersions, + skip = false, +}) { + if (skip || !semver || !requiredVersions) { + return { ok: true, skipped: true } + } + + const isSupported = semver.satisfies(currentNodeVersion, requiredVersions) + return isSupported + ? { ok: true, skipped: false } + : { ok: false, skipped: false } +} diff --git a/packages/workshop-app/package.json b/packages/workshop-app/package.json index 81ad87f3..daf620fb 100644 --- a/packages/workshop-app/package.json +++ b/packages/workshop-app/package.json @@ -9,6 +9,8 @@ "build", "dist", "start.js", + "node-version-check.js", + "node-version-check.d.ts", "instrument.js", "sentry-server-filters.js", "sentry-server-filters.d.ts", diff --git a/packages/workshop-app/start.js b/packages/workshop-app/start.js index 6aaefe65..7f7dd4fc 100644 --- a/packages/workshop-app/start.js +++ b/packages/workshop-app/start.js @@ -1,51 +1,40 @@ import fs from 'fs/promises' import path from 'path' import dotenv from 'dotenv' -import semver from 'semver' +import { checkNodeVersion, loadSemver } from './node-version-check.js' const packageJson = JSON.parse( await fs.readFile(path.resolve(process.cwd(), 'package.json'), 'utf-8'), ) -// Check Node.js version against the engines requirement -function checkNodeVersion() { - if ( +const semver = await loadSemver() +const nodeVersionCheck = checkNodeVersion({ + semver, + currentNodeVersion: process.version.slice(1), // Remove 'v' prefix + requiredVersions: packageJson.engines?.node, + skip: process.env.EPICSHOP_SKIP_NODE_VERSION_CHECK === 'true' || - process.env.EPICSHOP_SKIP_NODE_VERSION_CHECK === '1' - ) { - return - } + process.env.EPICSHOP_SKIP_NODE_VERSION_CHECK === '1', +}) - const currentNodeVersion = process.version.slice(1) // Remove 'v' prefix +if (!nodeVersionCheck.ok) { const requiredVersions = packageJson.engines?.node - - if (!requiredVersions) { - return // No engines specified, skip check - } - - // Use semver to check if current version satisfies the requirement - const isSupported = semver.satisfies(currentNodeVersion, requiredVersions) - - if (!isSupported) { - console.error('\n❌ Node.js version compatibility error') - console.error(`Current Node.js version: v${currentNodeVersion}`) - console.error(`Required Node.js versions: ${requiredVersions}`) - console.error( - `\nThis project only supports Node.js versions which match the semver range specified in the package.json file.`, - ) - console.error( - 'If you recently upgraded Node.js, update the Epic Workshop app package and reinstall dependencies.', - ) - console.error('Please update to a supported Node.js version and try again.') - console.error( - '\nYou can download the latest LTS version from: https://nodejs.org/', - ) - process.exit(1) - } + console.error('\n❌ Node.js version compatibility error') + console.error(`Current Node.js version: ${process.version}`) + console.error(`Required Node.js versions: ${requiredVersions}`) + console.error( + `\nThis project only supports Node.js versions which match the semver range specified in the package.json file.`, + ) + console.error( + 'If you recently upgraded Node.js, update the Epic Workshop app package and reinstall dependencies.', + ) + console.error('Please update to a supported Node.js version and try again.') + console.error( + '\nYou can download the latest LTS version from: https://nodejs.org/', + ) + process.exit(1) } -checkNodeVersion() - process.env.EPICSHOP_APP_VERSION ??= packageJson.version process.env.EPICSHOP_IS_PUBLISHED ??= packageJson.version.includes('0.0.0') ? 'false' diff --git a/packages/workshop-app/tests/node-version-check.test.ts b/packages/workshop-app/tests/node-version-check.test.ts new file mode 100644 index 00000000..b26fc7f0 --- /dev/null +++ b/packages/workshop-app/tests/node-version-check.test.ts @@ -0,0 +1,48 @@ +import { expect, test, vi } from 'vitest' +import { checkNodeVersion, loadSemver } from '../node-version-check.js' + +test('skips the engines check when semver failed to load (aha)', () => { + expect( + checkNodeVersion({ + semver: null, + currentNodeVersion: '22.20.0', + requiredVersions: '22 || 24 || 26', + }), + ).toEqual({ ok: true, skipped: true }) +}) + +test('rejects unsupported Node versions when semver is available', () => { + expect( + checkNodeVersion({ + semver: { + satisfies: (version: string, range: string) => + version === '24.0.0' && range.includes('24'), + }, + currentNodeVersion: '18.0.0', + requiredVersions: '22 || 24 || 26', + }), + ).toEqual({ ok: false, skipped: false }) +}) + +test('loadSemver returns null when the package cannot be resolved (aha)', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + await expect( + loadSemver(async () => { + throw new Error( + "Cannot find module './functions/prerelease'\nRequire stack:\n- D:\\Work\\full-stack-foundations\\epicshop\\node_modules\\@epic-web\\workshop-app\\node_modules\\semver\\index.js", + ) + }), + ).resolves.toBeNull() + expect(warn).toHaveBeenCalled() + } finally { + warn.mockRestore() + } +}) + +test('loadSemver returns the package default export', async () => { + const semver = { satisfies: () => true } + await expect(loadSemver(async () => ({ default: semver }))).resolves.toBe( + semver, + ) +}) diff --git a/packages/workshop-app/tests/published-files.test.ts b/packages/workshop-app/tests/published-files.test.ts index 6a538546..1c0c4d79 100644 --- a/packages/workshop-app/tests/published-files.test.ts +++ b/packages/workshop-app/tests/published-files.test.ts @@ -11,14 +11,15 @@ const packageJson = JSON.parse( readFileSync(path.join(packageRoot, 'package.json'), 'utf8'), ) as { files: Array } -test('npm package files include sentry-server-filters used by instrument.js (aha)', () => { - const instrument = readFileSync( - path.join(packageRoot, 'instrument.js'), - 'utf8', +function relativeImportsFrom(filename: string) { + const source = readFileSync(path.join(packageRoot, filename), 'utf8') + return [...source.matchAll(/from\s+['"](\.\/[^'"]+)['"]/g)].map((match) => + match[1]!.replace(/^\.\//, ''), ) - const relativeImports = [ - ...instrument.matchAll(/from\s+['"](\.\/[^'"]+)['"]/g), - ].map((match) => match[1]!.replace(/^\.\//, '')) +} + +test('npm package files include sentry-server-filters used by instrument.js (aha)', () => { + const relativeImports = relativeImportsFrom('instrument.js') expect(relativeImports.length).toBeGreaterThan(0) for (const relativeImport of relativeImports) { @@ -28,3 +29,17 @@ test('npm package files include sentry-server-filters used by instrument.js (aha ).toEqual(expect.arrayContaining([relativeImport])) } }) + +test('npm package files include node-version-check used by start.js (aha)', () => { + const relativeImports = relativeImportsFrom('start.js') + + expect(relativeImports).toEqual( + expect.arrayContaining(['node-version-check.js']), + ) + for (const relativeImport of relativeImports) { + expect( + packageJson.files, + `${relativeImport} is imported by start.js and must be published`, + ).toEqual(expect.arrayContaining([relativeImport])) + } +})