Lightweight Http Router Module

An Express-like, zero-dependency router module for Windmill HTTP triggers.

Script http

by as900 ยท 3/28/2026

  • Submitted by as900 Deno
    Created 60 days ago
    1
    /**
    2
     * Supported HTTP methods for routing.
    3
     */
    4
    export enum METHOD {
    5
      GET = "GET",
    6
      POST = "POST",
    7
      PUT = "PUT",
    8
      PATCH = "PATCH",
    9
      DELETE = "DELETE",
    10
    }
    11
    
    
    12
    /**
    13
     * The standard request object passed to route handlers.
    14
     * Contains parsed URL components, headers, and body.
    15
     */
    16
    type HttpRequest = {
    17
      /** HTTP method (e.g., "GET", "POST") */
    18
      method: string;
    19
      /** The matched route pattern (e.g., "/users/:id") */
    20
      route: string;
    21
      /** The full URL path */
    22
      path: string;
    23
      /** Extracted path parameters (e.g., { id: "123" }) */
    24
      params: Record<string, string>;
    25
      /** Query string parameters (e.g., { sort: "asc" }) */
    26
      query: Record<string, string>;
    27
      /** Request headers */
    28
      headers: Record<string, string>;
    29
      /** Parsed request body */
    30
      body: any;
    31
      /** Raw request string (if applicable) */
    32
      raw_string: string | null;
    33
    };
    34
    
    
    35
    /**
    36
     * Windmill HTTP event object, extending HttpRequest with trigger metadata.
    37
     */
    38
    type HttpEvent = HttpRequest & {
    39
      /** Always "http" for HTTP triggers */
    40
      kind: "http";
    41
      /** The path used to trigger the script */
    42
      trigger_path: string;
    43
    };
    44
    
    
    45
    /**
    46
     * A lightweight, chainable router for Windmill HTTP scripts.
    47
     * Supports method chaining, custom error handling, and automatic response formatting.
    48
     */
    49
    export class Router {
    50
      private routes: Record<string, Record<string, (req: HttpRequest) => any>> = {};
    51
    
    
    52
      /**
    53
       * Preprocessor hook for Windmill.
    54
       * Validates the event kind and prepares the request object.
    55
       * @param event - The incoming Windmill event
    56
       * @returns An object containing the normalized request
    57
       */
    58
      preprocessor = async function (event: HttpEvent) {
    59
        if (event.kind === "http") {
    60
          return { req: event };
    61
        }
    62
        throw new Error(`Unsupported trigger kind: ${event.kind}`);
    63
      };
    64
    
    
    65
      /**
    66
       * Normalizes HTTP method strings to uppercase for consistent routing.
    67
       */
    68
      private normalizeMethod(method: string): string {
    69
        return method.toUpperCase();
    70
      }
    71
    
    
    72
      /**
    73
       * Registers a GET route.
    74
       * @param route - The URL path pattern (e.g., "/users")
    75
       * @param handler - The function to execute when the route is matched
    76
       */
    77
      get(route: string, handler: (req: HttpRequest) => any) {
    78
        return this.addRoute(METHOD.GET, route, handler);
    79
      }
    80
    
    
    81
      /**
    82
       * Registers a POST route.
    83
       * @param route - The URL path pattern
    84
       * @param handler - The function to execute
    85
       */
    86
      post(route: string, handler: (req: HttpRequest) => any) {
    87
        return this.addRoute(METHOD.POST, route, handler);
    88
      }
    89
    
    
    90
      /**
    91
       * Registers a PUT route.
    92
       * @param route - The URL path pattern
    93
       * @param handler - The function to execute
    94
       */
    95
      put(route: string, handler: (req: HttpRequest) => any) {
    96
        return this.addRoute(METHOD.PUT, route, handler);
    97
      }
    98
    
    
    99
      /**
    100
       * Registers a PATCH route.
    101
       * @param route - The URL path pattern
    102
       * @param handler - The function to execute
    103
       */
    104
      patch(route: string, handler: (req: HttpRequest) => any) {
    105
        return this.addRoute(METHOD.PATCH, route, handler);
    106
      }
    107
    
    
    108
      /**
    109
       * Registers a DELETE route.
    110
       * @param route - The URL path pattern
    111
       * @param handler - The function to execute
    112
       */
    113
      delete(route: string, handler: (req: HttpRequest) => any) {
    114
        return this.addRoute(METHOD.DELETE, route, handler);
    115
      }
    116
    
    
    117
      /**
    118
       * Internal method to register a route.
    119
       * @param method - The HTTP method
    120
       * @param route - The path pattern
    121
       * @param handler - The handler function
    122
       * @returns The Router instance for chaining
    123
       */
    124
      private addRoute(method: METHOD, route: string, handler: (req: HttpRequest) => any) {
    125
        if (!this.routes[method]) {
    126
          this.routes[method] = {};
    127
        }
    128
        this.routes[method][route] = handler;
    129
        return this;
    130
      }
    131
    
    
    132
      /**
    133
       * Executes the matched handler or throws an appropriate HttpError.
    134
       * @param req - The request object
    135
       * @returns The handler's return value
    136
       * @throws HttpError if route or method is not found
    137
       */
    138
      private executeHandler(req: HttpRequest): any {
    139
        const method = this.normalizeMethod(req.method);
    140
        const route = req.route;
    141
    
    
    142
        if (!this.routes[method]) {
    143
          throw HttpError.MethodNotAllowed(`Method ${method} not allowed`);
    144
        }
    145
    
    
    146
        const handler = this.routes[method][route];
    147
    
    
    148
        if (!handler) {
    149
          throw HttpError.NotFound(`Route ${route} not found`);
    150
        }
    151
    
    
    152
        return handler(req);
    153
      }
    154
    
    
    155
      /**
    156
       * Main entry point for handling requests.
    157
       * Wraps execution in try-catch to standardize error responses.
    158
       * @param req - The request object
    159
       * @returns A formatted WindmillResponse object
    160
       */
    161
      handle(req: HttpRequest): any {
    162
        try {
    163
          return this.executeHandler(req);
    164
        } catch (error) {
    165
          console.error("Router Error:", error);
    166
    
    
    167
          if (!(error instanceof HttpError)) {
    168
            // Wrap unexpected errors in a 500 HttpError
    169
            throw HttpError.InternalError(undefined, error);
    170
          }
    171
    
    
    172
          // Format HttpError for Windmill
    173
          return {
    174
            windmill_status_code: error.status,
    175
            result: {
    176
              statusCode: error.status,
    177
              error: error.message
    178
            }
    179
          };
    180
        }
    181
      }
    182
    }
    183
    
    
    184
    /**
    185
     * Custom error class for HTTP status codes.
    186
     * Extends the native Error class with a status code and optional cause.
    187
     */
    188
    export class HttpError extends Error {
    189
      status: number;
    190
    
    
    191
      /**
    192
       * Creates a new HttpError.
    193
       * @param status - HTTP status code (e.g., 404, 500)
    194
       * @param message - Error message
    195
       * @param cause - The original error that caused this one (preserves stack trace)
    196
       */
    197
      constructor(status: number, message: string, cause?: any) {
    198
        super(message);
    199
        this.status = status;
    200
        this.name = 'HttpError';
    201
        // Manual assignment for runtime compatibility in serverless environments
    202
        if (cause) {
    203
          (this as any).cause = cause;
    204
        }
    205
      }
    206
    
    
    207
      static BadRequest(message = 'Bad Request', cause?: any) {
    208
        return new HttpError(400, message, cause);
    209
      }
    210
    
    
    211
      static Unauthorized(message = 'Unauthorized', cause?: any) {
    212
        return new HttpError(401, message, cause);
    213
      }
    214
    
    
    215
      static Forbidden(message = 'Forbidden', cause?: any) {
    216
        return new HttpError(403, message, cause);
    217
      }
    218
    
    
    219
      static NotFound(message = 'Not found', cause?: any) {
    220
        return new HttpError(404, message, cause);
    221
      }
    222
    
    
    223
      static MethodNotAllowed(message = 'Method not allowed', cause?: any) {
    224
        return new HttpError(405, message, cause);
    225
      }
    226
    
    
    227
      static UnprocessableContent(message = 'Unprocessable Content', cause?: any) {
    228
        return new HttpError(422, message, cause);
    229
      }
    230
    
    
    231
      static InternalError(message = 'Internal server error', cause?: any) {
    232
        return new HttpError(500, message, cause);
    233
      }
    234
    }
    235
    
    
    236
    /**
    237
     * Standard Windmill HTTP response structure.
    238
     */
    239
    type WindmillResponse = {
    240
      windmill_status_code: number;
    241
      windmill_headers?: Record<string, string>;
    242
      result: any;
    243
    };
    244
    
    
    245
    /**
    246
     * Helper class to construct HTTP responses with convenient status codes.
    247
     * Automatically formats output for Windmill.
    248
     */
    249
    export class HttpResponse {
    250
      public status: number;
    251
      public data: any;
    252
      public headers?: Record<string, string>;
    253
    
    
    254
      constructor(data: any, status: number = 200, headers?: Record<string, string>) {
    255
        this.data = data;
    256
        this.status = status;
    257
        this.headers = headers;
    258
      }
    259
    
    
    260
      /**
    261
       * Returns a 200 OK response.
    262
       */
    263
      static Ok(data: any, headers?: Record<string, string>) {
    264
        return new HttpResponse(data, 200, headers).toWindmillResponse();
    265
      }
    266
    
    
    267
      /**
    268
       * Returns a 201 Created response.
    269
       */
    270
      static Created(data: any, headers?: Record<string, string>) {
    271
        return new HttpResponse(data, 201, headers).toWindmillResponse();
    272
      }
    273
    
    
    274
      /**
    275
       * Returns a 202 Accepted response.
    276
       */
    277
      static Accepted(data: any, headers?: Record<string, string>) {
    278
        return new HttpResponse(data, 202, headers).toWindmillResponse();
    279
      }
    280
    
    
    281
      /**
    282
       * Returns a 204 No Content response.
    283
       */
    284
      static NoContent(headers?: Record<string, string>) {
    285
        return new HttpResponse(null, 204, headers).toWindmillResponse();
    286
      }
    287
    
    
    288
      private toWindmillResponse(): WindmillResponse {
    289
        return {
    290
          windmill_status_code: this.status,
    291
          windmill_headers: this.headers,
    292
          result: this.data
    293
        };
    294
      }
    295
    }
    296
    
    
    297
    // --- Usage Example ---
    298
    
    
    299
    const router = new Router();
    300
    
    
    301
    router
    302
      .get("router-test", (req) => {
    303
        return HttpResponse.Ok({ message: "GET TEST" });
    304
      })
    305
      .post("router-test", (req) => {
    306
        if (true) {
    307
          throw HttpError.Forbidden();
    308
        }
    309
    
    
    310
        return HttpResponse.NoContent();
    311
      });
    312
    
    
    313
    // # REQUEST MIDDLEWARE
    314
    export async function preprocessor(event: HttpEvent) {
    315
      return await router.preprocessor(event);
    316
    }
    317
    
    
    318
    // # MAIN ENTRY POINT
    319
    export async function main(req: HttpRequest) {
    320
      return router.handle(req);
    321
    }