Skip to content
Open
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}),
Expand Down
42 changes: 42 additions & 0 deletions src/certificate-expiration.ts
Original file line number Diff line number Diff line change
@@ -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`,
)
}
4 changes: 2 additions & 2 deletions src/certificate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 8 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
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'

interface Options {
certDir: string
domains: string[]
name: string
ttlDays: number
}

function viteBasicSslPlugin(options?: Partial<Options>): Plugin {
Expand All @@ -18,6 +20,7 @@ function viteBasicSslPlugin(options?: Partial<Options>): 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) {
Expand All @@ -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.')
}

Expand All @@ -52,6 +54,7 @@ export async function getCertificate(
const content = (await import('./certificate')).createCertificate(
name,
domains,
ttlDays,
)
fsp
.mkdir(cacheDir, { recursive: true })
Expand Down
66 changes: 65 additions & 1 deletion test/test.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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())
}
})
1 change: 1 addition & 0 deletions test/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ import { defineConfig } from 'vite'
export default defineConfig({
test: {
testTimeout: 100000,
restoreMocks: true,
},
})