Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Page rendering #2

Merged
merged 8 commits into from
Mar 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ servers.web.port.test=8081
servers.web.host=0.0.0.0
servers.web.apiRoute="/api"
servers.web.assetRoute="/assets"
servers.web.pageRoute="/pages"
servers.web.closeActiveConnectionsOnStop=false
servers.web.closeActiveConnectionsOnStop.test=true
1 change: 1 addition & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
33 changes: 31 additions & 2 deletions __tests__/servers/web.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,35 @@ describe("actions", () => {
});
});

describe("assets", () => {});
describe("assets", () => {
test("the web server can serve a static asset", async () => {
const res = await fetch(url + "/assets/actionhero.png");
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toEqual("image/png");
const body = await res.text();
expect(body).toContain("PNG"); // binary...
});

test("the web server can handle missing assets gracefully", async () => {
const res = await fetch(url + "/assets/missing.png");
expect(res.status).toBe(404);
expect(res.headers.get("Content-Type")).toEqual("application/json");
});
});

describe("pages", () => {});
describe("pages", () => {
test("the web server can serve a react page", async () => {
const res = await fetch(url + "/");
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toEqual("text/html");
const text = await res.text();
expect(text).toContain("<title>Hello World</title>"); // from the layout w/ props
expect(text).toContain("<h1>Hello World</h1>"); // from the child page
});

test("the web server can handle missing pages gracefully", async () => {
const res = await fetch(url + "/missing.html");
expect(res.status).toBe(404);
expect(res.headers.get("Content-Type")).toEqual("application/json");
});
});
Binary file modified bun.lockb
Binary file not shown.
10 changes: 5 additions & 5 deletions classes/API.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export class API {
constructor() {
this.bootTime = new Date().getTime();
this.rootDir = path.join(import.meta.path, "..", "..");
this.logger = this.buildLogger();
this.logger = new Logger(config.logger);

this.initialized = false;
this.started = false;
Expand All @@ -30,6 +30,7 @@ export class API {

async initialize() {
this.logger.warn("Initializing process");
this.initialized = false;

await this.findInitializers();
this.sortInitializers("loadPriority");
Expand All @@ -47,6 +48,8 @@ export class API {
}

async start() {
this.stopped = false;
this.started = false;
if (!this.initialized) await this.initialize();

this.logger.warn("Starting process");
Expand Down Expand Up @@ -77,13 +80,10 @@ export class API {
}

this.stopped = true;
this.started = false;
this.logger.warn("Stopping complete");
}

private buildLogger() {
return new Logger(config.logger);
}

private async findInitializers() {
const initializers = await globLoader<Initializer>("initializers");
for (const i of initializers) {
Expand Down
7 changes: 4 additions & 3 deletions classes/Action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,23 @@ import type { Inputs } from "./Inputs";
import type { Connection } from "./Connection";
import type { Input } from "./Input";

export const HTTP_METHODS = [
export const httpMethods = [
"GET",
"POST",
"PUT",
"DELETE",
"PATCH",
"OPTIONS",
] as const;
export type HTTP_METHOD = (typeof httpMethods)[number];

export type ActionConstructorInputs = {
name: string;
description?: string;
inputs?: Inputs;
web?: {
route?: RegExp | string;
method?: (typeof HTTP_METHODS)[number];
method?: HTTP_METHOD;
};
};

Expand All @@ -27,7 +28,7 @@ export abstract class Action {
inputs: Inputs;
web: {
route: RegExp | string;
method: (typeof HTTP_METHODS)[number];
method: HTTP_METHOD;
};

constructor(args: ActionConstructorInputs) {
Expand Down
1 change: 1 addition & 0 deletions config/server/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ export const configServerWeb = {
host: await loadFromEnvIfSet("servers.web.host", "0.0.0.0"),
apiRoute: await loadFromEnvIfSet("servers.web.apiRoute", "/api"),
assetRoute: await loadFromEnvIfSet("servers.web.assetRouter", "/assets"),
pageRoute: await loadFromEnvIfSet("servers.web.pageRoute", "/pages"),
};
23 changes: 23 additions & 0 deletions layouts/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React from "react";

export type LayoutProps = {
title: string;
};

export const MainLayout = (props: React.PropsWithChildren<LayoutProps>) => {
return (
<React.StrictMode>
<html lang="en">
<head>
<meta charSet="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>{props.title}</title>
</head>
<body>{props.children}</body>
</html>
</React.StrictMode>
);
};
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@
"type": "module",
"license": "MIT",
"devDependencies": {
"@types/bun": "^1.0.8",
"prettier": "^3.2.5",
"typedoc": "^0.25.12"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
"colors": "^1.4.0"
"@types/bun": "^1.0.8",
"@types/react-dom": "^18.2.22",
"colors": "^1.4.0",
"react-dom": "^18.2.0"
},
"scripts": {
"lint": "prettier --check .",
Expand Down
10 changes: 10 additions & 0 deletions pages/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { MainLayout } from "../layouts/main";

export const page = () => (
<MainLayout title="Hello World">
<div>
<h1>Hello World</h1>
<p>sups.</p>
</div>
</MainLayout>
);
1 change: 1 addition & 0 deletions pages/other.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<p>hi</p>
108 changes: 90 additions & 18 deletions servers/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import { config } from "../config";
import { logger, api } from "../api";
import { Connection } from "../classes/Connection";
import path from "path";
import { type HTTP_METHOD } from "../classes/Action";
import { renderToReadableStream } from "react-dom/server";
import type { BunFile } from "bun";

type URLParsed = import("url").URL;

const commonHeaders = {
"Content-Type": "application/json",
Expand Down Expand Up @@ -34,7 +39,8 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {

async stop() {
if (this.server) {
this.server.stop();
this.server.stop(false); // allow open connections to complete

while (
this.server.pendingRequests > 0 ||
this.server.pendingWebSockets > 0
Expand All @@ -44,26 +50,33 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
pendingWebSockets: this.server.pendingWebSockets,
});

await Bun.sleep(1000);
await Bun.sleep(100);
}
}
}

async fetch(request: Request): Promise<Response> {
let errorStatusCode = 500;
if (!this.server) throw new Error("server not started");
const url = new URL(request.url);

// assets
const requestedAsset = await this.findAsset(request);
if (requestedAsset) return new Response(requestedAsset);
if (url.pathname.startsWith(`${config.server.web.apiRoute}/`)) {
return this.handleAction(request, url);
} else if (url.pathname.startsWith(`${config.server.web.assetRoute}/`)) {
return this.handleAsset(request, url);
} else {
return this.handlePage(request, url);
}
}

// pages (TODO)
async handleAction(request: Request, url: URLParsed) {
if (!this.server) throw new Error("server not started");
let errorStatusCode = 500;

// actions
const ipAddress = this.server.requestIP(request)?.address || "unknown";
// const contentType = request.headers.get("content-type") || "";
const connection = new Connection(this.name, ipAddress);
const actionName = await this.determineActionName(request);
const actionName = await this.determineActionName(
url,
request.method as HTTP_METHOD,
);
if (!actionName) errorStatusCode = 404;

let params: FormData;
Expand All @@ -86,10 +99,25 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
: this.buildResponse(response);
}

async findAsset(request: Request) {
const url = new URL(request.url);
if (!url.pathname.startsWith(`${config.server.web.assetRoute}/`)) return;
async handleAsset(request: Request, url: URLParsed) {
const requestedAsset = await this.findAsset(url);
if (requestedAsset) {
return new Response(requestedAsset);
} else return this.buildError(new Error("Asset not found"), 404);
}

async handlePage(request: Request, url: URLParsed) {
const [requestedAsset, assetPath, isReact] = await this.findPage(url);
if (requestedAsset && isReact && assetPath) {
return this.renderReactPage(request, url, assetPath);
} else if (requestedAsset && !isReact) {
return new Response(requestedAsset);
} else {
return this.buildError(new Error("Page not found"), 404);
}
}

async findAsset(url: URLParsed) {
const replacer = new RegExp(`^${config.server.web.assetRoute}/`, "g");
const localPath = path.join(
api.rootDir,
Expand All @@ -104,10 +132,41 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
}
}

async determineActionName(request: Request) {
const url = new URL(request.url);
if (!url.pathname.startsWith(`${config.server.web.apiRoute}/`)) return;
async findPage(
url: URLParsed,
): Promise<[BunFile | undefined, string | undefined, boolean | undefined]> {
const replacer = new RegExp(`^${config.server.web.pageRoute}/`, "g");
const localPath = path.join(
api.rootDir,
"pages",
url.pathname.replace(replacer, ""),
);

const possiblePaths: [P: string, isReact: boolean][] = [
[localPath, false],
[localPath + ".htm", false],
[localPath + ".html", false],
[localPath + ".js", true],
[localPath + ".jsx", true],
[localPath + ".ts", true],
[localPath + ".tsx", true],
[localPath + "index.htm", false],
[localPath + "index.html", false],
[localPath + "index.js", true],
[localPath + "index.jsx", true],
[localPath + "index.ts", true],
[localPath + "index.tsx", true],
];

for (const [p, isReact] of possiblePaths) {
const filePointer = Bun.file(p);
if (await filePointer.exists()) return [filePointer, p, isReact];
}

return [undefined, undefined, undefined];
}

async determineActionName(url: URLParsed, method: HTTP_METHOD) {
const pathToMatch = url.pathname.replace(
new RegExp(`${config.server.web.apiRoute}`),
"",
Expand All @@ -123,7 +182,7 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {

if (
pathToMatch.match(matcher) &&
request.method.toUpperCase() === action.web.method
method.toUpperCase() === action.web.method
) {
return action.name;
}
Expand Down Expand Up @@ -151,4 +210,17 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
},
);
}

async renderReactPage(request: Request, url: URLParsed, assetPath: string) {
const constructors = (await import(assetPath)) as Record<
string,
() => React.ReactNode
>;
const outputStream = await renderToReadableStream(
Object.values(constructors)[0](),
);
return new Response(outputStream, {
headers: { "Content-Type": "text/html" },
});
}
}
26 changes: 14 additions & 12 deletions util/glob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,22 @@ import { api } from "../api";
*/
export async function globLoader<T>(searchDir: string) {
const results: T[] = [];
const glob = new Glob("**/*.ts");
const globs = [new Glob("**/*.ts"), new Glob("**/*.tsx")];
const dir = path.join(api.rootDir, searchDir);

for await (const file of glob.scan(dir)) {
const fullPath = path.join(dir, file);
const modules = (await import(fullPath)) as {
[key: string]: new () => T;
};
for (const [name, klass] of Object.entries(modules)) {
try {
const instance = new klass();
results.push(instance);
} catch (error) {
throw new Error(`Error loading from ${dir} - ${name} - ${error}`);
for (const glob of globs) {
for await (const file of glob.scan(dir)) {
const fullPath = path.join(dir, file);
const modules = (await import(fullPath)) as {
[key: string]: new () => T;
};
for (const [name, klass] of Object.entries(modules)) {
try {
const instance = new klass();
results.push(instance);
} catch (error) {
throw new Error(`Error loading from ${dir} - ${name} - ${error}`);
}
}
}
}
Expand Down