fix(acme-http-client): Destroy keep-alive HTTP agents and DNS client on shutdown to allow process exit; add destroy() on AcmeHttpClient and AcmeClient, wire agents into requests, and call client/smartdns destroy during SmartAcme.stop; documentation clarifications and expanded README (error handling, examples, default retry values).

This commit is contained in:
2026-02-15 20:43:06 +00:00
parent e968f8a039
commit 52e1295fd2
6 changed files with 114 additions and 38 deletions

View File

@@ -1,5 +1,13 @@
# Changelog
## 2026-02-15 - 9.0.1 - fix(acme-http-client)
Destroy keep-alive HTTP agents and DNS client on shutdown to allow process exit; add destroy() on AcmeHttpClient and AcmeClient, wire agents into requests, and call client/smartdns destroy during SmartAcme.stop; documentation clarifications and expanded README (error handling, examples, default retry values).
- ts/acme/acme.classes.http-client.ts: added per-protocol http/https agents (keepAlive: false), use agent for outgoing requests, and added destroy() to explicitly destroy agents and free sockets.
- ts/acme/acme.classes.client.ts: added destroy() that forwards to the HTTP client to allow transport cleanup.
- ts/smartacme.classes.smartacme.ts: SmartAcme.stop now calls client.destroy() and smartdns.destroy() (when present) to ensure child processes and sockets are terminated before exit; also ensures certmanager.close() is awaited.
- readme.md: documentation improvements and clarifications (Lets Encrypt spelling, added RFC 8555 compliance note, error handling / AcmeError usage examples, default retry parameter docs, UI/emoji improvements, and other wording/formatting updates).
## 2026-02-15 - 9.0.0 - BREAKING CHANGE(acme)
Replace external acme-client with a built-in RFC8555-compliant ACME implementation and update public APIs accordingly

114
readme.md
View File

