Skip to content
This repository was archived by the owner on Mar 13, 2025. It is now read-only.

Commit 3e96793

Browse files
committed
Add shared plugin helpers
1 parent 4d1c0e7 commit 3e96793

File tree

5 files changed

+299
-0
lines changed

5 files changed

+299
-0
lines changed
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { Headers } from "undici";
2+
import { Worker_Binding } from "../../runtime";
3+
import { Persistence, PersistenceSchema } from "./gateway";
4+
5+
export const SOCKET_ENTRY = "entry";
6+
7+
export const HEADER_PERSIST = "MF-Persist";
8+
9+
export const BINDING_SERVICE_LOOPBACK = "MINIFLARE_LOOPBACK";
10+
export const BINDING_TEXT_PLUGIN = "MINIFLARE_PLUGIN";
11+
export const BINDING_TEXT_NAMESPACE = "MINIFLARE_NAMESPACE";
12+
export const BINDING_TEXT_PERSIST = "MINIFLARE_PERSIST";
13+
14+
// TODO: make this an inherited worker in core plugin
15+
export const SCRIPT_PLUGIN_NAMESPACE_PERSIST = `addEventListener("fetch", (event) => {
16+
let request = event.request;
17+
const url = new URL(request.url);
18+
url.pathname = \`/\${${BINDING_TEXT_PLUGIN}}/\${${BINDING_TEXT_NAMESPACE}}\${url.pathname}\`;
19+
if (globalThis.${BINDING_TEXT_PERSIST} !== undefined) {
20+
request = new Request(request);
21+
request.headers.set("${HEADER_PERSIST}", ${BINDING_TEXT_PERSIST});
22+
}
23+
event.respondWith(${BINDING_SERVICE_LOOPBACK}.fetch(url, request));
24+
});`;
25+
26+
export function encodePersist(persist: Persistence): Worker_Binding[] {
27+
if (persist === undefined) return [];
28+
else return [{ name: BINDING_TEXT_PERSIST, text: JSON.stringify(persist) }];
29+
}
30+
31+
export function decodePersist(headers: Headers): Persistence {
32+
const header = headers.get(HEADER_PERSIST);
33+
return header === null
34+
? undefined
35+
: PersistenceSchema.parse(JSON.parse(header));
36+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import path from "path";
2+
import { Clock, Storage, defaultClock, sanitisePath } from "@miniflare/shared";
3+
import { FileStorage } from "@miniflare/storage-file";
4+
import { MemoryStorage } from "@miniflare/storage-memory";
5+
import { z } from "zod";
6+
7+
// TODO: explain why persist passed as header, want options set to be atomic,
8+
// if set gateway before script update, may be using new persist before new script
9+
export const PersistenceSchema = z.boolean().or(z.string()).optional();
10+
export type Persistence = z.infer<typeof PersistenceSchema>;
11+
12+
export interface GatewayConstructor<Gateway> {
13+
new (storage: Storage, clock: Clock): Gateway;
14+
}
15+
16+
const DEFAULT_PERSIST_ROOT = ".mf";
17+
18+
export class GatewayFactory<Gateway> {
19+
readonly #memoryStorages = new Map<string, MemoryStorage>();
20+
readonly #gateways = new Map<string, [Persistence, Gateway]>();
21+
22+
constructor(
23+
private readonly pluginName: string,
24+
private readonly gatewayClass: GatewayConstructor<Gateway>
25+
) {}
26+
27+
#storage(namespace: string, persist: Persistence): Storage {
28+
if (persist === undefined || persist === false) {
29+
let storage = this.#memoryStorages.get(namespace);
30+
if (storage !== undefined) return storage;
31+
this.#memoryStorages.set(namespace, (storage = new MemoryStorage()));
32+
return storage;
33+
}
34+
35+
const sanitised = sanitisePath(namespace);
36+
const root =
37+
persist === true
38+
? path.join(DEFAULT_PERSIST_ROOT, this.pluginName, sanitised)
39+
: path.join(persist, sanitised);
40+
return new FileStorage(root);
41+
42+
// TODO: support Redis/SQLite storages?
43+
}
44+
45+
get(namespace: string, persist: Persistence): Gateway {
46+
const cached = this.#gateways.get(namespace);
47+
if (cached !== undefined && cached[0] === persist) return cached[1];
48+
49+
const storage = this.#storage(namespace, persist);
50+
const gateway = new this.gatewayClass(storage, defaultClock);
51+
this.#gateways.set(namespace, [persist, gateway]);
52+
return gateway;
53+
}
54+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { z } from "zod";
2+
import { Awaitable, OptionalZodTypeOf } from "../../helpers";
3+
import { Service, Worker_Binding } from "../../runtime";
4+
import { GatewayConstructor } from "./gateway";
5+
import { RouterConstructor } from "./router";
6+
7+
export interface PluginServicesOptions<
8+
Options extends z.ZodType,
9+
SharedOptions extends z.ZodType | undefined
10+
> {
11+
options: z.infer<Options>;
12+
optionsVersion: number;
13+
sharedOptions: OptionalZodTypeOf<SharedOptions>;
14+
workerBindings: Worker_Binding[];
15+
workerIndex: number;
16+
}
17+
18+
export interface PluginBase<
19+
Options extends z.ZodType,
20+
SharedOptions extends z.ZodType | undefined
21+
> {
22+
options: Options;
23+
getBindings(
24+
options: z.infer<Options>
25+
): Awaitable<Worker_Binding[] | undefined>;
26+
getServices(
27+
options: PluginServicesOptions<Options, SharedOptions>
28+
): Awaitable<Service[] | undefined>;
29+
}
30+
31+
export type Plugin<
32+
Options extends z.ZodType,
33+
SharedOptions extends z.ZodType | undefined = undefined,
34+
Gateway = undefined
35+
> = PluginBase<Options, SharedOptions> &
36+
(SharedOptions extends undefined
37+
? { sharedOptions?: undefined }
38+
: { sharedOptions: SharedOptions }) &
39+
(Gateway extends undefined
40+
? { gateway?: undefined; router?: undefined }
41+
: {
42+
gateway: GatewayConstructor<Gateway>;
43+
router: RouterConstructor<Gateway>;
44+
});
45+
46+
export * from "./constants";
47+
export * from "./gateway";
48+
export * from "./router";
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { Awaitable } from "@miniflare/shared";
2+
import { Request, Response } from "undici";
3+
import { GatewayFactory } from "./gateway";
4+
5+
export type RouteHandler<Params = unknown> = (
6+
req: Request,
7+
params: Params,
8+
url: URL
9+
) => Awaitable<Response>;
10+
11+
export abstract class Router<Gateway> {
12+
// Routes added by @METHOD decorators
13+
routes?: Map<string, (readonly [RegExp, string | symbol])[]>;
14+
15+
constructor(protected readonly gatewayFactory: GatewayFactory<Gateway>) {
16+
// Make sure this.routes isn't undefined and has the prototype's value
17+
this.routes = new.target.prototype.routes;
18+
}
19+
20+
async route(req: Request, url?: URL): Promise<Response | undefined> {
21+
url ??= new URL(req.url);
22+
const methodRoutes = this.routes?.get(req.method);
23+
if (methodRoutes !== undefined) {
24+
for (const [path, key] of methodRoutes) {
25+
const match = path.exec(url.pathname);
26+
if (match !== null) {
27+
return (this as unknown as Record<string | symbol, RouteHandler>)[
28+
key
29+
](req, match.groups, url);
30+
}
31+
}
32+
}
33+
}
34+
}
35+
36+
export interface RouterConstructor<Gateway> {
37+
new (gatewayFactory: GatewayFactory<Gateway>): Router<Gateway>;
38+
}
39+
40+
function pathToRegexp(path: string): RegExp {
41+
// Optionally allow trailing slashes
42+
if (!path.endsWith("/")) path += "/?";
43+
// Escape forward slashes
44+
path = path.replace(/\//g, "\\/");
45+
// Replace `:key` with named capture groups
46+
path = path.replace(/:(\w+)/g, "(?<$1>[^\\/]+)");
47+
// Return RegExp, asserting start and end of line
48+
return new RegExp(`^${path}$`);
49+
}
50+
51+
const createRouteDecorator =
52+
(method: string) =>
53+
(path: string) =>
54+
(prototype: typeof Router.prototype, key: string | symbol) => {
55+
const route = [pathToRegexp(path), key] as const;
56+
const routes = (prototype.routes ??= new Map<
57+
string,
58+
(readonly [RegExp, string | symbol])[]
59+
>());
60+
const methodRoutes = routes.get(method);
61+
if (methodRoutes) methodRoutes.push(route);
62+
else routes.set(method, [route]);
63+
};
64+
65+
export const GET = createRouteDecorator("GET");
66+
export const HEAD = createRouteDecorator("HEAD");
67+
export const POST = createRouteDecorator("POST");
68+
export const PUT = createRouteDecorator("PUT");
69+
export const DELETE = createRouteDecorator("DELETE");
70+
export const PATCH = createRouteDecorator("PATCH");
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import assert from "assert";
2+
import {
3+
GET,
4+
GatewayFactory,
5+
POST,
6+
RouteHandler,
7+
Router,
8+
} from "@miniflare/tre";
9+
import test from "ava";
10+
import { Request, Response } from "undici";
11+
12+
// TODO: update to AVA 4
13+
14+
class TestGateway {
15+
constructor() {}
16+
}
17+
18+
class TestRouter extends Router<TestGateway> {
19+
constructor() {
20+
super(new GatewayFactory("test", TestGateway));
21+
}
22+
23+
@GET("/params/:foo/:bar")
24+
get: RouteHandler<{ foo: string; bar: string }> = (req, params, url) => {
25+
return Response.json({
26+
method: req.method,
27+
pathname: url.pathname,
28+
searchParams: Object.fromEntries(url.searchParams),
29+
params,
30+
});
31+
};
32+
33+
@POST("/")
34+
echo: RouteHandler = async (req) => {
35+
const body = await req.text();
36+
return new Response(`body:${body}`);
37+
};
38+
39+
@POST("/twice")
40+
echoTwice: RouteHandler = async (req) => {
41+
const body = await req.text();
42+
return new Response(`body:${body}:${body}`);
43+
};
44+
}
45+
46+
test("Router: routes requests", async (t) => {
47+
const router = new TestRouter();
48+
49+
// Check routing with params and search params
50+
let res = await router.route(
51+
new Request("http://localhost/params/one/two?q=thing")
52+
);
53+
assert(res);
54+
t.is(res.status, 200);
55+
t.deepEqual(await res.json(), {
56+
method: "GET",
57+
pathname: "/params/one/two",
58+
searchParams: { q: "thing" },
59+
params: { foo: "one", bar: "two" },
60+
});
61+
62+
// Check trailing slash allowed
63+
res = await router.route(new Request("http://localhost/params/a/b/"));
64+
assert(res);
65+
t.is(res.status, 200);
66+
t.like(await res.json(), { params: { foo: "a", bar: "b" } });
67+
68+
// Check routing with body and `async` handler
69+
res = await router.route(
70+
new Request("http://localhost/", { method: "POST", body: "test" })
71+
);
72+
assert(res);
73+
t.is(res.status, 200);
74+
t.is(await res.text(), "body:test");
75+
76+
// Check routing with multiple handlers for same method
77+
res = await router.route(
78+
new Request("http://localhost/twice", { method: "POST", body: "test" })
79+
);
80+
assert(res);
81+
t.is(res.status, 200);
82+
t.is(await res.text(), "body:test:test");
83+
84+
// Check unknown route doesn't match
85+
res = await router.route(new Request("http://localhost/unknown"));
86+
t.is(res, undefined);
87+
88+
// Check unknown method but known path doesn't match
89+
res = await router.route(new Request("http://localhost/", { method: "PUT" }));
90+
t.is(res, undefined);
91+
});

0 commit comments

Comments
 (0)