Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
15 changes: 15 additions & 0 deletions packages/workshop-app/node-version-check.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export function loadSemver(
importer?: (specifier: string) => Promise<unknown>,
): Promise<null | {
satisfies: (version: string, range: string) => 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 }
57 changes: 57 additions & 0 deletions packages/workshop-app/node-version-check.js
Original file line number Diff line number Diff line change
@@ -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<unknown>} [importer]
* @returns {Promise<null | { satisfies: (version: string, range: string) => 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 }
}
2 changes: 2 additions & 0 deletions packages/workshop-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
59 changes: 24 additions & 35 deletions packages/workshop-app/start.js
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
48 changes: 48 additions & 0 deletions packages/workshop-app/tests/node-version-check.test.ts
Original file line number Diff line number Diff line change
@@ -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,
)
})
29 changes: 22 additions & 7 deletions packages/workshop-app/tests/published-files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@ const packageJson = JSON.parse(
readFileSync(path.join(packageRoot, 'package.json'), 'utf8'),
) as { files: Array<string> }

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) {
Expand All @@ -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]))
}
})
Loading