Tech & Engineering Blog

Building a Core Edge Computing Library in TypeScript

My team is responsible for developing and maintaining our company’s server-side SDK. Our customers have vastly different backend architectures, so we’ve developed this same logic across 40+ different languages and platforms to offer as much widespread support as we can.

Lately, we’ve been working a lot with JavaScript. Our SDK’s ideal integration point is at the CDN layer, and JavaScript has been growing in popularity across edge providers due to its flexibility and ease of use. Think of Fastly Compute@Edge, Cloudflare Workers, and AWS Lambda@Edge, just to name a few.

The problem is that each cloud provider implements its JavaScript edge environment differently. They’re all JavaScript, but the JS runtimes, built-in functionalities, and event structures vary wildly from one platform to another.

So when I found my team writing our third library in JavaScript, I knew there had to be a better way.

The Problem

Our strategy of building a different JavaScript library for each platform violated one of the key tenets of programming: we were repeating ourselves. A lot.

This slowed us down considerably. New features needed to be written from scratch for each library. If we found a bug in one library, the others had to be checked, fixed, tested, and released individually. This repetition also created feature disparity and inconsistencies across the different libraries, which meant extra overhead adjusting end-to-end tests, tracking feature support, and maintaining documentation.

At the same time, the capabilities, environments, and APIs for these different platforms varied to the extent that a single JavaScript library simply wasn’t a feasible solution. What were we supposed to do?

What We Did

We decided to encapsulate our business logic into a single, platform-agnostic, business-logic-only library. This library implemented all the features and functionalities that our SDK needed while assuming as little as possible about the platform it was running on. We called this our core library, or JS Core. (Admittedly, “JS Core” is a bit of a misnomer since it’s actually written in TypeScript.) We could then use the core library as a dependency in the platform library, the public SDK we release for a particular platform.

For example, the Cloudflare Workers platform library would include the JS Core library as a dependency, leveraging it for the business logic while filling in the platform-specific gaps.

10-3 Tech Blog image

How We Did It

As with any project, our core library is something we’re continuously iterating on. That said, there are a few basic principles  we’ve centered the library on to make it as flexible, extensible, and reliable as possible.

Embrace dependency inversion.

TL;DR: Any logic that would require our core library to understand the inner workings of the platform should be defined as an interface and injected into the core library by the platform library.

Let’s take logging as an example. Most JavaScript runtimes use console.log to output logs; however, we can’t always trust that this will be available on every platform. Akamai EdgeWorkers, for example, has its own built-in logger module, and Azure Functions use a request context to log. So how can our core library perform basic logging when it has to remain totally separate from the platform’s implementation details?

Two words: dependency inversion.

The core library depends on the ability to output logs, but it doesn’t need to know how each platform outputs logs. It just needs a clear and consistent way to invoke logging functionality: an interface.

We defined an interface in the core library called ILogger:

// Core Library
export interface ILogger {
 debug(message: string): void;
 error(message: string): void;
}

export enum LoggerVerbosity {
 DEBUG,
 ERROR
}

Pretty self-explanatory.

Other classes within our core library business logic can use the ILogger interface whenever they need to log anything.

// Core Library
import { ILogger } from ‘./ILogger’;

export class CoreFunctionality {
 private readonly logger: ILogger;

 constructor(logger: ILogger) {
   this.logger = logger;
 }

 public execute(): void {
   try {
     // do the thing
     this.logger.debug("core functionality has been done");
   } catch (err) {
     this.logger.error(`failed to perform core functionality: ${err}`);
   }
 }
}

At no point did we define the debug and error functions because CoreFunctionality in our core library doesn’t care about their implementations. So where do we define what these functions do?

Each platform has a different way to output logs, right? So it makes sense that each platform will need to implement its own logger. As long as the implemented logger adheres to the ILogger interface, then the core library will have no problem using it.

The AzureFunctionLogger in our Azure platform library initially looked something like this:

// Platform Library (Azure)
import { Context } from '@azure/functions';
import { ILogger, LoggerVerbosity } from 'core-library';

export class AzureFunctionLogger implements ILogger {
 private readonly context: Context;
 private readonly verbosity: LoggerVerbosity;

 constructor(verbosity: LoggerVerbosity, context: Context) {
   this.verbosity = verbosity;
   this.context = context;
 }

 public debug(message: string): void {
   if (this.verbosity === LoggerVerbosity.DEBUG) {
     this.context.log(message);
   }
 }

 public error(message: string): void {
   this.context.log(message);
 }
}

