Skip to content

Creating a Custom Platform Adapter

Ts.ED provides built-in support for several web frameworks like Express.js, Koa.js, and Fastify through its Platform Adapter system. This guide explains how to create your own custom adapter to integrate any web framework with Ts.ED.

Understanding Platform Adapters

Platform adapters serve as a bridge between Ts.ED's platform-agnostic abstractions and specific web frameworks. They allow your application code to remain framework-agnostic while providing the necessary integration with your chosen web framework.

Ts.ED provides an abstraction layer based on decorators from @tsed/schema. These decorators create an abstraction model that allows the @tsed/platform-routers module to generate a routing table that can be mapped to any target framework (Express.js, Koa.js, Fastify.js, etc.).

The routing table contains all the necessary information to map HTTP verbs, paths, and handlers to the target framework. The adapter's primary responsibility is to perform this mapping, transforming Ts.ED handlers into handlers that are compatible with the target framework.

Target frameworks generally provide similar functionality, but when certain features are missing in a particular framework, it's the adapter's responsibility to fill these gaps.

The core of this system is the PlatformAdapter abstract class, which your custom adapter will extend.

Path Conventions Support

One of the key responsibilities of a platform adapter is to support Ts.ED's path conventions and adapt them to the target platform's routing system. Different web frameworks handle path patterns differently, and the adapter must ensure that Ts.ED's path syntax works correctly with the underlying framework.

Path Conversion

