From ca7042c448094d1a7e246514410ed220a5db5430 Mon Sep 17 00:00:00 2001 From: Noah Wilson Date: Tue, 10 Mar 2026 11:19:42 -0700 Subject: [PATCH 1/6] add optional ttlDays parameter --- README.md | 2 ++ src/certificate.ts | 4 ++-- src/index.ts | 4 ++++ 3 files changed, 8 insertions(+), 2 deletions(-) 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.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..e821f29 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ interface Options { certDir: string domains: string[] name: string + ttlDays: number } function viteBasicSslPlugin(options?: Partial): Plugin { @@ -18,6 +19,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,6 +36,7 @@ export async function getCertificate( cacheDir: string, name?: string, domains?: string[], + ttlDays?: number, ) { const cachePath = path.join(cacheDir, '_cert.pem') @@ -52,6 +55,7 @@ export async function getCertificate( const content = (await import('./certificate')).createCertificate( name, domains, + ttlDays, ) fsp .mkdir(cacheDir, { recursive: true }) From c17b724242e613d99240f90f3378f4f94c456aaf Mon Sep 17 00:00:00 2001 From: Noah Wilson Date: Wed, 11 Mar 2026 15:51:06 -0700 Subject: [PATCH 2/6] dynamically check certificate expiration --- src/index.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/index.ts b/src/index.ts index e821f29..0e8f025 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,10 @@ import path from 'node:path' import { promises as fsp } from 'node:fs' import type { Plugin } from 'vite' +// @ts-ignore +import forge from 'node-forge/lib/forge' +import 'node-forge/lib/pki' + const defaultCacheDir = 'node_modules/.vite' interface Options { @@ -41,13 +45,18 @@ export async function getCertificate( 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 certContent = content.match( + /-----BEGIN CERTIFICATE-----[\s\S]+-----END CERTIFICATE-----/, + ) + if (!certContent) { + throw new Error("certificate not detected.") + } + + const cert = forge.pki.certificateFromPem(certContent[0]) - if (Date.now() - stat.ctime.valueOf() > 30 * 24 * 60 * 60 * 1000) { - throw new Error('cache is outdated.') + if (new Date() > cert.validity.notAfter) { + throw new Error("cache is outdated.") } return content From 9c68060cdb4f21584b4f91ffc46a585fd2829c5d Mon Sep 17 00:00:00 2001 From: Noah Wilson Date: Wed, 11 Mar 2026 15:54:54 -0700 Subject: [PATCH 3/6] use single quotes --- src/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 0e8f025..dd026ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,18 +45,18 @@ export async function getCertificate( const cachePath = path.join(cacheDir, '_cert.pem') try { - const content = await fsp.readFile(cachePath, "utf8") + const content = await fsp.readFile(cachePath, 'utf8') const certContent = content.match( /-----BEGIN CERTIFICATE-----[\s\S]+-----END CERTIFICATE-----/, ) if (!certContent) { - throw new Error("certificate not detected.") + throw new Error('certificate not detected.') } const cert = forge.pki.certificateFromPem(certContent[0]) if (new Date() > cert.validity.notAfter) { - throw new Error("cache is outdated.") + throw new Error('cache is outdated.') } return content From 9fbb9c4abe445efefac09bd7e3ed9d94bb59d095 Mon Sep 17 00:00:00 2001 From: Noah Wilson Date: Wed, 11 Mar 2026 20:58:33 -0700 Subject: [PATCH 4/6] use X509Certificate to parse cert instead of node-forge --- src/index.ts | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/src/index.ts b/src/index.ts index dd026ff..36f667d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,8 @@ import path from 'node:path' +import { X509Certificate } from 'node:crypto' import { promises as fsp } from 'node:fs' import type { Plugin } from 'vite' -// @ts-ignore -import forge from 'node-forge/lib/forge' -import 'node-forge/lib/pki' - const defaultCacheDir = 'node_modules/.vite' interface Options { @@ -46,17 +43,13 @@ export async function getCertificate( try { const content = await fsp.readFile(cachePath, 'utf8') - const certContent = content.match( - /-----BEGIN CERTIFICATE-----[\s\S]+-----END CERTIFICATE-----/, - ) - if (!certContent) { - throw new Error('certificate not detected.') - } - - const cert = forge.pki.certificateFromPem(certContent[0]) + const cert = new X509Certificate(content); - if (new Date() > cert.validity.notAfter) { - throw new Error('cache is outdated.') + // validTo is a nonstandard format, but it successfully parses. validToDate + // is not available until node 22 + // https://github.com/nodejs/node/issues/52931 + if (Date.now() > Date.parse(cert.validTo)) { + throw new Error('cache is outdated.'); } return content From e351dac313f8dba00f10fd18a2a55097b6135c95 Mon Sep 17 00:00:00 2001 From: Noah Wilson Date: Thu, 12 Mar 2026 11:56:58 -0700 Subject: [PATCH 5/6] use validToDate if present, otherwise more explicitly parse validTo format, add unit test --- src/certificate-expiration.ts | 40 +++++++++++++++++++++++++++ src/index.ts | 11 +++----- test/test.spec.ts | 52 ++++++++++++++++++++++++++++++++++- test/vitest.config.ts | 1 + 4 files changed, 96 insertions(+), 8 deletions(-) create mode 100644 src/certificate-expiration.ts diff --git a/src/certificate-expiration.ts b/src/certificate-expiration.ts new file mode 100644 index 0000000..fad35de --- /dev/null +++ b/src/certificate-expiration.ts @@ -0,0 +1,40 @@ +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 + } + + // validTo is a nonstandard format: %s %2d %02d:%02d:%02d %d%s GMT + // https://github.com/nodejs/node/issues/52931 + const [month, day, time, year] = cert.validTo + .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/index.ts b/src/index.ts index 36f667d..1f93f1d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import path from 'node:path' -import { X509Certificate } from 'node:crypto' import { promises as fsp } from 'node:fs' import type { Plugin } from 'vite' +import { isCertificateExpired } from './certificate-expiration' const defaultCacheDir = 'node_modules/.vite' @@ -43,13 +43,10 @@ export async function getCertificate( try { const content = await fsp.readFile(cachePath, 'utf8') - const cert = new X509Certificate(content); + const isExpired = isCertificateExpired(content) - // validTo is a nonstandard format, but it successfully parses. validToDate - // is not available until node 22 - // https://github.com/nodejs/node/issues/52931 - if (Date.now() > Date.parse(cert.validTo)) { - throw new Error('cache is outdated.'); + if (isExpired) { + throw new Error('cache is outdated.') } return content diff --git a/test/test.spec.ts b/test/test.spec.ts index 6637704..8ef6938 100644 --- a/test/test.spec.ts +++ b/test/test.spec.ts @@ -1,5 +1,7 @@ -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 } from '../src/certificate-expiration' test('create certificate', () => { const content = createCertificate() @@ -10,3 +12,51 @@ 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) + }) + }) +}) 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, }, }) From 8f556afb3032068892b752708560d638fdfe9269 Mon Sep 17 00:00:00 2001 From: bluwy Date: Mon, 16 Mar 2026 10:41:53 +0800 Subject: [PATCH 6/6] test: add test for date parsing --- src/certificate-expiration.ts | 12 +++++++----- test/test.spec.ts | 20 +++++++++++++++++--- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/certificate-expiration.ts b/src/certificate-expiration.ts index fad35de..11cb0b6 100644 --- a/src/certificate-expiration.ts +++ b/src/certificate-expiration.ts @@ -27,11 +27,13 @@ function getCertificateExpirationDate(cert: X509Certificate): Date { return cert.validToDate } - // validTo is a nonstandard format: %s %2d %02d:%02d:%02d %d%s GMT - // https://github.com/nodejs/node/issues/52931 - const [month, day, time, year] = cert.validTo - .split(' ') - .filter((part) => !!part) + 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( diff --git a/test/test.spec.ts b/test/test.spec.ts index 8ef6938..ab63a0e 100644 --- a/test/test.spec.ts +++ b/test/test.spec.ts @@ -1,7 +1,10 @@ import { beforeEach, describe, test, expect, vi, Mock } from 'vitest' import { X509Certificate } from 'node:crypto' import { createCertificate } from '../src/certificate' -import { isCertificateExpired } from '../src/certificate-expiration' +import { + isCertificateExpired, + parseNonStandardDateString, +} from '../src/certificate-expiration' test('create certificate', () => { const content = createCertificate() @@ -31,7 +34,7 @@ describe('isCertificateExpired', () => { expect(isExpired).toBe(false) }) - test('returns true', () => { + test('returns true', () => { validToDateMock.mockReturnValue(new Date(Date.now() - 10000)) const content = createCertificate() @@ -49,7 +52,7 @@ describe('isCertificateExpired', () => { const isExpired = isCertificateExpired(content) expect(isExpired).toBe(false) }) - + test('returns true', () => { validToDateMock.mockReturnValue(undefined) validToMock.mockReturnValue('Jan 22 08:20:44 2022 GMT') @@ -60,3 +63,14 @@ describe('isCertificateExpired', () => { }) }) }) + +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()) + } +})