The function definitions specify how to perform logging in a way that the Azure platform will understand. At the same time, the class implements the ILogger interface, which ensures that it can be used by the core library.

Now, when we create our business logic class in the Azure platform library, we can inject the AzureFunctionLogger class into the core library’s CoreFunctionality.

// Platform Library (Azure)
import { HttpRequest, Context } from '@azure/types';
import { CoreFunctionality, LoggerVerbosity, Configuration } from 'core-library';
import { AzureFunctionLogger } from './AzureFunctionLogger';

export const middleware = (request: HttpRequest, context: Context, config: Configuration) => {
 const logger = new AzureFunctionLogger(config.verbosity, context);
 const functionality = new CoreFunctionality(logger);
 functionality.execute();
};

Utilize abstract base classes.

TL;DR: If you find yourself rewriting the same code in many different platform libraries, it’s likely business logic that should live in the core library (perhaps as an abstract base class).

At first glance, it may seem like our logger example above separates core logic from platform logic. But… it doesn’t.

What happens when we need to implement a logger for Akamai EdgeWorkers, a different platform library?

// Platform Library (Akamai EdgeWorker)
import { logger } from 'log';
import { ILogger, LoggerVerbosity } from 'core-library';

export class AkamaiEdgeWorkerLogger implements ILogger {
 private readonly verbosity: LoggerVerbosity;

 constructor(verbosity: LoggerVerbosity) {
   this.verbosity = verbosity;
 }

 public debug(message: string): void {
   if (this.verbosity === LoggerVerbosity.DEBUG) {
     logger.log(message);
   }
 }

 public error(message: string): void {
   logger.log(message);
 }
}

Looks familiar. Too familiar.

We replaced the context.log for logger.log, but all that stuff about verbosity stayed exactly the same. That’s because how the logger prints is platform-dependent, but when it prints is business logic. And keeping the loggers how they are means we’d need to implement this little bit of business logic in every platform library separately.

This little bit of logic might not seem like a big deal at first. But what if we decide to add a new verbosity level of NONE for when the logger shouldn’t print logs at all? We’d have to change the code in all the error functions of all the different platforms, which is exactly what we’re trying to avoid.

So instead, we moved this business logic into the core library using a base class with an abstract method called log.

// Core Library
import { ILogger } from './ILogger';
import { LoggerVerbosity } from './LoggerVerbosity';

export abstract class LoggerBase implements ILogger {
 protected readonly verbosity: LoggerVerbosity;

 protected constructor(verbosity: LoggerVerbosity) {
   this.verbosity = verbosity;
 }

 protected abstract log(message: string): void;

 public debug(message: string): void {
   if (this.verbosity === LoggerVerbosity.DEBUG) {
     this.log(message);
   }
 }

 public error(message: string): void {
   this.log(message);
 }
}

The core library doesn’t know how this.log is implemented; in other words, it doesn’t know exactly how the platform prints logs. What it does know is when to print them — that is, when to call this.log based on the logger verbosity.

This means our AzureFunctionLogger and our AkamaiEdgeWorkerLogger no longer need to worry about debug and error. Instead, they can extend the LoggerBase class and focus on the actual platform-dependent piece of the puzzle: the log function.

// Platform Library (Azure)
import { Context } from '@azure/functions';
import { LoggerBase, LoggerVerbosity } from 'core-library';

export class AzureFunctionLogger extends LoggerBase {
 private readonly context: Context;

 constructor(verbosity: LoggerVerbosity, context: Context) {
   super(verbosity);
   this.context = context;
 }

 protected log(message: string): void {
   this.context.log(message);
 }
}

// Platform Library (Akamai EdgeWorker)
import { logger } from 'log';
import { LoggerBbase, LoggerVerbosity } from 'core-library';

export class AkamaiEdgeWorkerLogger extends LoggerBase {
 constructor(verbosity: LoggerVerbosity) {
   super(verbosity);
 }

 protected log(message: string): void {
   logger.log(message);
 }
}

This better separates our business logic from the platform logic. Now, when we want to add new logger verbosities, we can do so in the LoggerBase class of the core library; the platform library loggers that extend it will automatically inherit these new capabilities.

Provide default implementations when appropriate.

TL;DR: If you find yourself rewriting the same code in many different platform libraries — and it’s not business logic — it may be a default implementation that should live in the core library anyway.

Platforms like Azure Functions and Akamai EdgeWorkers have their own logging implementations, but most JavaScript runtimes use the good ol’ fashioned console.log. That’s why, in keeping with the DRY principle, we decided to implement a default logger named ConsoleLogger and include it as part of the core library.

