Author SHA1 Message Date
tbenandClaude Opus 5.5 33145bea9e 1.4.1: nested pages dirs, CommonJS require of react in client bundles
- Page URL paths are now relative to src/pages. The old greedy
  `.*/pages/` cut mapped src/pages/admin/pages/index.tsx to /index, so
  every page under a nested `pages` directory collided with the home page
  and was never bundled. SSR also tells API routes by URL path now.
- CommonJS `require("react…")` in client bundles (for example
  use-sync-external-store/shim, pulled in by MUI) threw "Dynamic require
  of "react" is not supported" in the browser. Those requires now go
  through a small bundled ESM module that imports the external.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 08:37:44 +01:00
tbenandClaude Opus 5.5 96e392f556 1.4.0: not_found, server-only response options, compression
- Server functions can return not_found: true to render the 404 page
  with status 404.
- Page components and window.__PAGE_PROPS__ (and HMR updates) only get
  props, query, url and error. response_options, redirect, cache settings
  and res_transform stay on the server, so cookies and private headers no
  longer end up in the HTML. BunextPageProps is narrowed to match
  (breaking for code that read server-only fields from component props).
- response_options headers go through a Headers object, so repeated
  headers such as several Set-Cookie survive.
- Redirects: status_code wins over permanent; response_options headers
  and res_transform apply to redirects too.
- Gzip for compressible text responses; compress: false to turn it off.
- Files the router matches but never bundles (.server.ts, __root,
  excluded paths and directories) return 404 instead of looping through
  dev rebuild retries.
- Missing public files return 404 rather than 403, so dotted page paths
  like /users/john.doe fall through to pages again.
- importFresh detects rewrites within the filesystem's mtime granularity.
- README: self-hosted React (not esm.sh), snake_case option names, static
  file serving, cache headers, the new options.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:14:56 +01:00
tbenandClaude Opus 5.5 b410b92f9f Make the dist directory configurable
Set it with `dist_dir` in bunext.config.ts or `--dist-dir` on any CLI
command (precedence: --dist-dir > BUNEXT_DIST_DIR > dist_dir > .bunext).
setupDistDir() resolves it in the CLI's preAction hook and at the start
of bunextInit(), so custom servers get the config value too.

- Read dist-derived paths from grabDirNames() inside functions instead of
  at import time; a guard test enforces this.
- Derive the client asset URL prefix from the dist dir instead of
  hard-coding /.bunext.
- Make the dev watcher ignore rules and probe follow the dist dir.
- Validate the dir: inside the project root, outside src/, public/ and
  node_modules/, URL-safe characters only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:55:54 +01:00
