feat(handlers): Add in-memory HTTP-01 challenge handler and rename file-based handler to Http01Webroot
This commit is contained in:
		@@ -1,5 +1,12 @@
 | 
			
		||||
# Changelog
 | 
			
		||||
 | 
			
		||||
## 2025-04-30 - 6.2.0 - feat(handlers)
 | 
			
		||||
Add in-memory HTTP-01 challenge handler and rename file-based handler to Http01Webroot
 | 
			
		||||
 | 
			
		||||
- Renamed Http01Handler to Http01Webroot in both implementation and documentation
 | 
			
		||||
- Introduced Http01MemoryHandler for diskless HTTP-01 challenges
 | 
			
		||||
- Updated tests and README examples to reflect handler name changes and new feature
 | 
			
		||||
 | 
			
		||||
## 2025-04-30 - 6.1.3 - fix(Dns01Handler)
 | 
			
		||||
Update dependency versions and refine Dns01Handler implementation
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										25
									
								
								readme.md
									
									
									
									
									
								
							
							
						
						
									
										25
									
								
								readme.md
									
									
									
									
									
								
							@@ -61,7 +61,7 @@ const smartAcmeInstance = new SmartAcme({
 | 
			
		||||
  retryOptions: {},         // optional retry/backoff settings
 | 
			
		||||
  challengeHandlers: [
 | 
			
		||||
    new Dns01Handler(cfAccount),
 | 
			
		||||
    // you can add more handlers, e.g. Http01Handler
 | 
			
		||||
    // you can add more handlers, e.g. Http01Webroot
 | 
			
		||||
  ],
 | 
			
		||||
  challengePriority: ['dns-01'], // optional ordering of challenge types
 | 
			
		||||
});
 | 
			
		||||
