Skip to content

Commit

Permalink
feat(architectura): Added automatic preflight management. (#303)
Browse files Browse the repository at this point in the history
* feat(architectura): Added automatic preflight management.

* feat: rework InitializeOnce and make Server methods non-static

* chore(architecutra): Added unit tests for prelight automatic handling.

* chore: improve coverage

* fix: unused parameter in optional 'abstract' methods was causing errors

* chore: update semver

---------

Co-authored-by: Zamralik <[email protected]>
  • Loading branch information
SmashingQuasar and Zamralik authored Feb 25, 2025
1 parent 69e3728 commit 5c65551
Show file tree
Hide file tree
Showing 21 changed files with 1,036 additions and 212 deletions.
1 change: 1 addition & 0 deletions .eslint/configuration/strict/rules.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ const RULES = {
"allowAsThisParameter": true,
"allowInGenericTypeArguments": [
"Promise",
"PromiseWithResolvers",
"Generator"
]
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./mock-request.interface.mjs";
export * from "./mock-response.interface.mjs";
export * from "./mock-socket.interface.mjs";
export * from "./mock-server.interface.mjs";
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { SinonStub } from "sinon";
import type { BaseMockInterface } from "../../../../_index.mjs";
import type { Server } from "../../../../../src/_index.mjs";

interface MockServerInterface extends BaseMockInterface<Server>
{
stubs: BaseMockInterface<Server>["stubs"] & {
handlePublicAssets: SinonStub;
handleAutomaticPreflight: SinonStub;
handleEndpoints: SinonStub;
finalizeResponse: SinonStub;
};
}

export type { MockServerInterface };
38 changes: 38 additions & 0 deletions packages/architectura/mock/core/server/mock-server.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { stub } from "sinon";
import { Server, type ServerInstantiationType } from "../../../src/_index.mjs";
import { type MockServerInterface, baseMock } from "../../_index.mjs";

function mockServer(parameters?: ServerInstantiationType): MockServerInterface
{
const instantiation: ServerInstantiationType = parameters ?? {
https: false,
port: 80,
};

// @ts-expect-error: We are mocking a server instance.
const instance: Server = new Server(instantiation);

const MOCK: MockServerInterface = baseMock({
instance: instance,
stubs: {
start: stub(instance, "start"),
isHTTPS: stub(instance, "isHTTPS"),
handleError: stub(instance, "handleError"),
requestListener: stub(instance, "requestListener"),
// @ts-expect-error: Private method
handlePublicAssets: stub(instance, "handlePublicAssets"),
// @ts-expect-error: Private method
handleAutomaticPreflight: stub(instance, "handleAutomaticPreflight"),
// @ts-expect-error: Private method
handleEndpoints: stub(instance, "handleEndpoints"),
// @ts-expect-error: Private method
finalizeResponse: stub(instance, "finalizeResponse"),
},
});

MOCK.callThroughAllStubs();

return MOCK;
}

export { mockServer };
2 changes: 1 addition & 1 deletion packages/architectura/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@vitruvius-labs/architectura",
"version": "4.6.0",
"version": "4.7.0",
"description": "A light weight strongly typed Node.JS framework providing isolated context for each request.",
"author": {
"name": "VitruviusLabs"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { AccessControlDefinitionInstantiationInterface } from "./definition/interface/access-control-definition-instantiation.interface.mjs";

class AccessControlDefinition
{
/**
* Which headers are allowed for this endpoint.
*
* @remarks
* By default, no headers are allowed.
* If you want to allow all headers, set this property to "*".
* Otherwise, list the headers you want to allow.
* This property is used to set the Access-Control-Allow-Headers during automatic preflight.
*/
protected readonly allowedHeaders: Array<string> | "*" = [];

/**
* Which origins are allowed for this endpoint.
*
* @remarks
* By default, no origins are allowed.
* If you want to allow all origins, set this property to "*".
* Otherwise, list the origins you want to allow.
* This property is used to set the Access-Control-Allow-Origin during automatic preflight.
*/
protected readonly allowedOrigins: Array<string> | "*" = [];

/**
* How long the preflight response can be cached in seconds.
* Defaults to 0.
*
* @remarks
* This property is used to set the Access-Control-Max-Age during automatic preflight.
* If you want to disable caching, set this property to 0.
* Otherwise, set it to the number of seconds you want to cache the preflight response.
*
* Important: If you do not set this property, or set it to 0, the preflight response will not be cached.
* This can lead to a performance penalty, as the preflight request will be sent for every request.
*/
protected readonly maxAge: number = 0;

public constructor(parameters: AccessControlDefinitionInstantiationInterface)
{
this.allowedHeaders = parameters.allowedHeaders;
this.allowedOrigins = parameters.allowedOrigins;
this.maxAge = parameters.maxAge;
}

/**
* Get the allowed headers.
*
* @sealed
*/
public getAllowedHeaders(): Array<string> | "*"
{
return this.allowedHeaders;
}

/**
* Get the allowed origins.
*
* @sealed
*/
public getAllowedOrigins(): Array<string> | "*"
{
return this.allowedOrigins;
}

/**
* Get the max age.
*
* @sealed
*/
public getMaxAge(): number
{
return this.maxAge;
}

/**
* Generate the preflight headers
*
* @sealed
* @returns The preflight headers.
*/
public generatePreflightHeaders(): Headers
{
const headers: Headers = new Headers();

headers.set("Access-Control-Allow-Headers", this.allowedHeaders === "*" ? "*" : this.allowedHeaders.join(", "));
headers.set("Access-Control-Allow-Origin", this.allowedOrigins === "*" ? "*" : this.allowedOrigins.join(", "));
headers.set("Access-Control-Max-Age", this.maxAge.toString());
headers.set("Content-Length", "0");

return headers;
}
}

export { AccessControlDefinition };
35 changes: 32 additions & 3 deletions packages/architectura/src/core/endpoint/base.endpoint.mts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { ExecutionContext } from "../execution-context/execution-context.mj
import { RouteUtility } from "./route.utility.mjs";
import { assertString } from "@vitruvius-labs/ts-predicate/type-assertion";
import type { ExtractType } from "../../definition/type/extract.type.mjs";
import type { AccessControlDefinition } from "./access-control-definition.mjs";

/**
* Abstract endpoint class.
Expand All @@ -27,6 +28,15 @@ abstract class BaseEndpoint<T extends object = object>
*/
protected abstract readonly route: RegExp | string;

/**
* Access control definition for this endpoint.
*
* @remarks
* This property is optional.
* If you want to enable CORS for this endpoint, set this property to an instance of AccessControlDefinition.
*/
protected readonly accessControlDefinition?: AccessControlDefinition;

protected readonly preHooks: Array<BasePreHook | ConstructorOf<BasePreHook>> = [];
protected readonly excludedGlobalPreHooks: Array<ConstructorOf<BasePreHook>> = [];
protected readonly postHooks: Array<BasePostHook | ConstructorOf<BasePostHook>> = [];
Expand Down Expand Up @@ -69,6 +79,16 @@ abstract class BaseEndpoint<T extends object = object>
return RouteUtility.NormalizeRoute(this.route);
}

/**
* Get the access control definition for this endpoint.
*
* @sealed
*/
public getAccessControlDefinition(): AccessControlDefinition | undefined
{
return this.accessControlDefinition;
}

/**
* Get the pre hooks of the endpoint.
*
Expand Down Expand Up @@ -190,7 +210,10 @@ abstract class BaseEndpoint<T extends object = object>
*/
protected assertPathFragments(value: unknown): asserts value is ExtractType<T, "pathFragments">
{
throw new Error(`Method "assertPathFragments" need an override in endpoint ${this.constructor.name}.`);
// eslint-disable-next-line @ts/no-unused-expressions -- Pretend to use the value
value;

throw new Error(`Method "assertPathFragments" needs an override in endpoint ${this.constructor.name}.`);
}

/**
Expand Down Expand Up @@ -224,7 +247,10 @@ abstract class BaseEndpoint<T extends object = object>
*/
protected assertQuery(value: unknown): asserts value is ExtractType<T, "query">
{
throw new Error(`Method "assertQuery" need an override in endpoint ${this.constructor.name}.`);
// eslint-disable-next-line @ts/no-unused-expressions -- Pretend to use the value
value;

throw new Error(`Method "assertQuery" needs an override in endpoint ${this.constructor.name}.`);
}

/**
Expand Down Expand Up @@ -258,7 +284,10 @@ abstract class BaseEndpoint<T extends object = object>
*/
protected assertPayload(value: unknown): asserts value is ExtractType<T, "payload">
{
throw new Error(`Method "assertPayload" need an override in endpoint ${this.constructor.name}.`);
// eslint-disable-next-line @ts/no-unused-expressions -- Pretend to use the value
value;

throw new Error(`Method "assertPayload" needs an override in endpoint ${this.constructor.name}.`);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
interface AccessControlDefinitionInstantiationInterface
{
allowedHeaders: Array<string> | "*";
allowedOrigins: Array<string> | "*";
maxAge: number;
}

export type { AccessControlDefinitionInstantiationInterface };
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import type { AccessControlDefinition } from "../../../endpoint/access-control-definition.mjs";

/**
* Shared properties of server configuration
*/
interface BaseServerConfigurationInterface
{
port: number;
defaultAccessControlDefinition?: AccessControlDefinition | undefined;
}

export type { BaseServerConfigurationInterface };
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type { AccessControlDefinition } from "../../../endpoint/access-control-definition.mjs";

/** @internal */
interface BaseServerInstantiationInterface
{
port: number;
defaultAccessControlDefinition?: AccessControlDefinition | undefined;
}

export type { BaseServerInstantiationInterface };
Loading

0 comments on commit 5c65551

Please sign in to comment.