@aklinker1/aframe 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Aaron
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # aframe
2
+
3
+ Simple wrapper around Vite for creating pre-rendered, client-side web apps with a backend.
4
+
5
+ ## Project Structure
6
+
7
+ <!-- prettier-ignore -->
8
+ ```html
9
+ 📂 {rootDir}/
10
+ 📁 app/
11
+ 📄 .env
12
+ 📄 index.html
13
+ 📄 main.ts
14
+ 📁 public/
15
+ 📄 favicon.ico
16
+ 📁 server/
17
+ 📄 .env
18
+ 📄 main.ts
19
+ 📄 aframe.config.ts
20
+ ```
21
+
22
+ ```ts
23
+ // aframe.config.ts
24
+ export default defineConfig({
25
+ // See https://vite.dev/config/
26
+ vite: {
27
+ // ...
28
+ },
29
+ // List of routes to pre-render.
30
+ prerenderedRoutes: ["/"],
31
+ // See https://github.com/Tofandel/prerenderer?tab=readme-ov-file#prerenderer-options
32
+ prerenderer: {
33
+ // ...
34
+ },
35
+ });
36
+ ```
37
+
38
+ ```ts
39
+ // server/main.ts
40
+ import { Elysia } from "elysia";
41
+
42
+ // You don't have to use `elysia`, the default export just needs to have a
43
+ // `listen(port)` function.
44
+ const app = new Elysia()
45
+ .get("/", "Hello Elysia")
46
+ .get("/user/:id", ({ params }) => params.id)
47
+ .post("/form", ({ body }) => body);
48
+
49
+ export default app;
50
+ ```
51
+
52
+ ```html
53
+ <!-- app/index.html -->
54
+ <!doctype html>
55
+ <html lang="en">
56
+ <head>
57
+ <meta charset="UTF-8" />
58
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
59
+ <title>Hello Aframe</title>
60
+ </head>
61
+ <body></body>
62
+ <script type="module" src="./main.ts"></script>
63
+ </html>
64
+ ```
65
+
66
+ ```jsonc
67
+ // package.json
68
+ {
69
+ "name": "my-app",
70
+ "version": "1.0.0",
71
+ "packageManager": "bun@1.2.2",
72
+ "scripts": {
73
+ "dev": "aframe",
74
+ "build": "aframe build",
75
+ "preview": "bun --cwd .output server-entry.js"
76
+ },
77
+ "dependencies": {
78
+ "@aklinker1/aframe": "@latest",
79
+ "@prerenderer/prerenderer": "@latest",
80
+ "@prerenderer/renderer-puppeteer": "@latest"
81
+ "elysia": "@latest"
82
+ "vite": "@latest",
83
+ }
84
+ }
85
+ ```
package/bin/aframe.ts ADDED
@@ -0,0 +1,58 @@
1
+ import { build, createServer, resolveConfig } from "../src/mod";
2
+ import { RESET, BOLD, DIM, UNDERLINE, GREEN, CYAN } from "../src/color";
3
+ import { createTimer } from "../src/timer";
4
+
5
+ const [_bun, _aframe, ...args] = Bun.argv;
6
+
7
+ async function dev(root?: string) {
8
+ const devServerTimer = createTimer();
9
+ console.log(`${BOLD}${CYAN}ℹ${RESET} Spinning up dev servers...${RESET}`);
10
+ const config = await resolveConfig(root, "serve", "development");
11
+ const devServer = await createServer(config);
12
+
13
+ await devServer.listen(config.appPort).then(() => {
14
+ const js = [
15
+ `import server from '${config.serverModule}';`,
16
+ `server.listen(${config.serverPort});`,
17
+ ].join("\n");
18
+ Bun.spawn({
19
+ cmd: [
20
+ "bun",
21
+ "--watch",
22
+ "--define",
23
+ `import.meta.publicDir:"${config.publicDir}"`,
24
+ "--define",
25
+ `import.meta.command:"serve"`,
26
+ "--eval",
27
+ js,
28
+ ],
29
+ // stdio: ["ignore", "inherit", "inherit"],
30
+ cwd: config.serverDir,
31
+ });
32
+ });
33
+
34
+ console.log(`${GREEN}✔${RESET} Dev servers started in ${devServerTimer()}`);
35
+ console.log(
36
+ ` ${BOLD}${GREEN}→${RESET} ${BOLD}App${RESET} ${DIM}@${RESET} ${UNDERLINE}http://localhost:${config.appPort}${RESET}`,
37
+ );
38
+ console.log(
39
+ ` ${BOLD}${GREEN}→${RESET} ${BOLD}Server Proxy${RESET} ${DIM}@${RESET} ${UNDERLINE}http://localhost:${config.appPort}/api${RESET}`,
40
+ );
41
+ console.log(
42
+ ` ${BOLD}${GREEN}→${RESET} ${BOLD}Server${RESET} ${DIM}@${RESET} ${UNDERLINE}http://localhost:${config.serverPort}${RESET}`,
43
+ );
44
+ }
45
+
46
+ async function help() {
47
+ console.log("Help");
48
+ }
49
+
50
+ if (args[0] === "build") {
51
+ const root = args[1];
52
+ const config = await resolveConfig(root, "build", "production");
53
+ await build(config);
54
+ } else if (args.includes("--help") || args.includes("-h")) {
55
+ await help();
56
+ } else {
57
+ await dev(args[0]);
58
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@aklinker1/aframe",
3
+ "version": "0.1.0",
4
+ "packageManager": "bun@1.2.2",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": "./src/mod.ts",
9
+ "./server": "./src/server.ts",
10
+ "./env": "./src/env.d.ts"
11
+ },
12
+ "bin": {
13
+ "aframe": "bin/aframe.ts"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "bin"
18
+ ],
19
+ "scripts": {
20
+ "check": "check",
21
+ "dev": "bun bin/aframe.ts demo",
22
+ "build": "bun bin/aframe.ts build demo",
23
+ "preview": "bun --cwd demo/.output server-entry.js"
24
+ },
25
+ "dependencies": {},
26
+ "devDependencies": {
27
+ "@aklinker1/check": "^1.4.5",
28
+ "@prerenderer/renderer-puppeteer": "1.2.4",
29
+ "@types/bun": "latest",
30
+ "oxlint": "^0.15.11",
31
+ "prettier": "^3.5.2",
32
+ "publint": "^0.3.6",
33
+ "puppeteer": "^24.2.1",
34
+ "typescript": "^5.0.0",
35
+ "vite": "^6.1.1"
36
+ },
37
+ "peerDependencies": {
38
+ "vite": "*",
39
+ "@prerenderer/prerenderer": "*",
40
+ "@prerenderer/renderer-jsdom": "*",
41
+ "@prerenderer/renderer-puppeteer": "*"
42
+ },
43
+ "peerDependenciesMeta": {
44
+ "@prerenderer/prerenderer-jsdom": {
45
+ "optional": true
46
+ },
47
+ "@prerenderer/renderer-puppeteer": {
48
+ "optional": true
49
+ }
50
+ }
51
+ }
package/src/color.ts ADDED
@@ -0,0 +1,10 @@
1
+ export const RESET = "\x1b[0m";
2
+ export const BOLD = "\x1b[1m";
3
+ export const DIM = "\x1b[2m";
4
+ export const UNDERLINE = "\x1b[4m";
5
+ export const RED = "\x1b[31m";
6
+ export const GREEN = "\x1b[32m";
7
+ export const YELLOW = "\x1b[33m";
8
+ export const BLUE = "\x1b[34m";
9
+ export const MAGENTA = "\x1b[35m";
10
+ export const CYAN = "\x1b[36m";
package/src/env.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ import "vite/client";
2
+
3
+ declare global {
4
+ interface ImportMeta {
5
+ publicDir: string;
6
+ command: string;
7
+ }
8
+ }
9
+
10
+ export {};
package/src/mod.ts ADDED
@@ -0,0 +1,295 @@
1
+ import * as vite from "vite";
2
+ import Prerenderer from "@prerenderer/prerenderer";
3
+ import type { PrerendererOptions } from "@prerenderer/prerenderer";
4
+ import { resolve, join, relative } from "node:path/posix";
5
+ import { mkdir, rm, writeFile } from "node:fs/promises";
6
+ import { lstatSync } from "node:fs";
7
+ import type { BunPlugin } from "bun";
8
+ import { RESET, DIM, GREEN, BLUE, MAGENTA, CYAN, BOLD } from "./color";
9
+ import { createTimer } from "./timer";
10
+
11
+ export async function createServer(
12
+ config: ResolvedConfig,
13
+ ): Promise<vite.ViteDevServer> {
14
+ return await vite.createServer(config.vite);
15
+ }
16
+
17
+ export async function build(config: ResolvedConfig) {
18
+ const buildTimer = createTimer();
19
+
20
+ // Clear outDir
21
+ await rm(config.outDir, { force: true, recursive: true });
22
+ await mkdir(config.outDir, { recursive: true });
23
+
24
+ const appTimer = createTimer();
25
+ console.log(
26
+ `${BOLD}${CYAN}ℹ${RESET} Building ${CYAN}./app${RESET} with ${GREEN}Vite ${vite.version}${RESET}`,
27
+ );
28
+ const { output: app } = (await vite.build(
29
+ config.vite,
30
+ )) as vite.Rollup.RollupOutput;
31
+ console.log(`${GREEN}✔${RESET} Built in ${appTimer()}`);
32
+
33
+ console.log();
34
+
35
+ const serverTimer = createTimer();
36
+ console.log(
37
+ `${BOLD}${CYAN}ℹ${RESET} Building ${CYAN}./server${RESET} with ${MAGENTA}Bun ${Bun.version}${RESET}`,
38
+ );
39
+ const server = await Bun.build({
40
+ outdir: config.serverOutDir,
41
+ sourcemap: "external",
42
+ entrypoints: [config.serverEntry],
43
+ target: "bun",
44
+ define: {
45
+ // In production, the public directory is inside the CWD
46
+ "import.meta.publicDir": `"public"`,
47
+ "import.meta.command": `"build"`,
48
+ },
49
+ plugins: [aframeServerMainBunPlugin(config)],
50
+ throw: true,
51
+ });
52
+ console.log(`${GREEN}✔${RESET} Built in ${serverTimer()}`);
53
+
54
+ console.log();
55
+
56
+ const prerenderTimer = createTimer();
57
+ console.log(
58
+ `${BOLD}${CYAN}ℹ${RESET} Prerendering...\n` +
59
+ config.prerenderedRoutes
60
+ .map((route) => ` ${DIM}-${RESET} ${CYAN}${route}${RESET}`)
61
+ .join("\n"),
62
+ );
63
+ const prerenderer = new Prerenderer(await config.prerenderer());
64
+ const prerendered = await prerenderer
65
+ .initialize()
66
+ .then(() => prerenderer.renderRoutes(config.prerenderedRoutes))
67
+ .then((renderedRoutes) =>
68
+ Promise.all(
69
+ renderedRoutes.map(async (route) => {
70
+ const dir = join(config.prerenderToDir, route.route);
71
+ const file = join(dir, "index.html");
72
+ await mkdir(dir, { recursive: true });
73
+ await writeFile(file, route.html.trim());
74
+ return {
75
+ ...route,
76
+ file,
77
+ };
78
+ }),
79
+ ),
80
+ )
81
+ .catch((err) => {
82
+ throw err;
83
+ })
84
+ .finally(() => {
85
+ prerenderer.destroy();
86
+ });
87
+ console.log(`${GREEN}✔${RESET} Prerendered in ${prerenderTimer()}`);
88
+
89
+ console.log();
90
+
91
+ console.log(`${GREEN}✔${RESET} Application built in ${buildTimer()}`);
92
+ const relativeOutDir = `${relative(config.rootDir, config.outDir)}/`;
93
+ const files = [
94
+ ...server.outputs.map((output) => output.path),
95
+ ...prerendered.map((output) => output.file),
96
+ ...app
97
+ .filter((output) => output.fileName !== "index.html")
98
+ .map((output) => join(config.appOutDir, output.fileName)),
99
+ ].map((file): [file: string, size: number] => [
100
+ relative(config.outDir, file),
101
+ lstatSync(file).size,
102
+ ]);
103
+ const fileColumnCount = files.reduce(
104
+ (max, [file]) => Math.max(file.length, max),
105
+ 0,
106
+ );
107
+ const totalSize = files.reduce((sum, [_, bytes]) => sum + bytes, 0);
108
+ console.log(
109
+ files
110
+ .map(([file, size], i, array) => {
111
+ const boxChar = i === array.length - 1 ? "└" : "├";
112
+ return ` ${DIM}${boxChar}─ ${relativeOutDir}${RESET}${getColor(file)}${file.padEnd(fileColumnCount)}${RESET} ${DIM}${prettyBytes(size)}${RESET}`;
113
+ })
114
+ .join("\n"),
115
+ );
116
+ console.log(`${CYAN}Σ Total size:${RESET} ${prettyBytes(totalSize)}`);
117
+ console.log();
118
+ }
119
+
120
+ function aframeServerMainBunPlugin(config: ResolvedConfig): BunPlugin {
121
+ return {
122
+ name: "aframe:resolve-server-main",
123
+ setup(bun) {
124
+ bun.onResolve({ filter: /^aframe:server-main$/ }, () => ({
125
+ path: config.serverModule,
126
+ }));
127
+ },
128
+ };
129
+ }
130
+
131
+ function getColor(file: string) {
132
+ if (file.endsWith(".js")) return CYAN;
133
+ if (file.endsWith(".html")) return GREEN;
134
+ if (file.endsWith(".css")) return MAGENTA;
135
+ if (file.endsWith(".js.map")) return DIM;
136
+ return BLUE;
137
+ }
138
+
139
+ function prettyBytes(bytes: number) {
140
+ const units = ["B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
141
+ const base = 1024;
142
+ if (bytes === 0) return "0 B";
143
+ const exponent = Math.floor(Math.log(bytes) / Math.log(base));
144
+ const unit = units[exponent];
145
+ const value = bytes / Math.pow(base, exponent);
146
+ return `${unit === "B" ? value : value.toFixed(2)} ${unit}`;
147
+ }
148
+
149
+ ///
150
+ /// CONFIG
151
+ ///
152
+
153
+ export type UserConfig = {
154
+ vite?: vite.UserConfigExport;
155
+ prerenderedRoutes?: string[];
156
+ prerenderer?: PrerendererOptions;
157
+ };
158
+
159
+ export type ResolvedConfig = {
160
+ rootDir: string;
161
+ appDir: string;
162
+ publicDir: string;
163
+ serverDir: string;
164
+ serverModule: string;
165
+ serverEntry: string;
166
+ prerenderToDir: string;
167
+ outDir: string;
168
+ serverOutDir: string;
169
+ appOutDir: string;
170
+ appPort: number;
171
+ serverPort: number;
172
+ vite: vite.InlineConfig;
173
+ prerenderedRoutes: string[];
174
+ prerenderer: () => Promise<PrerendererOptions>;
175
+ };
176
+
177
+ export function defineConfig(config: UserConfig): UserConfig {
178
+ return config;
179
+ }
180
+
181
+ export async function resolveConfig(
182
+ root: string | undefined,
183
+ command: "build" | "serve",
184
+ mode: string,
185
+ ): Promise<ResolvedConfig> {
186
+ const rootDir = root ? resolve(root) : process.cwd();
187
+ const appDir = join(rootDir, "app");
188
+ const serverDir = join(rootDir, "server");
189
+ const serverModule = join(serverDir, "main.ts");
190
+ const serverEntry = join(import.meta.dir, "server-entry.ts");
191
+ const publicDir = join(rootDir, "public");
192
+ const outDir = join(rootDir, ".output");
193
+ const appOutDir = join(outDir, "public");
194
+ const serverOutDir = outDir;
195
+ const prerenderToDir = appOutDir;
196
+ const appPort = 3000;
197
+ const serverPort = 3001;
198
+
199
+ // Ensure required directories exist
200
+ await mkdir(prerenderToDir, { recursive: true });
201
+
202
+ const configFile = join(rootDir, "aframe.config"); // No file extension to resolve any JS/TS file
203
+ const relativeConfigFile = "./" + relative(import.meta.dir, configFile);
204
+ const { default: userConfig }: { default: UserConfig } = await import(
205
+ relativeConfigFile
206
+ );
207
+
208
+ let viteConfig = await vite.defineConfig((await userConfig.vite) ?? {});
209
+ if (typeof viteConfig === "function") {
210
+ viteConfig = await viteConfig({ command, mode });
211
+ }
212
+ // Apply opinionated config that can be overwritten
213
+
214
+ viteConfig = vite.mergeConfig<vite.InlineConfig, vite.InlineConfig>(
215
+ // Defaults
216
+ {
217
+ build: {
218
+ emptyOutDir: true,
219
+ },
220
+ },
221
+ // Overrides
222
+ viteConfig,
223
+ );
224
+
225
+ // Override required config
226
+ viteConfig = vite.mergeConfig<vite.InlineConfig, vite.InlineConfig>(
227
+ // Defaults
228
+ viteConfig,
229
+ // Overrides
230
+ {
231
+ logLevel: "warn",
232
+ configFile: false,
233
+ root: appDir,
234
+ publicDir,
235
+ build: {
236
+ outDir: appOutDir,
237
+ },
238
+ server: {
239
+ port: appPort,
240
+ strictPort: true,
241
+ proxy: {
242
+ "/api": {
243
+ target: `http://localhost:${serverPort}`,
244
+ changeOrigin: true,
245
+ },
246
+ },
247
+ },
248
+ },
249
+ );
250
+
251
+ const prerenderer = async (): Promise<PrerendererOptions> => {
252
+ const rendererModule =
253
+ tryResolve("@prerenderer/renderer-puppeteer") ??
254
+ tryResolve("@prerenderer/renderer-jsdom");
255
+ if (!rendererModule)
256
+ throw Error(
257
+ `No renderer installed. Did you forget to install @prerenderer/renderer-puppeteer or @prerenderer/renderer-jsdom?`,
258
+ );
259
+
260
+ const { default: Renderer } = await import(rendererModule);
261
+ const renderer = new Renderer();
262
+ return {
263
+ ...userConfig.prerenderer,
264
+ renderer,
265
+ staticDir: appOutDir,
266
+ };
267
+ };
268
+
269
+ return {
270
+ rootDir,
271
+ appDir,
272
+ publicDir,
273
+ serverDir,
274
+ serverModule,
275
+ serverEntry,
276
+ outDir,
277
+ serverOutDir,
278
+ appOutDir,
279
+ prerenderToDir,
280
+ appPort,
281
+ serverPort,
282
+
283
+ prerenderedRoutes: userConfig.prerenderedRoutes ?? ["/"],
284
+ prerenderer,
285
+ vite: viteConfig,
286
+ };
287
+ }
288
+
289
+ function tryResolve(specifier: string): string | undefined {
290
+ try {
291
+ return import.meta.resolve(specifier);
292
+ } catch {
293
+ return undefined;
294
+ }
295
+ }
@@ -0,0 +1,6 @@
1
+ // This import is resolved by a Bun plugin pointing to your project's `server/main.ts` file.
2
+ import server from "aframe:server-main";
3
+
4
+ const port = Number(process.env.PORT) || 3000;
5
+ console.log(`Server running @ http://localhost:${port}`);
6
+ server.listen(port);
package/src/server.ts ADDED
@@ -0,0 +1,40 @@
1
+ import type { BunFile } from "bun";
2
+
3
+ export interface AframeServer {
4
+ listen(port: number): void | never;
5
+ }
6
+
7
+ /**
8
+ * Fetches a file from the `public` directory.
9
+ */
10
+ export async function fetchStatic(request: Request): Promise<Response> {
11
+ const path = new URL(request.url).pathname.replace(/\/+$/, "");
12
+ if (!path) return fetchRootHtml();
13
+
14
+ const exactFile = Bun.file(`${import.meta.publicDir}${path}`);
15
+ if (await isFile(exactFile)) return new Response(exactFile);
16
+
17
+ const htmlFile = Bun.file(`${import.meta.publicDir}${path}/index.html`);
18
+ if (await isFile(htmlFile)) return new Response(exactFile);
19
+
20
+ return fetchRootHtml();
21
+ }
22
+
23
+ function fetchRootHtml() {
24
+ if (import.meta.command === "serve") {
25
+ return Response.json(
26
+ { status: 404, error: "Root index.html not served during development" },
27
+ { status: 404 },
28
+ );
29
+ }
30
+
31
+ return new Response(Bun.file(`${import.meta.publicDir}/index.html`));
32
+ }
33
+
34
+ async function isFile(file: BunFile): Promise<boolean> {
35
+ try {
36
+ return (await file.stat()).isFile();
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
package/src/timer.ts ADDED
@@ -0,0 +1,8 @@
1
+ export function createTimer() {
2
+ const start = performance.now();
3
+ return () => {
4
+ const duration = performance.now() - start;
5
+ if (duration > 1e3) return `${(duration / 1e3).toFixed(3)} s`;
6
+ return `${duration.toFixed(3)} ms`;
7
+ };
8
+ }
@@ -0,0 +1,4 @@
1
+ declare module "aframe:server-main" {
2
+ const server: import("./server").AframeServer;
3
+ export default server;
4
+ }