Skip to content

Commit

Permalink
feat: plugin support
Browse files Browse the repository at this point in the history
  • Loading branch information
pi0 committed Oct 14, 2024
1 parent 2b0d96b commit 53874f0
Show file tree
Hide file tree
Showing 17 changed files with 340 additions and 69 deletions.
74 changes: 74 additions & 0 deletions docs/1.guide/4.plugins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
---
icon: ri:addon
---

# Server plugins

Plugins allow creating reusable interceptors to modify server options and behavior.

## Example

This is a simple logging plugin:

```ts
import type { ServerPlugin } from "srvx";

export const logger: ServerPlugin = (server) => {
console.log(`Logger plugin enabled for ${server.runtime}`);
return {
name: "logger",
request: (req) => {
console.log(`[request] [${req.method}] ${req.url}`);
},
response: (req, res) => {
console.log(
`[response] [${req.method}] ${req.url} ${res.status} ${res.statusText}`,
);
},
};
};
```

We can use it in main server using `plugins` option:

```ts
import { serve } from "srvx";

import { logger } from "./plugins/logger";

const server = serve({
plugins: [logger],
fetch(request) {
return new Response(`👋 Hello there.`);
},
});

await server.ready();
```

## Defining plugins

```ts
import type { ServerPlugin } from "srvx";

const myPlugin: ServerPlugin = async (server) => {
// You can use server argument to:
// - Modify global options using server.options
// - Access to the runtime-specific server instance
// - Decide hooks based on server.runtime value

// Return plugin instance
return {
// Plugin display name
name: "my-plugin",

// Intercept incoming request
// You can return a Response value to early return (eg: auth and validation)
request: (request) => {},

// Intercept final response
// You can use to modify response
response: (request, response) => {},
};
};
```
File renamed without changes.
2 changes: 1 addition & 1 deletion docs/1.guide/5.bundler.md → docs/1.guide/6.bundler.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@ If you are directly using `srvx` in your project without bundling or having `srv

If srvx is being bundled (by for example [rollup](https://rollupjs.org/)), just during bundling the bundler also has to run the ESM resolution algorithm with a specific ESM condition. This will result `srvx` in the bundle to be only working with one specific runtime.

In order to avoid this, simplest way is to put `srvx` into the `externals` config.
In order to avoid this, simplest way is to put `srvx` into the `externals` options.
File renamed without changes.
18 changes: 18 additions & 0 deletions playground/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,29 @@
import type { ServerPlugin } from "../src/types.ts";

// prettier-ignore
const runtime = (globalThis as any).Deno ? "deno" : (globalThis.Bun ? "bun" : "node");
const { serve } = (await import(
`../src/${runtime}.ts`
)) as typeof import("../src/types.ts");

const logger: ServerPlugin = (server) => {
console.log(`Logger plugin enabled for ${server.runtime}`);
return {
name: "logger",
request: (req) => {
console.log(`[request] [${req.method}] ${req.url}`);
},
response: (req, res) => {
console.log(
`[response] [${req.method}] ${req.url} ${res.status} ${res.statusText}`,
);
},
};
};

export const server = serve({
xRemoteAddress: true,
plugins: [logger],
fetch(request) {
return new Response(
/* html */ `
Expand Down
4 changes: 2 additions & 2 deletions src/_common.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
export function resolvePort(
portConfig: string | number | undefined,
portOptions: string | number | undefined,
portEnv: string | undefined,
): number {
const portInput = portConfig ?? portEnv;
const portInput = portOptions ?? portEnv;
if (portInput === undefined) {
return 3000;
}
Expand Down
89 changes: 89 additions & 0 deletions src/_plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { ServerPluginInstance, ServerOptions, Server } from "./types.ts";

export async function applyPlugins(server: Server) {
const options = server.options as ServerOptions;

if (!options.plugins?.length) {
return;
}

const requestHooks: NonNullable<ServerPluginInstance["request"]>[] = [];
const responseHooks: NonNullable<ServerPluginInstance["response"]>[] = [];

for (const ctor of options.plugins) {
const plugin = typeof ctor === "function" ? await ctor(server) : ctor;
if (plugin.request) {
requestHooks.push(plugin.request);
}
if (plugin.response) {
responseHooks.push(plugin.response);
}
}

const hasRequestHooks = requestHooks.length > 0;
const hasResponseHooks = responseHooks.length > 0;

if (hasRequestHooks || hasResponseHooks) {
server.fetch = (request) => {
let resValue: undefined | Response;
let resPromise: undefined | Promise<Response | void>;

// Request hooks
if (hasRequestHooks) {
for (const reqHook of requestHooks) {
if (resPromise) {
resPromise = resPromise.then((res) => res || reqHook(request));
} else {
const res = reqHook(request);
if (res) {
if (res instanceof Promise) {
resPromise = res;
} else {
return res;
}
}
}
}
}

// User handler
if (resPromise) {
resPromise = resPromise.then((res) => res || options.fetch(request));
} else {
const res = options.fetch(request);
if (res instanceof Promise) {
resPromise = res;
} else {
resValue = res;
}
}

// Response hooks
if (hasResponseHooks) {
for (const resHook of responseHooks) {
if (resPromise) {
resPromise = resPromise.then((res) => {
if (res) {
resValue = res;
}
return resHook(request, resValue!);
});
} else {
const res = resHook(request, resValue!);
if (res) {
if (res instanceof Promise) {
resPromise = res;
} else {
resValue = res;
}
}
}
}
}

return (
resPromise ? resPromise.then((res) => res || resValue) : resValue
) as Response | Promise<Response>;
};
}
}
20 changes: 8 additions & 12 deletions src/bun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,10 @@ export function serve(options: ServerOptions): Server {
class BunServer extends Server {
readonly runtime = "bun";

readonly bunServer: NonNullable<Server["bunServer"]>;

constructor(options: ServerOptions) {
super(options);

let serverFetch = options.fetch;
if (options.xRemoteAddress) {
const userFetch = options.fetch;
protected _listen() {
let serverFetch = this.fetch;
if (this.options.xRemoteAddress) {
const userFetch = this.fetch;
serverFetch = (request) => {
Object.defineProperty(request, "xRemoteAddress", {
get: () => this.bunServer?.requestIP(request as Request)?.address,
Expand All @@ -29,7 +25,7 @@ class BunServer extends Server {
}

this.bunServer = Bun.serve({
port: resolvePort(options.port, globalThis.process?.env.PORT),
port: resolvePort(this.options.port, globalThis.process?.env.PORT),
hostname: this.options.hostname,
reusePort: this.options.reusePort,
...this.options.bun,
Expand All @@ -38,14 +34,14 @@ class BunServer extends Server {
}

get port() {
return this.bunServer.port;
return this.bunServer?.port ?? null;
}

get addr() {
return this.bunServer.hostname;
return this.bunServer?.hostname ?? null;
}

close(closeAll?: boolean) {
this.bunServer.stop(closeAll);
this.bunServer?.stop(closeAll);
}
}
19 changes: 7 additions & 12 deletions src/deno.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,14 @@ declare const Deno: typeof DenoTypes.Deno;
class DenoServer extends Server {
readonly runtime = "deno";

readonly denoServer: NonNullable<Server["denoServer"]>;

#listeningInfo?: { hostname: string; port: number };

constructor(options: ServerOptions) {
super(options);

const onListenPromise = Promise.withResolvers<void>(); // Supported since Deno 1.38
this._listening = onListenPromise.promise;
protected _listen() {
const onListenPromise = Promise.withResolvers<void>();

let serverFetch = options.fetch as DenoTypes.Deno.ServeHandler;
if (options.xRemoteAddress) {
const userFetch = serverFetch as typeof options.fetch;
let serverFetch = this.fetch as DenoTypes.Deno.ServeHandler;
if (this.options.xRemoteAddress) {
const userFetch = serverFetch as typeof this.fetch;
serverFetch = (request, info) => {
Object.defineProperty(request, "xRemoteAddress", {
get: () => info?.remoteAddr?.hostname,
Expand All @@ -39,7 +34,7 @@ class DenoServer extends Server {
this.denoServer = Deno.serve(
{
port: resolvePort(
options.port,
this.options.port,
(globalThis as any).Deno?.env.get("PORT"),
),
hostname: this.options.hostname,
Expand All @@ -66,6 +61,6 @@ class DenoServer extends Server {
}

close(_closeAll?: boolean /* TODO */) {
this.denoServer.shutdown();
this.denoServer?.shutdown();
}
}
5 changes: 5 additions & 0 deletions src/node-utils/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ export async function sendNodeResponse(
nodeRes: NodeHttp.ServerResponse,
webRes: Response | NodeFastResponse,
): Promise<void> {
if (!webRes) {
nodeRes.statusCode = 500;
return endNodeResponse(nodeRes);
}

// Fast path for NodeFastResponse
if ((webRes as NodeFastResponse).xNodeResponse) {
const res = (webRes as NodeFastResponse).xNodeResponse();
Expand Down
24 changes: 10 additions & 14 deletions src/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,32 +13,28 @@ export function serve(options: ServerOptions): Server {
class NodeServer extends Server {
readonly runtime = "node";

readonly nodeServer: NodeHttp.Server;

constructor(options: ServerOptions) {
super(options);

protected _listen() {
const nodeServer = (this.nodeServer = NodeHttp.createServer(
{
...this.options.node,
},
(nodeReq, nodeRes) => {
const request = new NodeRequestProxy(nodeReq) as xRequest;
request.xNode = { req: nodeReq, res: nodeRes };
const res = options.fetch(request);
const res = this.fetch(request);
return res instanceof Promise
? res.then((resolvedRes) => sendNodeResponse(nodeRes, resolvedRes))
: sendNodeResponse(nodeRes, res);
},
));

this._listening = new Promise<void>((resolve) => {
return new Promise<void>((resolve) => {
nodeServer.listen(
{
port: resolvePort(options.port, globalThis.process?.env.PORT),
host: options.hostname,
exclusive: !options.reusePort,
...options.node,
port: resolvePort(this.options.port, globalThis.process?.env.PORT),
host: this.options.hostname,
exclusive: !this.options.reusePort,
...this.options.node,
},
() => resolve(),
);
Expand All @@ -62,7 +58,7 @@ class NodeServer extends Server {
}

get #addr() {
const addr = this.nodeServer.address();
const addr = this.nodeServer?.address();
if (addr && typeof addr !== "string") {
return addr;
}
Expand All @@ -71,9 +67,9 @@ class NodeServer extends Server {
close(closeAll?: boolean): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (closeAll) {
this.nodeServer.closeAllConnections?.();
this.nodeServer?.closeAllConnections?.();
}
this.nodeServer.close((error?: Error) =>
this.nodeServer?.close((error?: Error) =>
error ? reject(error) : resolve(),
);
});
Expand Down
Loading

0 comments on commit 53874f0

Please sign in to comment.