diff --git a/README.md b/README.md index 8f7ccfb..a382c18 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ export default { name: 'test', /** custom trust domains */ domains: ['*.custom.com'], + /** optional, days before certificate expires */ + ttlDays: 30, /** custom certification directory */ certDir: '/Users/.../.devServer/cert', }), diff --git a/src/certificate-expiration.ts b/src/certificate-expiration.ts new file mode 100644 index 0000000..11cb0b6 --- /dev/null +++ b/src/certificate-expiration.ts @@ -0,0 +1,42 @@ +import { X509Certificate } from 'node:crypto' + +const MONTHS = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +] + +export function isCertificateExpired(content: string): boolean { + const cert = new X509Certificate(content) + const expirationDate = getCertificateExpirationDate(cert) + return new Date() > expirationDate +} + +function getCertificateExpirationDate(cert: X509Certificate): Date { + // validToDate is not available until node 22 + if (cert.validToDate) { + return cert.validToDate + } + + return parseNonStandardDateString(cert.validTo) +} + +// validTo is a nonstandard format: %s %2d %02d:%02d:%02d %d%s GMT +// https://github.com/nodejs/node/issues/52931 +export function parseNonStandardDateString(str: string): Date { + const [month, day, time, year] = str.split(' ').filter((part) => !!part) + // convert string month to number + const monthIndex = MONTHS.indexOf(month) + 1 + return new Date( + `${year}-${monthIndex.toString().padStart(2, '0')}-${day.padStart(2, '0')}T${time}Z`, + ) +} diff --git a/src/certificate.ts b/src/certificate.ts index 22dbb53..74b9751 100644 --- a/src/certificate.ts +++ b/src/certificate.ts @@ -55,8 +55,8 @@ function toPositiveHex(hexString: string) { export function createCertificate( name: string = 'example.org', domains?: string[], + ttlDays = 30, ): string { - const days = 30 const keySize = 2048 const appendDomains = domains @@ -146,7 +146,7 @@ export function createCertificate( cert.validity.notBefore = new Date() cert.validity.notAfter = new Date() - cert.validity.notAfter.setDate(cert.validity.notBefore.getDate() + days) + cert.validity.notAfter.setDate(cert.validity.notBefore.getDate() + ttlDays) cert.setSubject(attrs) cert.setIssuer(attrs) diff --git a/src/index.ts b/src/index.ts index 2b44c0c..1f93f1d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import path from 'node:path' import { promises as fsp } from 'node:fs' import type { Plugin } from 'vite' +import { isCertificateExpired } from './certificate-expiration' const defaultCacheDir = 'node_modules/.vite' @@ -8,6 +9,7 @@ interface Options { certDir: string domains: string[] name: string + ttlDays: number } function viteBasicSslPlugin(options?: Partial): Plugin { @@ -18,6 +20,7 @@ function viteBasicSslPlugin(options?: Partial): Plugin { options?.certDir ?? (config.cacheDir ?? defaultCacheDir) + '/basic-ssl', options?.name, options?.domains, + options?.ttlDays, ) const https = () => ({ cert: certificate, key: certificate }) if (config.server.https === undefined || !!config.server.https) { @@ -34,16 +37,15 @@ export async function getCertificate( cacheDir: string, name?: string, domains?: string[], + ttlDays?: number, ) { const cachePath = path.join(cacheDir, '_cert.pem') try { - const [stat, content] = await Promise.all([ - fsp.stat(cachePath), - fsp.readFile(cachePath, 'utf8'), - ]) + const content = await fsp.readFile(cachePath, 'utf8') + const isExpired = isCertificateExpired(content) - if (Date.now() - stat.ctime.valueOf() > 30 * 24 * 60 * 60 * 1000) { + if (isExpired) { throw new Error('cache is outdated.') } @@ -52,6 +54,7 @@ export async function getCertificate( const content = (await import('./certificate')).createCertificate( name, domains, + ttlDays, ) fsp .mkdir(cacheDir, { recursive: true }) diff --git a/test/test.spec.ts b/test/test.spec.ts index 6637704..ab63a0e 100644 --- a/test/test.spec.ts +++ b/test/test.spec.ts @@ -1,5 +1,10 @@ -import { test, expect } from 'vitest' +import { beforeEach, describe, test, expect, vi, Mock } from 'vitest' +import { X509Certificate } from 'node:crypto' import { createCertificate } from '../src/certificate' +import { + isCertificateExpired, + parseNonStandardDateString, +} from '../src/certificate-expiration' test('create certificate', () => { const content = createCertificate() @@ -10,3 +15,62 @@ test('create certificate', () => { /-----BEGIN CERTIFICATE-----(\n|\r|.)*-----END CERTIFICATE-----/, ) }) + +describe('isCertificateExpired', () => { + let validToDateMock: Mock + let validToMock: Mock + + beforeEach(() => { + validToDateMock = vi.spyOn(X509Certificate.prototype, 'validToDate', 'get') + validToMock = vi.spyOn(X509Certificate.prototype, 'validTo', 'get') + }) + + describe('with validToDate', () => { + test('returns false', () => { + validToDateMock.mockReturnValue(new Date(Date.now() + 10000)) + + const content = createCertificate() + const isExpired = isCertificateExpired(content) + expect(isExpired).toBe(false) + }) + + test('returns true', () => { + validToDateMock.mockReturnValue(new Date(Date.now() - 10000)) + + const content = createCertificate() + const isExpired = isCertificateExpired(content) + expect(isExpired).toBe(true) + }) + }) + + describe('with validTo', () => { + test('returns false', () => { + validToDateMock.mockReturnValue(undefined) + validToMock.mockReturnValue('Sep 3 21:40:37 2296 GMT') + + const content = createCertificate() + const isExpired = isCertificateExpired(content) + expect(isExpired).toBe(false) + }) + + test('returns true', () => { + validToDateMock.mockReturnValue(undefined) + validToMock.mockReturnValue('Jan 22 08:20:44 2022 GMT') + + const content = createCertificate() + const isExpired = isCertificateExpired(content) + expect(isExpired).toBe(true) + }) + }) +}) + +test('parseNonStandardDateString', () => { + const content = createCertificate() + const cert = new X509Certificate(content) + const date = parseNonStandardDateString(cert.validTo) + expect(date).toBeInstanceOf(Date) + expect(date.getTime()).toBeGreaterThan(0) + if (cert.validToDate) { + expect(date.getTime()).toBe(cert.validToDate.getTime()) + } +}) diff --git a/test/vitest.config.ts b/test/vitest.config.ts index a013249..930e9d2 100644 --- a/test/vitest.config.ts +++ b/test/vitest.config.ts @@ -4,5 +4,6 @@ import { defineConfig } from 'vite' export default defineConfig({ test: { testTimeout: 100000, + restoreMocks: true, }, })