diff --git a/src/assets.ts b/src/assets.ts index 785d618f..d3e531bf 100644 --- a/src/assets.ts +++ b/src/assets.ts @@ -1,7 +1,8 @@ +import { Assets } from "./types.ts"; import { ensureDir, extname, join, mime, walk } from "./deps.ts"; -const assets = async (dir: string) => { - const meta = { +export default async function assets(dir: string): Promise { + const meta: Assets = { raw: new Map(), transpile: new Map(), }; @@ -30,6 +31,4 @@ const assets = async (dir: string) => { } return meta; -}; - -export default assets; +} diff --git a/src/deps.ts b/src/deps.ts index 79a42949..8b6889c0 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -11,6 +11,7 @@ export { } from "https://deno.land/std@0.135.0/path/mod.ts"; export { Buffer, readLines } from "https://deno.land/std@0.135.0/io/mod.ts"; export { serve } from "https://deno.land/std@0.135.0/http/server.ts"; +export type { Handler } from "https://deno.land/std@0.135.0/http/server.ts"; export { readableStreamFromReader } from "https://deno.land/std@0.135.0/streams/conversion.ts"; export { default as mime } from "https://esm.sh/mime-types@2.1.35"; export { default as LRU } from "https://deno.land/x/lru@1.0.2/mod.ts"; diff --git a/src/oak/handler.ts b/src/oak/handler.ts index 7ec79f9c..18349b48 100644 --- a/src/oak/handler.ts +++ b/src/oak/handler.ts @@ -1,24 +1,14 @@ import { Context } from "https://deno.land/x/oak@v10.5.1/mod.ts"; -import { isDev, sourceDirectory, vendorDirectory } from "../env.ts"; -import { resolveConfig, resolveImportMap } from "../config.ts"; import { createRequestHandler } from "../server/requestHandler.ts"; +import { requestHandler } from "../server/middleware.ts"; -const cwd = Deno.cwd(); - -const config = await resolveConfig(cwd); -const importMap = await resolveImportMap(cwd, config); - -const requestHandler = await createRequestHandler({ - cwd, - importMap, - paths: { - source: sourceDirectory, - vendor: vendorDirectory, - }, - isDev, +const ultraRequestHandler = createRequestHandler({ + middleware: [ + requestHandler, + ], }); -export async function ultraHandler(context: Context) { +export async function ultraHandler(context: Context): Promise { const serverRequestBody = context.request.originalRequest.getBody(); const request = new Request(context.request.url.toString(), { @@ -27,7 +17,7 @@ export async function ultraHandler(context: Context) { body: serverRequestBody.body, }); - const response = await requestHandler(request); + const response = await ultraRequestHandler(request); context.response.status = response.status; context.response.headers = response.headers; diff --git a/src/resolver.ts b/src/resolver.ts index a8068ab4..a6bc5664 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -35,8 +35,8 @@ export const stripTrailingSlash = (url: string): string => { return url.endsWith("/") ? url.slice(0, -1) : url; }; -export const resolveFileUrl = (from: string, to: string) => { - return new URL(toFileUrl(resolve(from, to)).toString()); +export const resolveFileUrl = (directoryPath: string, fileName: string) => { + return new URL(toFileUrl(resolve(directoryPath, fileName)).toString()); }; export const isRemoteSource = (value: string): boolean => { diff --git a/src/server.ts b/src/server.ts index 7ef0b85b..4fa1a174 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,27 +1,12 @@ -import { serve } from "./deps.ts"; -import { - devServerWebsocketPort, - isDev, - port, - sourceDirectory, - vendorDirectory, -} from "./env.ts"; -import { resolveConfig, resolveImportMap } from "./config.ts"; +import { Middleware } from "./types.ts"; import { createRequestHandler } from "./server/requestHandler.ts"; +import { devServerWebsocketPort, isDev, port } from "./env.ts"; +import { serve } from "./deps.ts"; -const cwd = Deno.cwd(); -const config = await resolveConfig(cwd); -const importMap = await resolveImportMap(cwd, config); - -const server = async () => { - const requestHandler = await createRequestHandler({ - cwd, - importMap, - paths: { - source: sourceDirectory, - vendor: vendorDirectory, - }, - isDev, +export default function () { + const middleware: Middleware[] = []; + const requestHandler = createRequestHandler({ + middleware, }); let message = `Ultra running http://localhost:${port}`; @@ -32,7 +17,14 @@ const server = async () => { console.log(message); - return serve(requestHandler, { port: +port }); -}; - -export default server; + return { + start: () => { + serve(requestHandler, { + port: Number(port), + }); + }, + use: (middlewareFunction: Middleware) => { + middleware.push(middlewareFunction); + }, + }; +} diff --git a/src/server/middleware.ts b/src/server/middleware.ts new file mode 100644 index 00000000..086a1990 --- /dev/null +++ b/src/server/middleware.ts @@ -0,0 +1,190 @@ +import assets from "../assets.ts"; +import render from "../render.ts"; +import transform from "../transform.ts"; +import { Context, Middleware, Next } from "../types.ts"; +import { LRU, readableStreamFromReader } from "../deps.ts"; +import { createURL } from "./request.ts"; +import { + disableStreaming, + isDev, + lang, + sourceDirectory, + vendorDirectory, +} from "../env.ts"; +import { + replaceFileExt, + resolveFileUrl, + ValidExtensions, +} from "../resolver.ts"; +import { resolveConfig, resolveImportMap } from "../config.ts"; + +const memory = new LRU(500); +const [ + rawAssets, + vendorAssets, + importMap, +] = await Promise.all([ + assets(sourceDirectory), + assets(`.ultra/${vendorDirectory}`), + (async () => { + const cwd = Deno.cwd(); + const config = await resolveConfig(cwd); + const importMap = await resolveImportMap(cwd, config); + return importMap; + })(), +]); + +export function dispatch( + middlewares: Middleware[], + context: C, + index = 0, +): Next { + const nextMiddlewareFunction = middlewares[index]; + return nextMiddlewareFunction + ? async (shortCircuit?: boolean) => { + if (shortCircuit) { + return; + } + + await nextMiddlewareFunction( + context, + dispatch(middlewares, context, index + 1), + ); + } + : async () => {}; +} + +export function compose( + ...middlewares: Middleware[] +): Middleware { + return async function composedMiddleware( + context: C, + next: Next, + ) { + await dispatch(middlewares, context)(); + await next(); + }; +} + +export const renderPage: Middleware = async (context, next) => { + const url = createURL(context.request); + + const body = await render({ + url, + importMap, + lang, + disableStreaming, + }); + + context.response.body = body; + context.response.headers = { + ...context.response.headers, + "content-type": "text/html; charset=utf-8", + }; + + await next(true); +}; + +export const staticAsset: Middleware = async ({ request, response }, next) => { + const url = createURL(request); + + const filePath = `${sourceDirectory}${url.pathname}`; + if (!rawAssets.raw.has(filePath)) { + await next(); + return; + } + + const contentType = rawAssets.raw.get(filePath); + if (!contentType) { + response.status = 415; + response.statusText = "Unsupported Media Type"; + + await next(true); + return; + } + + response.body = readableStreamFromReader(await Deno.open(`./${filePath}`)); + response.headers = { + ...response.headers, + "content-type": contentType, + }; + + await next(true); +}; + +export const transpileSource: Middleware = async (context, next) => { + const url = createURL(context.request); + + const transpilation = async (file: string) => { + let js = memory.get(url.pathname); + + if (!js) { + const source = await Deno.readTextFile( + resolveFileUrl(Deno.cwd(), file), + ); + const t0 = performance.now(); + + js = await transform({ + source, + sourceUrl: url, + importMap, + }); + + const t1 = performance.now(); + const duration = (t1 - t0).toFixed(2); + + console.log(`Transpile ${file} in ${duration}ms`); + + if (!isDev) { + memory.set(url.pathname, js); + } + } + + return js; + }; + + const fileTypes: ValidExtensions[] = [".jsx", ".tsx", ".ts"]; + for (const fileType of fileTypes) { + const filePath = `${sourceDirectory}${ + replaceFileExt(url.pathname, fileType) + }`; + if (rawAssets.transpile.has(filePath)) { + context.response.body = await transpilation(filePath); + context.response.headers = { + ...context.response.headers, + "content-type": "application/javascript", + }; + await next(true); + return; + } + } + + await next(); +}; + +export const vendorMap: Middleware = async ({ request, response }, next) => { + const url = createURL(request); + + if (!vendorAssets.raw.has(`.ultra${url.pathname}`)) { + await next(); + return; + } + + const file = await Deno.open(`./.ultra${url.pathname}`); + const body = readableStreamFromReader(file); + + response.body = body; + response.headers = { + ...response.headers, + "content-type": "application/javascript", + }; + + await next(true); +}; + +export const requestHandler: Middleware = compose( + transpileSource, + staticAsset, + vendorMap, + renderPage, +); diff --git a/src/server/request.ts b/src/server/request.ts new file mode 100644 index 00000000..aca9fc12 --- /dev/null +++ b/src/server/request.ts @@ -0,0 +1,15 @@ +export function createURL(request: Request): URL { + const url = new URL(request.url); + + const xForwardedProto = request.headers.get("x-forwarded-proto"); + if (xForwardedProto) { + url.protocol = `${xForwardedProto}:`; + } + + const xForwardedHost = request.headers.get("x-forwarded-host"); + if (xForwardedHost) { + url.hostname = xForwardedHost; + } + + return url; +} diff --git a/src/server/requestHandler.ts b/src/server/requestHandler.ts index 114217b6..5274f5d9 100644 --- a/src/server/requestHandler.ts +++ b/src/server/requestHandler.ts @@ -1,176 +1,25 @@ -import assets from "../assets.ts"; -import { join, LRU, readableStreamFromReader } from "../deps.ts"; -import { disableStreaming, lang } from "../env.ts"; -import render from "../render.ts"; -import { - replaceFileExt, - resolveFileUrl, - stripTrailingSlash, -} from "../resolver.ts"; -import transform from "../transform.ts"; -import type { APIHandler, ImportMap } from "../types.ts"; +import type { Context, Middleware, RequestHandler } from "../types.ts"; +import { createResponse } from "./response.ts"; +import { compose } from "./middleware.ts"; -type CreateRequestHandlerOptions = { - cwd: string; - importMap: ImportMap; - paths: { - source: string; - vendor: string; - }; - isDev?: boolean; +export type CreateRequestHandlerOptions = { + middleware: Middleware[]; }; -export async function createRequestHandler( - options: CreateRequestHandlerOptions, -) { - const { - cwd, - importMap, - paths: { source: sourceDirectory, vendor: vendorDirectory }, - isDev, - } = options; - - const memory = new LRU(500); - const [{ raw, transpile }, vendor] = await Promise.all([ - assets(sourceDirectory), - assets(`.ultra/${vendorDirectory}`), - ]); - - return async function requestHandler(request: Request): Promise { - const requestUrl = new URL(request.url); - - const xForwardedProto = request.headers.get("x-forwarded-proto"); - if (xForwardedProto) requestUrl.protocol = xForwardedProto + ":"; - - const xForwardedHost = request.headers.get("x-forwarded-host"); - if (xForwardedHost) requestUrl.hostname = xForwardedHost; - - // vendor map - if (vendor.raw.has(".ultra" + requestUrl.pathname)) { - const headers = { - "content-type": "application/javascript", - }; - - const file = await Deno.open( - `./.ultra${requestUrl.pathname}`, - ); - const body = readableStreamFromReader(file); - - return new Response(body, { headers }); - } - - // static assets - if (raw.has(`${sourceDirectory}${requestUrl.pathname}`)) { - const contentType = raw.get(`${sourceDirectory}${requestUrl.pathname}`); - const headers = { - "content-type": contentType, - }; - - const file = await Deno.open( - join(".", sourceDirectory, requestUrl.pathname), - ); - const body = readableStreamFromReader(file); - - return new Response(body, { headers }); - } - - const transpilation = async (file: string) => { - const headers = { - "content-type": "application/javascript", - }; - - let js = memory.get(requestUrl.pathname); - - if (!js) { - const source = await Deno.readTextFile(resolveFileUrl(cwd, file)); - const t0 = performance.now(); - - js = await transform({ - source, - sourceUrl: requestUrl, - importMap, - }); - - const t1 = performance.now(); - const duration = (t1 - t0).toFixed(2); - - console.log(`Transpile ${file} in ${duration}ms`); - - if (!isDev) memory.set(requestUrl.pathname, js); - } - - //@ts-ignore any - return new Response(js, { headers }); +export function createRequestHandler( + { middleware }: CreateRequestHandlerOptions, +): RequestHandler { + return async (request: Request) => { + const context: Context = { + request, + response: { + status: 200, + headers: {}, + }, }; - // API - if (requestUrl.pathname.startsWith("/api")) { - const apiPaths = new Map([...raw, ...transpile]); - const importAPIRoute = async (pathname: string): Promise => { - let path = `${sourceDirectory}${pathname}`; - if (apiPaths.has(`${path}.js`)) { - path = `file://${cwd}/${path}.js`; - } else if (apiPaths.has(`${path}.ts`)) { - path = `file://${cwd}/${path}.ts`; - } else if (apiPaths.has(`${path}/index.js`)) { - path = `file://${cwd}/${path}/index.js`; - } else if (apiPaths.has(`${path}/index.ts`)) { - path = `file://${cwd}/${path}/index.ts`; - } - return (await import(path)).default; - }; - try { - const pathname = stripTrailingSlash(requestUrl.pathname); - const handler = await importAPIRoute(pathname); - const response = await handler(request); - return response; - } catch (error) { - console.error(error); - return new Response("Internal Server Error", { - status: 500, - headers: { - "content-type": "text/html; charset=utf-8", - }, - }); - } - } - - // jsx - const jsx = `${sourceDirectory}${ - replaceFileExt(requestUrl.pathname, ".jsx") - }`; - if (transpile.has(jsx)) { - return await transpilation(jsx); - } + await compose(...middleware)(context, async () => {}); - // tsx - const tsx = `${sourceDirectory}${ - replaceFileExt(requestUrl.pathname, ".tsx") - }`; - if (transpile.has(tsx)) { - return await transpilation(tsx); - } - - // ts - const ts = `${sourceDirectory}${ - replaceFileExt(requestUrl.pathname, ".ts") - }`; - if (transpile.has(ts)) { - return await transpilation(ts); - } - - return new Response( - await render({ - url: requestUrl, - importMap, - lang, - disableStreaming: !!disableStreaming, - }), - { - headers: { - "content-type": "text/html; charset=utf-8", - }, - }, - ); + return createResponse(context); }; } diff --git a/src/server/response.ts b/src/server/response.ts new file mode 100644 index 00000000..81255327 --- /dev/null +++ b/src/server/response.ts @@ -0,0 +1,19 @@ +import { Context } from "../types.ts"; + +export function createResponse(context: Context): Response { + const responseInit: ResponseInit = {}; + + if (context.response.headers) { + responseInit.headers = context.response.headers; + } + + if (context.response.status) { + responseInit.status = context.response.status; + } + + if (context.response.statusText) { + responseInit.statusText = context.response.statusText; + } + + return new Response(context.response.body, responseInit); +} diff --git a/src/types.ts b/src/types.ts index 613d1f6f..b652ae57 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,11 +6,13 @@ export type Config = { importMap?: string; }; -type Context = { +export type Context = { request: Request; response: { - body: string | ReadableStream; - type: string; + body?: BodyInit; + headers?: Record; + status?: number; + statusText?: string; }; }; @@ -35,4 +37,16 @@ export type RenderOptions = { export type Cache = Map; -export type APIHandler = (request: Request) => Response | Promise; +export type Assets = { + raw: Map; + transpile: Map; +}; + +export type RequestHandler = (request: Request) => Promise; + +export type Next = (shortCircuit?: boolean) => Promise; + +export type Middleware = ( + context: C, + next: Next, +) => Promise; diff --git a/workspace/server.js b/workspace/server.js index 41adfa21..0937a00f 100644 --- a/workspace/server.js +++ b/workspace/server.js @@ -1,3 +1,16 @@ +import { requestHandler } from "../src/server/middleware.ts"; import ultra from "../server.ts"; -ultra(); +const server = ultra(); + +server.use(async (context, next) => { + console.log(`<-- ${context.request.method} ${context.request.url}`); + await next(); + console.log( + `--> ${context.request.method} ${context.request.url} ${context.response.status}`, + ); +}); + +server.use(requestHandler); + +server.start();