116 changed files with 2180 additions and 292 deletions
+3 -1
View File
@@ -65,4 +65,6 @@ API routes live in `src/pages/api/` and follow standard Bun `Request → Respons
`bunext build` also updates `.bunext/page-lastmod.json` (`src/utils/page-lastmod-record.ts`): per page, a fingerprint of the bundle hash + CSS path and the build date it last changed. The sitemap uses these dates as `lastmod`.
### Config
Consumer projects define `bunext.config.ts` at their project root. The `BunextConfig` type fields: `distDir`, `assetsPrefix`, `origin`, `globalVars`, `port`, `development`.
Consumer projects define `bunext.config.ts` at their project root. The `BunextConfig` type fields include `dist_dir`, `assets_prefix`, `origin`, `global_vars`, `port`, `development`.
The dist directory (default `.bunext`) is resolved by `setupDistDir()` (`src/functions/setup-dist-dir.ts`): `--dist-dir` > `BUNEXT_DIST_DIR` env > config `dist_dir`. It runs in the CLI's `preAction` hook and at the start of `bunextInit()` (so custom servers get it too), and stores the result in `BUNEXT_DIST_DIR`, which `grabDirNames()` reads and child processes inherit. Because it runs after modules are imported, **never read a dist-derived path from `grabDirNames()` at module level** — call it inside the function that uses it. Only root-derived paths (`ROOT_DIR`, `PAGES_DIR`, `PUBLIC_DIR`, ...) may be read at module level; `src/__tests__/utils/grab-dist-dir.test.ts` enforces this. Client bundle URLs are their project-relative paths (`grabBunextPublicUrlPath()`), so never hard-code `/.bunext` in URLs or paths.
+105 -40
View File
@@ -28,6 +28,7 @@ The goal is a framework that is:
- [Server Function](#server-function)
- [Default Server Props](#default-server-props)
- [Redirects from Server](#redirects-from-server)
- [Not Found from Server](#not-found-from-server)
- [Custom Response Options](#custom-response-options)
- [SEO Metadata](#seo-metadata)
- [Dynamic Metadata](#dynamic-metadata)
@@ -160,7 +161,7 @@ bun run dev
| Command | Description |
| -------------- | ------------------------------------------------------------------------------ |
| `bunext dev` | Start the development server with HMR and file watching. |
| `bunext build` | Bundle all pages for production. Outputs artifacts to `.bunext/public/pages/` using paths relative to the project root. |
| `bunext build` | Bundle all pages for production. Outputs artifacts to `.bunext/public/pages/` (or the [dist directory](#dist-directory)) using paths relative to the project root. |
| `bunext start` | Start the production server using pre-built artifacts. |
| `bunext sitemap` | Generate `public/sitemap.xml` and `public/robots.txt` from your pages, using the `sitemap` and `robots` options in `bunext.config.ts`. See [Sitemap](#sitemap). |
@@ -197,6 +198,19 @@ bunext start
> **Note:** `bunext start` will exit with an error if `.bunext/public/pages/map.json` does not exist. Always run `bunext build` (or `bun run build`) before `bunext start`.
### Dist Directory
Build artifacts go to `.bunext/` by default. To use another directory, set `dist_dir` in `bunext.config.ts` or pass `--dist-dir` to any command (the flag wins over the config):
```bash
bunext build --dist-dir build
bunext start --dist-dir build
```
Use the same directory for every command: `bunext start` serves what `bunext build` wrote there, and `bunext sitemap` reads `page-lastmod.json` from it. Setting `dist_dir` in the config keeps them in sync. [Custom servers](#custom-server) read it too, through `bunextInit()`.
The directory must be inside the project root and outside `src/`, `public/` and `node_modules/`. Its path is also the URL path of the client bundles (e.g. `/build/public/pages/...`), so it may only contain letters, digits, `.`, `_`, `-` and `/`.
---
## Project Structure
@@ -301,7 +315,7 @@ const server: BunextPageServerFn<{
// ctx.req — the raw Request object
// ctx.url — the parsed URL
// ctx.query — query string parameters
// ctx.resTransform — optional response interceptor
// ctx.res_transform — optional response interceptor
const username = "alice";
const bio = "Software engineer";
@@ -345,7 +359,11 @@ The server function receives a `ctx` object (type `BunxRouteParams`) with:
| `url` | `URL` | Parsed URL |
| `body` | `any` | Parsed request body |
| `query` | `any` | Query string parameters |
| `resTransform` | `(res: Response) => Promise<Response>\|Response` | Intercept and transform the final response |
| `res_transform` | `(res: Response) => Promise<Response>\|Response` | Intercept and transform the final response |
### What the Component Receives
The page and root components receive `props`, `query`, `url` and `error` from the server function's result. The same object is serialized into the HTML as `window.__PAGE_PROPS__` for hydration, so treat everything in it as public. The rest of the result (`response_options`, `redirect`, `not_found`, `cache_page`, `cache_expiry`, `res_transform`) is server-only: it controls the response and never reaches the browser.
### Default Server Props
@@ -401,8 +419,8 @@ const server: BunextPageServerFn = async (ctx) => {
return {
redirect: {
destination: "/login",
permanent: false, // uses 302
// status_code: 307 // override status code
// permanent: true, // 301 instead of 302
// status_code: 307, // any status; wins over `permanent`
},
};
}
@@ -413,7 +431,28 @@ const server: BunextPageServerFn = async (ctx) => {
export default server;
```
`permanent: true` sends a `301` redirect. Otherwise it sends `302`, or the value of `status_code` if provided.
The status is `status_code` if set, otherwise `301` with `permanent: true`, otherwise `302`. Use `307`/`308` to keep the request method and body. Headers from `response_options` (e.g. a `Set-Cookie` after login) are sent with the redirect.
### Not Found from Server
Return `not_found: true` to answer with the 404 page and status `404`, exactly as if the route didn't exist. Use it for dynamic routes whose record doesn't exist:
```ts
// src/pages/blog/[slug].server.ts
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
const server: BunextPageServerFn = async (ctx) => {
const post = await findPost(ctx.query?.slug);
if (!post) {
return { not_found: true };
}
return { props: { post } };
};
export default server;
```
### Custom Response Options
@@ -426,7 +465,7 @@ import type { BunextPageServerFn } from "@moduletrace/bunext/types";
const server: BunextPageServerFn = async (ctx) => {
return {
props: { message: "Created" },
responseOptions: {
response_options: {
status: 201,
headers: {
"X-Custom-Header": "my-value",
@@ -438,6 +477,20 @@ const server: BunextPageServerFn = async (ctx) => {
export default server;
```
`response_options` is server-only: it is never serialized into the page, so it's safe for cookies and other private headers. A plain headers object holds each name once; to send a header several times, such as multiple cookies, pass a `Headers` object or an array of pairs:
```ts
return {
props: {},
response_options: {
headers: [
["Set-Cookie", "session=abc; HttpOnly; Secure; Path=/"],
["Set-Cookie", "theme=dark; Path=/"],
],
},
};
```
### SEO Metadata
Export a `meta` object from the **page file** (not the server file) to inject SEO and Open Graph tags into the `<head>`:
@@ -594,7 +647,7 @@ import type {
} from "@moduletrace/bunext/types";
export const config: BunextServerRouteConfig = {
maxRequestBodyMB: 50, // allow up to 50 MB
max_request_body_mb: 50, // allow up to 50 MB
};
export default async function handler(ctx: BunxRouteParams): Promise<Response> {
@@ -850,16 +903,17 @@ Nothing is written to disk, so the same build can be deployed to production with
## Static Files
Place static files in the `public/` directory. They are served at the `/public/` URL path:
Place static files in the `public/` directory. Every file is served under `/public/`, and files whose name has an extension are also served from the site root, like Next.js does:
```
public/
├── logo.png → http://localhost:7000/public/logo.png
├── styles.css → http://localhost:7000/public/styles.css
└── favicon.ico → http://localhost:7000/favicon.ico
├── logo.png → /public/logo.png and /logo.png
├── robots.txt → /public/robots.txt and /robots.txt
├── images/hero.webp → /public/images/hero.webp and /images/hero.webp
└── favicon.ico → /public/favicon.ico and /favicon.ico
```
> Favicons are also served from `/public/` but matched directly at `/favicon.*`.
A root path that looks like a file but has no match in `public/` falls through to the pages, so routes like `/users/john.doe` still work. Files get `Cache-Control: public, max-age=3600` in production.
---
@@ -907,8 +961,8 @@ const server: BunextPageServerFn = async (ctx) => {
return {
props: { data },
cachePage: true,
cacheExpiry: 600, // 10 minutes
cache_page: true,
cache_expiry: 600, // 10 minutes
};
};
@@ -928,17 +982,17 @@ export default function ProductsPage({ props }: any) {
}
```
If both `module.config.cachePage` and `serverRes.cachePage` are set, `module.config` takes precedence.
If both `config.cachePage` (page export) and `cache_page` (server return) are set, `config.cachePage` takes precedence.
### Cache Expiry
Expiry resolution order (first truthy value wins):
1. `cacheExpiry` on the page `config` export or `server` return (per-page, in seconds)
2. `defaultCacheExpiry` in `bunext.config.ts` (global default, in seconds)
1. `cacheExpiry` on the page `config` export, or `cache_expiry` in the `server` return (per-page, in seconds)
2. `default_cache_expiry` in `bunext.config.ts` (global default, in seconds)
3. Built-in default: **3600 seconds (1 hour)**
The cron job checks all cache entries every 30 seconds and deletes any whose age exceeds their expiry. Static bundled assets (JS/CSS in `.bunext/public/`) receive a separate HTTP `Cache-Control: public, max-age=604800` header (7 days) via the browser cache — this is independent of the page HTML cache.
The cron job checks all cache entries every 30 seconds and deletes any whose age exceeds their expiry. Bundled assets (JS/CSS in `.bunext/public/`) are served with `Cache-Control: public, max-age=3600` in production, independent of the page HTML cache.
### Cache Behavior and Limitations
@@ -961,9 +1015,9 @@ import type { BunextConfig } from "@moduletrace/bunext/types";
const config: BunextConfig = {
port: 3000, // default: 7000
origin: "https://example.com",
distDir: ".bunext", // directory for internal build artifacts
assetsPrefix: "_bunext/static",
globalVars: {
dist_dir: ".bunext", // directory for build artifacts
assets_prefix: "_bunext/static",
global_vars: {
MY_API_URL: "https://api.example.com",
},
public_envs: {
@@ -979,18 +1033,20 @@ export default config;
| -------------------- | --------------------------------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------- |
| `port` | `number` | `7000` | HTTP server port |
| `origin` | `string` | — | Canonical origin URL |
| `distDir` | `string` | `.bunext` | Internal artifact directory |
| `assetsPrefix` | `string` | `_bunext/static` | URL prefix for static assets |
| `globalVars` | `{ [k: string]: any }` | — | Variables injected globally at build time |
| `dist_dir` | `string` | `.bunext` | Build artifact directory, relative to the project root — see [Dist Directory](#dist-directory) |
| `assets_prefix` | `string` | `_bunext/static` | URL prefix for static assets |
| `global_vars` | `{ [k: string]: any }` | — | Variables injected globally at build time |
| `public_envs` | `Record<string, string>` | — | Public env vars exposed to the client via `window.process.env` (see [Environment Variables](#environment-variables)) |
| `development` | `boolean` | — | Overridden to `true` by `bunext dev` automatically |
| `defaultCacheExpiry` | `number` | `3600` | Global page cache expiry in seconds |
| `default_cache_expiry` | `number` | `3600` | Global page cache expiry in seconds |
| `middleware` | `(params: BunextConfigMiddlewareParams) => Response \| undefined \| Promise<...>` | — | Global middleware — see [Middleware](#middleware) |
| `websocket` | `WebSocketHandler<any>` | — | Bun WebSocket handler — see [WebSocket](#websocket) |
| `serverOptions` | `ServeOptions` | — | Extra options passed to `Bun.serve()` (excluding `fetch`) — see [Server Options](#server-options) |
| `server_options` | `ServeOptions` | — | Extra options passed to `Bun.serve()` (excluding `fetch`) — see [Server Options](#server-options) |
| `sitemap` | `BunextSitemapConfig` | — | Options for `bunext sitemap` — see [Sitemap](#sitemap) |
| `robots` | `BunextRobotsConfig \| false` | — | robots.txt options for `bunext sitemap`, or `false` to skip it — see [robots.txt](#robotstxt) |
| `disable_seo` | `boolean` | `false` | Keep the whole site out of search engines (e.g. preview deployments) — see [Disabling SEO](#disabling-seo) |
| `compress` | `boolean` | `true` | Gzip text responses (HTML, JS, CSS, JSON, SVG) for clients that accept it. Set `false` when a reverse proxy already compresses |
| `pages_exclude_patterns` | `RegExp[]` | — | Files and directories under `src/pages` to leave out of routing and bundling, matched against absolute paths. Requests for them get the 404 page |
| `minify_ssr_bundle` | `boolean` | `true` | Minify the SSR bundle. SSR output is served from local disk, not downloaded by browsers, so set `false` to cut build memory on constrained hosts. Never minified in dev |
### Middleware
@@ -1070,14 +1126,14 @@ export default config;
### Server Options
Pass additional options to the underlying `Bun.serve()` call via `serverOptions`. All standard [`ServeOptions`](https://bun.sh/docs/api/http) fields are accepted except `fetch`, which Bunext manages internally.
Pass additional options to the underlying `Bun.serve()` call via `server_options`. All standard [`ServeOptions`](https://bun.sh/docs/api/http) fields are accepted except `fetch`, which Bunext manages internally.
```ts
// bunext.config.ts
import type { BunextConfig } from "@moduletrace/bunext/types";
const config: BunextConfig = {
serverOptions: {
server_options: {
tls: {
cert: Bun.file("./certs/cert.pem"),
key: Bun.file("./certs/key.pem"),
@@ -1137,6 +1193,8 @@ bun run server.ts
> **Note:** When using a custom server, HMR and file watching are still driven by `bunextInit()`. Pass `development: true` in your `Bun.serve()` call to enable them.
> **Note:** `bunextInit()` reads `dist_dir` from `bunext.config.ts`, so a custom server uses the same directory as `bunext build`.
---
## Environment Variables
@@ -1144,6 +1202,7 @@ bun run server.ts
| Variable | Description |
| ---------------------- | ------------------------------------------------------------------------------------------------ |
| `PORT` | Override the server port (takes precedence over config) |
| `BUNEXT_DIST_DIR` | Build artifact directory. Overrides `dist_dir` in the config; `--dist-dir` overrides both |
| `BUNEXT_PUBLIC_*` | Any env var prefixed with `BUNEXT_PUBLIC_` is exposed to the client via `window.process.env` |
### Public Environment Variables
@@ -1210,7 +1269,7 @@ The bundler uses **ESBuild** with a virtual namespace plugin that generates in-m
In development, an `esbuild.context()` is created once and rebuilt incrementally whenever the FS watcher detects a file change. In production, a single `esbuild.build()` call runs with minification enabled.
React is loaded externally — `react`, `react-dom`, `react-dom/client`, and `react/jsx-runtime` are all marked as external in the ESBuild config. The correct React version is resolved from the framework's own `node_modules` at startup and injected into every HTML page via a `<script type="importmap">` pointing at `esm.sh`. This guarantees a single shared React instance across all page bundles and HMR updates regardless of project size.
React is loaded externally — `react`, `react-dom`, `react-dom/client`, and `react/jsx-runtime` are all marked as external in the ESBuild config. At startup Bunext bundles the project's own `react` and `react-dom` into `.bunext/public/vendor/` and injects a `<script type="importmap">` into every page that maps the React specifiers to those files, so nothing is loaded from a CDN. This guarantees a single shared React instance across all page bundles and HMR updates regardless of project size.
After each build, ESBuild's metafile is used to map each output file back to its source page, producing a `BundlerCTXMap[]`. This map is stored in `global.BUNDLER_CTX_MAP` and written to `.bunext/public/pages/map.json`.
@@ -1246,33 +1305,39 @@ Request
│
├── /api/* → API route handler
│ Matches src/pages/api/<path>.ts
│ Checks content-length against maxRequestBodyMB / 10 MB default
│ Checks content-length against max_request_body_mb / 10 MB default
│ Calls module.default(ctx) → returns Response directly
│
├── /public/* → Serve static file from public/
│
├── /favicon.* → Serve favicon from public/
├── /<path>.<ext> → Serve public/<path>.<ext> if it exists,
│ otherwise fall through to the pages
│
└── Everything else → Server-side render a page
[Production only] Check .bunext/public/cache/ for key = pathname + search
Cache HIT → return cached HTML with X-Bunext-Cache: HIT header
Cache MISS → continue ↓
1. Match route via FileSystemRouter
1. Match route via FileSystemRouter (404 for .server.ts, __root, excluded files)
2. Find bundled artifact in BUNDLER_CTX_MAP
3. Import page module (with cache-busting timestamp in dev)
3. Import page module (re-imported when the file changes in dev)
4. Import companion server module (<page>.server.ts/tsx) if it exists; run its exported function for server-side data
redirect → redirect response; not_found → 404 page
5. Resolve meta (static object or async function)
6. renderToString(component) → inject into HTML template
7. Inject window.__PAGE_PROPS__, hydration <script>, CSS <link>
8. If module.config.cachePage or serverRes.cachePage → write to cache
9. Return HTML response
6. renderToReadableStream(<html> document)
7. Inject window.__PAGE_PROPS__ (client-safe props only), hydration <script>, CSS <link>
8. If config.cachePage or cache_page → write to cache
9. Return HTML response with response_options status/headers
On error → render 404 or 500 error page
Every response from the branches above (not middleware short-circuits) is
then gzipped if it is compressible text and the client accepts gzip,
unless `compress: false`.
```
Server-rendered HTML includes:
- `window.__PAGE_PROPS__` — the serialized server function return value, read by `hydrateRoot` on the client.
- A `<script type="importmap">` mapping React package specifiers to the esm.sh CDN (uses the `?dev` build in development).
- `window.__PAGE_PROPS__` — the client-safe part of the server function's result (`props`, `query`, `url`, `error`), read by `hydrateRoot` on the client.
- A `<script type="importmap">` mapping React package specifiers to the self-hosted builds in `.bunext/public/vendor/` (development builds in development).
- A `<script type="module" async>` tag pointing to the page's bundled client script.
- A `<link rel="stylesheet">` tag if the bundler emitted a CSS file for the page.
- In development: the HMR client script.
+3 -1
View File
@@ -1,4 +1,5 @@
import { Command } from "commander";
import { DistDirOptionDescription, DistDirOptionFlags, } from "../dist-dir-option";
import { log } from "../../utils/log";
import grabDirNames from "../../utils/grab-dir-names";
import { rmSync, writeFileSync } from "fs";
@@ -6,11 +7,12 @@ import bunextInit from "../../functions/bunext-init";
import { v4 } from "uuid";
import { updatePageLastmodRecord } from "../../utils/page-lastmod-record";
import disposeBundlerContexts from "../../functions/bundler/dispose-bundler-contexts";
const { HYDRATION_DST_DIR, BUNX_CWD_PAGES_REWRITE_DIR, BUNDLER_CTX_MAP_JSON, SSR_BUNDLER_CTX_MAP_JSON, BUNX_CWD_MODULE_CACHE_DIR, BUILD_INFO_JSON, } = grabDirNames();
export default function () {
return new Command("build")
.description("Build Project")
.option(DistDirOptionFlags, DistDirOptionDescription)
.action(async () => {
const { HYDRATION_DST_DIR, BUNX_CWD_PAGES_REWRITE_DIR, BUNDLER_CTX_MAP_JSON, SSR_BUNDLER_CTX_MAP_JSON, BUNX_CWD_MODULE_CACHE_DIR, BUILD_INFO_JSON, } = grabDirNames();
try {
rmSync(HYDRATION_DST_DIR, { recursive: true });
rmSync(BUNX_CWD_PAGES_REWRITE_DIR, { recursive: true });
+2 -1
View File
@@ -4,7 +4,6 @@ import bunextInit from "../../functions/bunext-init";
import grabDirNames from "../../utils/grab-dir-names";
import writeErrorFile from "../../functions/write-error-file";
import { rmSync } from "fs";
const { HYDRATION_DST_DIR, BUNX_CWD_PAGES_REWRITE_DIR } = grabDirNames();
// The supervisor only restarts the dev server when this process actually
// exits with a code. Registering a listener that merely logs (the previous
// behavior) keeps Bun from exiting: the crash is swallowed, no error file is
@@ -22,6 +21,8 @@ process.on("unhandledRejection", (reason) => {
});
log.info("Running development server ...");
try {
// The dist directory comes from the parent (`BUNEXT_DIST_DIR`).
const { HYDRATION_DST_DIR, BUNX_CWD_PAGES_REWRITE_DIR } = grabDirNames();
rmSync(HYDRATION_DST_DIR, { recursive: true });
rmSync(BUNX_CWD_PAGES_REWRITE_DIR, { recursive: true });
}
+3 -1
View File
@@ -1,15 +1,17 @@
import { Command } from "commander";
import { DistDirOptionDescription, DistDirOptionFlags, } from "../dist-dir-option";
import path from "path";
import grabDirNames from "../../utils/grab-dir-names";
import writeErrorFile from "../../functions/write-error-file";
import { existsSync, rmSync } from "fs";
const { BUNDLER_CTX_MAP_JSON, SSR_BUNDLER_CTX_MAP_JSON, BUNX_BUNDLER_ERROR_EXIT_FILE, } = grabDirNames();
const MAX_RETRIES = 5;
const RETRY_RESET_AFTER_MS = 10000;
export default function () {
return new Command("dev")
.description("Run development server")
.option(DistDirOptionFlags, DistDirOptionDescription)
.action(async () => {
const { BUNDLER_CTX_MAP_JSON, SSR_BUNDLER_CTX_MAP_JSON, BUNX_BUNDLER_ERROR_EXIT_FILE, } = grabDirNames();
try {
rmSync(SSR_BUNDLER_CTX_MAP_JSON, { recursive: true });
rmSync(BUNDLER_CTX_MAP_JSON, { recursive: true });
+2
View File
@@ -0,0 +1,2 @@
export declare const DistDirOptionFlags = "--dist-dir <path>";
export declare const DistDirOptionDescription: string;
+3
View File
@@ -0,0 +1,3 @@
export const DistDirOptionFlags = "--dist-dir <path>";
export const DistDirOptionDescription = "Build artifact directory, relative to the project root " +
"(default: `dist_dir` from bunext.config.ts, else .bunext)";
+16 -1
View File
@@ -5,6 +5,7 @@ import dev from "./dev";
import build from "./build";
import sitemap from "./sitemap";
import { log } from "../utils/log";
import setupDistDir from "../functions/setup-dist-dir";
/**
* # Describe Program
*/
@@ -19,6 +20,20 @@ program.addCommand(dev());
program.addCommand(start());
program.addCommand(build());
program.addCommand(sitemap());
/**
* # Resolve the Dist Directory
* Before any command runs, from `--dist-dir` or the config. Child server
* processes inherit it through `BUNEXT_DIST_DIR`.
*/
program.hook("preAction", async (_program, action_command) => {
try {
await setupDistDir({ dist_dir: action_command.opts().distDir });
}
catch (error) {
log.error(error?.message || String(error));
process.exit(1);
}
});
/**
* # Handle Unavailable Commands
*/
@@ -31,4 +46,4 @@ program.on("command:*", () => {
/**
* # Parse Arguments
*/
program.parse(Bun.argv);
await program.parseAsync(Bun.argv);
+2
View File
@@ -1,4 +1,5 @@
import { Command } from "commander";
import { DistDirOptionDescription, DistDirOptionFlags, } from "../dist-dir-option";
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "fs";
import path from "path";
import { log } from "../../utils/log";
@@ -15,6 +16,7 @@ export default function () {
.option("-o, --output <path>", "Output file relative to the project root (default: public/sitemap.xml)")
.option("--origin <url>", "Base URL for sitemap links")
.option("--no-robots", "Skip generating robots.txt")
.option(DistDirOptionFlags, DistDirOptionDescription)
.action(async (options) => {
const { ROOT_DIR, PUBLIC_DIR } = grabDirNames();
global.BUNEXT_CONFIG = (await grabConfig()) || {};
+3 -1
View File
@@ -1,15 +1,17 @@
import { Command } from "commander";
import { DistDirOptionDescription, DistDirOptionFlags, } from "../dist-dir-option";
import path from "path";
import writeErrorFile from "../../functions/write-error-file";
import grabDirNames from "../../utils/grab-dir-names";
import { existsSync, rmSync } from "fs";
const { BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames();
const MAX_RETRIES = 5;
const RETRY_RESET_AFTER_MS = 10000;
export default function () {
return new Command("start")
.description("Start production server")
.option(DistDirOptionFlags, DistDirOptionDescription)
.action(async () => {
const { BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames();
// A stale error file from a previous session makes the bundler
// run in "silent" mode and pages fail with misleading messages.
rmSync(BUNX_BUNDLER_ERROR_EXIT_FILE, { force: true });
@@ -1,3 +1,4 @@
import * as esbuild from "esbuild";
import type { BundlerCTXMap } from "../../types";
type Params = {
post_build_fn?: (params: {
@@ -17,4 +18,13 @@ type Params = {
*/
export declare function isHydrationEntryPage(local_path: string): boolean;
export default function allPagesESBuildContextBundler(params?: Params): Promise<void>;
/**
* CommonJS `require("react")` (e.g. `use-sync-external-store/shim`, pulled
* in by MUI) for a module that is external in ESM output becomes esbuild's
* `__require`, which throws "Dynamic require of "react" is not supported" in
* the browser. Give those requires a small bundled ESM module that imports
* the external instead; esbuild wires `require()` to it.
*/
export declare function externalRequireShim(specifier: string): string;
export declare function forceExternalReact(): esbuild.Plugin;
export {};
+28 -2
View File
@@ -10,7 +10,6 @@ import esbuildCTXArtifactTracker from "./plugins/esbuild-ctx-artifact-tracker";
import { logBuildErrorsOnce, } from "../../utils/build-error-log";
import { log } from "../../utils/log";
import grabRootFilePath from "../server/web-pages/grab-root-file-path";
const { HYDRATION_DST_DIR, BUNX_HYDRATION_SRC_DIR } = grabDirNames();
// Snapshot of the entry key set + root file presence of the last build.
// When it differs from the freshly computed set, the esbuild context must
// be recreated (entry points are fixed at context creation).
@@ -36,6 +35,7 @@ function arraysEqual(a, b) {
return true;
}
export default async function allPagesESBuildContextBundler(params) {
const { HYDRATION_DST_DIR, BUNX_HYDRATION_SRC_DIR } = grabDirNames();
try {
const pages = grabAllPages({ exclude_api: true });
global.BUNEXT_PAGE_FILES = pages;
@@ -128,18 +128,44 @@ export default async function allPagesESBuildContextBundler(params) {
], (line) => log.error(line));
}
}
function forceExternalReact() {
const ExternalRequireNamespace = "bunext-external-require";
/**
* CommonJS `require("react")` (e.g. `use-sync-external-store/shim`, pulled
* in by MUI) for a module that is external in ESM output becomes esbuild's
* `__require`, which throws "Dynamic require of "react" is not supported" in
* the browser. Give those requires a small bundled ESM module that imports
* the external instead; esbuild wires `require()` to it.
*/
export function externalRequireShim(specifier) {
const spec = JSON.stringify(specifier);
// A namespace import never fails to link, even when the vendor module has
// no default export (jsx-runtime); fall back to the namespace for
// `require(...).default` (Babel interop).
return [
`import * as ns from ${spec};`,
`export * from ${spec};`,
`export default ns.default ?? ns;`,
].join("\n");
}
export function forceExternalReact() {
return {
name: "force-external-react",
setup(build) {
build.onResolve({ filter: /^react(-dom)?(\/.*)?$/ }, (args) => {
if (args.pluginData?.externalReact)
return null;
if (args.kind === "require-call") {
return { path: args.path, namespace: ExternalRequireNamespace };
}
return {
path: args.path,
external: true,
};
});
build.onLoad({ filter: /.*/, namespace: ExternalRequireNamespace }, (args) => ({
contents: externalRequireShim(args.path),
loader: "js",
}));
},
};
}
+1 -1
View File
@@ -2,8 +2,8 @@ import grabAllPages from "../../utils/grab-all-pages";
import grabDirNames from "../../utils/grab-dir-names";
import isDevelopment from "../../utils/is-development";
import tailwindcss from "bun-plugin-tailwind";
const { BUNX_CWD_MODULE_CACHE_DIR } = grabDirNames();
export default async function apiRoutesBundler() {
const { BUNX_CWD_MODULE_CACHE_DIR } = grabDirNames();
const api_routes = grabAllPages({ api_only: true });
const dev = isDevelopment();
try {
-2
View File
@@ -1,10 +1,8 @@
import * as esbuild from "esbuild";
import grabAllPages from "../../utils/grab-all-pages";
import grabDirNames from "../../utils/grab-dir-names";
import isDevelopment from "../../utils/is-development";
import tailwindEsbuildPlugin from "../server/web-pages/tailwind-esbuild-plugin";
import apiRoutesCTXArtifactTracker from "./plugins/api-routes-ctx-artifact-tracker";
const { BUNX_CWD_MODULE_CACHE_DIR } = grabDirNames();
export default async function apiRoutesContextBundler() {
const pages = grabAllPages({ api_only: true });
const dev = isDevelopment();
+3 -2
View File
@@ -1,8 +1,8 @@
import grabDirNames from "../../utils/grab-dir-names";
import grabBunextPublicUrlPath from "../../utils/grab-bunext-public-url-path";
import isDevelopment from "../../utils/is-development";
import path from "path";
import { rmSync, mkdirSync, writeFileSync } from "fs";
const { BUNEXT_VENDOR_DIR, BUNX_CWD_DIR } = grabDirNames();
const VENDOR_ENTRIES = {
react: `
import React from "react";
@@ -39,6 +39,7 @@ const VENDOR_ENTRIES = {
`,
};
export default async function bunReactModulesBundler() {
const { BUNEXT_VENDOR_DIR } = grabDirNames();
const dev = isDevelopment();
rmSync(BUNEXT_VENDOR_DIR, { force: true, recursive: true });
const tmpDir = path.join(BUNEXT_VENDOR_DIR, "_tmp");
@@ -61,7 +62,7 @@ export default async function bunReactModulesBundler() {
},
});
rmSync(tmpDir, { force: true, recursive: true });
const PUBLIC_ROOT = BUNEXT_VENDOR_DIR.replace(BUNX_CWD_DIR, "/.bunext");
const PUBLIC_ROOT = `${grabBunextPublicUrlPath()}/vendor`;
global.BUNEXT_REACT_IMPORTS_MAP = {
imports: {
react: `${PUBLIC_ROOT}/react.js`,
+3 -2
View File
@@ -14,7 +14,6 @@ import path from "path";
import { log } from "../../utils/log";
import { logBuildErrorsOnce } from "../../utils/build-error-log";
import shouldMinifySSR from "../../utils/should-minify-ssr";
const { BUNX_CWD_MODULE_CACHE_DIR, BUNX_TMP_DIR } = grabDirNames();
// Current virtual entry contents. The SSR virtual plugin resolves its
// onLoad contents lazily from this map on every rebuild, so edits to
// `.server.ts`/api files (whose entries mirror their raw file content) are
@@ -36,6 +35,7 @@ function arraysEqual(a, b) {
return true;
}
async function computeEntryToPage() {
const { BUNX_TMP_DIR } = grabDirNames();
const pages = grabAllPages({
include_server: true,
});
@@ -46,7 +46,7 @@ async function computeEntryToPage() {
}
catch (error) { }
for (const page of pages) {
if (page.local_path.match(/\/pages\/api\//) ||
if (page.url_path.startsWith("/api/") ||
page.local_path.match(/\.server\.tsx?$/)) {
const ts = await Bun.file(page.local_path).text();
if (ts.match(/(export default)|(export \w+ handler)|(export \w+ server)/)) {
@@ -98,6 +98,7 @@ export async function awaitSSRRebuild() {
catch (error) { }
}
async function doSSRRebuild(params) {
const { BUNX_CWD_MODULE_CACHE_DIR, BUNX_TMP_DIR } = grabDirNames();
const force = Boolean(params?.force);
const dev = isDevelopment();
const config = global.BUNEXT_CONFIG;
+3 -3
View File
@@ -2,7 +2,7 @@
import * as esbuild from "esbuild";
import path from "path";
import grabDirNames from "../../../utils/grab-dir-names";
const { BUNEXT_VENDOR_DIR } = grabDirNames();
import grabBunextPublicUrlPath from "../../../utils/grab-bunext-public-url-path";
const REACT_MODULES = new Set([
"react",
"react-dom",
@@ -10,7 +10,6 @@ const REACT_MODULES = new Set([
"react/jsx-runtime",
"react/jsx-dev-runtime",
]);
const VENDOR_BASE = "/.bunext/public/vendor";
const REACT_ENTRIES = {
react: `
import React from "react";
@@ -52,9 +51,10 @@ const REACT_ENTRIES = {
// Map bare specifier -> browser path
function vendorPath(specifier) {
const filename = specifier.replace(/\//g, "_") + ".js";
return `${VENDOR_BASE}/${filename}`;
return `${grabBunextPublicUrlPath()}/vendor/${filename}`;
}
function vendorOutfile(specifier) {
const { BUNEXT_VENDOR_DIR } = grabDirNames();
const filename = specifier.replace(/\//g, "_") + ".js";
return path.join(BUNEXT_VENDOR_DIR, filename);
}
@@ -12,9 +12,9 @@ import cleanupLogsDirs from "../../cleanup-logs-dir";
import grabRelativePath from "../../../utils/grab-relative-path";
import cleanupArtifacts from "../../server/cleanup-artifacts";
import { pushClientUpdate } from "../../server/server-post-build-fn";
const { BUNX_BUNDLER_ERROR_EXIT_FILE, BUNX_ERROR_LOGS_DIR } = grabDirNames();
let build_start = 0;
export default function esbuildCTXArtifactTracker({ entryToPage, post_build_fn, build_only, refresh_props_on_first_build, }) {
const { BUNX_BUNDLER_ERROR_EXIT_FILE, BUNX_ERROR_LOGS_DIR } = grabDirNames();
let force_props_refresh = Boolean(refresh_props_on_first_build);
const artifactTracker = {
name: "artifact-tracker",
+1 -1
View File
@@ -7,8 +7,8 @@ import grabRelativePath from "../../../utils/grab-relative-path";
import { logBuildErrorsOnce, resetBuildErrorLog, } from "../../../utils/build-error-log";
import { log } from "../../../utils/log";
let build_start = 0;
const { BUNX_TMP_DIR } = grabDirNames();
export default function ssrCTXArtifactTracker({ entryToPage }) {
const { BUNX_TMP_DIR } = grabDirNames();
const artifactTracker = {
name: "ssr-artifact-tracker",
setup(build) {
+4 -2
View File
@@ -1,9 +1,10 @@
import * as esbuild from "esbuild";
import grabDirNames from "../../utils/grab-dir-names";
import grabBunextPublicUrlPath from "../../utils/grab-bunext-public-url-path";
import isDevelopment from "../../utils/is-development";
import path from "path";
import { rmSync, mkdirSync, writeFileSync } from "fs";
const { BUNEXT_VENDOR_DIR, BUNX_CWD_DIR, ROOT_DIR } = grabDirNames();
const { ROOT_DIR } = grabDirNames();
const VENDOR_ENTRIES = {
react: `
import React from "react";
@@ -40,6 +41,7 @@ const VENDOR_ENTRIES = {
`,
};
export default async function reactModulesBundler() {
const { BUNEXT_VENDOR_DIR } = grabDirNames();
const dev = isDevelopment();
rmSync(BUNEXT_VENDOR_DIR, { force: true, recursive: true });
const tmpDir = path.join(BUNEXT_VENDOR_DIR, "_tmp");
@@ -64,7 +66,7 @@ export default async function reactModulesBundler() {
},
});
rmSync(tmpDir, { force: true, recursive: true });
const PUBLIC_ROOT = BUNEXT_VENDOR_DIR.replace(BUNX_CWD_DIR, "/.bunext");
const PUBLIC_ROOT = `${grabBunextPublicUrlPath()}/vendor`;
global.BUNEXT_REACT_IMPORTS_MAP = {
imports: {
react: `${PUBLIC_ROOT}/react.js`,
+7 -2
View File
@@ -10,9 +10,14 @@ import grabConstants from "../utils/grab-constants";
import watcherEsbuildCTX from "./server/watcher-esbuild-ctx";
import disposeBundlerContexts from "./bundler/dispose-bundler-contexts";
import { readFileSync } from "fs";
const dirNames = grabDirNames();
const { PAGES_DIR, BUNDLER_CTX_MAP_JSON, SSR_BUNDLER_CTX_MAP_JSON } = dirNames;
import setupDistDir from "./setup-dist-dir";
export default async function bunextInit(params) {
// First: everything below reads the artifact paths. A no-op under the
// CLI, which already resolved it; a custom server gets `dist_dir` from
// the config here.
await setupDistDir();
const dirNames = grabDirNames();
const { PAGES_DIR, BUNDLER_CTX_MAP_JSON, SSR_BUNDLER_CTX_MAP_JSON } = dirNames;
const is_dev = isDevelopment();
if (!is_dev || params?.start) {
try {
+1 -1
View File
@@ -3,8 +3,8 @@ import { mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "fs";
import grabDirNames from "../utils/grab-dir-names";
import grabConstants from "../utils/grab-constants";
import { AppData } from "../data/app-data";
const { BUNX_LOGS_DIR } = grabDirNames();
export default function cleanupLogsDirs() {
const { BUNX_LOGS_DIR } = grabDirNames();
const logs_dirs = readdirSync(BUNX_LOGS_DIR);
const { config } = grabConstants();
const MAX_LOGS = config.max_logs || AppData["DefaultMaxLogs"];
+6 -1
View File
@@ -10,8 +10,10 @@ import { AppData } from "../../data/app-data";
import { log } from "../../utils/log";
import fullRebuild from "./full-rebuild";
import grabRouter from "../../utils/grab-router";
import grabBunextPublicUrlPath from "../../utils/grab-bunext-public-url-path";
import { NoSEORobotsValue } from "../../utils/disable-seo";
import generateRobotsTxt from "../../utils/generate-robots-txt";
import compressResponse from "../../utils/compress-response";
const HMR_RETRY_COOLDOWN_MS = 5000;
let lastHmrRetryTime = 0;
export default async function bunextRequestHandler({ req: initial_req, server, }) {
@@ -58,7 +60,7 @@ export default async function bunextRequestHandler({ req: initial_req, server, }
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
else if (url.pathname.startsWith("/.bunext")) {
else if (url.pathname.startsWith(`${grabBunextPublicUrlPath()}/`)) {
response = await handleBunextPublicAssets({ req });
}
else if (url.pathname.startsWith("/api/")) {
@@ -87,6 +89,9 @@ export default async function bunextRequestHandler({ req: initial_req, server, }
if (is_dev) {
response.headers.set("Cache-Control", "no-cache, no-store, must-revalidate");
}
if (global.BUNEXT_CONFIG?.compress !== false) {
response = compressResponse(req, response);
}
return response;
}
catch (error) {
+2 -1
View File
@@ -2,7 +2,7 @@ import { log } from "../../utils/log";
import path from "path";
import grabDirNames from "../../utils/grab-dir-names";
import { existsSync, readdirSync, statSync, unlinkSync } from "fs";
const { ROOT_DIR, HYDRATION_DST_DIR, BUNX_CWD_MODULE_CACHE_DIR } = grabDirNames();
const { ROOT_DIR } = grabDirNames();
// esbuild's content hashes are 8-char base64-ish (e.g. `CMSI7VSQ`). Entry
// outputs are `<dir>/<hash>.js` / `<dir>/<hash>.css`. Shared chunks are
// `chunk-*.js` and MUST be kept: any current entry may still reference an
@@ -19,6 +19,7 @@ const ENTRY_HASH_FILE = /^[A-Za-z0-9]{8}\.(js|css)$/;
* left untouched.
*/
export default async function cleanupArtifacts({ paradigm }) {
const { HYDRATION_DST_DIR, BUNX_CWD_MODULE_CACHE_DIR } = grabDirNames();
const is_hydration = paradigm === "hydration";
const root_dir = is_hydration
? HYDRATION_DST_DIR
+4 -2
View File
@@ -1,14 +1,16 @@
import grabDirNames from "../../utils/grab-dir-names";
import grabBunextPublicUrlPath from "../../utils/grab-bunext-public-url-path";
import path from "path";
import isDevelopment from "../../utils/is-development";
import { readFileResponse } from "./handle-public";
import isSafePath from "../../utils/is-safe-path";
const { BUNEXT_PUBLIC_DIR } = grabDirNames();
export default async function ({ req }) {
const { BUNEXT_PUBLIC_DIR } = grabDirNames();
const BUNEXT_PUBLIC_URL_PATH = grabBunextPublicUrlPath();
try {
const is_dev = isDevelopment();
const url = new URL(req.url);
const file_path = path.join(BUNEXT_PUBLIC_DIR, decodeURIComponent(url.pathname).replace(/\/\.bunext\/public\//, ""));
const file_path = path.join(BUNEXT_PUBLIC_DIR, decodeURIComponent(url.pathname).slice(BUNEXT_PUBLIC_URL_PATH.length));
if (!isSafePath({ filePath: file_path, allowedDir: BUNEXT_PUBLIC_DIR })) {
return new Response("Forbidden", { status: 403 });
}
+5 -6
View File
@@ -1,22 +1,21 @@
import grabDirNames from "../../utils/grab-dir-names";
import path from "path";
import isDevelopment from "../../utils/is-development";
import { existsSync } from "fs";
import isSafePath from "../../utils/is-safe-path";
import resolvePublicFile from "../../utils/resolve-public-file";
const { PUBLIC_DIR } = grabDirNames();
export default async function ({ req }) {
try {
const is_dev = isDevelopment();
const url = new URL(req.url);
const file_path = path.join(PUBLIC_DIR, decodeURIComponent(url.pathname));
if (!isSafePath({ filePath: file_path, allowedDir: PUBLIC_DIR })) {
const resolved = resolvePublicFile(PUBLIC_DIR, url.pathname);
if (resolved.status == 403) {
return new Response("Forbidden", { status: 403 });
}
if (!existsSync(file_path)) {
if (resolved.status != 200) {
return new Response(`File Doesn't Exist`, {
status: 404,
});
}
const { file_path } = resolved;
const file = Bun.file(file_path);
const headers = new Headers();
if (!is_dev) {
+9 -4
View File
@@ -1,17 +1,22 @@
import grabDirNames from "../../utils/grab-dir-names";
import path from "path";
import isDevelopment from "../../utils/is-development";
import { existsSync } from "fs";
import isSafePath from "../../utils/is-safe-path";
import resolvePublicFile from "../../utils/resolve-public-file";
const { PUBLIC_DIR } = grabDirNames();
export default async function ({ req }) {
try {
const is_dev = isDevelopment();
const url = new URL(req.url);
const file_path = path.join(PUBLIC_DIR, decodeURIComponent(url.pathname).replace(/^\/public/, ""));
if (!isSafePath({ filePath: file_path, allowedDir: PUBLIC_DIR })) {
const resolved = resolvePublicFile(PUBLIC_DIR, url.pathname.replace(/^\/public/, ""));
if (resolved.status == 403) {
return new Response("Forbidden", { status: 403 });
}
if (resolved.status != 200) {
return new Response(`Public File Doesn't Exist`, {
status: 404,
});
}
const { file_path } = resolved;
return readFileResponse({ file_path });
}
catch (error) {
+2 -1
View File
@@ -1,3 +1,4 @@
import grabClientPageProps from "../../utils/grab-client-page-props";
import _ from "lodash";
import grabPageComponent from "./web-pages/grab-page-component";
/**
@@ -102,7 +103,7 @@ export default async function serverPostBuildFn(params) {
delete final_artifact.target_map;
}
// Always replace so prior error props cannot linger
final_artifact.page_props = serverRes || {};
final_artifact.page_props = grabClientPageProps(serverRes);
try {
let final_data = {};
if (global.BUNEXT_ROOT_FILE_UPDATED) {
+38 -9
View File
@@ -10,11 +10,16 @@ import grabAllPages from "../../utils/grab-all-pages";
import serverPostBuildFn from "./server-post-build-fn";
import { log } from "../../utils/log";
import { logBuildErrorsOnce } from "../../utils/build-error-log";
const { ROOT_DIR, BUNX_CWD_DIR, BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames();
const { ROOT_DIR } = grabDirNames();
// The watchdog writes this file and expects the watcher to report it. It
// sits under `.bunext` (otherwise ignored), so only this path is watched.
const WATCH_PROBE_DIR = path.join(BUNX_CWD_DIR, "watch-probe");
const WATCH_PROBE_FILE = path.join(WATCH_PROBE_DIR, "probe");
// sits under the dist directory (otherwise ignored), so only this path is
// watched.
function grabWatchPaths() {
const { BUNX_CWD_DIR } = grabDirNames();
const WATCH_PROBE_DIR = path.join(BUNX_CWD_DIR, "watch-probe");
const WATCH_PROBE_FILE = path.join(WATCH_PROBE_DIR, "probe");
return { BUNX_CWD_DIR, WATCH_PROBE_DIR, WATCH_PROBE_FILE };
}
// Dedupes near-simultaneous restarts (fullRebuild, watchdog).
let last_watcher_restart = 0;
// The current watcher finished its initial scan; probes written before
@@ -62,6 +67,7 @@ function grabPagesSnapshot() {
* project tree.
*/
export function watcherWatchdogTick() {
const { WATCH_PROBE_DIR, WATCH_PROBE_FILE } = grabWatchPaths();
if (!global.BUNEXT_PAGES_SRC_WATCHER || !watcher_ready)
return;
if (probe_pending) {
@@ -76,7 +82,25 @@ export function watcherWatchdogTick() {
catch (error) { }
}
function isWatchProbePath(p) {
return (p === WATCH_PROBE_FILE || p === WATCH_PROBE_DIR || p === BUNX_CWD_DIR);
const { WATCH_PROBE_DIR, WATCH_PROBE_FILE } = grabWatchPaths();
return (p === WATCH_PROBE_FILE ||
p === WATCH_PROBE_DIR ||
isDistDirOrAncestor(p));
}
/**
* The dist directory and the folders between it and the project root must
* stay watched for the watcher to reach the probe, even when an ignore rule
* matches them (dot folders, `dist`, a custom `dist_dir`).
*/
function isDistDirOrAncestor(p) {
const { BUNX_CWD_DIR } = grabDirNames();
if (p === ROOT_DIR)
return false;
return p === BUNX_CWD_DIR || BUNX_CWD_DIR.startsWith(p + path.sep);
}
function isInDistDir(p) {
const { BUNX_CWD_DIR } = grabDirNames();
return p === BUNX_CWD_DIR || p.startsWith(BUNX_CWD_DIR + path.sep);
}
const TARGET_FILES_MATCH = /\.(tsx?|jsx?|css)$/;
// Rebuild coalescing: editors emit bursts of events per save (change +
@@ -193,14 +217,16 @@ function isPathExcluded(filename, full_file_path) {
// Heavy directories are filtered at the watcher level so inotify doesn't
// install watches (and deliver events) for trees the bundler never reads.
function buildIgnoredPatterns() {
const ignored = [
(p) => !isWatchProbePath(p) && /(^|[\/\\])\../.test(p),
const builtin_patterns = [
/(^|[\/\\])\../,
/(^|[\/\\])node_modules([\/\\]|$)/,
/(^|[\/\\])public([\/\\]|$)/,
(p) => !isWatchProbePath(p) && /(^|[\/\\])\.bunext([\/\\]|$)/.test(p),
/(^|[\/\\])\.git([\/\\]|$)/,
/(^|[\/\\])dist([\/\\]|$)/,
/bun\.lockb$/,
];
const ignored = [
(p) => !isWatchProbePath(p) &&
(isInDistDir(p) || builtin_patterns.some((re) => re.test(p))),
(p) => p.endsWith(AppData["BunextTmpFileExt"]),
];
const user_patterns = global.BUNEXT_CONFIG?.exclude_watch_patterns;
@@ -219,6 +245,7 @@ function buildIgnoredPatterns() {
return ignored;
}
export default async function watcherEsbuildCTX(params) {
const { WATCH_PROBE_DIR } = grabWatchPaths();
try {
mkdirSync(WATCH_PROBE_DIR, { recursive: true });
}
@@ -247,6 +274,8 @@ export default async function watcherEsbuildCTX(params) {
global.BUNEXT_PAGES_SRC_WATCHER = pages_src_watcher;
}
function handleEvent(event, filePath) {
const { WATCH_PROBE_FILE } = grabWatchPaths();
const { BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames();
if (isWatchProbePath(filePath)) {
if (filePath === WATCH_PROBE_FILE) {
probe_pending = false;
+7 -2
View File
@@ -9,6 +9,7 @@ import { AppData } from "../../../data/app-data";
import _ from "lodash";
import grabDirNames from "../../../utils/grab-dir-names";
import { NoSEORobotsValue } from "../../../utils/disable-seo";
import grabClientPageProps from "../../../utils/grab-client-page-props";
const { ROOT_DIR } = grabDirNames();
export default async function genWebHTML({ component: Main, pageProps, bundledMap, module, routeParams, debug, root_module, }) {
const { ClientRootElementIDName, ClientWindowPagePropsName } = grabContants();
@@ -20,9 +21,13 @@ export default async function genWebHTML({ component: Main, pageProps, bundledMa
if (!Main) {
throw new Error(`Main Component not found!`);
}
// Only the client-safe part reaches the components and the HTML;
// response_options (headers, cookies) etc. stay on the server. Head and
// meta still get the full server result: they only render here.
const client_props = grabClientPageProps(pageProps);
let serializedProps = "{}";
try {
serializedProps = serializeForScript(pageProps || {});
serializedProps = serializeForScript(client_props);
}
catch (error) { }
const page_hydration_script = await grabWebPageHydrationScript();
@@ -64,7 +69,7 @@ export default async function genWebHTML({ component: Main, pageProps, bundledMa
__html: serializeForScript(global.BUNEXT_REACT_IMPORTS_MAP),
}, defer: true, "data-bunext-head": true }), _jsx("script", { src: `/${bundledMap.path}`, type: "module", id: AppData["BunextClientHydrationScriptID"], defer: true, "data-bunext-head": true })] })) : null, is_dev ? (_jsx("script", { defer: true, dangerouslySetInnerHTML: {
__html: page_hydration_script,
}, "data-bunext-head": true })) : null] }), _jsx("body", { children: _jsx("div", { id: ClientRootElementIDName, suppressHydrationWarning: !dev, children: _jsx(Main, { ...pageProps }) }) })] }));
}, "data-bunext-head": true })) : null] }), _jsx("body", { children: _jsx("div", { id: ClientRootElementIDName, suppressHydrationWarning: !dev, children: _jsx(Main, { ...client_props }) }) })] }));
let html = `<!DOCTYPE html>\n`;
// const stream = await renderToReadableStream(final_component, {
// onError(error: any) {
@@ -3,12 +3,11 @@ import isDevelopment from "../../../utils/is-development";
import { log } from "../../../utils/log";
import genWebHTML from "./generate-web-html";
import generateWebPageGetCachePage from "./generate-web-page-get-cache-page";
import grabRedirectResponse from "../../../utils/grab-redirect-response";
export default async function generateWebPageResponseFromComponentReturn({ component, module, bundledMap, routeParams, serverRes, debug, root_module, }) {
const is_dev = isDevelopment();
if (serverRes?.redirect?.destination) {
return Response.redirect(serverRes.redirect.destination, serverRes.redirect.permanent
? 301
: serverRes.redirect.status_code || 302);
return await grabRedirectResponse({ serverRes, routeParams });
}
const html = await genWebHTML({
component,
@@ -22,21 +21,21 @@ export default async function generateWebPageResponseFromComponentReturn({ compo
if (debug) {
log.info("html", html);
}
// A Headers object (not an object spread) so repeated headers such as
// several Set-Cookie survive, whichever HeadersInit form was returned.
const headers = new Headers(serverRes?.response_options?.headers);
if (!headers.has("Content-Type")) {
headers.set("Content-Type", "text/html");
}
if (is_dev) {
headers.set("Cache-Control", "no-cache, no-store, must-revalidate");
headers.set("Pragma", "no-cache");
headers.set("Expires", "0");
}
const res_opts = {
...serverRes?.response_options,
headers: {
"Content-Type": "text/html",
...serverRes?.response_options?.headers,
},
headers,
};
if (is_dev) {
res_opts.headers = {
...res_opts.headers,
"Cache-Control": "no-cache, no-store, must-revalidate",
Pragma: "no-cache",
Expires: "0",
};
}
if (!is_dev) {
await generateWebPageGetCachePage({
html,
+10 -8
View File
@@ -12,15 +12,11 @@ import grabDirNames from "../../../utils/grab-dir-names";
import grabRelativePath from "../../../utils/grab-relative-path";
import { logBuildErrorsOnce } from "../../../utils/build-error-log";
import { RequestBodyTooLargeError } from "../../../utils/read-request-body";
const { BUNX_BUNDLER_ERROR_EXIT_FILE, ROOT_DIR } = grabDirNames();
class NotFoundError extends Error {
status = 404;
constructor(message) {
super(message);
this.name = "NotFoundError";
}
}
import NotFoundError from "../../../utils/not-found-error";
import isRoutablePage from "../../../utils/is-routable-page";
const { ROOT_DIR } = grabDirNames();
export default async function grabPageComponent(params) {
const { BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames();
const { req, file_path: passed_file_path, debug, return_server_res_only, skip_server_res, is_hydration, no_retry, } = params;
const url = req?.url ? new URL(req.url) : undefined;
const router = global.BUNEXT_ROUTER;
@@ -46,6 +42,12 @@ export default async function grabPageComponent(params) {
if (!match?.filePath && url?.pathname) {
throw new NotFoundError(`Page ${url.pathname} not found`);
}
// The router also matches files that are never bundled (.server.ts
// companions, __root, excluded paths). Without this they would sit
// in the dev rebuild/retry loop and never respond.
if (match?.filePath && !isRoutablePage(match.filePath)) {
throw new NotFoundError(`Page ${url?.pathname} not found`);
}
const file_path = match?.filePath || passed_file_path;
if (debug) {
log.info(`file_path:`, file_path);
+7 -3
View File
@@ -4,6 +4,8 @@ import { log } from "../../../utils/log";
import grabRootFilePath from "./grab-root-file-path";
import grabPageCombinedServerRes from "./grab-page-combined-server-res";
import importFresh from "../../../utils/import-fresh";
import grabRedirectResponse from "../../../utils/grab-redirect-response";
import NotFoundError from "../../../utils/not-found-error";
export default async function grabPageModules({ file_path, debug, url, query, routeParams, skip_server_res, }) {
const { serverRes } = skip_server_res
? {}
@@ -15,9 +17,11 @@ export default async function grabPageModules({ file_path, debug, url, query, ro
url,
});
if (serverRes?.redirect?.destination) {
return Response.redirect(serverRes.redirect.destination, serverRes.redirect.permanent
? 301
: serverRes.redirect.status_code || 302);
return await grabRedirectResponse({ serverRes, routeParams });
}
// Rendered as the 404 page by grabPageComponent's error path
if (serverRes?.not_found) {
throw new NotFoundError(`Page ${url?.pathname || file_path} not found`);
}
const { component, page_module, root_module: bundled_root_module, } = (await grabPageBundledReactComponent({
file_path,
+3 -1
View File
@@ -4,14 +4,16 @@ import grabDirNames from "../../../utils/grab-dir-names";
import path from "path";
import tailwindEsbuildPlugin from "./tailwind-esbuild-plugin";
import { existsSync, rmSync, unlinkSync } from "fs";
const { PAGES_DIR, BUNX_CWD_MODULE_CACHE_DIR, ROOT_DIR } = grabDirNames();
const { PAGES_DIR, ROOT_DIR } = grabDirNames();
function toModPath(page_file_path) {
const { BUNX_CWD_MODULE_CACHE_DIR } = grabDirNames();
return path.join(BUNX_CWD_MODULE_CACHE_DIR, page_file_path.replace(PAGES_DIR, "").replace(/\.(t|j)sx?$/, ".js"));
}
function isBatch(params) {
return "tsx_map" in params;
}
async function buildEntries({ entries, clean_cache }) {
const { BUNX_CWD_MODULE_CACHE_DIR } = grabDirNames();
const dev = isDevelopment();
const toBuild = [];
for (const entry of entries) {
+17
View File
@@ -0,0 +1,17 @@
type Params = {
/**
* Takes precedence over the env var and the config (`--dist-dir`)
*/
dist_dir?: string;
};
/**
* # Set the dist directory for this process and its children
* Precedence: `params.dist_dir` (the CLI's `--dist-dir`), then an existing
* `BUNEXT_DIST_DIR` env var, then `dist_dir` from bunext.config.ts. Stores
* the absolute path in `BUNEXT_DIST_DIR`, which `grabDirNames()` reads and
* child processes inherit. Runs at the start of every CLI command and of
* `bunextInit()`, before anything reads the artifact paths. Throws on an
* invalid directory.
*/
export default function setupDistDir(params?: Params): Promise<void>;
export {};
+34
View File
@@ -0,0 +1,34 @@
import { existsSync } from "fs";
import grabDirNames from "../utils/grab-dir-names";
import { DistDirEnvName, resolveDistDir } from "../utils/grab-dist-dir";
/**
* # Set the dist directory for this process and its children
* Precedence: `params.dist_dir` (the CLI's `--dist-dir`), then an existing
* `BUNEXT_DIST_DIR` env var, then `dist_dir` from bunext.config.ts. Stores
* the absolute path in `BUNEXT_DIST_DIR`, which `grabDirNames()` reads and
* child processes inherit. Runs at the start of every CLI command and of
* `bunextInit()`, before anything reads the artifact paths. Throws on an
* invalid directory.
*/
export default async function setupDistDir(params) {
const root_dir = process.cwd();
const dist_dir = params?.dist_dir ||
process.env[DistDirEnvName] ||
(await grabConfigDistDir());
if (!dist_dir)
return;
process.env[DistDirEnvName] = resolveDistDir({ dist_dir, root_dir });
}
async function grabConfigDistDir() {
const { CONFIG_FILE } = grabDirNames();
if (!existsSync(CONFIG_FILE))
return;
try {
const config = (await import(CONFIG_FILE)).default;
return config?.dist_dir;
}
catch (error) {
// The config is loaded again later, which reports the error.
return;
}
}
+1 -1
View File
@@ -1,7 +1,6 @@
import path from "path";
import { existsSync, mkdirSync, writeFileSync } from "fs";
import grabDirNames from "../utils/grab-dir-names";
const { BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames();
/**
* Writes the `BUNDLER_EXIT.txt` flag that the bundler pipeline watches to
* detect that the dev/prod child process previously crashed.
@@ -13,6 +12,7 @@ const { BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames();
* crash handler's richer error/stack payload is not clobbered.
*/
export default function writeErrorFile({ force, error, exitCode, }) {
const { BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames();
const has_error = error != null;
const abnormal_exit = exitCode != null && exitCode !== 0;
if (!has_error && !abnormal_exit)
+35 -3
View File
@@ -36,6 +36,12 @@ export type PageModule = {
staticParams: StaticParams;
};
export type BunextConfig = {
/**
* Build artifact directory, relative to the project root. Defaults to
* `.bunext`. Must be inside the project root, outside `src/`, `public/`
* and `node_modules/`. Overridden by the `BUNEXT_DIST_DIR` env var and
* by `--dist-dir` on the CLI.
*/
dist_dir?: string;
assets_prefix?: string;
origin?: string;
@@ -63,6 +69,12 @@ export type BunextConfig = {
* reverse proxy that sets it). Defaults to `false`.
*/
trust_proxy?: boolean;
/**
* Gzip text responses (HTML, JS, CSS, JSON, SVG, ...) when the client
* accepts it. Defaults to `true`; set `false` when a reverse proxy
* already compresses.
*/
compress?: boolean;
websocket?: WebSocketHandler<any>;
server_options?: Omit<Bun.Serve.Options<any>, "fetch" | "routes">;
/**
@@ -197,7 +209,7 @@ export type BunextServerRouteConfig = {
max_request_body_mb?: number;
};
export type LivePageDistGenParams = {
component?: FC;
component?: FC<any>;
pageProps?: any;
module?: BunextPageModule;
root_module?: BunextRootModule;
@@ -294,6 +306,17 @@ export type BunextPageModuleServerReturn<T extends {
props?: T;
query?: Q;
redirect?: BunextPageModuleServerRedirect;
/**
* Render the 404 page with status 404 instead of this page, as if the
* route did not exist. Ignored when `redirect` is set.
*/
not_found?: boolean;
/**
* Status and headers of the HTML response. Server-only: never sent to
* the browser or passed to the page and root components. A `Headers`
* object or `[name, value][]` keeps repeated headers (e.g. several
* `Set-Cookie`); a plain object can hold each name once.
*/
response_options?: ResponseInit;
/**
* Whether to cache the current page
@@ -311,6 +334,12 @@ export type BunextCachePageType = boolean | ((params: {
ctx: Omit<BunxRouteParams, "body">;
serverRes?: BunextPageModuleServerReturn;
}) => Promise<boolean> | boolean);
/**
* What the page and root components receive, and what is serialized into
* the HTML for hydration: the client-safe part of the server function's
* result. Server-only fields (`response_options`, `redirect`, cache
* settings, `res_transform`) are never included.
*/
export type BunextPageProps<T extends {
[k: string]: any;
} = {
@@ -319,7 +348,7 @@ export type BunextPageProps<T extends {
[k: string]: any;
} = {
[k: string]: any;
}> = BunextPageModuleServerReturn<T, Q>;
}> = Pick<BunextPageModuleServerReturn<T, Q>, "props" | "query" | "url" | "error">;
export type BunextPageModuleServerReturnURLObject = {
hash: string;
host: string;
@@ -336,7 +365,9 @@ export type BunextPageModuleServerReturnURLObject = {
};
export type BunextPageModuleServerRedirect = {
destination: string;
/** 301 instead of the default 302. Ignored when `status_code` is set. */
permanent?: boolean;
/** e.g. 307/308 to keep the request method. Wins over `permanent`. */
status_code?: number;
};
export type BunextPageModuleMetadata = {
@@ -468,7 +499,8 @@ export type BunextGenerateSitemapParams = {
default_lastmod?: Date | string;
/**
* Page last-modified record written by `bunext build`, relative to the
* project root. Defaults to `.bunext/page-lastmod.json`. Point it at a
* project root. Defaults to `page-lastmod.json` in the dist directory
* (`.bunext/page-lastmod.json`). Point it at a
* cached or committed location when builds start from a fresh checkout.
*/
lastmod_file?: string;
+7
View File
@@ -0,0 +1,7 @@
/**
* Gzips `res` when the client accepts gzip and the body is compressible
* text. Streams (no buffering), so it is safe for large and streamed
* responses. Responses that are already encoded, ranged, bodiless or
* event streams pass through untouched.
*/
export default function compressResponse(req: Request, res: Response): Response;
+45
View File
@@ -0,0 +1,45 @@
const COMPRESSIBLE = /^(text\/(?!event-stream)|application\/(javascript|json|xml|manifest\+json|ld\+json|rss\+xml|atom\+xml)|image\/svg\+xml)/i;
/** Smaller bodies aren't worth the gzip overhead. */
const MIN_SIZE = 1024;
/**
* Gzips `res` when the client accepts gzip and the body is compressible
* text. Streams (no buffering), so it is safe for large and streamed
* responses. Responses that are already encoded, ranged, bodiless or
* event streams pass through untouched.
*/
export default function compressResponse(req, res) {
if (!res.body || req.method === "HEAD")
return res;
if (res.status < 200 || res.status === 204 || res.status === 206) {
return res;
}
if (res.headers.has("Content-Encoding"))
return res;
if (!/\bgzip\b/i.test(req.headers.get("Accept-Encoding") || "")) {
return res;
}
if (!COMPRESSIBLE.test(res.headers.get("Content-Type") || ""))
return res;
if (/\bno-transform\b/i.test(res.headers.get("Cache-Control") || "")) {
return res;
}
const length = Number(res.headers.get("Content-Length"));
if (length && length < MIN_SIZE)
return res;
// Copy: headers of fetch()/Response.redirect responses are immutable
const headers = new Headers(res.headers);
headers.set("Content-Encoding", "gzip");
headers.delete("Content-Length");
const vary = headers.get("Vary");
if (!vary) {
headers.set("Vary", "Accept-Encoding");
}
else if (!/\b(accept-encoding|\*)\b/i.test(vary)) {
headers.set("Vary", `${vary}, Accept-Encoding`);
}
return new Response(res.body.pipeThrough(new CompressionStream("gzip")), {
status: res.status,
statusText: res.statusText,
headers,
});
}
+9
View File
@@ -5,4 +5,13 @@ type Params = {
include_server?: boolean;
};
export default function grabAllPages(params?: Params): PageFiles[];
/**
* The URL path is the file's path relative to the pages root, so a nested
* directory that happens to be called `pages` (e.g. `src/pages/admin/pages/
* index.tsx` -> `/admin/pages/index`) is kept, not treated as the root.
*/
export declare function grabPageFileObject({ file_path, pages_root, }: {
file_path: string;
pages_root: string;
}): PageFiles | undefined;
export {};
+17 -11
View File
@@ -17,13 +17,9 @@ export default function grabAllPages(params) {
}
return pages;
}
function grabPageDirRecursively({ page_dir, include_server, }) {
function grabPageDirRecursively({ page_dir, pages_root = page_dir, include_server, }) {
const pages = readdirSync(page_dir);
const pages_files = [];
const root_pages_file = grabPageFileObject({ file_path: `` });
if (root_pages_file) {
pages_files.push(root_pages_file);
}
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
const page_name = page.split("/").pop();
@@ -56,6 +52,7 @@ function grabPageDirRecursively({ page_dir, include_server, }) {
continue;
const new_page_files = grabPageDirRecursively({
page_dir: full_page_path,
pages_root,
include_server,
});
pages_files.push(...new_page_files);
@@ -63,6 +60,7 @@ function grabPageDirRecursively({ page_dir, include_server, }) {
else if (page.match(/\.(ts|js)x?$/)) {
const pages_file = grabPageFileObject({
file_path: full_page_path,
pages_root,
});
if (pages_file) {
pages_files.push(pages_file);
@@ -77,14 +75,22 @@ function grabPageDirRecursively({ page_dir, include_server, }) {
return 0;
});
}
function grabPageFileObject({ file_path, }) {
let url_path = file_path
.replace(/.*\/pages\//, "/")
?.replace(/\.(ts|js)x?$/, "");
let file_name = url_path.split("/").pop();
/**
* The URL path is the file's path relative to the pages root, so a nested
* directory that happens to be called `pages` (e.g. `src/pages/admin/pages/
* index.tsx` -> `/admin/pages/index`) is kept, not treated as the root.
*/
export function grabPageFileObject({ file_path, pages_root, }) {
const relative_path = path
.relative(pages_root, file_path)
.split(path.sep)
.join("/");
if (!relative_path || relative_path.startsWith("../"))
return;
const url_path = "/" + relative_path.replace(/\.(ts|js)x?$/, "");
const file_name = url_path.split("/").pop();
if (!file_name)
return;
// const transformed_path = pagePathTransform({ page_path: file_path });
return {
local_path: file_path,
url_path,
+7
View File
@@ -0,0 +1,7 @@
/**
* # URL path the dist `public` directory is served at
* Its path relative to the project root, e.g. `/.bunext/public`. Bundle
* paths in the bundler maps are project-relative, so `/${path}` is their
* URL. Kept out of `grabDirNames()`: `init` creates every path listed there.
*/
export default function grabBunextPublicUrlPath(): string;
+15
View File
@@ -0,0 +1,15 @@
import path from "path";
import grabDirNames from "./grab-dir-names";
/**
* # URL path the dist `public` directory is served at
* Its path relative to the project root, e.g. `/.bunext/public`. Bundle
* paths in the bundler maps are project-relative, so `/${path}` is their
* URL. Kept out of `grabDirNames()`: `init` creates every path listed there.
*/
export default function grabBunextPublicUrlPath() {
const { ROOT_DIR, BUNEXT_PUBLIC_DIR } = grabDirNames();
return `/${path
.relative(ROOT_DIR, BUNEXT_PUBLIC_DIR)
.split(path.sep)
.join("/")}`;
}
+11
View File
@@ -0,0 +1,11 @@
import type { BunextPageModuleServerReturn, BunextPageProps } from "../types";
/**
* The part of a server function's result that is sent to the browser (as
* `window.__PAGE_PROPS__` and in HMR updates) and passed to the page and
* root components.
*
* An allowlist: everything else (`response_options` with its headers and
* cookies, `redirect`, cache settings, `res_transform`) is server-only and
* must not end up in the HTML.
*/
export default function grabClientPageProps(serverRes?: BunextPageModuleServerReturn | null): BunextPageProps;
+23
View File
@@ -0,0 +1,23 @@
/**
* The part of a server function's result that is sent to the browser (as
* `window.__PAGE_PROPS__` and in HMR updates) and passed to the page and
* root components.
*
* An allowlist: everything else (`response_options` with its headers and
* cookies, `redirect`, cache settings, `res_transform`) is server-only and
* must not end up in the HTML.
*/
export default function grabClientPageProps(serverRes) {
if (!serverRes)
return {};
const client_props = {};
if (serverRes.props !== undefined)
client_props.props = serverRes.props;
if (serverRes.query !== undefined)
client_props.query = serverRes.query;
if (serverRes.url !== undefined)
client_props.url = serverRes.url;
if (serverRes.error !== undefined)
client_props.error = serverRes.error;
return client_props;
}
+7
View File
@@ -31,4 +31,11 @@ export type DirNames = {
BUILD_INFO_JSON: string;
PAGE_LASTMOD_JSON: string;
};
/**
* # Project and artifact paths
* The dist directory comes from `BUNEXT_DIST_DIR`, which `setupDistDir()`
* sets from `--dist-dir` / `dist_dir` in the config. Call this where the
* paths are used, not at module load: a module loaded before
* `setupDistDir()` runs would keep the default directory.
*/
export default function grabDirNames(): DirNames;
+20 -1
View File
@@ -1,14 +1,33 @@
import path from "path";
import { DistDirEnvName, resolveDistDir } from "./grab-dist-dir";
let cached;
/**
* # Project and artifact paths
* The dist directory comes from `BUNEXT_DIST_DIR`, which `setupDistDir()`
* sets from `--dist-dir` / `dist_dir` in the config. Call this where the
* paths are used, not at module load: a module loaded before
* `setupDistDir()` runs would keep the default directory.
*/
export default function grabDirNames() {
if (global.BUNEXT_DIR_NAMES)
return global.BUNEXT_DIR_NAMES;
const key = `${process.cwd()}\0${process.env[DistDirEnvName] || ""}`;
if (cached?.key !== key) {
cached = { key, dir_names: computeDirNames() };
}
return cached.dir_names;
}
function computeDirNames() {
const ROOT_DIR = process.cwd();
const SRC_DIR = path.join(ROOT_DIR, "src");
const PAGES_DIR = path.join(SRC_DIR, "pages");
const API_DIR = path.join(PAGES_DIR, "api");
const PUBLIC_DIR = path.join(ROOT_DIR, "public");
const CONFIG_FILE = path.join(ROOT_DIR, "bunext.config.ts");
const BUNX_CWD_DIR = path.resolve(ROOT_DIR, ".bunext");
const BUNX_CWD_DIR = resolveDistDir({
dist_dir: process.env[DistDirEnvName],
root_dir: ROOT_DIR,
});
const BUNX_CWD_MODULE_CACHE_DIR = path.resolve(BUNX_CWD_DIR, "module-cache");
const BUNX_CWD_PAGES_REWRITE_DIR = path.resolve(BUNX_CWD_DIR, "pages");
const BUNX_TMP_DIR = path.resolve(BUNX_CWD_DIR, ".tmp");
+21
View File
@@ -0,0 +1,21 @@
/**
* Env var holding the dist directory. The CLI sets it from `--dist-dir` or
* `dist_dir` in bunext.config.ts before any other module loads: most
* modules read the directory names once, at import time. Child processes
* (dev/start servers) inherit it.
*/
export declare const DistDirEnvName = "BUNEXT_DIST_DIR";
type Params = {
dist_dir?: string;
root_dir: string;
};
/**
* # Resolve the dist directory
* Returns the absolute path of the build artifact directory. `dist_dir` is
* relative to the project root (or absolute) and must sit inside it: the
* dev watcher only watches the project root, and checks it is alive through
* a probe file in the dist directory. Its relative path is also the URL
* path of the client bundles, so it is limited to URL-safe characters.
*/
export declare function resolveDistDir({ dist_dir, root_dir }: Params): string;
export {};
+51
View File
@@ -0,0 +1,51 @@
import path from "path";
import AppNames from "./grab-app-names";
/**
* Env var holding the dist directory. The CLI sets it from `--dist-dir` or
* `dist_dir` in bunext.config.ts before any other module loads: most
* modules read the directory names once, at import time. Child processes
* (dev/start servers) inherit it.
*/
export const DistDirEnvName = "BUNEXT_DIST_DIR";
const ReservedDirs = ["src", "public", "node_modules"];
/**
* # Resolve the dist directory
* Returns the absolute path of the build artifact directory. `dist_dir` is
* relative to the project root (or absolute) and must sit inside it: the
* dev watcher only watches the project root, and checks it is alive through
* a probe file in the dist directory. Its relative path is also the URL
* path of the client bundles, so it is limited to URL-safe characters.
*/
export function resolveDistDir({ dist_dir, root_dir }) {
const resolved = path.resolve(root_dir, dist_dir?.trim() || AppNames.defaultDistDir);
if (!isInsideDir(resolved, root_dir)) {
throw new Error(`Invalid dist directory \`${dist_dir}\`: it must be a directory ` +
`inside the project root (${root_dir}).`);
}
const url_path = path
.relative(root_dir, resolved)
.split(path.sep)
.join("/");
if (!/^[\w.\-\/]+$/.test(url_path)) {
throw new Error(`Invalid dist directory \`${dist_dir}\`: use only letters, ` +
`digits, \`.\`, \`_\`, \`-\` and \`/\`.`);
}
for (const reserved of ReservedDirs) {
const reserved_dir = path.join(root_dir, reserved);
if (resolved === reserved_dir || isInsideDir(resolved, reserved_dir)) {
throw new Error(`Invalid dist directory \`${dist_dir}\`: it can't be ` +
`\`${reserved}/\` or inside it.`);
}
}
return resolved;
}
/**
* Whether `target` is strictly inside `dir` (not `dir` itself)
*/
function isInsideDir(target, dir) {
const relative = path.relative(dir, target);
return (Boolean(relative) &&
relative !== ".." &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative));
}
+13
View File
@@ -0,0 +1,13 @@
import type { BunextPageModuleServerReturn, BunxRouteParams } from "../types";
type Params = {
serverRes: BunextPageModuleServerReturn;
routeParams?: BunxRouteParams;
};
/**
* Builds the redirect Response for a server function that returned
* `redirect`. An explicit `status_code` wins; otherwise `permanent` picks
* 301, and the default is 302. Headers from `response_options` (e.g. a
* Set-Cookie on login) and `res_transform` apply to the redirect too.
*/
export default function grabRedirectResponse({ serverRes, routeParams, }: Params): Promise<Response>;
export {};
+21
View File
@@ -0,0 +1,21 @@
/**
* Builds the redirect Response for a server function that returned
* `redirect`. An explicit `status_code` wins; otherwise `permanent` picks
* 301, and the default is 302. Headers from `response_options` (e.g. a
* Set-Cookie on login) and `res_transform` apply to the redirect too.
*/
export default async function grabRedirectResponse({ serverRes, routeParams, }) {
const redirect = serverRes.redirect;
const status = redirect.status_code || (redirect.permanent ? 301 : 302);
const headers = new Headers(serverRes.response_options?.headers);
headers.set("Location", redirect.destination);
const res = new Response(null, { status, headers });
// Same precedence as HTML responses
if (routeParams?.res_transform) {
return await routeParams.res_transform(res);
}
if (serverRes.res_transform) {
return await serverRes.res_transform(res);
}
return res;
}
+8 -6
View File
@@ -2,8 +2,9 @@ import { existsSync, statSync } from "fs";
import path from "path";
import isDevelopment from "./is-development";
/**
* File path -> mtime of the version currently held in Bun's module
* registry (dev only).
* File path -> version (mtime, size, inode) of the module currently held
* in Bun's module registry (dev only). mtime alone misses rewrites within
* the filesystem's timestamp granularity (several ms on ext4).
*/
const loaded_versions = new Map();
/**
@@ -20,18 +21,19 @@ export default async function importFresh(file_path) {
if (!isDevelopment()) {
return import(file_path);
}
let mtime;
let version;
try {
mtime = statSync(file_path).mtimeMs;
const stat = statSync(file_path);
version = `${stat.mtimeMs}:${stat.size}:${stat.ino}`;
}
catch { }
if (loaded_versions.has(file_path) &&
loaded_versions.get(file_path) !== mtime) {
loaded_versions.get(file_path) !== version) {
evictModule(file_path);
}
try {
const mod = await import(file_path);
loaded_versions.set(file_path, mtime);
loaded_versions.set(file_path, version);
return mod;
}
catch (error) {
+10
View File
@@ -0,0 +1,10 @@
/**
* Whether a file matched by the FileSystemRouter is actually a page.
*
* `Bun.FileSystemRouter` routes every file under `src/pages`, including
* ones the bundler never builds: `.server.ts` companions, the `__root`
* layout, tests, and paths excluded by `pages_exclude_patterns` (or inside
* an excluded directory). Requests for those must 404 rather than wait for
* a bundle that will never exist. Mirrors the rules in `grabAllPages`.
*/
export default function isRoutablePage(file_path: string): boolean;
+39
View File
@@ -0,0 +1,39 @@
import path from "path";
import AppNames from "./grab-app-names";
import checkExcludedPatterns from "./check-excluded-patterns";
import grabDirNames from "./grab-dir-names";
/**
* Whether a file matched by the FileSystemRouter is actually a page.
*
* `Bun.FileSystemRouter` routes every file under `src/pages`, including
* ones the bundler never builds: `.server.ts` companions, the `__root`
* layout, tests, and paths excluded by `pages_exclude_patterns` (or inside
* an excluded directory). Requests for those must 404 rather than wait for
* a bundle that will never exist. Mirrors the rules in `grabAllPages`.
*/
export default function isRoutablePage(file_path) {
const { PAGES_DIR } = grabDirNames();
const file_name = path.basename(file_path);
if (/\.server\.(t|j)sx?$/.test(file_name))
return false;
if (/\.test\.(t|j)sx?$/.test(file_name))
return false;
if (/__tests__/.test(file_path))
return false;
if (file_name.match(new RegExp(AppNames["RootPagesComponentName"]))) {
return false;
}
// grabAllPages skips an excluded directory without looking inside it,
// so check every directory between PAGES_DIR and the file as well.
const relative = path.relative(PAGES_DIR, file_path);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
return !checkExcludedPatterns({ path: file_path });
}
let current = PAGES_DIR;
for (const segment of relative.split(path.sep)) {
current = path.join(current, segment);
if (checkExcludedPatterns({ path: current }))
return false;
}
return true;
}
+9
View File
@@ -0,0 +1,9 @@
/**
* Thrown to render the 404 page (with status 404) instead of the matched
* page: for unmatched or non-routable paths, and when a page's server
* function returns `not_found: true`.
*/
export default class NotFoundError extends Error {
status: number;
constructor(message: string);
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Thrown to render the 404 page (with status 404) instead of the matched
* page: for unmatched or non-routable paths, and when a page's server
* function returns `not_found: true`.
*/
export default class NotFoundError extends Error {
status = 404;
constructor(message) {
super(message);
this.name = "NotFoundError";
}
}
+2 -1
View File
@@ -1,11 +1,12 @@
import path from "path";
import grabDirNames from "./grab-dir-names";
const { ROOT_DIR, BUNX_CWD_PAGES_REWRITE_DIR } = grabDirNames();
const { ROOT_DIR } = grabDirNames();
/**
* # Transform a page path to the destination
* path in the .bunext directory
*/
export default function pagePathTransform({ page_path }) {
const { BUNX_CWD_PAGES_REWRITE_DIR } = grabDirNames();
const page_path_relative_dir = page_path
.replace(ROOT_DIR, "")
.replace(/\/src\/pages/, "");
+15
View File
@@ -0,0 +1,15 @@
type Return = {
status: 200;
file_path: string;
} | {
status: 403 | 404;
};
/**
* Maps a URL path onto a file in `dir`, in this order:
* - `..` segments (encoded or not) are resolved against `/`, so they
* can never climb above `dir`; the 403 check is a second guard
* - 404 if there is no such file (so callers can fall through to pages)
* - 403 if a symlink points outside `dir`
*/
export default function resolvePublicFile(dir: string, url_path: string): Return;
export {};
+31
View File
@@ -0,0 +1,31 @@
import { existsSync, statSync } from "fs";
import path from "path";
import isSafePath from "./is-safe-path";
/**
* Maps a URL path onto a file in `dir`, in this order:
* - `..` segments (encoded or not) are resolved against `/`, so they
* can never climb above `dir`; the 403 check is a second guard
* - 404 if there is no such file (so callers can fall through to pages)
* - 403 if a symlink points outside `dir`
*/
export default function resolvePublicFile(dir, url_path) {
let decoded;
try {
decoded = decodeURIComponent(url_path);
}
catch {
return { status: 404 };
}
const root = path.resolve(dir);
const file_path = path.resolve(root, "." + path.posix.join("/", decoded));
if (file_path !== root && !file_path.startsWith(root + path.sep)) {
return { status: 403 };
}
if (!existsSync(file_path) || !statSync(file_path).isFile()) {
return { status: 404 };
}
if (!isSafePath({ filePath: file_path, allowedDir: root })) {
return { status: 403 };
}
return { status: 200, file_path };
}
+1 -1
View File
@@ -1,8 +1,8 @@
import { writeFileSync } from "fs";
import grabDirNames from "./grab-dir-names";
import path from "path";
const { BUNX_LOGS_DIR } = grabDirNames();
export default function writeLogs(log) {
const { BUNX_LOGS_DIR } = grabDirNames();
try {
const now = Date.now();
let new_log_name = `${now}`;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@moduletrace/bunext",
"version": "1.3.5",
"version": "1.4.1",
"main": "dist/index.js",
"module": "index.ts",
"dependencies": {
+120 -1
View File
@@ -179,4 +179,123 @@ export default server;
// Clean up
fs.unlinkSync(serverFilePath);
});
});
const serverFilePath = () => path.join(fixturePagesDir, "index.server.ts");
async function withServerFn(body: string, run: () => Promise<void>) {
await Bun.write(
serverFilePath(),
`export default async function server() { ${body} }\n`,
);
try {
await run();
} finally {
fs.unlinkSync(serverFilePath());
}
}
test("keeps response_options out of the HTML and sends every Set-Cookie", async () => {
await withServerFn(
`return {
props: { greeting: "visible-prop" },
response_options: {
status: 201,
headers: [
["Set-Cookie", "a=SECRET_ONE; HttpOnly"],
["Set-Cookie", "b=SECRET_TWO; HttpOnly"],
["X-Custom", "yes"],
],
},
};`,
async () => {
const response = await fetch(`http://localhost:${server.port}/`);
const html = await response.text();
expect(response.status).toBe(201);
expect(response.headers.get("X-Custom")).toBe("yes");
expect(response.headers.getSetCookie()).toEqual([
"a=SECRET_ONE; HttpOnly",
"b=SECRET_TWO; HttpOnly",
]);
expect(html).toContain("visible-prop");
expect(html).not.toContain("SECRET_ONE");
expect(html).not.toContain("response_options");
},
);
});
test("redirect status_code wins over permanent and keeps headers", async () => {
await withServerFn(
`return {
redirect: { destination: "/elsewhere", permanent: true, status_code: 308 },
response_options: { headers: [["Set-Cookie", "session=1"]] },
};`,
async () => {
const response = await fetch(`http://localhost:${server.port}/`, {
redirect: "manual",
});
expect(response.status).toBe(308);
expect(response.headers.get("Location")).toBe("/elsewhere");
expect(response.headers.getSetCookie()).toEqual(["session=1"]);
},
);
await withServerFn(
`return { redirect: { destination: "/elsewhere", permanent: true } };`,
async () => {
const response = await fetch(`http://localhost:${server.port}/`, {
redirect: "manual",
});
expect(response.status).toBe(301);
},
);
});
test("not_found renders the 404 page with status 404", async () => {
await withServerFn(
`return { not_found: true, props: { greeting: "should-not-render" } };`,
async () => {
const response = await fetch(`http://localhost:${server.port}/`);
const html = await response.text();
expect(response.status).toBe(404);
expect(html).toContain("404");
expect(html).not.toContain("Hello E2E");
expect(html).not.toContain("should-not-render");
},
);
});
test("files the router matches but never bundles return 404 without hanging", async () => {
await withServerFn(`return { props: {} };`, async () => {
const started = Date.now();
const response = await fetch(
`http://localhost:${server.port}/index.server`,
);
expect(response.status).toBe(404);
expect(Date.now() - started).toBeLessThan(2000);
});
});
test("gzips HTML when the client accepts it", async () => {
await withServerFn(
`return { props: { filler: "x".repeat(4000) } };`,
async () => {
const response = await fetch(`http://localhost:${server.port}/`, {
headers: { "Accept-Encoding": "gzip" },
decompress: false,
} as RequestInit);
expect(response.headers.get("Content-Encoding")).toBe("gzip");
expect(response.headers.get("Vary")).toContain("Accept-Encoding");
const html = new TextDecoder().decode(
Bun.gunzipSync(new Uint8Array(await response.arrayBuffer())),
);
expect(html).toContain("Hello E2E");
},
);
});
});
@@ -0,0 +1,67 @@
import { describe, it, expect, afterAll } from "bun:test";
import * as esbuild from "esbuild";
import fs from "fs";
import path from "path";
import {
externalRequireShim,
forceExternalReact,
} from "../../../functions/bundler/all-pages-esbuild-context-bundler";
// Under /test (gitignored) so the bundle can resolve the repo's react at runtime
const outDir = path.resolve(__dirname, "../../../../test/force-external-react");
afterAll(() => {
fs.rmSync(outDir, { recursive: true, force: true });
});
async function bundle(contents: string) {
fs.mkdirSync(outDir, { recursive: true });
const outfile = path.join(outDir, `out-${Date.now()}-${Math.random().toString(36).slice(2)}.mjs`);
await esbuild.build({
stdin: { contents, resolveDir: outDir, loader: "js" },
bundle: true,
format: "esm",
platform: "browser",
outfile,
plugins: [forceExternalReact()],
logLevel: "silent",
});
return { outfile, code: fs.readFileSync(outfile, "utf-8") };
}
describe("forceExternalReact", () => {
it("keeps ESM imports of react external", async () => {
const { code } = await bundle(`import { useState } from "react"; export default useState;`);
expect(code).toContain(`from "react"`);
expect(code).not.toContain("Dynamic require");
});
it("turns CommonJS require('react') into an import of the external", async () => {
// The shape of use-sync-external-store/shim and Babel-compiled packages
const { outfile, code } = await bundle(`
const React = require("react");
const interop = (m) => (m && m.__esModule ? m : { default: m });
const jsx = require("react/jsx-runtime");
export const plain = typeof React.useState;
export const viaDefault = typeof interop(require("react")).default.createElement;
export const jsxFn = typeof jsx.jsx;
export const jsxDefault = typeof (jsx.default || jsx).jsx;
`);
expect(code).not.toMatch(/__require\("react/);
expect(code).toContain(`from "react"`);
const mod = await import(outfile);
expect(mod.plain).toBe("function");
expect(mod.viaDefault).toBe("function");
expect(mod.jsxFn).toBe("function");
expect(mod.jsxDefault).toBe("function");
});
it("shim re-exports everything and a default", () => {
const shim = externalRequireShim("react-dom");
expect(shim).toContain(`import * as ns from "react-dom";`);
expect(shim).toContain(`export * from "react-dom";`);
expect(shim).toContain("export default ns.default ?? ns;");
});
});
@@ -0,0 +1,63 @@
import { describe, it, expect } from "bun:test";
import compressResponse from "../../utils/compress-response";
const big = "x".repeat(5000);
const gzipReq = (method = "GET") =>
new Request("http://localhost/", {
method,
headers: { "Accept-Encoding": "gzip, deflate, br" },
});
describe("compressResponse", () => {
it("gzips compressible text and keeps status and headers", async () => {
const res = compressResponse(
gzipReq(),
new Response(big, {
status: 201,
headers: [
["Content-Type", "text/html"],
["Content-Length", "5000"],
["Set-Cookie", "a=1"],
["Set-Cookie", "b=2"],
["Vary", "Cookie"],
],
}),
);
expect(res.status).toBe(201);
expect(res.headers.get("Content-Encoding")).toBe("gzip");
expect(res.headers.get("Content-Length")).toBeNull();
expect(res.headers.get("Vary")).toBe("Cookie, Accept-Encoding");
expect(res.headers.getSetCookie()).toEqual(["a=1", "b=2"]);
const body = Bun.gunzipSync(new Uint8Array(await res.arrayBuffer()));
expect(new TextDecoder().decode(body)).toBe(big);
});
it("leaves the response alone when it shouldn't compress", () => {
const html = { "Content-Type": "text/html" };
const cases: [Request, Response][] = [
[new Request("http://localhost/"), new Response(big, { headers: html })],
[gzipReq("HEAD"), new Response(big, { headers: html })],
[gzipReq(), new Response(big, { headers: { "Content-Type": "image/png" } })],
[gzipReq(), new Response(big, { headers: { "Content-Type": "text/event-stream" } })],
[gzipReq(), new Response(big, { headers: { ...html, "Content-Encoding": "br" } })],
[gzipReq(), new Response(big, { headers: { ...html, "Cache-Control": "no-transform" } })],
[gzipReq(), new Response("tiny", { headers: { ...html, "Content-Length": "4" } })],
[gzipReq(), new Response(big, { status: 206, headers: html })],
[gzipReq(), new Response(null, { status: 204 })],
];
for (const [req, res] of cases) {
expect(compressResponse(req, res)).toBe(res);
}
});
it("works on responses with immutable headers", () => {
const res = compressResponse(
gzipReq(),
Response.json({ data: big }),
);
expect(res.headers.get("Content-Encoding")).toBe("gzip");
});
});
@@ -0,0 +1,58 @@
import { describe, it, expect, afterAll, beforeAll } from "bun:test";
import fs from "fs";
import os from "os";
import path from "path";
import grabAllPages, { grabPageFileObject } from "../../utils/grab-all-pages";
// grabAllPages skips anything under __tests__, so fixtures live in the OS tmp dir
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bunext-pages-"));
const pagesDir = path.join(tmpDir, "src", "pages");
const files = [
"index.tsx",
"about/index.tsx",
"admin/pages/index.tsx",
"admin/su/pages/[page_id]/edit/index.tsx",
"api/pages/list.ts",
];
beforeAll(() => {
for (const f of files) {
const full = path.join(pagesDir, f);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, "export default function P() { return null }\n");
}
(global as any).BUNEXT_DIR_NAMES = { PAGES_DIR: pagesDir };
(global as any).BUNEXT_CONSTANTS = { RouteIgnorePatterns: [/\/\(/] };
(global as any).BUNEXT_CONFIG = {};
});
afterAll(() => {
(global as any).BUNEXT_DIR_NAMES = undefined;
(global as any).BUNEXT_CONSTANTS = undefined;
(global as any).BUNEXT_CONFIG = undefined;
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe("grabAllPages", () => {
it("derives URL paths relative to src/pages, even under a nested `pages` dir", () => {
const urls = grabAllPages().map((p) => p.url_path).sort();
expect(urls).toEqual([
"/about/index",
"/admin/pages/index",
"/admin/su/pages/[page_id]/edit/index",
"/api/pages/list",
"/index",
]);
});
it("splits API routes by URL path, not by any `pages/api` in the file path", () => {
expect(grabAllPages({ api_only: true }).map((p) => p.url_path)).toEqual(["/api/pages/list"]);
expect(grabAllPages({ exclude_api: true }).some((p) => p.url_path.startsWith("/api/"))).toBe(false);
});
it("ignores paths outside the pages root", () => {
expect(grabPageFileObject({ file_path: path.join(tmpDir, "other.tsx"), pages_root: pagesDir })).toBeUndefined();
expect(grabPageFileObject({ file_path: pagesDir, pages_root: pagesDir })).toBeUndefined();
});
});
@@ -0,0 +1,28 @@
import { describe, it, expect } from "bun:test";
import grabClientPageProps from "../../utils/grab-client-page-props";
describe("grabClientPageProps", () => {
it("keeps only props, query, url and error", () => {
const client = grabClientPageProps({
props: { a: 1 },
query: { q: "x" },
error: "oops",
response_options: {
status: 201,
headers: { "Set-Cookie": "session=secret" },
},
redirect: { destination: "/x" },
not_found: false,
cache_page: true,
cache_expiry: 60,
res_transform: (res) => res,
});
expect(client).toEqual({ props: { a: 1 }, query: { q: "x" }, error: "oops" });
});
it("handles a missing result", () => {
expect(grabClientPageProps(undefined)).toEqual({});
expect(grabClientPageProps(null)).toEqual({});
});
});
+230
View File
@@ -0,0 +1,230 @@
import { describe, it, expect, afterEach, beforeEach } from "bun:test";
import path from "path";
import os from "os";
import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
import { DistDirEnvName, resolveDistDir } from "../../utils/grab-dist-dir";
import grabDirNames from "../../utils/grab-dir-names";
import grabBunextPublicUrlPath from "../../utils/grab-bunext-public-url-path";
import setupDistDir from "../../functions/setup-dist-dir";
const root_dir = "/projects/app";
describe("resolveDistDir", () => {
it("defaults to .bunext", () => {
expect(resolveDistDir({ root_dir })).toBe(
path.join(root_dir, ".bunext"),
);
expect(resolveDistDir({ dist_dir: " ", root_dir })).toBe(
path.join(root_dir, ".bunext"),
);
});
it("resolves relative and nested paths against the root", () => {
expect(resolveDistDir({ dist_dir: "build", root_dir })).toBe(
path.join(root_dir, "build"),
);
expect(resolveDistDir({ dist_dir: "./out/bunext", root_dir })).toBe(
path.join(root_dir, "out", "bunext"),
);
});
it("accepts absolute paths inside the root", () => {
expect(
resolveDistDir({ dist_dir: `${root_dir}/build`, root_dir }),
).toBe(path.join(root_dir, "build"));
});
it("rejects the root itself and paths outside it", () => {
for (const dist_dir of [".", "..", "../other", "/tmp/out", root_dir]) {
expect(() => resolveDistDir({ dist_dir, root_dir })).toThrow(
/inside the project root/,
);
}
});
it("accepts names that only start with dots", () => {
expect(resolveDistDir({ dist_dir: "..out", root_dir })).toBe(
path.join(root_dir, "..out"),
);
});
it("rejects src, public and node_modules and their subfolders", () => {
for (const dist_dir of [
"src",
"src/out",
"public",
"public/out",
"node_modules/.cache",
]) {
expect(() => resolveDistDir({ dist_dir, root_dir })).toThrow(
/can't be/,
);
}
});
it("rejects characters that are not URL-safe", () => {
for (const dist_dir of ["my out", "out?x", "out#1", "dïst"]) {
expect(() => resolveDistDir({ dist_dir, root_dir })).toThrow(
/use only/,
);
}
});
});
describe("setupDistDir", () => {
const original_env = process.env[DistDirEnvName];
const original_cwd = process.cwd();
let project_dir: string;
// A fresh temp dir per test, so the config module cache never collides.
function writeConfig(body: string) {
project_dir = mkdtempSync(path.join(os.tmpdir(), "bunext-dist-"));
writeFileSync(path.join(project_dir, "bunext.config.ts"), body);
process.chdir(project_dir);
}
beforeEach(() => {
delete process.env[DistDirEnvName];
});
afterEach(() => {
process.chdir(original_cwd);
if (project_dir) rmSync(project_dir, { recursive: true, force: true });
if (original_env === undefined) delete process.env[DistDirEnvName];
else process.env[DistDirEnvName] = original_env;
});
it("uses dist_dir from bunext.config.ts", async () => {
writeConfig(`export default { dist_dir: "build/out" };\n`);
await setupDistDir();
const dist = path.join(process.cwd(), "build", "out");
expect(process.env[DistDirEnvName]).toBe(dist);
expect(grabDirNames().BUNX_CWD_DIR).toBe(dist);
});
it("prefers the explicit dist_dir (--dist-dir) over the config", async () => {
writeConfig(`export default { dist_dir: "from-config" };\n`);
await setupDistDir({ dist_dir: "from-flag" });
expect(grabDirNames().BUNX_CWD_DIR).toBe(
path.join(process.cwd(), "from-flag"),
);
});
it("prefers an existing BUNEXT_DIST_DIR over the config", async () => {
writeConfig(`export default { dist_dir: "from-config" };\n`);
process.env[DistDirEnvName] = "from-env";
await setupDistDir();
expect(grabDirNames().BUNX_CWD_DIR).toBe(
path.join(process.cwd(), "from-env"),
);
});
it("keeps the default without dist_dir", async () => {
writeConfig(`export default {};\n`);
await setupDistDir();
expect(process.env[DistDirEnvName]).toBeUndefined();
expect(grabDirNames().BUNX_CWD_DIR).toBe(
path.join(process.cwd(), ".bunext"),
);
});
it("throws on an invalid dist_dir", async () => {
writeConfig(`export default { dist_dir: "../outside" };\n`);
expect(setupDistDir()).rejects.toThrow(/inside the project root/);
});
});
/**
* `bunextInit()` resolves `dist_dir` from the config after the library is
* imported (custom servers), so no module may read a dist-dependent path at
* import time. Paths derived only from the project root are fine.
*/
describe("no import-time reads of dist paths", () => {
const RootOnlyKeys = new Set([
"ROOT_DIR",
"SRC_DIR",
"PAGES_DIR",
"API_DIR",
"PUBLIC_DIR",
"CONFIG_FILE",
"BUNX_ROOT_DIR",
"BUNX_ROOT_SRC_DIR",
"BUNX_ROOT_PRESETS_DIR",
"BUNX_ROOT_500_PRESET_COMPONENT",
"BUNX_ROOT_500_FILE_NAME",
"BUNX_ROOT_404_PRESET_COMPONENT",
"BUNX_ROOT_404_FILE_NAME",
]);
function listSourceFiles(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const full = path.join(dir, name);
if (name === "__tests__") return [];
if (statSync(full).isDirectory()) return listSourceFiles(full);
return /\.tsx?$/.test(name) ? [full] : [];
});
}
it("only reads root-derived paths at module level", () => {
const src_dir = path.resolve(import.meta.dir, "../..");
const offenders: string[] = [];
const top_level_read =
/^(?:export )?(?:const|let|var)\s+(\{[^}]*\}|\w+)\s*=\s*(grabDirNames|grabBunextPublicUrlPath)\(\)/gm;
for (const file of listSourceFiles(src_dir)) {
const source = readFileSync(file, "utf-8");
for (const match of source.matchAll(top_level_read)) {
const [, binding, fn] = match;
const keys = binding.startsWith("{")
? binding
.slice(1, -1)
.split(",")
.map((k) => k.trim().split(":")[0].trim())
.filter(Boolean)
: [binding];
const bad = keys.filter(
(k) => fn !== "grabDirNames" || !RootOnlyKeys.has(k),
);
if (bad.length) {
offenders.push(
`${path.relative(src_dir, file)}: ${bad.join(", ")}`,
);
}
}
}
expect(offenders).toEqual([]);
});
});
describe("grabDirNames with BUNEXT_DIST_DIR", () => {
const original = process.env[DistDirEnvName];
afterEach(() => {
if (original === undefined) delete process.env[DistDirEnvName];
else process.env[DistDirEnvName] = original;
});
it("places all artifact paths under the configured directory", () => {
process.env[DistDirEnvName] = "build/bunext";
const dirs = grabDirNames();
const dist = path.join(process.cwd(), "build", "bunext");
expect(dirs.BUNX_CWD_DIR).toBe(dist);
expect(dirs.HYDRATION_DST_DIR).toBe(path.join(dist, "public", "pages"));
expect(dirs.BUNDLER_CTX_MAP_JSON.startsWith(dist + path.sep)).toBe(true);
expect(dirs.PAGE_LASTMOD_JSON).toBe(path.join(dist, "page-lastmod.json"));
expect(grabBunextPublicUrlPath()).toBe("/build/bunext/public");
});
it("serves the default dist directory at /.bunext/public", () => {
delete process.env[DistDirEnvName];
expect(grabBunextPublicUrlPath()).toBe("/.bunext/public");
});
});
@@ -0,0 +1,44 @@
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
import path from "path";
import grabDirNames from "../../utils/grab-dir-names";
import isRoutablePage from "../../utils/is-routable-page";
const { PAGES_DIR } = grabDirNames();
const page = (p: string) => path.join(PAGES_DIR, p);
let original_constants: any;
beforeAll(() => {
original_constants = global.BUNEXT_CONSTANTS;
global.BUNEXT_CONSTANTS = {
...(original_constants || {}),
RouteIgnorePatterns: [/\/\(/, /\/drafts$/, /\/secret\.tsx$/],
};
});
afterAll(() => {
global.BUNEXT_CONSTANTS = original_constants;
});
describe("isRoutablePage", () => {
it("accepts ordinary pages", () => {
expect(isRoutablePage(page("index.tsx"))).toBe(true);
expect(isRoutablePage(page("blog/[slug].tsx"))).toBe(true);
expect(isRoutablePage(page("api/users.ts"))).toBe(true);
});
it("rejects files the bundler never builds", () => {
expect(isRoutablePage(page("index.server.ts"))).toBe(false);
expect(isRoutablePage(page("blog/[slug].server.tsx"))).toBe(false);
expect(isRoutablePage(page("__root.tsx"))).toBe(false);
expect(isRoutablePage(page("index.test.tsx"))).toBe(false);
expect(isRoutablePage(page("__tests__/a.tsx"))).toBe(false);
});
it("rejects excluded files and files inside excluded directories", () => {
expect(isRoutablePage(page("secret.tsx"))).toBe(false);
expect(isRoutablePage(page("drafts/post.tsx"))).toBe(false);
expect(isRoutablePage(page("drafts/deep/post.tsx"))).toBe(false);
expect(isRoutablePage(page("(group)/a.tsx"))).toBe(false);
expect(isRoutablePage(page("drafts-old/post.tsx"))).toBe(true);
});
});
@@ -0,0 +1,61 @@
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "fs";
import os from "os";
import path from "path";
import resolvePublicFile from "../../utils/resolve-public-file";
let tmp: string;
let pub: string;
beforeAll(() => {
tmp = mkdtempSync(path.join(os.tmpdir(), "bunext-public-file-"));
pub = path.join(tmp, "public");
mkdirSync(path.join(pub, "images"), { recursive: true });
writeFileSync(path.join(pub, "robots.txt"), "ok");
writeFileSync(path.join(pub, "images", "a b.png"), "png");
writeFileSync(path.join(tmp, "secret.txt"), "secret");
symlinkSync(path.join(tmp, "secret.txt"), path.join(pub, "leak.txt"));
});
afterAll(() => {
rmSync(tmp, { recursive: true, force: true });
});
describe("resolvePublicFile", () => {
it("resolves root and nested files, decoding the URL", () => {
expect(resolvePublicFile(pub, "/robots.txt")).toEqual({
status: 200,
file_path: path.join(pub, "robots.txt"),
});
expect(resolvePublicFile(pub, "/images/a%20b.png")).toEqual({
status: 200,
file_path: path.join(pub, "images", "a b.png"),
});
});
it("returns 404 (not 403) for files that don't exist", () => {
expect(resolvePublicFile(pub, "/users/john.doe")).toEqual({
status: 404,
});
expect(resolvePublicFile(pub, "/images")).toEqual({ status: 404 });
});
it("never resolves above the directory", () => {
expect(resolvePublicFile(pub, "/../secret.txt")).toEqual({
status: 404,
});
expect(resolvePublicFile(pub, "/%2e%2e/secret.txt")).toEqual({
status: 404,
});
});
it("refuses symlinks that point outside", () => {
expect(resolvePublicFile(pub, "/leak.txt")).toEqual({ status: 403 });
});
it("treats malformed escapes as missing", () => {
expect(resolvePublicFile(pub, "/%E0%A4%A.txt")).toEqual({
status: 404,
});
});
});
+14 -9
View File
@@ -1,4 +1,8 @@
import { Command } from "commander";
import {
DistDirOptionDescription,
DistDirOptionFlags,
} from "../dist-dir-option";
import { log } from "../../utils/log";
import grabDirNames from "../../utils/grab-dir-names";
import { rmSync, writeFileSync } from "fs";
@@ -8,19 +12,20 @@ import { v4 } from "uuid";
import { updatePageLastmodRecord } from "../../utils/page-lastmod-record";
import disposeBundlerContexts from "../../functions/bundler/dispose-bundler-contexts";
const {
HYDRATION_DST_DIR,
BUNX_CWD_PAGES_REWRITE_DIR,
BUNDLER_CTX_MAP_JSON,
SSR_BUNDLER_CTX_MAP_JSON,
BUNX_CWD_MODULE_CACHE_DIR,
BUILD_INFO_JSON,
} = grabDirNames();
export default function () {
return new Command("build")
.description("Build Project")
.option(DistDirOptionFlags, DistDirOptionDescription)
.action(async () => {
const {
HYDRATION_DST_DIR,
BUNX_CWD_PAGES_REWRITE_DIR,
BUNDLER_CTX_MAP_JSON,
SSR_BUNDLER_CTX_MAP_JSON,
BUNX_CWD_MODULE_CACHE_DIR,
BUILD_INFO_JSON,
} = grabDirNames();
try {
rmSync(HYDRATION_DST_DIR, { recursive: true });
rmSync(BUNX_CWD_PAGES_REWRITE_DIR, { recursive: true });
+3 -2
View File
@@ -5,8 +5,6 @@ import grabDirNames from "../../utils/grab-dir-names";
import writeErrorFile from "../../functions/write-error-file";
import { rmSync } from "fs";
const { HYDRATION_DST_DIR, BUNX_CWD_PAGES_REWRITE_DIR } = grabDirNames();
// The supervisor only restarts the dev server when this process actually
// exits with a code. Registering a listener that merely logs (the previous
// behavior) keeps Bun from exiting: the crash is swallowed, no error file is
@@ -27,6 +25,9 @@ process.on("unhandledRejection", (reason) => {
log.info("Running development server ...");
try {
// The dist directory comes from the parent (`BUNEXT_DIST_DIR`).
const { HYDRATION_DST_DIR, BUNX_CWD_PAGES_REWRITE_DIR } = grabDirNames();
rmSync(HYDRATION_DST_DIR, { recursive: true });
rmSync(BUNX_CWD_PAGES_REWRITE_DIR, { recursive: true });
} catch (error) {}
+11 -6
View File
@@ -1,23 +1,28 @@
import { Command } from "commander";
import {
DistDirOptionDescription,
DistDirOptionFlags,
} from "../dist-dir-option";
import path from "path";
import type { BunSpawnOptions } from "../../types";
import grabDirNames from "../../utils/grab-dir-names";
import writeErrorFile from "../../functions/write-error-file";
import { existsSync, rmSync } from "fs";
const {
BUNDLER_CTX_MAP_JSON,
SSR_BUNDLER_CTX_MAP_JSON,
BUNX_BUNDLER_ERROR_EXIT_FILE,
} = grabDirNames();
const MAX_RETRIES = 5;
const RETRY_RESET_AFTER_MS = 10000;
export default function () {
return new Command("dev")
.description("Run development server")
.option(DistDirOptionFlags, DistDirOptionDescription)
.action(async () => {
const {
BUNDLER_CTX_MAP_JSON,
SSR_BUNDLER_CTX_MAP_JSON,
BUNX_BUNDLER_ERROR_EXIT_FILE,
} = grabDirNames();
try {
rmSync(SSR_BUNDLER_CTX_MAP_JSON, { recursive: true });
rmSync(BUNDLER_CTX_MAP_JSON, { recursive: true });
+4
View File
@@ -0,0 +1,4 @@
export const DistDirOptionFlags = "--dist-dir <path>";
export const DistDirOptionDescription =
"Build artifact directory, relative to the project root " +
"(default: `dist_dir` from bunext.config.ts, else .bunext)";
+16 -1
View File
@@ -6,6 +6,7 @@ import dev from "./dev";
import build from "./build";
import sitemap from "./sitemap";
import { log } from "../utils/log";
import setupDistDir from "../functions/setup-dist-dir";
/**
* # Describe Program
@@ -23,6 +24,20 @@ program.addCommand(start());
program.addCommand(build());
program.addCommand(sitemap());
/**
* # Resolve the Dist Directory
* Before any command runs, from `--dist-dir` or the config. Child server
* processes inherit it through `BUNEXT_DIST_DIR`.
*/
program.hook("preAction", async (_program, action_command) => {
try {
await setupDistDir({ dist_dir: action_command.opts().distDir });
} catch (error: any) {
log.error(error?.message || String(error));
process.exit(1);
}
});
/**
* # Handle Unavailable Commands
*/
@@ -39,4 +54,4 @@ program.on("command:*", () => {
/**
* # Parse Arguments
*/
program.parse(Bun.argv);
await program.parseAsync(Bun.argv);
+5
View File
@@ -1,4 +1,8 @@
import { Command } from "commander";
import {
DistDirOptionDescription,
DistDirOptionFlags,
} from "../dist-dir-option";
import {
existsSync,
mkdirSync,
@@ -40,6 +44,7 @@ export default function () {
)
.option("--origin <url>", "Base URL for sitemap links")
.option("--no-robots", "Skip generating robots.txt")
.option(DistDirOptionFlags, DistDirOptionDescription)
.action(async (options: Options) => {
const { ROOT_DIR, PUBLIC_DIR } = grabDirNames();
+7 -2
View File
@@ -1,19 +1,24 @@
import { Command } from "commander";
import {
DistDirOptionDescription,
DistDirOptionFlags,
} from "../dist-dir-option";
import path from "path";
import type { BunSpawnOptions } from "../../types";
import writeErrorFile from "../../functions/write-error-file";
import grabDirNames from "../../utils/grab-dir-names";
import { existsSync, rmSync } from "fs";
const { BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames();
const MAX_RETRIES = 5;
const RETRY_RESET_AFTER_MS = 10000;
export default function () {
return new Command("start")
.description("Start production server")
.option(DistDirOptionFlags, DistDirOptionDescription)
.action(async () => {
const { BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames();
// A stale error file from a previous session makes the bundler
// run in "silent" mode and pages fail with misleading messages.
rmSync(BUNX_BUNDLER_ERROR_EXIT_FILE, { force: true });
@@ -14,8 +14,6 @@ import {
import { log } from "../../utils/log";
import grabRootFilePath from "../server/web-pages/grab-root-file-path";
const { HYDRATION_DST_DIR, BUNX_HYDRATION_SRC_DIR } = grabDirNames();
type Params = {
post_build_fn?: (params: {
artifacts: BundlerCTXMap[];
@@ -58,6 +56,8 @@ function arraysEqual(a: string[], b: string[]): boolean {
}
export default async function allPagesESBuildContextBundler(params?: Params) {
const { HYDRATION_DST_DIR, BUNX_HYDRATION_SRC_DIR } = grabDirNames();
try {
const pages = grabAllPages({ exclude_api: true });
@@ -180,17 +180,49 @@ export default async function allPagesESBuildContextBundler(params?: Params) {
}
}
function forceExternalReact(): esbuild.Plugin {
const ExternalRequireNamespace = "bunext-external-require";
/**
* CommonJS `require("react")` (e.g. `use-sync-external-store/shim`, pulled
* in by MUI) for a module that is external in ESM output becomes esbuild's
* `__require`, which throws "Dynamic require of "react" is not supported" in
* the browser. Give those requires a small bundled ESM module that imports
* the external instead; esbuild wires `require()` to it.
*/
export function externalRequireShim(specifier: string): string {
const spec = JSON.stringify(specifier);
// A namespace import never fails to link, even when the vendor module has
// no default export (jsx-runtime); fall back to the namespace for
// `require(...).default` (Babel interop).
return [
`import * as ns from ${spec};`,
`export * from ${spec};`,
`export default ns.default ?? ns;`,
].join("\n");
}
export function forceExternalReact(): esbuild.Plugin {
return {
name: "force-external-react",
setup(build) {
build.onResolve({ filter: /^react(-dom)?(\/.*)?$/ }, (args) => {
if (args.pluginData?.externalReact) return null;
if (args.kind === "require-call") {
return { path: args.path, namespace: ExternalRequireNamespace };
}
return {
path: args.path,
external: true,
};
});
build.onLoad(
{ filter: /.*/, namespace: ExternalRequireNamespace },
(args) => ({
contents: externalRequireShim(args.path),
loader: "js",
}),
);
},
};
}
+2 -2
View File
@@ -3,9 +3,9 @@ import grabDirNames from "../../utils/grab-dir-names";
import isDevelopment from "../../utils/is-development";
import tailwindcss from "bun-plugin-tailwind";
const { BUNX_CWD_MODULE_CACHE_DIR } = grabDirNames();
export default async function apiRoutesBundler() {
const { BUNX_CWD_MODULE_CACHE_DIR } = grabDirNames();
const api_routes = grabAllPages({ api_only: true });
const dev = isDevelopment();
@@ -1,12 +1,9 @@
import * as esbuild from "esbuild";
import grabAllPages from "../../utils/grab-all-pages";
import grabDirNames from "../../utils/grab-dir-names";
import isDevelopment from "../../utils/is-development";
import tailwindEsbuildPlugin from "../server/web-pages/tailwind-esbuild-plugin";
import apiRoutesCTXArtifactTracker from "./plugins/api-routes-ctx-artifact-tracker";
const { BUNX_CWD_MODULE_CACHE_DIR } = grabDirNames();
export default async function apiRoutesContextBundler() {
const pages = grabAllPages({ api_only: true });
const dev = isDevelopment();
@@ -1,10 +1,9 @@
import grabDirNames from "../../utils/grab-dir-names";
import grabBunextPublicUrlPath from "../../utils/grab-bunext-public-url-path";
import isDevelopment from "../../utils/is-development";
import path from "path";
import { rmSync, mkdirSync, writeFileSync } from "fs";
const { BUNEXT_VENDOR_DIR, BUNX_CWD_DIR } = grabDirNames();
const VENDOR_ENTRIES: Record<string, string> = {
react: `
import React from "react";
@@ -42,6 +41,8 @@ const VENDOR_ENTRIES: Record<string, string> = {
};
export default async function bunReactModulesBundler() {
const { BUNEXT_VENDOR_DIR } = grabDirNames();
const dev = isDevelopment();
rmSync(BUNEXT_VENDOR_DIR, { force: true, recursive: true });
@@ -72,7 +73,7 @@ export default async function bunReactModulesBundler() {
rmSync(tmpDir, { force: true, recursive: true });
const PUBLIC_ROOT = BUNEXT_VENDOR_DIR.replace(BUNX_CWD_DIR, "/.bunext");
const PUBLIC_ROOT = `${grabBunextPublicUrlPath()}/vendor`;
global.BUNEXT_REACT_IMPORTS_MAP = {
imports: {
@@ -16,8 +16,6 @@ import { log } from "../../utils/log";
import { logBuildErrorsOnce } from "../../utils/build-error-log";
import shouldMinifySSR from "../../utils/should-minify-ssr";
const { BUNX_CWD_MODULE_CACHE_DIR, BUNX_TMP_DIR } = grabDirNames();
type SSRVirtualEntry = PageFiles & { tsx: string };
// Current virtual entry contents. The SSR virtual plugin resolves its
@@ -53,6 +51,8 @@ async function computeEntryToPage(): Promise<{
entryToPage: Map<string, SSRVirtualEntry>;
root_file_path: string | undefined;
}> {
const { BUNX_TMP_DIR } = grabDirNames();
const pages = grabAllPages({
include_server: true,
});
@@ -68,7 +68,7 @@ async function computeEntryToPage(): Promise<{
for (const page of pages) {
if (
page.local_path.match(/\/pages\/api\//) ||
page.url_path.startsWith("/api/") ||
page.local_path.match(/\.server\.tsx?$/)
) {
const ts = await Bun.file(page.local_path).text();
@@ -131,6 +131,8 @@ export async function awaitSSRRebuild() {
}
async function doSSRRebuild(params?: RebuildParams) {
const { BUNX_CWD_MODULE_CACHE_DIR, BUNX_TMP_DIR } = grabDirNames();
const force = Boolean(params?.force);
const dev = isDevelopment();
const config = global.BUNEXT_CONFIG;
+4 -5
View File
@@ -2,8 +2,7 @@
import * as esbuild from "esbuild";
import path from "path";
import grabDirNames from "../../../utils/grab-dir-names";
const { BUNEXT_VENDOR_DIR } = grabDirNames();
import grabBunextPublicUrlPath from "../../../utils/grab-bunext-public-url-path";
const REACT_MODULES = new Set([
"react",
@@ -13,8 +12,6 @@ const REACT_MODULES = new Set([
"react/jsx-dev-runtime",
]);
const VENDOR_BASE = "/.bunext/public/vendor";
const REACT_ENTRIES: Record<string, string> = {
react: `
import React from "react";
@@ -57,10 +54,12 @@ const REACT_ENTRIES: Record<string, string> = {
// Map bare specifier -> browser path
function vendorPath(specifier: string): string {
const filename = specifier.replace(/\//g, "_") + ".js";
return `${VENDOR_BASE}/${filename}`;
return `${grabBunextPublicUrlPath()}/vendor/${filename}`;
}
function vendorOutfile(specifier: string): string {
const { BUNEXT_VENDOR_DIR } = grabDirNames();
const filename = specifier.replace(/\//g, "_") + ".js";
return path.join(BUNEXT_VENDOR_DIR, filename);
}
@@ -17,8 +17,6 @@ import grabRelativePath from "../../../utils/grab-relative-path";
import cleanupArtifacts from "../../server/cleanup-artifacts";
import { pushClientUpdate } from "../../server/server-post-build-fn";
const { BUNX_BUNDLER_ERROR_EXIT_FILE, BUNX_ERROR_LOGS_DIR } = grabDirNames();
let build_start = 0;
type Params = {
@@ -47,6 +45,8 @@ export default function esbuildCTXArtifactTracker({
build_only,
refresh_props_on_first_build,
}: Params) {
const { BUNX_BUNDLER_ERROR_EXIT_FILE, BUNX_ERROR_LOGS_DIR } = grabDirNames();
let force_props_refresh = Boolean(refresh_props_on_first_build);
const artifactTracker: Plugin = {
@@ -13,8 +13,6 @@ import { log } from "../../../utils/log";
let build_start = 0;
const { BUNX_TMP_DIR } = grabDirNames();
type Params = {
entryToPage: Map<
string,
@@ -25,6 +23,8 @@ type Params = {
};
export default function ssrCTXArtifactTracker({ entryToPage }: Params) {
const { BUNX_TMP_DIR } = grabDirNames();
const artifactTracker: Plugin = {
name: "ssr-artifact-tracker",
setup(build) {
@@ -1,10 +1,11 @@
import * as esbuild from "esbuild";
import grabDirNames from "../../utils/grab-dir-names";
import grabBunextPublicUrlPath from "../../utils/grab-bunext-public-url-path";
import isDevelopment from "../../utils/is-development";
import path from "path";
import { rmSync, mkdirSync, writeFileSync } from "fs";
const { BUNEXT_VENDOR_DIR, BUNX_CWD_DIR, ROOT_DIR } = grabDirNames();
const { ROOT_DIR } = grabDirNames();
const VENDOR_ENTRIES: Record<string, string> = {
react: `
@@ -43,6 +44,8 @@ const VENDOR_ENTRIES: Record<string, string> = {
};
export default async function reactModulesBundler() {
const { BUNEXT_VENDOR_DIR } = grabDirNames();
const dev = isDevelopment();
rmSync(BUNEXT_VENDOR_DIR, { force: true, recursive: true });
@@ -75,7 +78,7 @@ export default async function reactModulesBundler() {
rmSync(tmpDir, { force: true, recursive: true });
const PUBLIC_ROOT = BUNEXT_VENDOR_DIR.replace(BUNX_CWD_DIR, "/.bunext");
const PUBLIC_ROOT = `${grabBunextPublicUrlPath()}/vendor`;
global.BUNEXT_REACT_IMPORTS_MAP = {
imports: {
+10 -3
View File
@@ -18,6 +18,7 @@ import grabConstants from "../utils/grab-constants";
import watcherEsbuildCTX from "./server/watcher-esbuild-ctx";
import disposeBundlerContexts from "./bundler/dispose-bundler-contexts";
import { readFileSync } from "fs";
import setupDistDir from "./setup-dist-dir";
/**
* # Declare Global Variables
@@ -64,15 +65,21 @@ declare global {
var BUNEXT_INITIAL_BUILD_PROMISE: Promise<void> | undefined;
}
const dirNames = grabDirNames();
const { PAGES_DIR, BUNDLER_CTX_MAP_JSON, SSR_BUNDLER_CTX_MAP_JSON } = dirNames;
type Params = {
build_only?: boolean;
start?: boolean;
};
export default async function bunextInit(params?: Params) {
// First: everything below reads the artifact paths. A no-op under the
// CLI, which already resolved it; a custom server gets `dist_dir` from
// the config here.
await setupDistDir();
const dirNames = grabDirNames();
const { PAGES_DIR, BUNDLER_CTX_MAP_JSON, SSR_BUNDLER_CTX_MAP_JSON } =
dirNames;
const is_dev = isDevelopment();
if (!is_dev || params?.start) {
+2 -2
View File
@@ -4,9 +4,9 @@ import grabDirNames from "../utils/grab-dir-names";
import grabConstants from "../utils/grab-constants";
import { AppData } from "../data/app-data";
const { BUNX_LOGS_DIR } = grabDirNames();
export default function cleanupLogsDirs() {
const { BUNX_LOGS_DIR } = grabDirNames();
const logs_dirs = readdirSync(BUNX_LOGS_DIR);
const { config } = grabConstants();
+7 -1
View File
@@ -10,8 +10,10 @@ import { AppData } from "../../data/app-data";
import { log } from "../../utils/log";
import fullRebuild from "./full-rebuild";
import grabRouter from "../../utils/grab-router";
import grabBunextPublicUrlPath from "../../utils/grab-bunext-public-url-path";
import { NoSEORobotsValue } from "../../utils/disable-seo";
import generateRobotsTxt from "../../utils/generate-robots-txt";
import compressResponse from "../../utils/compress-response";
const HMR_RETRY_COOLDOWN_MS = 5000;
let lastHmrRetryTime = 0;
@@ -79,7 +81,7 @@ export default async function bunextRequestHandler({
response = new Response(generateRobotsTxt({ disallow_all: true }), {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
} else if (url.pathname.startsWith("/.bunext")) {
} else if (url.pathname.startsWith(`${grabBunextPublicUrlPath()}/`)) {
response = await handleBunextPublicAssets({ req });
} else if (url.pathname.startsWith("/api/")) {
response = await handleRoutes({ req });
@@ -114,6 +116,10 @@ export default async function bunextRequestHandler({
);
}
if (global.BUNEXT_CONFIG?.compress !== false) {
response = compressResponse(req, response);
}
return response;
} catch (error: any) {
log.error(
+3 -2
View File
@@ -7,8 +7,7 @@ type Params = {
paradigm: "hydration" | "ssr";
};
const { ROOT_DIR, HYDRATION_DST_DIR, BUNX_CWD_MODULE_CACHE_DIR } =
grabDirNames();
const { ROOT_DIR } = grabDirNames();
// esbuild's content hashes are 8-char base64-ish (e.g. `CMSI7VSQ`). Entry
// outputs are `<dir>/<hash>.js` / `<dir>/<hash>.css`. Shared chunks are
@@ -27,6 +26,8 @@ const ENTRY_HASH_FILE = /^[A-Za-z0-9]{8}\.(js|css)$/;
* left untouched.
*/
export default async function cleanupArtifacts({ paradigm }: Params) {
const { HYDRATION_DST_DIR, BUNX_CWD_MODULE_CACHE_DIR } = grabDirNames();
const is_hydration = paradigm === "hydration";
const root_dir = is_hydration
? HYDRATION_DST_DIR
@@ -1,25 +1,26 @@
import grabDirNames from "../../utils/grab-dir-names";
import grabBunextPublicUrlPath from "../../utils/grab-bunext-public-url-path";
import path from "path";
import isDevelopment from "../../utils/is-development";
import { readFileResponse } from "./handle-public";
import isSafePath from "../../utils/is-safe-path";
const { BUNEXT_PUBLIC_DIR } = grabDirNames();
type Params = {
req: Request;
};
export default async function ({ req }: Params): Promise<Response> {
const { BUNEXT_PUBLIC_DIR } = grabDirNames();
const BUNEXT_PUBLIC_URL_PATH = grabBunextPublicUrlPath();
try {
const is_dev = isDevelopment();
const url = new URL(req.url);
const file_path = path.join(
BUNEXT_PUBLIC_DIR,
decodeURIComponent(url.pathname).replace(
/\/\.bunext\/public\//,
"",
decodeURIComponent(url.pathname).slice(
BUNEXT_PUBLIC_URL_PATH.length,
),
);
+6 -6
View File
@@ -1,8 +1,6 @@
import grabDirNames from "../../utils/grab-dir-names";
import path from "path";
import isDevelopment from "../../utils/is-development";
import { existsSync } from "fs";
import isSafePath from "../../utils/is-safe-path";
import resolvePublicFile from "../../utils/resolve-public-file";
const { PUBLIC_DIR } = grabDirNames();
@@ -14,18 +12,20 @@ export default async function ({ req }: Params): Promise<Response> {
try {
const is_dev = isDevelopment();
const url = new URL(req.url);
const file_path = path.join(PUBLIC_DIR, decodeURIComponent(url.pathname));
const resolved = resolvePublicFile(PUBLIC_DIR, url.pathname);
if (!isSafePath({ filePath: file_path, allowedDir: PUBLIC_DIR })) {
if (resolved.status == 403) {
return new Response("Forbidden", { status: 403 });
}
if (!existsSync(file_path)) {
if (resolved.status != 200) {
return new Response(`File Doesn't Exist`, {
status: 404,
});
}
const { file_path } = resolved;
const file = Bun.file(file_path);
const headers = new Headers();
+12 -5
View File
@@ -1,8 +1,7 @@
import grabDirNames from "../../utils/grab-dir-names";
import path from "path";
import isDevelopment from "../../utils/is-development";
import { existsSync } from "fs";
import isSafePath from "../../utils/is-safe-path";
import resolvePublicFile from "../../utils/resolve-public-file";
const { PUBLIC_DIR } = grabDirNames();
@@ -15,15 +14,23 @@ export default async function ({ req }: Params): Promise<Response> {
const is_dev = isDevelopment();
const url = new URL(req.url);
const file_path = path.join(
const resolved = resolvePublicFile(
PUBLIC_DIR,
decodeURIComponent(url.pathname).replace(/^\/public/, ""),
url.pathname.replace(/^\/public/, ""),
);
if (!isSafePath({ filePath: file_path, allowedDir: PUBLIC_DIR })) {
if (resolved.status == 403) {
return new Response("Forbidden", { status: 403 });
}
if (resolved.status != 200) {
return new Response(`Public File Doesn't Exist`, {
status: 404,
});
}
const { file_path } = resolved;
return readFileResponse({ file_path });
} catch (error) {
return new Response(`Public File Not Found`, {
+2 -1
View File
@@ -1,3 +1,4 @@
import grabClientPageProps from "../../utils/grab-client-page-props";
import _ from "lodash";
import type { GlobalHMRControllerObject } from "../../types";
import grabPageComponent from "./web-pages/grab-page-component";
@@ -145,7 +146,7 @@ export default async function serverPostBuildFn(params?: Params) {
}
// Always replace so prior error props cannot linger
final_artifact.page_props = serverRes || {};
final_artifact.page_props = grabClientPageProps(serverRes);
try {
let final_data: { [k: string]: any } = {};
+49 -11
View File
@@ -13,13 +13,18 @@ import serverPostBuildFn from "./server-post-build-fn";
import { log } from "../../utils/log";
import { logBuildErrorsOnce } from "../../utils/build-error-log";
const { ROOT_DIR, BUNX_CWD_DIR, BUNX_BUNDLER_ERROR_EXIT_FILE } =
grabDirNames();
const { ROOT_DIR } = grabDirNames();
// The watchdog writes this file and expects the watcher to report it. It
// sits under `.bunext` (otherwise ignored), so only this path is watched.
const WATCH_PROBE_DIR = path.join(BUNX_CWD_DIR, "watch-probe");
const WATCH_PROBE_FILE = path.join(WATCH_PROBE_DIR, "probe");
// sits under the dist directory (otherwise ignored), so only this path is
// watched.
function grabWatchPaths() {
const { BUNX_CWD_DIR } = grabDirNames();
const WATCH_PROBE_DIR = path.join(BUNX_CWD_DIR, "watch-probe");
const WATCH_PROBE_FILE = path.join(WATCH_PROBE_DIR, "probe");
return { BUNX_CWD_DIR, WATCH_PROBE_DIR, WATCH_PROBE_FILE };
}
// Dedupes near-simultaneous restarts (fullRebuild, watchdog).
let last_watcher_restart = 0;
@@ -70,6 +75,8 @@ function grabPagesSnapshot(): string | undefined {
* project tree.
*/
export function watcherWatchdogTick() {
const { WATCH_PROBE_DIR, WATCH_PROBE_FILE } = grabWatchPaths();
if (!global.BUNEXT_PAGES_SRC_WATCHER || !watcher_ready) return;
if (probe_pending) {
@@ -85,11 +92,34 @@ export function watcherWatchdogTick() {
}
function isWatchProbePath(p: string) {
const { WATCH_PROBE_DIR, WATCH_PROBE_FILE } = grabWatchPaths();
return (
p === WATCH_PROBE_FILE || p === WATCH_PROBE_DIR || p === BUNX_CWD_DIR
p === WATCH_PROBE_FILE ||
p === WATCH_PROBE_DIR ||
isDistDirOrAncestor(p)
);
}
/**
* The dist directory and the folders between it and the project root must
* stay watched for the watcher to reach the probe, even when an ignore rule
* matches them (dot folders, `dist`, a custom `dist_dir`).
*/
function isDistDirOrAncestor(p: string) {
const { BUNX_CWD_DIR } = grabDirNames();
if (p === ROOT_DIR) return false;
return p === BUNX_CWD_DIR || BUNX_CWD_DIR.startsWith(p + path.sep);
}
function isInDistDir(p: string) {
const { BUNX_CWD_DIR } = grabDirNames();
return p === BUNX_CWD_DIR || p.startsWith(BUNX_CWD_DIR + path.sep);
}
const TARGET_FILES_MATCH = /\.(tsx?|jsx?|css)$/;
// Rebuild coalescing: editors emit bursts of events per save (change +
@@ -219,15 +249,18 @@ function isPathExcluded(filename: string, full_file_path: string): boolean {
// Heavy directories are filtered at the watcher level so inotify doesn't
// install watches (and deliver events) for trees the bundler never reads.
function buildIgnoredPatterns() {
const ignored: Array<RegExp | ((p: string) => boolean)> = [
(p: string) => !isWatchProbePath(p) && /(^|[\/\\])\../.test(p),
const builtin_patterns: RegExp[] = [
/(^|[\/\\])\../,
/(^|[\/\\])node_modules([\/\\]|$)/,
/(^|[\/\\])public([\/\\]|$)/,
(p: string) =>
!isWatchProbePath(p) && /(^|[\/\\])\.bunext([\/\\]|$)/.test(p),
/(^|[\/\\])\.git([\/\\]|$)/,
/(^|[\/\\])dist([\/\\]|$)/,
/bun\.lockb$/,
];
const ignored: Array<(p: string) => boolean> = [
(p: string) =>
!isWatchProbePath(p) &&
(isInDistDir(p) || builtin_patterns.some((re) => re.test(p))),
(p: string) => p.endsWith(AppData["BunextTmpFileExt"]),
];
@@ -252,6 +285,8 @@ function buildIgnoredPatterns() {
export default async function watcherEsbuildCTX(params?: {
pages_snapshot?: string;
}) {
const { WATCH_PROBE_DIR } = grabWatchPaths();
try {
mkdirSync(WATCH_PROBE_DIR, { recursive: true });
} catch (error) {}
@@ -292,6 +327,9 @@ function handleEvent(
event: "add" | "change" | "unlink" | "addDir" | "unlinkDir",
filePath: string,
) {
const { WATCH_PROBE_FILE } = grabWatchPaths();
const { BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames();
if (isWatchProbePath(filePath)) {
if (filePath === WATCH_PROBE_FILE) {
probe_pending = false;
@@ -9,6 +9,7 @@ import { AppData } from "../../../data/app-data";
import _ from "lodash";
import grabDirNames from "../../../utils/grab-dir-names";
import { NoSEORobotsValue } from "../../../utils/disable-seo";
import grabClientPageProps from "../../../utils/grab-client-page-props";
const { ROOT_DIR } = grabDirNames();
@@ -38,10 +39,15 @@ export default async function genWebHTML({
throw new Error(`Main Component not found!`);
}
// Only the client-safe part reaches the components and the HTML;
// response_options (headers, cookies) etc. stay on the server. Head and
// meta still get the full server result: they only render here.
const client_props = grabClientPageProps(pageProps);
let serializedProps = "{}";
try {
serializedProps = serializeForScript(pageProps || {});
serializedProps = serializeForScript(client_props);
} catch (error) {}
const page_hydration_script = await grabWebPageHydrationScript();
@@ -157,7 +163,7 @@ export default async function genWebHTML({
id={ClientRootElementIDName}
suppressHydrationWarning={!dev}
>
<Main {...pageProps} />
<Main {...client_props} />
</div>
</body>
</html>
@@ -4,6 +4,7 @@ import isDevelopment from "../../../utils/is-development";
import { log } from "../../../utils/log";
import genWebHTML from "./generate-web-html";
import generateWebPageGetCachePage from "./generate-web-page-get-cache-page";
import grabRedirectResponse from "../../../utils/grab-redirect-response";
export default async function generateWebPageResponseFromComponentReturn({
component,
@@ -17,12 +18,7 @@ export default async function generateWebPageResponseFromComponentReturn({
const is_dev = isDevelopment();
if (serverRes?.redirect?.destination) {
return Response.redirect(
serverRes.redirect.destination,
serverRes.redirect.permanent
? 301
: serverRes.redirect.status_code || 302,
);
return await grabRedirectResponse({ serverRes, routeParams });
}
const html = await genWebHTML({
@@ -39,23 +35,25 @@ export default async function generateWebPageResponseFromComponentReturn({
log.info("html", html);
}
const res_opts: ResponseInit = {
...serverRes?.response_options,
headers: {
"Content-Type": "text/html",
...serverRes?.response_options?.headers,
},
};
// A Headers object (not an object spread) so repeated headers such as
// several Set-Cookie survive, whichever HeadersInit form was returned.
const headers = new Headers(serverRes?.response_options?.headers);
if (!headers.has("Content-Type")) {
headers.set("Content-Type", "text/html");
}
if (is_dev) {
res_opts.headers = {
...res_opts.headers,
"Cache-Control": "no-cache, no-store, must-revalidate",
Pragma: "no-cache",
Expires: "0",
};
headers.set("Cache-Control", "no-cache, no-store, must-revalidate");
headers.set("Pragma", "no-cache");
headers.set("Expires", "0");
}
const res_opts: ResponseInit = {
...serverRes?.response_options,
headers,
};
if (!is_dev) {
await generateWebPageGetCachePage({
html,
@@ -13,17 +13,10 @@ import grabDirNames from "../../../utils/grab-dir-names";
import grabRelativePath from "../../../utils/grab-relative-path";
import { logBuildErrorsOnce } from "../../../utils/build-error-log";
import { RequestBodyTooLargeError } from "../../../utils/read-request-body";
import NotFoundError from "../../../utils/not-found-error";
import isRoutablePage from "../../../utils/is-routable-page";
const { BUNX_BUNDLER_ERROR_EXIT_FILE, ROOT_DIR } = grabDirNames();
class NotFoundError extends Error {
status = 404;
constructor(message: string) {
super(message);
this.name = "NotFoundError";
}
}
const { ROOT_DIR } = grabDirNames();
type Params = {
req?: Request;
@@ -44,6 +37,8 @@ type Params = {
export default async function grabPageComponent(
params: Params,
): Promise<GrabPageComponentRes | Response> {
const { BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames();
const {
req,
file_path: passed_file_path,
@@ -89,6 +84,13 @@ export default async function grabPageComponent(
throw new NotFoundError(`Page ${url.pathname} not found`);
}
// The router also matches files that are never bundled (.server.ts
// companions, __root, excluded paths). Without this they would sit
// in the dev rebuild/retry loop and never respond.
if (match?.filePath && !isRoutablePage(match.filePath)) {
throw new NotFoundError(`Page ${url?.pathname} not found`);
}
const file_path = match?.filePath || passed_file_path;
if (debug) {
@@ -11,6 +11,8 @@ import grabRootFilePath from "./grab-root-file-path";
import grabPageCombinedServerRes from "./grab-page-combined-server-res";
import type { FC } from "react";
import importFresh from "../../../utils/import-fresh";
import grabRedirectResponse from "../../../utils/grab-redirect-response";
import NotFoundError from "../../../utils/not-found-error";
type Params = {
file_path: string;
@@ -47,12 +49,12 @@ export default async function grabPageModules({
});
if (serverRes?.redirect?.destination) {
return Response.redirect(
serverRes.redirect.destination,
serverRes.redirect.permanent
? 301
: serverRes.redirect.status_code || 302,
);
return await grabRedirectResponse({ serverRes, routeParams });
}
// Rendered as the 404 page by grabPageComponent's error path
if (serverRes?.not_found) {
throw new NotFoundError(`Page ${url?.pathname || file_path} not found`);
}
const {
@@ -9,11 +9,13 @@ import type {
} from "../../../types";
import { existsSync, rmSync, unlinkSync } from "fs";
const { PAGES_DIR, BUNX_CWD_MODULE_CACHE_DIR, ROOT_DIR } = grabDirNames();
const { PAGES_DIR, ROOT_DIR } = grabDirNames();
type Params = GrabTSXModuleSingleParams | GrabTSXModuleBatchParams;
function toModPath(page_file_path: string) {
const { BUNX_CWD_MODULE_CACHE_DIR } = grabDirNames();
return path.join(
BUNX_CWD_MODULE_CACHE_DIR,
page_file_path.replace(PAGES_DIR, "").replace(/\.(t|j)sx?$/, ".js"),
@@ -30,6 +32,8 @@ type BuildEntriesParams = {
};
async function buildEntries({ entries, clean_cache }: BuildEntriesParams) {
const { BUNX_CWD_MODULE_CACHE_DIR } = grabDirNames();
const dev = isDevelopment();
const toBuild: {

Some files were not shown because too many files have changed in this diff Show More