Skip to content

TransparentServicesPlanning

William Lahti edited this page Feb 7, 2021 · 13 revisions

This page is for planning Alterior's Transparent Services model.

Introduction

Alterior has always been focused on a future where Javascript packages are isomorphic. This means a package can be consumed transparently in the environment you want to use it, and the right thing happens.

For Alterior, which is a server-side framework, part of this vision means that projects which are built on Alterior should be consumable within the browser, where the end result is that the package acts as a transparent "proxy" for the backend service hosted somewhere on the Internet.

For example, imagine an Alterior web service defined in package @example/backend:

import { WebService } from '@alterior/web-server';

@WebService()
export class ExampleBackend {
    @Get('/random-quote')
    randomQuote() {
        return {
            text: "All the world’s a stage",
            author: "William Shakespeare"
        };
    }
}

From within your frontend project (ie Angular/React), you should be able to:

npm install @example/backend

And then,

import { ExampleService } from '@example/backend';

@Component()
export RandomQuoteComponent {
    constructor(
        private exampleService : ExampleService
    ) {
    }

    quote : { text : string, author : string };
    ngOnInit() {
        let service : ExampleService;
        this.quote = await service.randomQuote();
        // this.quote is:
        // { "text": "All the world's a stage", "author": "William Shakespeare" }
    }
}

This is an example of a Transparent Service.

Wait, I can't bundle my backend's codebase into my frontend!

The magic that makes this work relies on some package.json features exposed by Webpack. Previously there was the browser field, and now there is a far more flexible exports field which allows a package author to specify different implementations for any particular environment.

An NPM package which contains an Alterior transparent service will use exports/browser to override the implementation of a backend when it is utilized by Webpack or any other bundler which supports these fields. When this is done, Webpack will have no reason to bundle your backend code, because it is not actually referenced by your frontend. Instead, it will reference the code you intend when the service is used within a browser context.

Is it that simple? No

Typescript

First, when you reference interfaces and other compile-time types, those are going to be erased at compile time automatically. Imports consisting only of types automatically disappear. So these non-concrete types are not the issue.

When referencing concrete types however, Typescript is unaware of the context you are intending to use the package. As such, it has no way to express different module declarations depending on context. So your package can only have a single set of type declarations that tell Typescript about the shape of your module. Some types may be backend-only, and be totally "unexposed" to the client. How do we handle this fact in a transparent-services system?

Story

We should never emit (into the browser target) a concrete type unless it is exposed. Any concrete type that is unexposed but referenced within the frontend app will then result in a runtime failure.

Then, when we think about what elements to export at the top level, we realize we need only export the @Service classes which need to be exposed. When you boot your backend, you do it via main.ts, not index.ts after all.

So an isomorphic backend should export only types that should be used by a consumer of it, like @Service, any necessary @Controller classes that are mounted, and non-concrete types like interfaces which can be easily consumed via the generated .d.ts declarations.

Unified exports as a design concept

The goal here is to use fundamental concepts for expressing the components of a service. This is already being explored in @/web-server, where you can define a web service in a single class using the @WebService decorator. When you do this, it automatically declares itself a @Module, as well as an @Controller. You can then "include" further controller classes using @Mount.

The problem with Alterior's design right now is that this is optional. You can also choose to specify controllers on a @Module and this will include them in the overall service.

As part of the Transparent Services effort, we will be deprecating this in favor of always specifying your service via @Service and @Mount. You will only be able to expose @Service to the frontend, all other concrete classes will either not be generated or in some way proxied into an interface.

When all controllers are exposed via @Mount, they have natural type affinity to the final proxy that will be created for frontend usage. If we can guarantee this via typing, it greatly simplifies the effort of automating browser proxies.

This concept can be applied to other use cases for Alterior. Imagine if a @Task from @/tasks could be transparently used from the frontend. If it were automatically exposed as a REST endpoint (when opted into via a decorator), the client could trigger specific long running actions without requiring an intermediary @Controller function, dramatically reducing boilerplate.

So the design of @/tasks needs to flow from Transparent Services as well.

Even command line tooling (coming soon to Alterior) can benefit from this as well-- If we can express commands as Typescript classes, we could call them programmatically from another package without needing any special effort on the part of the caller or the caller.

Serialization: The source of the leak

There are specific constraints here around serialization, specifically how are objects passed between a true source and its proxy.

However, a properly built Alterior web service already speaks interfaces as it's primary language. Almost every case where one would accept data from or send data to the client is specified with an interface.

One exception to this is the entity system, for preparing rich backend models for Serialization when returning via a service. This design issue needs to be solved.

RouteEvent considered harmful

In @/web-server@3.beta we allow controller methods to specify a method parameter of type RouteEvent for the purposes of accessing the request/response of the incoming web request. In the Transparent Services era this is an antipattern and actively prevents us from generating service proxies for the frontend. Therefore, RouteEvent will be deprecated in an upcoming release in favor of accessing the current request/response statically with the aid of Zone.js. For more information see TransparentServicesAndZone.

Mechanics

Putting it together

Using decorator metadata and runtime type information (RTTI) emitted by Typescript when emitDecoratorMetadata is true along with the metadata emitted via the @Controller/@Service/@Task/@Mount infrastructure we can get most of the way toward generating compatible proxies without needing to traverse actual Typescript types. Non-concrete types which fall away do not require special handling. Any concrete type (ie class) which is not exposed will be omitted, allowing a fault at runtime. Requiring exposed services to be "whole", meaning all associated controllers can be reached via property references completes the circle here.

@NgModule({
    providers: [
        AngularPlatform.bridge(
            ExampleModule
        )
    ]
})
export class AppModule {}

Using AngularPlatform.bridge(), we can expose the (proxied) @Service classes as injectable objects. What we need then is the ability to specify where the hosting Alterior service is located.

@NgModule({
    providers: [
        AngularPlatform.configure(ExampleService, {
            baseUrl: 'https://production.backend.example.com'
        }),
        AngularPlatform.bridge(
            ExampleService
        )
    ]
})