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]>
This commit is contained in:
2026-11-09 04:14:56 +01:00
co-authored by Claude Opus 5.5
parent b410b92f9f
commit 96e392f556
79 changed files with 1334 additions and 184 deletions
+86 -37
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)
@@ -314,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";
@@ -358,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
@@ -414,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`
},
};
}
@@ -426,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
@@ -439,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",
@@ -451,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>`:
@@ -607,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> {
@@ -863,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.
---
@@ -920,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
};
};
@@ -941,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
@@ -975,8 +1016,8 @@ const config: BunextConfig = {
port: 3000, // default: 7000
origin: "https://example.com",
dist_dir: ".bunext", // directory for build artifacts
assetsPrefix: "_bunext/static",
globalVars: {
assets_prefix: "_bunext/static",
global_vars: {
MY_API_URL: "https://api.example.com",
},
public_envs: {
@@ -993,17 +1034,19 @@ export default config;
| `port` | `number` | `7000` | HTTP server port |
| `origin` | `string` | — | Canonical origin URL |
| `dist_dir` | `string` | `.bunext` | Build artifact directory, relative to the project root — see [Dist Directory](#dist-directory) |
| `assetsPrefix` | `string` | `_bunext/static` | URL prefix for static assets |
| `globalVars` | `{ [k: string]: any }` | — | Variables injected globally at build time |
| `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
@@ -1083,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"),
@@ -1226,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`.
@@ -1262,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 });
@@ -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;
+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`,
+2 -1
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,
});
@@ -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,
});
}
+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.0",
"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,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,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({});
});
});
@@ -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,
});
});
});
@@ -13,6 +13,7 @@ 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;
@@ -115,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(
+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 } = {};
@@ -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,18 +13,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";
import NotFoundError from "../../../utils/not-found-error";
import isRoutablePage from "../../../utils/is-routable-page";
const { ROOT_DIR } = grabDirNames();
class NotFoundError extends Error {
status = 404;
constructor(message: string) {
super(message);
this.name = "NotFoundError";
}
}
type Params = {
req?: Request;
file_path?: string;
@@ -91,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 {
+27 -2
View File
@@ -86,6 +86,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">;
/**
@@ -225,7 +231,7 @@ export type BunextServerRouteConfig = {
};
export type LivePageDistGenParams = {
component?: FC;
component?: FC<any>;
pageProps?: any;
module?: BunextPageModule;
root_module?: BunextRootModule;
@@ -332,6 +338,17 @@ export type BunextPageModuleServerReturn<
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
@@ -353,10 +370,16 @@ export type BunextCachePageType =
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 } = { [k: string]: any },
Q extends { [k: string]: any } = { [k: string]: any },
> = BunextPageModuleServerReturn<T, Q>;
> = Pick<BunextPageModuleServerReturn<T, Q>, "props" | "query" | "url" | "error">;
export type BunextPageModuleServerReturnURLObject = {
hash: string;
@@ -375,7 +398,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;
};
+50
View File
@@ -0,0 +1,50 @@
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: Request,
res: Response,
): Response {
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,
});
}
+25
View File
@@ -0,0 +1,25 @@
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 {
if (!serverRes) return {};
const client_props: BunextPageProps = {};
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;
}
+36
View File
@@ -0,0 +1,36 @@
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 async function grabRedirectResponse({
serverRes,
routeParams,
}: Params): Promise<Response> {
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;
}
+9 -7
View File
@@ -3,10 +3,11 @@ 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<string, number | undefined>();
const loaded_versions = new Map<string, string | undefined>();
/**
* Imports a module by its stable path.
@@ -25,21 +26,22 @@ export default async function importFresh<T = any>(
return import(file_path);
}
let mtime: number | undefined;
let version: string | undefined;
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) {
// Don't keep a failed evaluation around: the next request retries.
+42
View File
@@ -0,0 +1,42 @@
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: string): boolean {
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;
}
+13
View File
@@ -0,0 +1,13 @@
/**
* 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: string) {
super(message);
this.name = "NotFoundError";
}
}
+42
View File
@@ -0,0 +1,42 @@
import { existsSync, statSync } from "fs";
import path from "path";
import isSafePath from "./is-safe-path";
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 {
let decoded: string;
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 };
}