// Core Library
import { LoggerBase } from './LoggerBase';
import { LoggerVerbosity } from './LoggerVerbosity';

export class ConsoleLogger extends LoggerBase {
 constructor(verbosity: LoggerVerbosity) {
   super(verbosity);
 }

 protected log(message: string): void {
   console.log(message);
 }
}

We did this for any default implementation we thought might be used by more than one platform. For example, since multiple platforms run on Node.js, we decided to implement a default PhinHttpClient based on the phin package. Even though not all platforms would use this HTTP client (just as not all platforms would use the ConsoleLogger), adding it to the core library meant that bug fixes or improvements would only need to happen in one place.

Leverage generics for type safety.

TL;DR: Generic types ensure your core library remains both platform-agnostic and type-safe.

Another issue we came across while developing the core library was ensuring type safety of platform-dependent types.

Let’s look at another example: HTTP requests. Regardless of platform, our core library needs to access HTTP request attributes (e.g., URL, headers, method) in a uniform way. We created an interface called IHttpRequest to do just that.

// Core Library
export interface IHttpRequest {
 url: string;
 headers: Record<string, string="">;
 method: string;
 readBody(): string | Promise;
}</string,>

So far, so good.

Our platform libraries allow users to configure custom functions that are invoked at certain points in the core flow. Some of these functions require the platform’s incoming HTTP request as a parameter.

Let’s say we have a configurable isFilteredRequest function that accepts the HTTP request returns a boolean indicating whether the request should be filtered from certain logic within the core library.

// Core Library
export class CoreFunctionality {
 private readonly config: Configuration;

 constructor(config: Configuration) {
   this.config = config;
 }

 public execute(request: IHttpRequest): void {
   if (!this.config.isFilteredRequest(request)) {
     // ...
   }
 }
}

Based on this code, the Configuration type’s isFilteredRequest function should have an IHttpRequest as its function parameter.

// Core Library
export type Configuration = {
 isFilteredRequest: (request: IHttpRequest) => boolean;
};

This might seem okay if we’re looking at the core library only, but what happens when we expose this Configuration from the Azure platform library and ask end users to define isFilteredRequest?

// End User Application
import { HttpRequest, Context } from '@azure/functions';
import { Configuration } from 'azure-platform-library';

const config: Configuration = {
 isFilteredRequest: (req: IHttpRequest): boolean => {
   // what is IHttpRequest???
 }
};

Users expect to work directly with the built-in HTTP request structures in the platform (in this case, HttpRequest from the @azure/functions package). But when defining the isFilteredRequest function, they’re forced to work with the unfamiliar IHttpRequest interface because our core library has to remain blind to the platform-specific types.

This is undesirable and clunky to say the least. So how can the core library ensure we’re using the platform-specific HTTP request type without forgoing type safety and without being aware of what that type actually is?

Thankfully, we’ve got TypeScript generics to help us here. By defining our Configuration as dependent on a generic request type Req, we can maintain type safety within the core library without needing to know the concrete types used by the platform library.

// Core Library
export type Configuration = {
 isFilteredRequest: (request: Req) => boolean;
};

// Platform Library (Azure)
import { HttpRequest } from '@azure/functions';
import { Configuration } from 'core-library';

export type AzureFunctionsConfiguration = Configuration;

/*
* Equivalent to this:
* export type AzureFunctionsConfiguration = {
*   isFilteredRequest: (request: HttpRequest) => boolean;
* };
*/

Of course, we need to make our IHttpRequest interface depend on a generic Req as well, and add another function to retrieve the underlying HTTP request.

// Core Library
export interface IHttpRequest {
 url: string;
 headers: Record<string, string="">;
 method: string;
 readBody(): string | Promise;
 getUnderlyingRequest(): Req;
}</string,>

Including the getUnderlyingRequest function allows us to pass in the correct argument when invoking the isFilteredRequest function.

// Core Library
export class CoreFunctionality {
 private readonly config: Configuration;

 constructor(config: Configuration) {
   this.config = conig;
 }

 public execute(request: IHttpRequest): void {
   if (!this.config.isFilteredRequest(request.getUnderlyingRequest())) {
     // ...
   }
 }
}

The core code can use the IHttpRequest interface, while platform-specific code can use the underlying native HTTP request implementation.

Notice that adding this generic Req type to our Configuration resulted in a snowball effect: our IHttpRequest and CoreFunctionality now depend on Req as well. This is what ended up happening to pretty much every class in our core library — but this makes sense conceptually. After all, everything in the core library is meant to be generic.

Encapsulate dependencies in your core library.

