pages-template-plugin.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. import { match } from "path-to-regexp";
  2. //note: this explicitly does not include the * character, as pages requires this
  3. const escapeRegex = /[.+?^${}()|[\]\\]/g;
  4. type HTTPMethod =
  5. | "HEAD"
  6. | "OPTIONS"
  7. | "GET"
  8. | "POST"
  9. | "PUT"
  10. | "PATCH"
  11. | "DELETE";
  12. /* TODO: Grab these from @cloudflare/workers-types instead */
  13. type Params<P extends string = string> = Record<P, string | string[]>;
  14. type EventContext<Env, P extends string, Data> = {
  15. request: Request;
  16. functionPath: string;
  17. waitUntil: (promise: Promise<unknown>) => void;
  18. passThroughOnException: () => void;
  19. next: (input?: Request | string, init?: RequestInit) => Promise<Response>;
  20. env: Env & { ASSETS: { fetch: typeof fetch } };
  21. params: Params<P>;
  22. data: Data;
  23. };
  24. type EventPluginContext<Env, P extends string, Data, PluginArgs> = {
  25. request: Request;
  26. functionPath: string;
  27. waitUntil: (promise: Promise<unknown>) => void;
  28. passThroughOnException: () => void;
  29. next: (input?: Request | string, init?: RequestInit) => Promise<Response>;
  30. env: Env & { ASSETS: { fetch: typeof fetch } };
  31. params: Params<P>;
  32. data: Data;
  33. pluginArgs: PluginArgs;
  34. };
  35. declare type PagesFunction<
  36. Env = unknown,
  37. P extends string = string,
  38. Data extends Record<string, unknown> = Record<string, unknown>
  39. > = (context: EventContext<Env, P, Data>) => Response | Promise<Response>;
  40. declare type PagesPluginFunction<
  41. Env = unknown,
  42. P extends string = string,
  43. Data extends Record<string, unknown> = Record<string, unknown>,
  44. PluginArgs = unknown
  45. > = (
  46. context: EventPluginContext<Env, P, Data, PluginArgs>
  47. ) => Response | Promise<Response>;
  48. /* end @cloudflare/workers-types */
  49. type RouteHandler = {
  50. routePath: string;
  51. mountPath: string;
  52. method?: HTTPMethod;
  53. modules: PagesFunction[];
  54. middlewares: PagesFunction[];
  55. };
  56. // inject `routes` via ESBuild
  57. declare const routes: RouteHandler[];
  58. function* executeRequest(request: Request, relativePathname: string) {
  59. // First, iterate through the routes (backwards) and execute "middlewares" on partial route matches
  60. for (const route of [...routes].reverse()) {
  61. if (route.method && route.method !== request.method) {
  62. continue;
  63. }
  64. // replaces with "\\$&", this prepends a backslash to the matched string, e.g. "[" becomes "\["
  65. const routeMatcher = match(route.routePath.replace(escapeRegex, "\\$&"), {
  66. end: false,
  67. });
  68. const mountMatcher = match(route.mountPath.replace(escapeRegex, "\\$&"), {
  69. end: false,
  70. });
  71. const matchResult = routeMatcher(relativePathname);
  72. const mountMatchResult = mountMatcher(relativePathname);
  73. if (matchResult && mountMatchResult) {
  74. for (const handler of route.middlewares.flat()) {
  75. yield {
  76. handler,
  77. params: matchResult.params as Params,
  78. path: mountMatchResult.path,
  79. };
  80. }
  81. }
  82. }
  83. // Then look for the first exact route match and execute its "modules"
  84. for (const route of routes) {
  85. if (route.method && route.method !== request.method) {
  86. continue;
  87. }
  88. const routeMatcher = match(route.routePath.replace(escapeRegex, "\\$&"), {
  89. end: true,
  90. });
  91. const mountMatcher = match(route.mountPath.replace(escapeRegex, "\\$&"), {
  92. end: false,
  93. });
  94. const matchResult = routeMatcher(relativePathname);
  95. const mountMatchResult = mountMatcher(relativePathname);
  96. if (matchResult && mountMatchResult && route.modules.length) {
  97. for (const handler of route.modules.flat()) {
  98. yield {
  99. handler,
  100. params: matchResult.params as Params,
  101. path: matchResult.path,
  102. };
  103. }
  104. break;
  105. }
  106. }
  107. }
  108. export default function (pluginArgs: unknown) {
  109. const onRequest: PagesPluginFunction = async (workerContext) => {
  110. let { request } = workerContext;
  111. const { env, next } = workerContext;
  112. let { data } = workerContext;
  113. const url = new URL(request.url);
  114. // TODO: Replace this with something actually legible.
  115. const relativePathname = `/${
  116. url.pathname.replace(workerContext.functionPath, "") || ""
  117. }`.replace(/^\/\//, "/");
  118. const handlerIterator = executeRequest(request, relativePathname);
  119. const pluginNext = async (input?: RequestInfo, init?: RequestInit) => {
  120. if (input !== undefined) {
  121. let url = input;
  122. if (typeof input === "string") {
  123. url = new URL(input, request.url).toString();
  124. }
  125. request = new Request(url, init);
  126. }
  127. const result = handlerIterator.next();
  128. // Note we can't use `!result.done` because this doesn't narrow to the correct type
  129. if (result.done === false) {
  130. const { handler, params, path } = result.value;
  131. const context = {
  132. request: new Request(request.clone()),
  133. functionPath: workerContext.functionPath + path,
  134. next: pluginNext,
  135. params,
  136. get data() {
  137. return data;
  138. },
  139. set data(value) {
  140. if (typeof value !== "object" || value === null) {
  141. throw new Error("context.data must be an object");
  142. }
  143. // user has overriden context.data, so we need to merge it with the existing data
  144. data = value;
  145. },
  146. pluginArgs,
  147. env,
  148. waitUntil: workerContext.waitUntil.bind(workerContext),
  149. passThroughOnException:
  150. workerContext.passThroughOnException.bind(workerContext),
  151. };
  152. const response = await handler(context);
  153. return cloneResponse(response);
  154. } else {
  155. return next(request);
  156. }
  157. };
  158. return pluginNext();
  159. };
  160. return onRequest;
  161. }
  162. // This makes a Response mutable
  163. const cloneResponse = (response: Response) =>
  164. // https://fetch.spec.whatwg.org/#null-body-status
  165. new Response(
  166. [101, 204, 205, 304].includes(response.status) ? null : response.body,
  167. response
  168. );