Each adapter must implement a convertPath utility function that transforms Ts.ED path patterns into the format expected by the target framework. This function handles various path syntaxes, including:

  • Simple wildcards (/*)
  • Named wildcards (/:param*)
  • Regular expression patterns (/(.*))
  • Optional parameters (/:param? or /{:param})
  • Special patterns with brackets or regex

For example, here's how the Fastify adapter converts paths:

typescript
import {isString} from "@tsed/core";

interface ConvertPathResult {
  path: string | RegExp;
  wildcard?: string;
}

export function convertPath(path: string | RegExp): ConvertPathResult {
  if (isString(path)) {
    const parsed = path.split("/").reduce(
      (options, segment, index) => {
        const isLastSegment = index === path.split("/").length - 1;

        if (isLastSegment && (segment === "*" || segment === "(.*)")) {
          options.wildcard = "*";
          options.path.push("*");

          return options;
        }

        if (isLastSegment && segment.startsWith("*")) {
          options.wildcard = segment.substring(1);
          options.path.push("*");

          return options;
        }

        if (segment.startsWith(":") && segment.endsWith("*")) {
          options.wildcard = segment.substring(1, segment.length - 1);
          options.path.push("*");

          return options;
        }

        if (segment.startsWith("{:") && segment.endsWith("}")) {
          // Handle v5 style parameters like /{param}
          const paramName = segment.substring(2, segment.length - 1);
          options.path.push(`:${paramName}?`);

          return options;
        }

        options.path.push(segment);

        return options;
      },
      {path: [], wildcard: undefined} as {path: string[]; wildcard?: string}
    );

    return {
      path: parsed.path.join("/"),
      wildcard: parsed.wildcard
    };
  }

  return {path};
}
ts
import {convertPath} from "./convertPath.js";

describe("Path conversion", () => {
  it("should convert path with parameters correctly", () => {
    // v4
    expect(convertPath("/*")).toEqual({path: "/*", wildcard: "*"});
    expect(convertPath("/foo/*")).toEqual({path: "/foo/*", wildcard: "*"});
    expect(convertPath("/test/foo/*")).toEqual({path: "/test/foo/*", wildcard: "*"});
    expect(convertPath("/test/:foo/*")).toEqual({path: "/test/:foo/*", wildcard: "*"});
    expect(convertPath("/:param?")).toEqual({path: "/:param?"});
    expect(convertPath("/foo/:param?")).toEqual({path: "/foo/:param?"});
    expect(convertPath("/test/:foo/:param?")).toEqual({path: "/test/:foo/:param?"});
    expect(convertPath("/test/:foo?/:param?")).toEqual({path: "/test/:foo?/:param?"});

    // Ts.ED syntax
    expect(convertPath("/:param*")).toEqual({path: "/*", wildcard: "param"});
    expect(convertPath("/foo/:param*")).toEqual({path: "/foo/*", wildcard: "param"});
    expect(convertPath("/:foo/:param*")).toEqual({path: "/:foo/*", wildcard: "param"});

    // Express v5 compatibility to @koa/router
    expect(convertPath("/*splat")).toEqual({path: "/*", wildcard: "splat"});
    expect(convertPath("/foo/*splat")).toEqual({path: "/foo/*", wildcard: "splat"});
    expect(convertPath("/{:param}")).toEqual({path: "/:param?"});
    expect(convertPath("/foo/{:param}")).toEqual({path: "/foo/:param?"});
    expect(convertPath("/test/{:foo}/{:param}")).toEqual({path: "/test/:foo?/:param?"});
    expect(convertPath("/test/:foo/{:param}")).toEqual({path: "/test/:foo/:param?"});

    // v4 pattern to v4 wildcard
    expect(convertPath("/(.*)")).toEqual({path: "/*", wildcard: "*"});
    expect(convertPath("/foo/(.*)")).toEqual({path: "/foo/*", wildcard: "*"});

    // preserve the original path for v4, not supported in v5
    expect(convertPath("/[discussion|page]/:slug")).toEqual({path: "/[discussion|page]/:slug"});
    expect(convertPath("/test/(.*)/end")).toEqual({path: "/test/(.*)/end"});
  });
});

The adapter must handle all supported path conventions and ensure they work correctly with the target framework. For more details on the supported path conventions, see the Routing documentation.

Adapter Implementation Steps

1. Project Structure

Create a new package with the following structure:

text
platform-yourframework/
├── src/
│   ├── components/
│   │   └── PlatformYourFramework.ts
│   ├── interfaces/
│   │   └── PlatformYourFrameworkSettings.ts
│   ├── services/
│   │   ├── PlatformYourFrameworkHandler.ts
│   │   ├── PlatformYourFrameworkRequest.ts
│   │   └── PlatformYourFrameworkResponse.ts
│   ├── middlewares/
│   │   └── staticsMiddleware.ts
│   └── index.ts
└── package.json

You can take inspiration from the existing adapters in the Ts.ED repository, such as:

2. Extend PlatformAdapter

Create your adapter class by extending PlatformAdapter:

typescript
import {adapter, PlatformAdapter, PlatformHandler, PlatformResponse} from "@tsed/platform-http";
import YourFramework from "your-framework";

export class PlatformYourFramework extends PlatformAdapter<YourFramework.Application> {
  readonly NAME = "yourframework";

  // Implementation of required methods
  // ...
}

// Register your adapter and its services
adapter(PlatformYourFramework, [
  {
    token: PlatformResponse,
    useClass: PlatformYourFrameworkResponse
  },
  {
    token: PlatformHandler,
    useClass: PlatformYourFrameworkHandler
  }
]);

3. Implement Required Methods

Your adapter must implement several key methods:

createApp()

Creates and configures the framework application instance:

typescript
import {PlatformAdapter} from "@tsed/platform-http";

class PlatformYourFramework extends PlatformAdapter {
  createApp() {
    const app = new YourFramework();

    // Configure app with any default settings

    return {
      app,
      callback: () => app
    };
  }
}
typescript
class PlatformExpress extends PlatformAdapter {
  createApp() {
    const app = constant<Express.Express>("express.app") || Express();

    return {
      app,
      callback: () => app
    };
  }
}
typescript
import {PlatformAdapter} from "@tsed/platform-http";

class PlatformKoa extends PlatformAdapter {
  createApp() {
    const app = constant<Koa | undefined>("koa.app") || new Koa();
    koaQs(app, "extended");

    return {
      app,
      callback() {
        return app.callback();
      }
    };
  }
}
typescript
import {PlatformAdapter} from "@tsed/platform-http";
import type {FastifyInstance} from "fastify";

class PlatformFastify extends PlatformAdapter {
  createApp() {
    const {app, ...props} = constant<PlatformFastifySettings>("fastify") || {};
    // Configure options...

    const instance: FastifyInstance = app || Fastify(opts);
    instance.decorateRequest("$ctx", null as never);
    instance.decorateReply("locals", null);

    return {
      app: instance,
      callback: () => {
        return async (request, response) => {
          await instance.ready();
          instance.server.emit("request", request, response);
        };
      }
    };
  }
}

useContext()

This method is essential to the framework's operation. It enables the transportation of the $ctx object across all methods executed from the controller to the service layer.

This system relies on Node.js's async_hooks feature, which allows context to be maintained throughout asynchronous operations.

Without this feature properly implemented, Ts.ED's functionality would be degraded or completely non-functional, as the context that carries request-specific data would be lost between middleware and controller executions.

Sets up the context for each request:

typescript
class PlatformYourFramework extends PlatformAdapter {
  useContext(): void {
    const invoke = createContext();
    const app = application();

    // Add middleware to create context for each request
    app.use(async (request, response, next) => {
      const $ctx = invoke({request, response});
      await $ctx.start();

      // Store context on request for later use
      request.$ctx = $ctx;

      // Clean up when response is finished
      response.on("finish", () => $ctx.finish());

      return next();
    });
  }
}

mapHandler()

Maps Ts.ED handlers to the target framework handler convention:

typescript
class PlatformYourFramework extends PlatformAdapter {
  mapHandler(handler: Function, metadata: PlatformHandlerMetadata) {
    // For error middleware
    if (metadata.type == PlatformHandlerType.ERR_MIDDLEWARE) {
      return (error, req, res, next) => {
        // signature may vary by framework
        // Handle error middleware
        // ...
      };
    }

    // For regular handler
    return (req, res, next) => {
      // signature may vary by framework
      // Execute handler in context
      // ...
    };
  }
}
typescript
import {PlatformAdapter} from "@tsed/platform-http";
import {PlatformHandlerMetadata, PlatformHandlerType} from "@tsed/platform-router";
import {runInContext} from "@tsed/di";
import {catchAsyncError} from "@tsed/core";

class PlatformExpress extends PlatformAdapter {
  mapHandler(handler: Function, metadata: PlatformHandlerMetadata) {
    if (metadata.type == PlatformHandlerType.ERR_MIDDLEWARE) {
      return (error: unknown, req: any, res: any, next: any) => {
        return runInContext(req.$ctx, async () => {
          const {$ctx} = req;
          $ctx.next = next;
          $ctx.error = error;
          $ctx.error = await catchAsyncError(() => handler($ctx));
          return callNext(next, metadata, $ctx);
        });
      };
    }

    return (req: any, res: any, next: any) => {
      return runInContext(req.$ctx, async () => {
        const {$ctx} = req;
        $ctx.next = next;
        $ctx.error = await catchAsyncError(() => handler($ctx));
        return callNext(next, metadata, $ctx);
      });
    };
  }
}
typescript
import {PlatformAdapter} from "@tsed/platform-http";
import {PlatformHandlerMetadata, PlatformHandlerType} from "@tsed/platform-router";
import Koa from "koa";
import {catchAsyncError} from "@tsed/core";

class PlatformKoa extends PlatformAdapter {
  mapHandler(handler: Function, metadata: PlatformHandlerMetadata) {
    return async (koaContext: Koa.Context, next: Koa.Next) => {
      const {$ctx} = koaContext.request;
      $ctx.next = next;
      const error = await catchAsyncError(() => handler($ctx));
      if (error) {
        $ctx.error = error;
      }
      if (metadata.type !== PlatformHandlerType.RESPONSE_FN) {
        return $ctx.next && $ctx.error ? $ctx.next($ctx.error) : $ctx.next();
      }
    };
  }
}
typescript
import {PlatformAdapter} from "@tsed/platform-http";
import {PlatformHandlerMetadata, PlatformHandlerType} from "@tsed/platform-router";
import type {IncomingMessage} from "node:http";
import {runInContext} from "@tsed/di";
import type {FastifyRequest} from "fastify";

class PlatformFastify extends PlatformAdapter {
  mapHandler(handler: (...args: any[]) => any, metadata: PlatformHandlerMetadata) {
    if (metadata.isRawMiddleware()) {
      return handler;
    }

    switch (metadata.type) {
      case PlatformHandlerType.MIDDLEWARE:
        return (request: IncomingMessage, _: ServerResponse, done: (err?: any) => void) => {
          const {$ctx} = request;
          $ctx.next = done;
          return runInContext($ctx, () => handler($ctx));
        };

      default:
        return async (request: FastifyRequest, _: FastifyReply, done: (err?: any) => void) => {
          const {$ctx} = request;
          $ctx.next = done;
          await runInContext($ctx, () => handler($ctx));
          if (metadata.type === PlatformHandlerType.CTX_FN) {
            done();
          }
        };
    }
  }
}

mapLayers()

Maps platform layers to framework routes. This method is where path conversion typically happens, transforming Ts.ED path patterns to the format expected by the target framework:

typescript
class PlatformYourFramework extends PlatformAdapter {
  mapLayers(layers: PlatformLayer[]) {
    const rawApp = this.app.getApp();

    layers.forEach((layer) => {
      // Convert the path to the format expected by the target framework
      const {path, wildcard} = convertPath(layer.path);

      layer.path = path;

      if (layer.method === "statics") {
        // Handle static files
        rawApp.use(path, this.statics(path as string, layer.opts as any));
        return;
      }

      // Handle routes
      // If the path has a wildcard, you may need to add special handling
      if (wildcard) {
        // Add wildcard-specific handling if needed
      }

      rawApp[layer.method](path, ...layer.getArgs(false));
    });
  }
}
typescript
import {PlatformAdapter} from "@tsed/platform-http";

class PlatformExpress extends PlatformAdapter {
  mapLayers(layers: PlatformLayer[]) {
    const rawApp: any = this.app.getApp();

    layers.forEach((layer) => {
      const handlers = layer.getArgs(false);
      const {path, wildcard} = convertPath(layer.path, v as "v4" | "v5");

      layer.path = path;

      if (layer.method === "statics") {
        rawApp.use(path, this.statics(path, layer.opts as any));
        return;
      }

      if (wildcard) {
        handlers.unshift(((req: Express.Request, _: any, next: Express.NextFunction) => {
          if (req.params["0"] && !req.params[wildcard]) {
            req.params[wildcard] = req.params["0"];
          }

          next();
        }) as any);
      }

      rawApp[layer.method](path, ...handlers);
    });
  }
}
typescript
import {PlatformAdapter} from "@tsed/platform-http";

class PlatformKoa extends PlatformAdapter {
  mapLayers(layers: PlatformLayer[]) {
    const options = constant("koa.router", {});
    const rawRouter = new KoaRouter(options) as any;

    layers.forEach((layer) => {
      const {path, wildcard} = convertPath(layer.path);
      layer.path = path;

      if (layer.method === "statics") {
        rawRouter.use(path, this.statics(layer.path as string, layer.opts as any));
        return;
      }

      const handlers = layer.getArgs(false);

      if (wildcard === "*") {
        handlers.unshift(((koaContext: any, next: any) => {
          koaContext.request.params["*"] = koaContext.request.params["0"];

          return next();
        }) as any);
      }

      rawRouter[layer.method](path, ...handlers);
    });

    application().getApp().use(rawRouter.routes()).use(rawRouter.allowedMethods());
  }
}
typescript
import {PlatformAdapter} from "@tsed/platform-http";

class PlatformFastify extends PlatformAdapter {
  mapLayers(layers: PlatformLayer[]) {
    const {app} = this;
    const rawApp: FastifyInstance = app.getApp();

    layers.forEach((layer) => {
      const {path, wildcard} = convertPath(layer.path);
      const handlers = layer.getArgs(false);

      switch (layer.method) {
        case "use":
          if ((rawApp as any).use) {
            (rawApp as any).use(path, handlers);
          }
          return;
        case "statics":
          this.statics(path as string, layer.opts as any);

          // rawApp.register();
          return;
      }

      try {
        rawApp.route({
          method: layer.method.toUpperCase() as any,
          url: path as any,
          handler: this.compose(layer, wildcard),
          config: {
            rawBody: layer.handlers.some((handler) => handler.opts?.paramsTypes?.RAW_BODY)
          }
        });
      } catch (er) {
        logger().warn({
          error_name: er.code,
          error_message: er.message
        });
      }
    });
  }
}

bodyParser()

Configures body parsing middleware:

typescript
class PlatformYourFramework extends PlatformAdapter {
  bodyParser(type: "json" | "text" | "urlencoded", additionalOptions: any = {}): any {
    // Return appropriate body parser middleware
    // ...
  }
}

statics()

Configures static file serving:

typescript
class PlatformYourFramework extends PlatformAdapter {
  statics(endpoint: string, options: PlatformStaticsOptions) {
    // Return middleware for serving static files
    // ...
  }
}

4. Implement Response and Request Classes

Create custom implementations for PlatformResponse and PlatformRequest:

typescript
// PlatformYourFrameworkResponse.ts
import {PlatformResponse} from "@tsed/platform-http";

export class PlatformYourFrameworkResponse extends PlatformResponse {
  // Implement methods to adapt your framework's response object
  // ...
}

// PlatformYourFrameworkRequest.ts
import {PlatformRequest} from "@tsed/platform-http";

export class PlatformYourFrameworkRequest extends PlatformRequest {
  // Implement methods to adapt your framework's request object
  // ...
}

5. Add Bootstrap Methods

Add static methods to create and bootstrap applications:

typescript
class PlatformYourFramework extends PlatformAdapter {
  static create(module: Type<any>, settings: Partial<TsED.Configuration> = {}) {
    return PlatformBuilder.create<YourFramework.Application>(module, {
      ...settings,
      adapter: PlatformYourFramework
    });
  }

  static bootstrap(module: Type<any>, settings: Partial<TsED.Configuration> = {}) {
    return PlatformBuilder.bootstrap<YourFramework.Application>(module, {
      ...settings,
      adapter: PlatformYourFramework
    });
  }
}

Best Practices

  1. Framework Specifics: Understand the specifics of your target framework, especially its middleware system and request/response lifecycle.

  2. Error Handling: Implement proper error handling in your adapter to ensure exceptions are caught and processed correctly.

  3. Context Management: Ensure that the Ts.ED context is properly created, stored, and cleaned up for each request.

  4. Testing: Create comprehensive tests for your adapter to ensure it works correctly with the Ts.ED framework.

  5. Documentation: Document any framework-specific behaviors or limitations of your adapter.

Testing Your Adapter

Ts.ED provides the @tsed/platform-test-sdk package to help you test your custom platform adapter. This package includes a comprehensive test suite that verifies your adapter's compatibility with the Ts.ED framework.

It is strongly recommended to set up these tests early in your development process to ensure your adapter meets all the framework requirements.

Setting Up Tests

First, install the package:

bash
npm install --save-dev @tsed/platform-test-sdk

Then create a test file for your adapter:

typescript
// platform-yourframework.spec.ts
import {PlatformTestSdk} from "@tsed/platform-test-sdk";
import {PlatformYourFramework} from "../src/components/PlatformYourFramework";
import {rootDir, Server} from "./app/Server";

const utils = PlatformTestSdk.create({
  rootDir,
  adapter: PlatformYourFramework,
  server: Server,
  logger: {
    level: "off"
  }
});

describe("PlatformYourFramework", () => {
  describe("Handlers", () => {
    utils.test("handlers");
  });
  describe("Response", () => {
    utils.test("response");
  });
  describe("Middlewares", () => {
    utils.test("middlewares");
  });
  describe("Headers", () => {
    utils.test("headers");
  });
  describe("BodyParams", () => {
    utils.test("bodyParams");
  });
  // Add more tests as needed
});

The PlatformTestSdk will run a series of tests against your adapter to ensure it correctly implements all the required functionality.

What Gets Tested

The test suite covers various aspects of the platform adapter:

  • Request and response handling
  • Middleware execution
  • Parameter binding (path, query, body, headers)
  • Error handling
  • Routing
  • Content negotiation
  • File uploads
  • And more

Each test verifies that your adapter correctly implements the expected behavior for a specific feature of the Ts.ED framework.

Example Test Server

You'll need to create a test server for your tests:

typescript
// app/Server.ts
import {Configuration} from "@tsed/di";
import {PlatformYourFramework} from "../../src/components/PlatformYourFramework";
import path from "path";

export const rootDir = path.join(__dirname, "..");

@Configuration({
  rootDir,
  mount: {
    "/rest": [`${rootDir}/controllers/**/*.ts`]
  },
  logger: {
    level: "off"
  }
})
export class Server {}

Integration Tests

Looking at the existing adapters, you can see how they implement their test suites:

typescript
// platform-express.spec.ts
import {PlatformTestSdk} from "@tsed/platform-test-sdk";
import {PlatformExpress} from "../src/components/PlatformExpress";
import {rootDir, Server} from "./app/Server";

const utils = PlatformTestSdk.create({
  rootDir,
  adapter: PlatformExpress,
  server: Server,
  logger: {
    level: "off"
  }
});

describe("PlatformExpress", () => {
  describe("Handlers", () => {
    utils.test("handlers");
  });
  // More tests...
});
typescript
// platform-koa.spec.ts
import {PlatformTestSdk} from "@tsed/platform-test-sdk";
import {PlatformKoa} from "../src/components/PlatformKoa";
import {rootDir, Server} from "./app/Server";

const utils = PlatformTestSdk.create({
  rootDir,
  adapter: PlatformKoa,
  server: Server,
  logger: {
    level: "off"
  }
});

describe("PlatformKoa", () => {
  describe("Handlers", () => {
    utils.test("handlers");
  });
  // More tests...
});
typescript
// platform-fastify.spec.ts
import {PlatformTestSdk} from "@tsed/platform-test-sdk";
import {PlatformFastify} from "../src/components/PlatformFastify";
import {rootDir, Server} from "./app/Server";

const utils = PlatformTestSdk.create({
  rootDir,
  adapter: PlatformFastify,
  server: Server,
  logger: {
    level: "off"
  }
});

describe("PlatformFastify", () => {
  describe("Handlers", () => {
    utils.test("handlers");
  });
  // More tests...
});

By implementing these tests early in your development process, you can ensure that your adapter correctly implements all the required functionality and meets the expectations of the Ts.ED framework.

Conclusion

Creating a custom platform adapter allows you to integrate any web framework with Ts.ED. By implementing the required methods and following the patterns established by the built-in adapters, you can create a seamless integration between your chosen framework and the Ts.ED ecosystem.

For more details, examine the source code of the existing adapters:

Released under the MIT License.