TL;DR: Use dependency inversion for all core library dependencies. You can provide a dependency-based default implementation in your core library, but don’t import these dependent implementations into other core library files directly.

Let’s say part of our core logic consists of creating UUIDs for an incoming request. The uuid package on NPM is an extremely popular dependency that touts zero dependencies and cross-platform support, so using it in the core library shouldn’t be a problem, right?

// Core Library
import { v4 as uuidv4 } from 'uuid';

export class CoreFunctionality {
 public execute(): void {
   const uuid = uuidv4();
   // ...
 }
}

If all the platform libraries can support this dependency, there’s no issue. But if a particular platform can’t support this dependency for any reason, it means we can’t use the CoreFunctionality class, either. Or any class that depends on the CoreFunctionality class. Or any class that depends on the class that depends on the CoreFunctionality class. And so on.

Once again, dependency inversion comes to save the day. By wrapping this dependency in a separate class and defining an interface for it, we can easily encapsulate it from the rest of the core functionality.

// Core Library
export interface IUuidGenerator {
 generateUuid(): string;
}

// Core Library
import { v4 as uuidv4 } from 'uuid';

export class DefaultUuidGenerator implements IUuidGenerator {
 public generateUuid(): string {
   return uuidv4();
 }
}

// Core Library
import { IUuidGenerator } from './IUuidGenerator';

export class CoreFunctionality {
 private readonly uuidGenerator: IUuidGenerator;

 constructor(uuidGenerator: IUuidGenerator) {
   this.uuidGenerator = uuidGenerator;
 }

 public execute(): void {
   const uuid = this.uuidGenerator.generateUuid();
   // ...
 }
}

The core library can still provide both the CoreFunctionality and the default functionality through the uuid dependency, but the two don’t depend on each other. This makes the CoreFunctionality class more compatible and therefore reusable across different platforms. If a platform library can’t support the uuid package, it can simply implement its own IUuidGenerator and inject it into the CoreFunctionality class.

Notice, too, that we didn’t provide a default parameter for uuidGenerator, since that would require importing DefaultUuidGenerator, which would require importing the uuid package, which would defeat the whole purpose of separating it from the CoreFunctionality class to begin with. (In general, the core library probably shouldn’t have default parameters — and definitely none that require external dependencies.)

Separate core library constants, enums, and utility functions.

TL;DR: Constants, enums, and utility functions are the simplest components of the core library. Make sure that these can be used even when nothing else can.

Even with all the precautions we already mentioned (interfaces, generics, base classes, etc.), platforms can impose unexpected limitations that may render certain components of the core library unusable. For this reason, separating the core library code as much as possible will increase the chances that even when one component cannot be used, another one can be.

This is especially true with standalone code such as constants, enums, and utility functions. These bits of logic are often small, compact, and rarely rely on other modules if at all; this means they’re more likely to be compatible with any given platform library.

Modularizing these bits of logic into individual constants, functions, and files may seem like a minor thing; however, small optimizations like these serve to reduce duplicate code and maximize consistency across platform libraries. The less you repeat yourself — even if it’s just to define a constant — the better.

The Benefits

Faster development time.

When we first rolled out our JS Core module, I was expecting it to take some time before we’d see any benefits. Boy, was I wrong.

Two weeks after we finished our first iteration of the JS Core module, my team was charged with developing a library for a new and unexpected platform. Developing an SDK from scratch would usually take us two to four weeks for a basic version, and another few weeks for all the bells and whistles.

With the JS Core library, all that development was already done. Since we only had to fill in the platform-specific gaps, we were able to release a complete, feature-rich library for a brand new platform in a few days.

Faster debugging time.

During initial development of our JS Core, we applied the Single Responsibility Principle constantly. Even the idea of separating business logic and platform logic is an application of this very principle. As you can probably tell from the examples above, this resulted in lots of classes with clear, concise functions.

Organizing our code in this way allowed us to find and fix bugs faster than before. And since our business logic was housed in one place, the team became familiar with the code quickly without having to relearn the different implementations of the business logic that we had rewritten across half a dozen different repositories.

Reusable and consistent documentation.

The JS Core library gave all features for all platform libraries the same uniform behavior. This meant large sections of documentation explaining the various features and configurations of our SDKs could be reused when writing up documentation. Translation: less work for our technical writers, and consistency for our end users.

Conclusion

My team separated our business logic into a core library that could be reused across CDN and cloud providers by leveraging dependency inversion, encapsulation, and single responsibility via TypeScript’s interfaces, generic types, and abstract classes. This core library allowed us to build libraries, ship features, and fix bugs faster than before.