@@ -143,7 +143,7 @@ async function main() {
 | 
			
		||||
 
 | 
			
		||||
 ## Built-in Challenge Handlers
 | 
			
		||||
 
 | 
			
		||||
 This module includes two out-of-the-box ACME challenge handlers:
 | 
			
		||||
 This module includes three out-of-the-box ACME challenge handlers:
 | 
			
		||||
 
 | 
			
		||||
 - **Dns01Handler**
 | 
			
		||||
   - Uses a Cloudflare account (from `@apiclient.xyz/cloudflare`) and Smartdns client to set and remove DNS TXT records, then wait for propagation.
 | 
			
		||||
@@ -158,18 +158,31 @@ async function main() {
 | 
			
		||||
     const dnsHandler = new Dns01Handler(cfAccount);
 | 
			
		||||
     ```
 | 
			
		||||
 
 | 
			
		||||
 - **Http01Handler**
 | 
			
		||||
 - **Http01Webroot**
 | 
			
		||||
   - Writes ACME HTTP-01 challenge files under a file-system webroot (`/.well-known/acme-challenge/`), and removes them on cleanup.
 | 
			
		||||
   - Import path:
 | 
			
		||||
     ```typescript
 | 
			
		||||
     import { Http01Handler } from '@push.rocks/smartacme/ts/handlers/Http01Handler.js';
 | 
			
		||||
     import { Http01Webroot } from '@push.rocks/smartacme/ts/handlers/Http01Handler.js';
 | 
			
		||||
     ```
 | 
			
		||||
   - Example:
 | 
			
		||||
     ```typescript
 | 
			
		||||
     const httpHandler = new Http01Handler({ webroot: '/var/www/html' });
 | 
			
		||||
     const httpHandler = new Http01Webroot({ webroot: '/var/www/html' });
 | 
			
		||||
     ```
 | 
			
		||||
 
 | 
			
		||||
 Both handlers implement the `IChallengeHandler<T>` interface and can be combined in the `challengeHandlers` array.
 | 
			
		||||
 - **Http01MemoryHandler**
 | 
			
		||||
   - In-memory HTTP-01 challenge handler that stores and serves ACME tokens without disk I/O.
 | 
			
		||||
   - Import path:
 | 
			
		||||
     ```typescript
 | 
			
		||||
     import { Http01MemoryHandler } from '@push.rocks/smartacme/ts/handlers/Http01MemoryHandler.js';
 | 
			
		||||
     ```
 | 
			
		||||
   - Example (Express integration):
 | 
			
		||||
     ```typescript
 | 
			
		||||
     import { Http01MemoryHandler } from '@push.rocks/smartacme/ts/handlers/Http01MemoryHandler.js';
 | 
			
		||||
     const memoryHandler = new Http01MemoryHandler();
 | 
			
		||||
     app.use((req, res, next) => memoryHandler.handleRequest(req, res, next));
 | 
			
		||||
     ```
 | 
			
		||||
  
 | 
			
		||||
 All handlers implement the `IChallengeHandler<T>` interface and can be combined in the `challengeHandlers` array.
 | 
			
		||||
 
 | 
			
		||||
 ## Creating Custom Handlers
 | 
			
		||||
 
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										44
									
								
								readme.plan.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								readme.plan.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,44 @@
 | 
			
		||||
# Plan: Diskless HTTP-01 Handler and Renaming Existing Handler
 | 
			
		||||
 | 
			
		||||
This plan outlines steps to rename the existing filesystem-based HTTP-01 handler to `Http01Webroot`
 | 
			
		||||
and introduce a new diskless (in-memory) HTTP-01 handler for integration with arbitrary HTTP servers
 | 
			
		||||
(e.g., Express).
 | 
			
		||||
 | 
			
		||||
## 1. Rename existing handler to Http01Webroot
 | 
			
		||||
- In `ts/handlers/Http01Handler.ts`:
 | 
			
		||||
  - Rename `Http01HandlerOptions` to `Http01WebrootOptions`.
 | 
			
		||||
  - Rename class `Http01Handler` to `Http01Webroot`.
 | 
			
		||||
  - Remove the legacy alias; rename the handler directly.
 | 
			
		||||
  - In `ts/handlers/index.ts`:
 | 
			
		||||
    - Export `Http01Webroot` under its new name.
 | 
			
		||||
    - Remove any `Http01Handler` export.
 | 
			
		||||
    - Update existing tests (e.g., `test.handlers-http01.ts`) to import `Http01Webroot` instead of `Http01Handler`.
 | 
			
		||||
 | 
			
		||||
## 2. Add new diskless (in-memory) HTTP-01 handler
 | 
			
		||||
- Create `ts/handlers/Http01MemoryHandler.ts`:
 | 
			
		||||
  - Implement `IChallengeHandler<{ token: string; keyAuthorization: string; webPath: string }>`, storing challenges in a private `Map<string, string>`.
 | 
			
		||||
  - `prepare()`: add token→keyAuthorization mapping.
 | 
			
		||||
  - `verify()`: no-op.
 | 
			
		||||
  - `cleanup()`: remove mapping.
 | 
			
		||||
  - Add `handleRequest(req, res, next?)` method:
 | 
			
		||||
    - Parse `/.well-known/acme-challenge/:token` from `req.url`.
 | 
			
		||||
    - If token exists, respond with the key authorization and status 200.
 | 
			
		||||
    - If missing and `next` provided, call `next()`, otherwise respond 404.
 | 
			
		||||
- Export `Http01MemoryHandler` in `ts/handlers/index.ts`.
 | 
			
		||||
 | 
			
		||||
## 3. Write tests for Http01MemoryHandler
 | 
			
		||||
- Create `test/test.handlers-http01-memory.ts`:
 | 
			
		||||
  - Use `tap` and `expect` to:
 | 
			
		||||
    1. `prepare()` a challenge.
 | 
			
		||||
    2. Invoke `handleRequest()` with a fake `req`/`res` to confirm 200 and correct body.
 | 
			
		||||
    3. `cleanup()` the challenge.
 | 
			
		||||
    4. Confirm `handleRequest()` now yields 404.
 | 
			
		||||
 | 
			
		||||
## 4. Update documentation
 | 
			
		||||
- Add examples in `readme.md` showing how to use both `Http01Webroot` and the new `Http01MemoryHandler`:
 | 
			
		||||
  - Sample code for Express integration using `handleRequest`.
 | 
			
		||||
 | 
			
		||||
## 5. Build and test
 | 
			
		||||
- Run `pnpm build` and `pnpm test`, ensuring existing tests are updated for `Http01Webroot` and new tests pass.
 | 
			
		||||
 | 
			
		||||
Please review and let me know if this plan makes sense before proceeding with implementation.
 | 
			
		||||
							
								
								
									
										58
									
								
								test/test.handlers-http01-memory.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								test/test.handlers-http01-memory.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,58 @@
 | 
			
		||||
import { tap, expect } from '@push.rocks/tapbundle';
 | 
			
		||||
import { Http01MemoryHandler } from '../ts/handlers/Http01MemoryHandler.js';
 | 
			
		||||
 | 
			
		||||
tap.test('Http01MemoryHandler serves in-memory challenges and cleans up', async () => {
 | 
			
		||||
  const handler = new Http01MemoryHandler();
 | 
			
		||||
  const token = 'testtoken';
 | 
			
		||||
  const keyAuth = 'keyAuthValue';
 | 
			
		||||
  const webPath = `/.well-known/acme-challenge/${token}`;
 | 
			
		||||
  const challenge = { type: 'http-01', token, keyAuthorization: keyAuth, webPath };
 | 
			
		||||
 | 
			
		||||
  // Prepare challenge (store in memory)
 | 
			
		||||
  await handler.prepare(challenge);
 | 
			
		||||
 | 
			
		||||
  // Serve existing challenge without next()
 | 
			
		||||
  const req1: any = { url: webPath };
 | 
			
		||||
  const res1: any = {
 | 
			
		||||
    statusCode: 0,
 | 
			
		||||
    headers: {} as Record<string, string>,
 | 
			
		||||
    body: '',
 | 
			
		||||
    setHeader(name: string, value: string) { this.headers[name] = value; },
 | 
			
		||||
    end(body?: string) { this.body = body || ''; },
 | 
			
		||||
  };
 | 
			
		||||
  handler.handleRequest(req1, res1);
 | 
			
		||||
  expect(res1.statusCode).toEqual(200);
 | 
			
		||||
  expect(res1.body).toEqual(keyAuth);
 | 
			
		||||
  expect(res1.headers['content-type']).toEqual('text/plain');
 | 
			
		||||
 | 
			
		||||
  // Cleanup challenge (remove from memory)
 | 
			
		||||
  await handler.cleanup(challenge);
 | 
			
		||||
 | 
			
		||||
  // Serve after cleanup without next() should give 404
 | 
			
		||||
  const req2: any = { url: webPath };
 | 
			
		||||
  const res2: any = {
 | 
			
		||||
    statusCode: 0,
 | 
			
		||||
    headers: {} as Record<string, string>,
 | 
			
		||||
    body: '',
 | 
			
		||||
    setHeader(name: string, value: string) { this.headers[name] = value; },
 | 
			
		||||
    end(body?: string) { this.body = body || ''; },
 | 
			
		||||
  };
 | 
			
		||||
  handler.handleRequest(req2, res2);
 | 
			
		||||
  expect(res2.statusCode).toEqual(404);
 | 
			
		||||
 | 
			
		||||
  // Serve after cleanup with next() should call next
 | 
			
		||||
  const req3: any = { url: webPath };
 | 
			
		||||
  let nextCalled = false;
 | 
			
		||||
  const next = () => { nextCalled = true; };
 | 
			
		||||
  const res3: any = {
 | 
			
		||||
    statusCode: 0,
 | 
			
		||||
    headers: {} as Record<string, string>,
 | 
			
		||||
    body: '',
 | 
			
		||||
    setHeader(name: string, value: string) { this.headers[name] = value; },
 | 
			
		||||
    end(body?: string) { this.body = body || ''; },
 | 
			
		||||
  };
 | 
			
		||||
  handler.handleRequest(req3, res3, next);
 | 
			
		||||
  expect(nextCalled).toEqual(true);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export default tap.start();
 | 
			
		||||
@@ -1,13 +1,13 @@
 | 
			
		||||
import { tap, expect } from '@push.rocks/tapbundle';
 | 
			
		||||
import { Http01Handler } from '../ts/handlers/Http01Handler.js';
 | 
			
		||||
import { Http01Webroot } from '../ts/handlers/Http01Handler.js';
 | 
			
		||||
import { promises as fs } from 'fs';
 | 
			
		||||
import * as path from 'path';
 | 
			
		||||
import os from 'os';
 | 
			
		||||
 | 
			
		||||
tap.test('Http01Handler writes challenge file and removes it on cleanup', async () => {
 | 
			
		||||
tap.test('Http01Webroot writes challenge file and removes it on cleanup', async () => {
 | 
			
		||||
  // create temporary webroot directory
 | 
			
		||||
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'http01-'));
 | 
			
		||||
  const handler = new Http01Handler({ webroot: tmpDir });
 | 
			
		||||
  const handler = new Http01Webroot({ webroot: tmpDir });
 | 
			
		||||
  const token = 'testtoken';
 | 
			
		||||
  const keyAuth = 'keyAuthValue';
 | 
			
		||||
  const webPath = `/.well-known/acme-challenge/${token}`;
 | 
			
		||||
 
 | 
			
		||||
@@ -3,6 +3,6 @@
 | 
			
		||||
 */
 | 
			
		||||
export const commitinfo = {
 | 
			
		||||
  name: '@push.rocks/smartacme',
 | 
			
		||||
  version: '6.1.3',
 | 
			
		||||
  version: '6.2.0',
 | 
			
		||||
  description: 'A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power.'
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -6,14 +6,14 @@ import type { IChallengeHandler } from './IChallengeHandler.js';
 | 
			
		||||
 * HTTP-01 ACME challenge handler using file-system webroot.
 | 
			
		||||
 * Writes and removes the challenge file under <webroot>/.well-known/acme-challenge/. 
 | 
			
		||||
 */
 | 
			
		||||
export interface Http01HandlerOptions {
 | 
			
		||||
export interface Http01WebrootOptions {
 | 
			
		||||
  /**
 | 
			
		||||
   * Directory that serves HTTP requests for /.well-known/acme-challenge
 | 
			
		||||
   */
 | 
			
		||||
  webroot: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class Http01Handler implements IChallengeHandler<{
 | 
			
		||||
export class Http01Webroot implements IChallengeHandler<{
 | 
			
		||||
  type: string;
 | 
			
		||||
  token: string;
 | 
			
		||||
  keyAuthorization: string;
 | 
			
		||||
@@ -21,7 +21,7 @@ export class Http01Handler implements IChallengeHandler<{
 | 
			
		||||
}> {
 | 
			
		||||
  private webroot: string;
 | 
			
		||||
 | 
			
		||||
  constructor(options: Http01HandlerOptions) {
 | 
			
		||||
  constructor(options: Http01WebrootOptions) {
 | 
			
		||||
    this.webroot = options.webroot;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										67
									
								
								ts/handlers/Http01MemoryHandler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								ts/handlers/Http01MemoryHandler.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,67 @@
 | 
			
		||||
import type { IChallengeHandler } from './IChallengeHandler.js';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * HTTP-01 ACME challenge handler using in-memory storage.
 | 
			
		||||
 * Stores challenge tokens and key authorizations in memory
 | 
			
		||||
 * and serves them via handleRequest for arbitrary HTTP servers.
 | 
			
		||||
 */
 | 
			
		||||
export interface Http01MemoryHandlerChallenge {
 | 
			
		||||
  type: string;
 | 
			
		||||
  token: string;
 | 
			
		||||
  keyAuthorization: string;
 | 
			
		||||
  webPath: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class Http01MemoryHandler implements IChallengeHandler<Http01MemoryHandlerChallenge> {
 | 
			
		||||
  private store: Map<string, string> = new Map();
 | 
			
		||||
 | 
			
		||||
  public getSupportedTypes(): string[] {
 | 
			
		||||
    return ['http-01'];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async prepare(ch: Http01MemoryHandlerChallenge): Promise<void> {
 | 
			
		||||
    this.store.set(ch.token, ch.keyAuthorization);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async verify(_ch: Http01MemoryHandlerChallenge): Promise<void> {
 | 
			
		||||
    // No-op
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async cleanup(ch: Http01MemoryHandlerChallenge): Promise<void> {
 | 
			
		||||
    this.store.delete(ch.token);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * HTTP request handler for serving ACME HTTP-01 challenges.
 | 
			
		||||
   * @param req HTTP request object (should have url property)
 | 
			
		||||
   * @param res HTTP response object
 | 
			
		||||
   * @param next Optional next() callback for Express-style fallthrough
 | 
			
		||||
   */
 | 
			
		||||
  public handleRequest(req: any, res: any, next?: () => void): void {
 | 
			
		||||
    const url = req.url || '';
 | 
			
		||||
    const prefix = '/.well-known/acme-challenge/';
 | 
			
		||||
    if (!url.startsWith(prefix)) {
 | 
			
		||||
      if (next) {
 | 
			
		||||
        return next();
 | 
			
		||||
      }
 | 
			
		||||
      res.statusCode = 404;
 | 
			
		||||
      return res.end();
 | 
			
		||||
    }
 | 
			
		||||
    const token = url.slice(prefix.length);
 | 
			
		||||
    const keyAuth = this.store.get(token);
 | 
			
		||||
    if (keyAuth !== undefined) {
 | 
			
		||||
      if (typeof res.status === 'function' && typeof res.send === 'function') {
 | 
			
		||||
        return res.status(200).send(keyAuth);
 | 
			
		||||
      }
 | 
			
		||||
      res.statusCode = 200;
 | 
			
		||||
      res.setHeader('content-type', 'text/plain');
 | 
			
		||||
      return res.end(keyAuth);
 | 
			
		||||
    }
 | 
			
		||||
    if (next) {
 | 
			
		||||
      return next();
 | 
			
		||||
    }
 | 
			
		||||
    res.statusCode = 404;
 | 
			
		||||
    return res.end();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,4 +1,5 @@
 | 
			
		||||
export type { IChallengeHandler } from './IChallengeHandler.js';
 | 
			
		||||
// Removed legacy handler adapter
 | 
			
		||||
export { Dns01Handler } from './Dns01Handler.js';
 | 
			
		||||
export { Http01Handler } from './Http01Handler.js';
 | 
			
		||||
export { Http01Webroot } from './Http01Handler.js';
 | 
			
		||||
export { Http01MemoryHandler } from './Http01MemoryHandler.js';
 | 
			
		||||
		Reference in New Issue
	
	Block a user