@@ -1,6 +1,6 @@
# @push.rocks/smartacme
A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power. 🔒
A TypeScript-based ACME client for Let's Encrypt certificate management with a focus on simplicity and power. 🔒
## Issue Reporting and Security
@@ -16,9 +16,9 @@ Ensure your project uses TypeScript and ECMAScript Modules (ESM).
## Usage
`@push.rocks/smartacme` automates the full ACME certificate lifecycle — obtaining, renewing, and storing SSL/TLS certificates from Let's Encrypt. It supports pluggable challenge handlers (DNS-01, HTTP-01) and pluggable certificate storage backends (MongoDB, in-memory, or your own).
`@push.rocks/smartacme` automates the full ACME certificate lifecycle — obtaining, renewing, and storing SSL/TLS certificates from Let's Encrypt. It features a built-in RFC 8555-compliant ACME protocol implementation, pluggable challenge handlers (DNS-01, HTTP-01), pluggable certificate storage backends (MongoDB, in-memory, or your own), and structured error handling with smart retry logic.
### Quick Start
### 🚀 Quick Start
```typescript
import { SmartAcme, certmanagers, handlers } from '@push.rocks/smartacme';
@@ -47,39 +47,40 @@ await smartAcme.start();
// 4. Get a certificate
const cert = await smartAcme.getCertificateForDomain('example.com');
console.log(cert.publicKey); // PEM certificate
console.log(cert.publicKey); // PEM certificate chain
console.log(cert.privateKey); // PEM private key
// 5. Clean up
await smartAcme.stop();
```
### SmartAcme Options
### ⚙️ SmartAcme Options
```typescript
interface ISmartAcmeOptions {
accountEmail: string; // ACME account email
accountPrivateKey?: string; // Optional account key (auto-generated if omitted)
certManager: ICertManager; // Certificate storage backend
environment: 'production' | 'integration'; // LetsEncrypt environment
environment: 'production' | 'integration'; // Let's Encrypt environment
challengeHandlers: IChallengeHandler[]; // At least one handler required
challengePriority?: string[]; // e.g. ['dns-01', 'http-01']
retryOptions?: { // Optional retry/backoff config
retries?: number;
factor?: number;
minTimeoutMs?: number;
maxTimeoutMs?: number;
retries?: number; // Default: 10
factor?: number; // Default: 4
minTimeoutMs?: number; // Default: 1000
maxTimeoutMs?: number; // Default: 60000
};
}
```
### Getting Certificates
### 📜 Getting Certificates
```typescript
// Standard certificate for a single domain
const cert = await smartAcme.getCertificateForDomain('example.com');
// Include wildcard certificate (requires DNS-01 handler)
// Include wildcard coverage (requires DNS-01 handler)
// Issues a single cert covering example.com AND *.example.com
const certWithWildcard = await smartAcme.getCertificateForDomain('example.com', {
includeWildcard: true,
});
@@ -90,25 +91,32 @@ const wildcardCert = await smartAcme.getCertificateForDomain('*.example.com');
Certificates are automatically cached and reused when still valid. Renewal happens automatically when a certificate is within 10 days of expiration.
### Certificate Object
### 📦 Certificate Object
The returned `SmartacmeCert` object has these properties:
The returned `SmartacmeCert` (also exported as `Cert`) object has these properties:
| Property | Type | Description |
|-------------|----------|--------------------------------------|
| `id` | `string` | Unique certificate identifier |
| `domainName`| `string` | Domain the cert is issued for |
| `publicKey` | `string` | PEM-encoded certificate |
| `publicKey` | `string` | PEM-encoded certificate chain |
| `privateKey`| `string` | PEM-encoded private key |
| `csr` | `string` | Certificate Signing Request |
| `created` | `number` | Timestamp of creation |
| `validUntil`| `number` | Timestamp of expiration |
Useful methods:
```typescript
cert.isStillValid(); // true if not expired
cert.shouldBeRenewed(); // true if expires within 10 days
```
## Certificate Managers
SmartAcme uses the `ICertManager` interface for pluggable certificate storage.
### MongoCertManager
### 🗄️ MongoCertManager
Persistent storage backed by MongoDB using `@push.rocks/smartdata`:
@@ -122,7 +130,7 @@ const certManager = new certmanagers.MongoCertManager({
});
```
### MemoryCertManager
### 🧪 MemoryCertManager
In-memory storage, ideal for testing or ephemeral workloads:
@@ -132,13 +140,12 @@ import { certmanagers } from '@push.rocks/smartacme';
const certManager = new certmanagers.MemoryCertManager();
```
### Custom Certificate Manager
### 🔧 Custom Certificate Manager
Implement the `ICertManager` interface for your own storage backend:
```typescript
import type { ICertManager } from '@push.rocks/smartacme';
import { Cert } from '@push.rocks/smartacme';
import type { ICertManager, Cert } from '@push.rocks/smartacme';
class RedisCertManager implements ICertManager {
async init(): Promise<void> { /* connect */ }
@@ -166,7 +173,7 @@ const cfAccount = new cloudflare.CloudflareAccount('YOUR_CF_TOKEN');
const dnsHandler = new handlers.Dns01Handler(cfAccount);
```
DNS-01 is required for wildcard certificates and works regardless of server accessibility.
DNS-01 is **required** for wildcard certificates and works regardless of server accessibility.
### 📁 Http01Webroot
@@ -197,12 +204,12 @@ app.use((req, res, next) => memHandler.handleRequest(req, res, next));
Perfect for serverless or container environments where filesystem access is limited.
### Custom Challenge Handler
### 🔧 Custom Challenge Handler
Implement `IChallengeHandler<T>` for custom challenge types:
```typescript
import type { IChallengeHandler } from '@push.rocks/smartacme';
import type { handlers } from '@push.rocks/smartacme';
interface MyChallenge {
type: string;
@@ -210,28 +217,54 @@ interface MyChallenge {
keyAuthorization: string;
}
class MyHandler implements IChallengeHandler<MyChallenge> {
class MyHandler implements handlers.IChallengeHandler<MyChallenge> {
getSupportedTypes(): string[] { return ['http-01']; }
async prepare(ch: MyChallenge): Promise<void> { /* ... */ }
async cleanup(ch: MyChallenge): Promise<void> { /* ... */ }
async prepare(ch: MyChallenge): Promise<void> { /* set up challenge response */ }
async cleanup(ch: MyChallenge): Promise<void> { /* tear down */ }
async checkWetherDomainIsSupported(domain: string): Promise<boolean> { return true; }
}
```
## Error Handling
SmartAcme provides structured ACME error handling via the `AcmeError` class, which carries full RFC 8555 error information:
```typescript
import { AcmeError } from '@push.rocks/smartacme/ts/acme/acme.classes.error.js';
try {
const cert = await smartAcme.getCertificateForDomain('example.com');
} catch (err) {
if (err instanceof AcmeError) {
console.log(err.status); // HTTP status code (e.g. 429)
console.log(err.type); // ACME error URN (e.g. 'urn:ietf:params:acme:error:rateLimited')
console.log(err.detail); // Human-readable message
console.log(err.subproblems); // Per-identifier sub-errors (RFC 8555 §6.7.1)
console.log(err.retryAfter); // Retry-After value in seconds
console.log(err.isRateLimited); // true for 429 or rateLimited type
console.log(err.isRetryable); // true for 429, 503, 5xx, badNonce; false for 403/404/409
}
}
```
The built-in retry logic is **error-aware**: non-retryable errors (403, 404, 409) are thrown immediately without wasting retry attempts, and rate-limited responses respect the server's `Retry-After` header instead of using blind exponential backoff.
## Domain Matching
SmartAcme automatically maps subdomains to their base domain for certificate lookups:
```typescript
// subdomain.example.com → certificate for example.com
// *.example.com → certificate for example.com
// a.b.example.com → not supported (4+ level domains)
```
subdomain.example.com → certificate for example.com
*.example.com → certificate for example.com
a.b.example.com → not supported (4+ levels) ❌
```
## Environment
- **`production`** — Uses LetsEncrypt production servers. Rate limits apply.
- **`integration`** — Uses LetsEncrypt staging servers. No rate limits, but certificates are not trusted by browsers. Use for testing.
| Environment | Description |
|----------------|-------------|
| `production` | Let's Encrypt production servers. Certificates are browser-trusted. [Rate limits](https://letsencrypt.org/docs/rate-limits/) apply. |
| `integration` | Let's Encrypt staging servers. No rate limits, but certificates are **not** browser-trusted. Use for testing. |
## Complete Example with HTTP-01
@@ -269,13 +302,20 @@ await smartAcme.stop();
server.close();
```
## Testing
## Architecture
```bash
pnpm test
```
Under the hood, SmartAcme uses a fully custom RFC 8555-compliant ACME protocol implementation (no external ACME libraries). Key internal modules:
Tests use `@git.zone/tstest` with the tapbundle assertion library.
| Module | Purpose |
|--------|---------|
| `AcmeClient` | Top-level ACME facade — orders, authorizations, finalization |
| `AcmeCrypto` | RSA key generation, JWK/JWS (RFC 7515/7638), CSR via `@peculiar/x509` |
| `AcmeHttpClient` | JWS-signed HTTP transport with nonce management and structured logging |
| `AcmeError` | Structured error class with type URN, subproblems, Retry-After, retryability |
| `AcmeOrderManager` | Order lifecycle — create, poll, finalize, download certificate |
| `AcmeChallengeManager` | Key authorization computation and challenge completion |
All cryptographic operations use `node:crypto`. The only external crypto dependency is `@peculiar/x509` for CSR generation.
## License and Legal Information

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartacme',
version: '9.0.0',
version: '9.0.1',
description: 'A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power.'
}

View File

@@ -96,4 +96,11 @@ export class AcmeClient {
async getCertificate(order: IAcmeOrder): Promise<string> {
return this.orderManager.getCertificate(order);
}
/**
* Destroy HTTP transport to release sockets and allow process exit.
*/
destroy(): void {
this.httpClient.destroy();
}
}

View File

@@ -17,11 +17,23 @@ export class AcmeHttpClient {
private nonce: string | null = null;
public kid: string | null = null;
private logger?: TAcmeLogger;
private httpsAgent: https.Agent;
private httpAgent: http.Agent;
constructor(directoryUrl: string, accountKeyPem: string, logger?: TAcmeLogger) {
this.directoryUrl = directoryUrl;
this.accountKeyPem = accountKeyPem;
this.logger = logger;
this.httpsAgent = new https.Agent({ keepAlive: false });
this.httpAgent = new http.Agent({ keepAlive: false });
}
/**
* Destroy HTTP agents to release sockets and allow process exit.
*/
destroy(): void {
this.httpsAgent.destroy();
this.httpAgent.destroy();
}
private log(level: string, message: string, data?: any): void {
@@ -186,6 +198,7 @@ export class AcmeHttpClient {
path: urlObj.pathname + urlObj.search,
method,
headers: requestHeaders,
agent: isHttps ? this.httpsAgent : this.httpAgent,
};
const req = lib.request(options, (res) => {

View File

@@ -169,6 +169,14 @@ export class SmartAcme {
process.removeListener('SIGTERM', this.boundSigtermHandler);
this.boundSigtermHandler = null;
}
// Destroy ACME HTTP transport (closes keep-alive sockets)
if (this.client) {
this.client.destroy();
}
// Destroy DNS client (kills Rust bridge child process if spawned)
if (this.smartdns) {
this.smartdns.destroy();
}
if (this.certmanager && typeof (this.certmanager as any).close === 'function') {
await (this.certmanager as any).close();
}