27 Commits
Author SHA1 Message Date
tbenandClaude Opus 5.5 4d4a79a6dd Push HMR updates before the SSR rebuild in dev
Client-only edits are sent to the browser right after the hydration build,
without re-running server functions; SSR rebuilds afterwards and props are
re-fetched only when a .server.ts output changed or after fullRebuild. Page
requests wait for an in-flight SSR rebuild. The SSR build no longer runs
Tailwind, the PostCSS processor is reused across rebuilds, and the watcher
debounce drops from 200ms to 50ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 07:32:33 +01:00
tben ed788dff97 Add SSR bundle minify config 2026-10-17 15:03:30 +01:00
tben 8a981c854a Builds errors bugfix 2026-10-16 16:54:55 +01:00
tben af12852fb9 Republish dev memory leak bugfix branch 2026-10-16 16:42:27 +01:00
tben 1e3e9c4d69 Merge branch 'fix-memory-errors-in-dev' 2026-10-16 14:47:24 +01:00
tben c64501cae5 Revert to v1.2.4 2026-10-16 14:43:31 +01:00
tben 5dc783fe97 Fix memory leak in dev server 2026-10-16 14:25:18 +01:00
tbenandClaude Opus 5.5 3d0771f2d0 Stop per-request module re-imports leaking memory in production
Page rendering imported the SSR bundle and .server.ts modules with a
unique ?t= query on every request, in production too. Each one became a
new, never-evicted module record (and re-ran module top-level code), so
RSS grew with request count. Cache-bust only in dev via importFresh.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:37:15 +01:00
tben 7694e80c60 Build with bun 1.4.3 2026-10-15 18:04:52 +01:00
tbenandClaude Opus 5.5 40c17f903e Keep bunext sitemap alive while sitemap exports query the DB
Bun's SQL client (mariadb/mysql adapters, Bun 1.3.14) doesn't keep the
event loop alive for a query on an idle connection, so a second dynamic
route's sitemap export querying the DB made the command exit silently.
Hold the loop open with a timer during generation, and log what a stuck
run is waiting on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:00:40 +01:00
tbenandClaude Opus 5.5 23a309005a Add disable_seo config to keep preview deployments out of search engines
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 05:29:08 +01:00
tbenandClaude Opus 5.5 3a75880ae4 Date sitemap lastmod from the build pipeline; warn on skipped pages
- bunext build records when each page's client bundle hash last changed
  in .bunext/page-lastmod.json (sitemap.lastmod_file to relocate). The
  hash covers the page's whole import graph, so component edits re-date
  every page that uses them. The sitemap uses these dates as lastmod
- Add sitemap.default_lastmod
- BREAKING: use_file_lastmod now defaults to false (file mtimes are reset
  by checkouts); it is a fallback after the build record
- Warn when a page or __root.tsx is skipped because its default export
  is not at the start of a line (e.g. export { Page as default })
- Replace leaking module mocks in tests (log, grab-root-file-path) with
  spies and fixtures

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:25:38 +01:00
tbenandClaude Opus 5.5 be4bebaa22 Add robots.txt generation and sitemap chunking
- bunext sitemap now writes public/robots.txt (configurable via the
  robots config key; robots: false or --no-robots to skip). Hand-written
  robots.txt files are never overwritten without a robots config
- Large sitemaps are split into public/sitemap-chunks/sitemap-N.xml
  (sitemap.chunk_size, default 10,000, max 50,000; 50MB per file) with
  sitemap.xml as a sitemap index; stale chunks are cleaned up
- Export bunextGenerateRobotsTxt
- Run tests in publish.sh

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:02:52 +01:00
tben 1169729b06 Add sitemap generation utility 2026-10-14 17:42:27 +01:00
tben 632881e174 Add sitemap generation utility 2026-10-14 17:41:50 +01:00
tbenandClaude Opus 5.5 d9de858439 Add sitemap generation and bunext sitemap command
- Add generateSitemap util (exported as bunext.bunextGenerateSitemap)
  that builds sitemap.xml from static pages, with exclude patterns,
  additional paths (array or sync/async function), lastmod from file
  mtime, trailing slash, and default changefreq/priority options
- Let page .server.ts files export a `sitemap` function to list URLs
  for dynamic routes, or override a static page's entry
- Add `bunext sitemap` command reading `sitemap` from bunext.config.ts,
  writing public/sitemap.xml by default, with --output/--origin flags
- Fix handle-routes test leaking a grab-router module mock into
  bunext-req-handler tests; set global.BUNEXT_ROUTER instead
- Ignore /.tmp
- Document sitemap usage in README and CLAUDE.md

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 17:41:21 +01:00
tben 3025b1681d Fix module watcher errors 2026-10-14 12:30:41 +01:00
tben eefb2ea5d2 Fix security vulnerabilities 2026-10-13 15:52:34 +01:00
tbenandClaude Opus 5.5 74103a19c9 Fix security vulnerabilities and request handling bugs
- Move page cache out of the publicly served .bunext/public dir and hash cache keys
- Skip caching credentialed requests, non-2xx and Set-Cookie responses; cap entries
- Enforce API body size limit while reading; keep req body readable for handlers
- Hide error messages from clients in production
- Escape inline script JSON; strip cookies from HMR payloads
- Only trust X-Forwarded-Proto with trust_proxy config
- Fix console race in SSR, dotted page paths, middleware rewrites,
  excluded path status, encoded static file names, symlinked dirs
- Upgrade vulnerable deps, remove micromatch, pin bun versions, delete dead code

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 15:48:08 +01:00
tben 4238d9a4ea Fix watcher rebuild on page add and delete 2026-10-13 15:18:41 +01:00
tben 7bab610b38 Fix failing tests, update page component handler to exclude 404 pages on retries 2026-10-03 03:32:01 +01:00
tben 6e6cfef5e5 Fix failing tests 2026-10-03 03:18:03 +01:00
tben 3a4ece5255 Updates 2026-10-02 14:50:13 +01:00
tben 8fa5b64b48 Fix build lags 2026-10-02 14:48:38 +01:00
tben 17a5c3df01 Updates 2026-10-02 07:23:33 +01:00
tben 7e353dbeb3 Error handling bugfix: pass #2 2026-10-02 07:20:55 +01:00
tben d8ea4f8e02 Error handling bugfix: pass #1 2026-10-02 07:04:22 +01:00
202 changed files with 8645 additions and 3055 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
bun-version: 1.3.14
- name: Install dependencies
run: bun install --frozen-lockfile
+1
View File
@@ -183,3 +183,4 @@ __fixtures__
/.vscode
/source.md
SECURITY.md
/.tmp
+3
View File
@@ -19,6 +19,7 @@ bun run dev # tsc --watch
bunext dev # Start dev server with HMR and file watcher
bunext build # Bundle all pages for production
bunext start # Start production server from pre-built artifacts
bunext sitemap # Write public/sitemap.xml and public/robots.txt using config.sitemap / config.robots
```
## Architecture
@@ -51,6 +52,7 @@ Pages live in `src/pages/`. Server logic is **separated from page files** into c
**Server file** (`page.server.ts` or `page.server.tsx`) — server-only, never sent to the browser:
- Default export or `export const server`: `BunextPageServerFn` — runs server-side before rendering, return value becomes props
- `export const sitemap`: `BunextPageSitemapFn` — optional; returns the page's sitemap entries (how dynamic routes get into `bunext sitemap`)
The framework resolves the companion by replacing the page extension with `.server.ts` or `.server.tsx`. If neither exists, no server function runs and only the default `url` prop is injected.
@@ -60,6 +62,7 @@ API routes live in `src/pages/api/` and follow standard Bun `Request → Respons
### Bundler artifact tracking
`BundlerCTXMap` (stored in `global.BUNDLER_CTX_MAP`) maps each page to its bundled output path, content hash, and entrypoint. In production this is serialized to `public/pages/map.json` and loaded at startup.
`bunext build` also updates `.bunext/page-lastmod.json` (`src/utils/page-lastmod-record.ts`): per page, a fingerprint of the bundle hash + CSS path and the build date it last changed. The sitemap uses these dates as `lastmod`.
### Config
Consumer projects define `bunext.config.ts` at their project root. The `BunextConfig` type fields: `distDir`, `assetsPrefix`, `origin`, `globalVars`, `port`, `development`.
+228
View File
@@ -36,6 +36,13 @@ The goal is a framework that is:
- [API Routes](#api-routes)
- [Route Config (Body Size Limit)](#route-config-body-size-limit)
- [Error Pages](#error-pages)
- [Sitemap](#sitemap)
- [Page `sitemap` Export](#page-sitemap-export)
- [`bunext sitemap` Command](#bunext-sitemap-command)
- [Last-Modified Dates](#last-modified-dates)
- [Large Sitemaps](#large-sitemaps)
- [Generating at Runtime](#generating-at-runtime)
- [robots.txt](#robotstxt)
- [Static Files](#static-files)
- [Caching](#caching)
- [Enabling Cache Per Page](#enabling-cache-per-page)
@@ -155,6 +162,7 @@ bun run dev
| `bunext dev` | Start the development server with HMR and file watching. |
| `bunext build` | Bundle all pages for production. Outputs artifacts to `.bunext/public/pages/` using paths relative to the project root. |
| `bunext start` | Start the production server using pre-built artifacts. |
| `bunext sitemap` | Generate `public/sitemap.xml` and `public/robots.txt` from your pages, using the `sitemap` and `robots` options in `bunext.config.ts`. See [Sitemap](#sitemap). |
### Running the CLI
@@ -325,6 +333,8 @@ export default function ProfilePage({ props, url }: Props) {
}
```
> Server files can also export a `sitemap` function to list a page's URLs in the sitemap — see [Page `sitemap` Export](#page-sitemap-export).
> **Why separate files?** Bundling server code (Bun APIs, database clients, `fs`, secrets) into the same file as a React component causes TypeScript compilation errors because the bundler processes the page file for the browser. The `.server.ts` companion file is loaded only by the server at request time and is never included in the client bundle.
The server function receives a `ctx` object (type `BunxRouteParams`) with:
@@ -624,6 +634,220 @@ If no custom error pages exist, Bunext uses built-in preset components.
---
## Sitemap
Bunext builds a sitemap from every static page in `src/pages/`. API routes, `404`/`500` pages and ignored directories are left out. Dynamic routes (`[slug]`) cannot be discovered from the file system, so they are only included when their server file exports a `sitemap` function (see below), or when you pass their URLs through `additional_paths`.
### Page `sitemap` Export
A page's `.server.ts` file can export a `sitemap` function returning that page's entries. This is the way to list URLs for a dynamic route:
```ts
// src/pages/blog/[slug].server.ts
import type { BunextPageServerFn, BunextPageSitemapFn } from "@moduletrace/bunext/types";
const server: BunextPageServerFn = async (ctx) => {
// ...
};
export default server;
export const sitemap: BunextPageSitemapFn = async ({ route }) => {
// route === "/blog/[slug]"
const posts = await getPublishedPosts();
return posts.map((post) => ({
path: `/blog/${post.slug}`,
lastmod: post.updated_at,
priority: 0.7,
}));
};
```
- It can be sync or async, and returns strings or `BunextSitemapEntry` objects, like `additional_paths`.
- On a static page, the returned entries **replace** the page's default entry, so you can set its `priority`/`changefreq`, or return `[]` to leave the page out.
- `exclude` and the `default_*` options still apply to the returned entries.
- Generating a sitemap imports every page's server file, so their top-level code runs. If a `sitemap` function throws, generation fails with the file name in the error.
### `bunext sitemap` Command
Add a `sitemap` key to `bunext.config.ts`, then run `bunext sitemap`. By default the file is written to `public/sitemap.xml`, which is served at `/sitemap.xml`. Run it after `bunext build` in your deploy step to keep it current.
```ts
// bunext.config.ts
import type { BunextConfig } from "@moduletrace/bunext/types";
const config: BunextConfig = {
origin: "https://example.com",
sitemap: {
exclude: ["/login", "/admin/*"],
default_changefreq: "weekly",
// A function (sync or async) lets you load dynamic routes
additional_paths: async () => {
const posts = await getPublishedPosts();
return posts.map((post) => ({
path: `/blog/${post.slug}`,
lastmod: post.updated_at,
}));
},
},
};
export default config;
```
```bash
bunext sitemap # writes public/sitemap.xml and public/robots.txt
bunext sitemap -o public/sitemaps/main.xml # custom sitemap output path
bunext sitemap --origin https://staging.example.com
bunext sitemap --no-robots # sitemap only
```
#### Last-Modified Dates
`lastmod` comes from the build pipeline. On every `bunext build`, Bunext compares each page's client bundle hash with the previous build and records the build date for pages whose bundle changed in `.bunext/page-lastmod.json`. The hash covers the page's whole import graph, so editing a shared component re-dates every page that uses it, while untouched pages keep their date. Editing `__root.tsx` changes every page.
Each URL's `lastmod` is the first of:
1. The entry's own `lastmod` (from `additional_paths` or a page's `sitemap` export)
2. The page's date from the build record (also used for entries returned by that page's `sitemap` export)
3. The page file's modification time, if `use_file_lastmod: true`
4. `default_lastmod`
With no date, `<lastmod>` is left out. Run `bunext build` before `bunext sitemap`; the command warns when there is no record.
- **The record must persist between builds.** Without the previous record, every page is dated with the current build. Building on the server you deploy to keeps `.bunext/` between builds. For CI builds from a fresh checkout, set `sitemap.lastmod_file` to a path you cache between runs or commit (it is sorted JSON for small diffs).
- **Server data is not covered.** Content loaded in a server function (e.g. from a database) does not change the bundle, so set `lastmod` on those entries in the page's `sitemap` export.
- Upgrading esbuild or changing build options can change every hash once, re-dating all pages.
#### Large Sitemaps
A sitemap file may hold at most 50,000 URLs and 50MB. When the sitemap has more than `chunk_size` URLs (default 10,000), `bunext sitemap` splits it into chunk files and makes `sitemap.xml` a sitemap index pointing to them:
```
public/
├── sitemap.xml ← <sitemapindex> referencing the chunks
└── sitemap-chunks/
├── sitemap-1.xml ← up to chunk_size URLs each
├── sitemap-2.xml
└── sitemap-3.xml
```
Each index entry's `lastmod` is the newest `lastmod` in its chunk. Chunk files left over from a previous, larger run are removed; other files in the chunks directory are left alone. If the sitemap shrinks back under `chunk_size`, `sitemap.xml` returns to a plain `<urlset>`.
| Config option | Type | Description |
| ------------- | -------- | --------------------------------------------------------------------------------------------------- |
| `chunk_size` | `number` | Maximum URLs per file. Default `10000`, capped at `50000`. Chunks are also split at 50MB. |
| `chunks_dir` | `string` | Chunk directory relative to the project root. Must be inside `public/`. Default `public/sitemap-chunks`. |
`--output` and `--origin` override `sitemap.output` and `sitemap.origin` from the config. `--origin` also applies to robots.txt. The command also writes a `robots.txt` — see [robots.txt](#robotstxt).
### Generating at Runtime
`bunextGenerateSitemap()` resolves to the same XML as a single `<urlset>` (it does not chunk, and logs a warning above 50,000 URLs — use `bunext sitemap` for large sites), so you can serve it from a route instead of a file:
```ts
// src/pages/api/sitemap.ts
import bunext from "@moduletrace/bunext";
export default async function handler(req: Request) {
const xml = await bunext.bunextGenerateSitemap({
exclude: ["/login", "/admin/*", /^\/drafts/],
additional_paths: async () => {
const posts = await getPublishedPosts();
return posts.map((post) => ({
path: `/blog/${post.slug}`,
lastmod: post.updated_at,
}));
},
default_changefreq: "weekly",
});
return new Response(xml, {
headers: { "Content-Type": "application/xml" },
});
}
```
| Option | Type | Description |
| -------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
| `origin` | `string` | Base URL for each `<loc>`. Defaults to `config.origin`, then `http://localhost:<port>`. |
| `exclude` | `(string \| RegExp)[]` | Exact paths, subtrees ending in `/*` (`/admin/*` also excludes `/admin`), or regexes tested on the path. |
| `additional_paths` | `entries \| () => entries \| Promise<entries>` | Extra paths or entries (`path`, `lastmod`, `changefreq`, `priority`), or a sync/async function returning them, e.g. for dynamic routes. Each entry is a `string` or `BunextSitemapEntry`. |
| `default_lastmod` | `Date \| string` | `lastmod` for entries with no other date. See [Last-Modified Dates](#last-modified-dates). |
| `lastmod_file` | `string` | Page date record written by `bunext build`. Default `.bunext/page-lastmod.json`. |
| `use_file_lastmod` | `boolean` | Fall back to each page file's modification time. Default `false`. |
| `trailing_slash` | `boolean` | Append `/` to every non-root path. Default `false`. |
| `default_changefreq` | `BunextSitemapChangeFreq` | `changefreq` for entries that do not set one. |
| `default_priority` | `number` | `priority` (0.0 to 1.0) for entries that do not set one. |
The `sitemap` config key takes the same options, plus `output` (the file path relative to the project root, default `public/sitemap.xml`) and the [chunking options](#large-sitemaps).
### robots.txt
`bunext sitemap` also writes `public/robots.txt`, served at `/robots.txt`. With no `robots` config it allows every crawler and points to the generated sitemap:
```
# Generated by bunext
User-agent: *
Allow: /
Sitemap: https://example.com/sitemap.xml
```
Configure it with the `robots` key:
```ts
// bunext.config.ts
const config: BunextConfig = {
origin: "https://example.com",
robots: {
rules: [
{ user_agent: "*", disallow: ["/admin", "/api/"] },
{ user_agent: ["GPTBot", "CCBot"], disallow: ["/"] },
],
// Block everything on staging
disallow_all: process.env.STAGING === "true",
},
};
```
| Option | Type | Description |
| -------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `rules` | `BunextRobotsRule[]` | Groups of `user_agent` (string or array), `allow`, `disallow` and `crawl_delay`. Default: allow everything for `*`. |
| `sitemaps` | `string[]` | Extra `Sitemap:` URLs or paths. The generated sitemap is added automatically when it is inside `public/`. |
| `disallow_all` | `boolean` | Block every crawler from the whole site. `rules` and `sitemaps` are ignored. |
| `origin` | `string` | Base URL for relative `sitemaps` paths. Defaults to `--origin`, then `sitemap.origin`, then `config.origin`. |
| `output` | `string` | Output file relative to the project root. Default `public/robots.txt`. |
- **Existing files are protected.** Generated files start with `# Generated by bunext`. Without a `robots` config, a `robots.txt` that lacks this line (one you wrote yourself) is left alone and a warning is shown. Setting `robots` in the config overwrites it.
- **Opting out.** Set `robots: false`, or pass `--no-robots`, to skip robots.txt entirely.
- **Sitemap `exclude` does not add `Disallow` lines.** Leaving a page out of the sitemap and blocking crawlers from it are different things; add `disallow` rules explicitly. Remember robots.txt is public, so `Disallow` paths are visible to anyone.
To serve it from a route instead, `bunext.bunextGenerateRobotsTxt()` takes the same options except `output` and returns the text.
### Disabling SEO
For preview or test deployments that must never be indexed, set `disable_seo`:
```ts
// bunext.config.ts
const config: BunextConfig = {
disable_seo: process.env.PREVIEW === "true",
};
```
With it on, the server:
- adds `X-Robots-Tag: noindex, nofollow` to every response (pages, API routes and files),
- renders `<meta name="robots" content="noindex, nofollow">` on every page, replacing any `meta.robots`,
- answers `/robots.txt` with `Disallow: /` for all crawlers, ignoring `public/robots.txt`.
Nothing is written to disk, so the same build can be deployed to production with the option off. Responses returned directly by [middleware](#middleware) are left untouched.
---
## Static Files
Place static files in the `public/` directory. They are served at the `/public/` URL path:
@@ -764,6 +988,10 @@ export default config;
| `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) |
| `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) |
| `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
+78 -94
View File
@@ -5,34 +5,32 @@
"": {
"name": "bun-next",
"dependencies": {
"@tailwindcss/postcss": "^4.2.2",
"@types/bun": "latest",
"@tailwindcss/postcss": "^4.3.3",
"@types/bun": "1.3.14",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"bun-plugin-tailwind": "^0.1.2",
"chalk": "^5.6.2",
"chokidar": "^5.0.0",
"commander": "^14.0.2",
"esbuild": "^0.27.4",
"esbuild": "^0.28.2",
"lightningcss-wasm": "^1.32.0",
"lodash": "^4.17.23",
"micromatch": "^4.0.8",
"lodash": "^4.18.1",
"ora": "^9.0.0",
"postcss": "^8.5.8",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwindcss": "^4.2.2",
"postcss": "^8.5.30",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"tailwindcss": "^4.3.3",
"typescript": "^5.0.0",
"uuid": "^14.0.2",
},
"devDependencies": {
"@testing-library/dom": "^10.4.1",
"@types/chokidar": "^2.1.7",
"@types/lodash": "^4.17.24",
"@types/micromatch": "^4.0.10",
"@types/lodash": "^4.17.26",
"@types/uuid": "^11.0.0",
"happy-dom": "^20.8.4",
"happy-dom": "^20.14.6",
},
},
},
@@ -45,57 +43,57 @@
"@babel/runtime": ["@babel/[email protected]", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
"@esbuild/aix-ppc64": ["@esbuild/[email protected]7.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="],
"@esbuild/aix-ppc64": ["@esbuild/[email protected]8.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
"@esbuild/android-arm": ["@esbuild/[email protected]7.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="],
"@esbuild/android-arm": ["@esbuild/[email protected]8.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
"@esbuild/android-arm64": ["@esbuild/[email protected]7.4", "", { "os": "android", "cpu": "arm64" }, "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw=="],
"@esbuild/android-arm64": ["@esbuild/[email protected]8.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="],
"@esbuild/android-x64": ["@esbuild/[email protected]7.4", "", { "os": "android", "cpu": "x64" }, "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw=="],
"@esbuild/android-x64": ["@esbuild/[email protected]8.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="],
"@esbuild/darwin-arm64": ["@esbuild/[email protected]7.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ=="],
"@esbuild/darwin-arm64": ["@esbuild/[email protected]8.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="],
"@esbuild/darwin-x64": ["@esbuild/[email protected]7.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw=="],
"@esbuild/darwin-x64": ["@esbuild/[email protected]8.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="],
"@esbuild/freebsd-arm64": ["@esbuild/[email protected]7.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw=="],
"@esbuild/freebsd-arm64": ["@esbuild/[email protected]8.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="],
"@esbuild/freebsd-x64": ["@esbuild/[email protected]7.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ=="],
"@esbuild/freebsd-x64": ["@esbuild/[email protected]8.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="],
"@esbuild/linux-arm": ["@esbuild/[email protected]7.4", "", { "os": "linux", "cpu": "arm" }, "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg=="],
"@esbuild/linux-arm": ["@esbuild/[email protected]8.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="],
"@esbuild/linux-arm64": ["@esbuild/[email protected]7.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA=="],
"@esbuild/linux-arm64": ["@esbuild/[email protected]8.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="],
"@esbuild/linux-ia32": ["@esbuild/[email protected]7.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA=="],
"@esbuild/linux-ia32": ["@esbuild/[email protected]8.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="],
"@esbuild/linux-loong64": ["@esbuild/[email protected]7.4", "", { "os": "linux", "cpu": "none" }, "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA=="],
"@esbuild/linux-loong64": ["@esbuild/[email protected]8.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="],
"@esbuild/linux-mips64el": ["@esbuild/[email protected]7.4", "", { "os": "linux", "cpu": "none" }, "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw=="],
"@esbuild/linux-mips64el": ["@esbuild/[email protected]8.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="],
"@esbuild/linux-ppc64": ["@esbuild/[email protected]7.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA=="],
"@esbuild/linux-ppc64": ["@esbuild/[email protected]8.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="],
"@esbuild/linux-riscv64": ["@esbuild/[email protected]7.4", "", { "os": "linux", "cpu": "none" }, "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw=="],
"@esbuild/linux-riscv64": ["@esbuild/[email protected]8.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="],
"@esbuild/linux-s390x": ["@esbuild/[email protected]7.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA=="],
"@esbuild/linux-s390x": ["@esbuild/[email protected]8.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="],
"@esbuild/linux-x64": ["@esbuild/[email protected]7.4", "", { "os": "linux", "cpu": "x64" }, "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA=="],
"@esbuild/linux-x64": ["@esbuild/[email protected]8.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="],
"@esbuild/netbsd-arm64": ["@esbuild/[email protected]7.4", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="],
"@esbuild/netbsd-arm64": ["@esbuild/[email protected]8.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="],
"@esbuild/netbsd-x64": ["@esbuild/[email protected]7.4", "", { "os": "none", "cpu": "x64" }, "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg=="],
"@esbuild/netbsd-x64": ["@esbuild/[email protected]8.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="],
"@esbuild/openbsd-arm64": ["@esbuild/[email protected]7.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="],
"@esbuild/openbsd-arm64": ["@esbuild/[email protected]8.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="],
"@esbuild/openbsd-x64": ["@esbuild/[email protected]7.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ=="],
"@esbuild/openbsd-x64": ["@esbuild/[email protected]8.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="],
"@esbuild/openharmony-arm64": ["@esbuild/[email protected]7.4", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="],
"@esbuild/openharmony-arm64": ["@esbuild/[email protected]8.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="],
"@esbuild/sunos-x64": ["@esbuild/[email protected]7.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g=="],
"@esbuild/sunos-x64": ["@esbuild/[email protected]8.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="],
"@esbuild/win32-arm64": ["@esbuild/[email protected]7.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg=="],
"@esbuild/win32-arm64": ["@esbuild/[email protected]8.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="],
"@esbuild/win32-ia32": ["@esbuild/[email protected]7.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw=="],
"@esbuild/win32-ia32": ["@esbuild/[email protected]8.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="],
"@esbuild/win32-x64": ["@esbuild/[email protected]7.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="],
"@esbuild/win32-x64": ["@esbuild/[email protected]8.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
@@ -131,55 +129,51 @@
"@oven/bun-windows-x64-baseline": ["@oven/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-gh3UAHbUdDUG6fhLc1Csa4IGdtghue6U8oAIXWnUqawp6lwb3gOCRvp25IUnLF5vUHtgfMxuEUYV7YA2WxVutw=="],
"@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.2", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="],
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "postcss": "^8.5.6", "tailwindcss": "4.2.2" } }, "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ=="],
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.3", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "postcss": "^8.5.16", "tailwindcss": "4.3.3" } }, "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg=="],
"@testing-library/dom": ["@testing-library/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
"@types/aria-query": ["@types/[email protected]", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
"@types/braces": ["@types/b[email protected]", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@types/bun": ["@types/b[email protected]", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/chokidar": ["@types/[email protected]", "", { "dependencies": { "chokidar": "*" } }, "sha512-A7/MFHf6KF7peCzjEC1BBTF8jpmZTokb3vr/A0NxRGfwRLK3Ws+Hq6ugVn6cJIMfM6wkCak/aplWrxbTcu8oig=="],
"@types/lodash": ["@types/[email protected]4", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="],
"@types/micromatch": ["@types/[email protected]", "", { "dependencies": { "@types/braces": "*" } }, "sha512-5jOhFDElqr4DKTrTEbnW8DZ4Hz5LRUEmyrGpCMrD/NphYv3nUnaF08xmSLx1rGGnyEs/kFnhiw6dCgcDqMr5PQ=="],
"@types/lodash": ["@types/[email protected]6", "", {}, "sha512-48NWfiY6oNcKOh47Z4h+sIvwcQR91h6KPhaYKgeMIrkT2jV82Gy90SErj60vW8jQbeMSOSbhIv3bZ7JoqPPVhA=="],
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
"@types/uuid": ["@types/[email protected]", "", { "dependencies": { "uuid": "*" } }, "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA=="],
@@ -193,13 +187,13 @@
"aria-query": ["[email protected]", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
"braces": ["[email protected]", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"buffer-image-size": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="],
"bun": ["[email protected]", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.10", "@oven/bun-darwin-x64": "1.3.10", "@oven/bun-darwin-x64-baseline": "1.3.10", "@oven/bun-linux-aarch64": "1.3.10", "@oven/bun-linux-aarch64-musl": "1.3.10", "@oven/bun-linux-x64": "1.3.10", "@oven/bun-linux-x64-baseline": "1.3.10", "@oven/bun-linux-x64-musl": "1.3.10", "@oven/bun-linux-x64-musl-baseline": "1.3.10", "@oven/bun-windows-aarch64": "1.3.10", "@oven/bun-windows-x64": "1.3.10", "@oven/bun-windows-x64-baseline": "1.3.10" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-S/CXaXXIyA4CMjdMkYQ4T2YMqnAn4s0ysD3mlsY4bUiOCqGlv28zck4Wd4H4kpvbekx15S9mUeLQ7Uxd0tYTLA=="],
"bun-plugin-tailwind": ["[email protected]", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="],
"bun-types": ["[email protected]1", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"bun-types": ["[email protected]4", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"chalk": ["[email protected]", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
@@ -219,27 +213,23 @@
"dom-accessibility-api": ["[email protected]", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
"enhanced-resolve": ["[email protected]0.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="],
"enhanced-resolve": ["[email protected]6.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw=="],
"entities": ["[email protected]", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"esbuild": ["[email protected]7.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
"fill-range": ["[email protected]", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"esbuild": ["[email protected]8.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
"get-east-asian-width": ["[email protected]", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
"graceful-fs": ["[email protected]", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"happy-dom": ["happy-dom@20.8.4", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-GKhjq4OQCYB4VLFBzv8mmccUadwlAusOZOI7hC1D9xDIT5HhzkJK17c4el2f6R6C715P9xB4uiMxeKUa2nHMwQ=="],
"happy-dom": ["happy-dom@20.14.6", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-5DDsf4QmEIlRL6NkCv/fxSjYHHkZceL/IaYQdJc2h3G7DRTEai++VTjbfL0LB9geW2VrA3+vrjF3Efx+OTtX0Q=="],
"is-interactive": ["[email protected]", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="],
"is-number": ["[email protected]", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-unicode-supported": ["[email protected]", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
@@ -269,7 +259,7 @@
"lightningcss-win32-x64-msvc": ["[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lodash": ["[email protected]7.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
"lodash": ["[email protected]8.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
"log-symbols": ["[email protected]", "", { "dependencies": { "is-unicode-supported": "^2.0.0", "yoctocolors": "^2.1.1" } }, "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg=="],
@@ -277,11 +267,9 @@
"magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"micromatch": ["[email protected]", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mimic-function": ["[email protected]", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
"nanoid": ["[email protected].11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"nanoid": ["[email protected].20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
"onetime": ["[email protected]", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
@@ -289,15 +277,13 @@
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["[email protected]", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA=="],
"pretty-format": ["[email protected]", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="],
"react-is": ["[email protected]", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
@@ -305,11 +291,11 @@
"restore-cursor": ["[email protected]", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
"scheduler": ["[email protected]7.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"scheduler": ["[email protected]8.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
"signal-exit": ["[email protected]", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"source-map-js": ["[email protected].1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"source-map-js": ["[email protected].2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
"stdin-discarder": ["[email protected]", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="],
@@ -317,11 +303,9 @@
"strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="],
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
"tapable": ["[email protected].0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"to-regex-range": ["[email protected]", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"tapable": ["[email protected].3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="],
@@ -331,19 +315,19 @@
"whatwg-mimetype": ["[email protected]", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
"ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="],
"yoctocolors": ["[email protected]", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/[email protected].0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/[email protected].3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" }, "bundled": true }, "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/[email protected].1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/[email protected].4", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["[email protected]", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+21 -2
View File
@@ -3,8 +3,9 @@ import { log } from "../../utils/log";
import grabDirNames from "../../utils/grab-dir-names";
import { rmSync, writeFileSync } from "fs";
import bunextInit from "../../functions/bunext-init";
import pagesSSRBundler from "../../functions/bundler/pages-ssr-bundler";
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")
@@ -21,7 +22,13 @@ export default function () {
catch (error) { }
global.BUNEXT_SKIPPED_BROWSER_MODULES = new Set();
await bunextInit({ build_only: true });
// await pagesSSRBundler();
await disposeBundlerContexts({ stop_service: true });
// A failed bundle must fail the command: CI and scripts rely on
// the exit code instead of grepping logs.
if (global.BUNEXT_BUILD_FAILED) {
log.error("Build failed with errors. Exiting.");
process.exit(1);
}
if (global.BUNEXT_BUNDLER_CTX_MAP) {
let bundler_ctx_json_str = JSON.stringify(global.BUNEXT_BUNDLER_CTX_MAP);
writeFileSync(BUNDLER_CTX_MAP_JSON, bundler_ctx_json_str, "utf-8");
@@ -35,6 +42,18 @@ export default function () {
build_date_unix: Date.now(),
build_date: Date(),
};
if (global.BUNEXT_BUNDLER_CTX_MAP) {
try {
updatePageLastmodRecord({
ctx_map: global.BUNEXT_BUNDLER_CTX_MAP,
build_date: new Date(build_data.build_date_unix),
});
}
catch (error) {
// Only sitemap dates depend on this; never fail the build.
log.warn(`Could not update page lastmod record: ${error?.message || error}`);
}
}
writeFileSync(BUILD_INFO_JSON, JSON.stringify(build_data, null, 4), "utf-8");
log.success("Modules Built Successfully!");
process.exit();
+14
View File
@@ -2,13 +2,23 @@ import startServer from "../../functions/server/start-server";
import { log } from "../../utils/log";
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
// written, and the supervisor never restarts the server. Log, record the real
// error, and exit non-zero so recovery actually runs.
process.on("uncaughtException", (error) => {
log.error(`Uncaught exception: ${error}`);
writeErrorFile({ error, exitCode: 1, force: true });
process.exit(1);
});
process.on("unhandledRejection", (reason) => {
log.error(`Unhandled rejection: ${reason}`);
writeErrorFile({ error: reason, exitCode: 1, force: true });
process.exit(1);
});
log.info("Running development server ...");
try {
@@ -21,5 +31,9 @@ try {
await startServer();
}
catch (error) {
// Startup failures (e.g. port in use, config errors) must exit non-zero;
// otherwise the process hangs alive with no server and no restart.
log.error(`Failed to start development server: ${error}`);
writeErrorFile({ error, exitCode: 1, force: true });
process.exit(1);
}
+25 -20
View File
@@ -3,10 +3,9 @@ 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 } = grabDirNames();
let retries = 0;
let timeout;
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")
@@ -14,13 +13,20 @@ export default function () {
try {
rmSync(SSR_BUNDLER_CTX_MAP_JSON, { recursive: true });
rmSync(BUNDLER_CTX_MAP_JSON, { recursive: true });
// A stale error file from a previous session makes the bundler
// run in "silent" mode and pages fail with misleading
// "Application Error" messages until the first rebuild.
rmSync(BUNX_BUNDLER_ERROR_EXIT_FILE, { force: true });
}
catch (error) { }
await dev();
});
}
async function dev() {
clearTimeout(timeout);
// A loop, not recursion: each restart would otherwise keep the previous
// call (and its subprocess) alive for the supervisor's whole lifetime.
let retries = 0;
while (true) {
if (retries >= MAX_RETRIES) {
console.error(`Dev server crashed ${MAX_RETRIES} times. Exiting.`);
process.exit(1);
@@ -41,27 +47,26 @@ async function dev() {
NODE_ENV: "development",
},
};
let dev_process;
const started_at = Date.now();
let child_process;
try {
dev_process = Bun.spawn(spawn_options);
child_process = Bun.spawn(spawn_options);
}
catch (error) {
console.error(`Failed to start dev process:`, error);
retries++;
timeout = setTimeout(() => {
retries = 0;
}, 10000);
return await dev();
continue;
}
const exited = await dev_process.exited;
if (exited) {
retries++;
timeout = setTimeout(() => {
retries = 0;
}, 10000);
return await dev();
const exited = await child_process.exited;
// A null exit means the child was killed by a signal (e.g. Ctrl+C
// in the shared terminal), which we must not restart.
if (exited == null) {
return;
}
// Restart on any numeric exit (0 included: the server should never
// exit cleanly). Only consecutive quick crashes count toward the
// limit; a child that ran for a while starts the count over.
retries =
Date.now() - started_at >= RETRY_RESET_AFTER_MS ? 1 : retries + 1;
}
timeout = setTimeout(() => {
retries = 0;
}, 10000);
}
+2
View File
@@ -3,6 +3,7 @@ import { program } from "commander";
import start from "./start";
import dev from "./dev";
import build from "./build";
import sitemap from "./sitemap";
import { log } from "../utils/log";
/**
* # Describe Program
@@ -17,6 +18,7 @@ program
program.addCommand(dev());
program.addCommand(start());
program.addCommand(build());
program.addCommand(sitemap());
/**
* # Handle Unavailable Commands
*/
+2
View File
@@ -0,0 +1,2 @@
import { Command } from "commander";
export default function (): Command;
+149
View File
@@ -0,0 +1,149 @@
import { Command } from "commander";
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "fs";
import path from "path";
import { log } from "../../utils/log";
import grabDirNames from "../../utils/grab-dir-names";
import grabConfig from "../../functions/grab-config";
import grabConstants from "../../utils/grab-constants";
import { generateSitemapFiles, grabPendingSitemapTask, } from "../../utils/generate-sitemap";
import { grabPageLastmodFile } from "../../utils/page-lastmod-record";
import generateRobotsTxt, { RobotsTxtGeneratedMarker, } from "../../utils/generate-robots-txt";
const SitemapStillWaitingInterval = 30_000;
export default function () {
return new Command("sitemap")
.description("Generate sitemap.xml and robots.txt from the pages directory")
.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")
.action(async (options) => {
const { ROOT_DIR, PUBLIC_DIR } = grabDirNames();
global.BUNEXT_CONFIG = (await grabConfig()) || {};
global.BUNEXT_CONSTANTS = grabConstants();
if (global.BUNEXT_CONFIG.disable_seo) {
log.warn(`\`disable_seo\` is set: the server blocks all crawlers at ` +
`/robots.txt, so the generated robots.txt is not served.`);
}
const { output, chunk_size, chunks_dir, ...sitemap_config } = global.BUNEXT_CONFIG.sitemap || {};
if (!existsSync(grabPageLastmodFile(sitemap_config.lastmod_file))) {
log.warn(`No page lastmod record found. Run \`bunext build\` first ` +
`so pages get lastmod dates.`);
}
let sitemap_file;
try {
const output_path = options.output || output;
sitemap_file = output_path
? path.resolve(ROOT_DIR, output_path)
: path.join(PUBLIC_DIR, "sitemap.xml");
const chunks_dir_path = path.resolve(ROOT_DIR, chunks_dir || "public/sitemap-chunks");
const chunks_public_path = grabPublicUrlPath(chunks_dir_path);
if (chunks_public_path === undefined) {
throw new Error(`\`sitemap.chunks_dir\` must be inside public/ ` +
`so the chunks are served.`);
}
// Bun exits silently (code 0) when the event loop empties
// while a promise is pending. Bun's SQL client (mariadb /
// mysql adapters, Bun 1.3.14) doesn't keep the loop alive
// for a query on a connection that has gone idle, so a
// second `sitemap` export querying the DB would end the
// process mid-generation. Hold the loop open with a timer,
// which also reports what a genuinely stuck run waits on.
const keep_alive = setInterval(() => {
log.warn(`Still waiting on ` +
`${grabPendingSitemapTask() || "sitemap generation"}...`);
}, SitemapStillWaitingInterval);
const { sitemap, chunks, url_count } = await generateSitemapFiles({
...sitemap_config,
origin: options.origin || sitemap_config.origin,
chunk_size,
chunks_url_path: chunks_public_path,
}).finally(() => clearInterval(keep_alive));
// Remove chunks from a previous, larger sitemap
if (existsSync(chunks_dir_path)) {
for (const file of readdirSync(chunks_dir_path)) {
if (file.match(/^sitemap-\d+\.xml$/)) {
rmSync(path.join(chunks_dir_path, file));
}
}
}
if (chunks.length) {
mkdirSync(chunks_dir_path, { recursive: true });
}
for (const chunk of chunks) {
writeFileSync(path.join(chunks_dir_path, chunk.file_name), chunk.xml, "utf-8");
}
mkdirSync(path.dirname(sitemap_file), { recursive: true });
writeFileSync(sitemap_file, sitemap, "utf-8");
const sitemap_relative_path = path.relative(ROOT_DIR, sitemap_file);
if (chunks.length) {
log.success(`Sitemap index with ${url_count} URLs in ${chunks.length} chunks ` +
`written to ${sitemap_relative_path} ` +
`(chunks in ${path.relative(ROOT_DIR, chunks_dir_path)})`);
}
else {
log.success(`Sitemap with ${url_count} URLs written to ${sitemap_relative_path}`);
}
}
catch (error) {
log.error(`Failed to generate sitemap: ${error?.message || error}`);
process.exit(1);
}
const robots_config = global.BUNEXT_CONFIG.robots;
if (options.robots && robots_config !== false) {
writeRobotsTxt({
sitemap_file,
cli_origin: options.origin,
sitemap_origin: sitemap_config.origin,
});
}
process.exit();
});
}
function writeRobotsTxt({ sitemap_file, cli_origin, sitemap_origin, }) {
const { ROOT_DIR, PUBLIC_DIR } = grabDirNames();
const robots_config = global.BUNEXT_CONFIG.robots || undefined;
const { output, ...robots_params } = robots_config || {};
const robots_file = output
? path.resolve(ROOT_DIR, output)
: path.join(PUBLIC_DIR, "robots.txt");
const robots_relative_path = path.relative(ROOT_DIR, robots_file);
// Without a robots config, never replace a hand-written robots.txt.
if (!robots_config &&
existsSync(robots_file) &&
!readFileSync(robots_file, "utf-8").startsWith(RobotsTxtGeneratedMarker)) {
log.warn(`Skipped ${robots_relative_path}: it was not generated by bunext. ` +
`Set \`robots\` in bunext.config.ts to overwrite it, ` +
`or \`robots: false\` to silence this.`);
return;
}
const sitemap_public_path = grabPublicUrlPath(sitemap_file);
if (sitemap_public_path === undefined && !robots_params.sitemaps?.length) {
log.warn(`Sitemap is outside public/, so robots.txt has no Sitemap line. ` +
`Add its URL to \`robots.sitemaps\`.`);
}
const robots_txt = generateRobotsTxt({
...robots_params,
origin: cli_origin || robots_params.origin || sitemap_origin,
sitemaps: [
...(sitemap_public_path !== undefined
? [sitemap_public_path]
: []),
...(robots_params.sitemaps || []),
],
});
mkdirSync(path.dirname(robots_file), { recursive: true });
writeFileSync(robots_file, robots_txt, "utf-8");
log.success(`robots.txt written to ${robots_relative_path}`);
}
/**
* # URL path a file in public/ is served at
* Public files are served from the site root, e.g. public/sitemap.xml
* at /sitemap.xml. Returns `undefined` for paths outside public/.
*/
function grabPublicUrlPath(file_path) {
const { PUBLIC_DIR } = grabDirNames();
const relative_path = path.relative(PUBLIC_DIR, file_path);
if (relative_path.startsWith("..") || path.isAbsolute(relative_path)) {
return;
}
return `/${relative_path.split(path.sep).join("/")}`;
}
+26 -20
View File
@@ -1,19 +1,26 @@
import { Command } from "commander";
import path from "path";
import writeErrorFile from "../../functions/write-error-file";
import { existsSync } from "fs";
let retries = 0;
let timeout;
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")
.action(async () => {
// 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 });
await start();
});
}
async function start() {
clearTimeout(timeout);
// A loop, not recursion: each restart would otherwise keep the previous
// call (and its subprocess) alive for the supervisor's whole lifetime.
let retries = 0;
while (true) {
if (retries >= MAX_RETRIES) {
console.error(`Production server crashed ${MAX_RETRIES} times. Exiting.`);
process.exit(1);
@@ -34,27 +41,26 @@ async function start() {
NODE_ENV: "production",
},
};
let dev_process;
const started_at = Date.now();
let child_process;
try {
dev_process = Bun.spawn(spawn_options);
child_process = Bun.spawn(spawn_options);
}
catch (error) {
console.error(`Failed to start production process:`, error);
retries++;
timeout = setTimeout(() => {
retries = 0;
}, 10000);
return await start();
continue;
}
const exited = await dev_process.exited;
if (exited) {
retries++;
timeout = setTimeout(() => {
retries = 0;
}, 10000);
return await start();
const exited = await child_process.exited;
// A null exit means the child was killed by a signal (e.g. Ctrl+C
// in the shared terminal), which we must not restart.
if (exited == null) {
return;
}
// Restart on any numeric exit (0 included: the server should never
// exit cleanly). Only consecutive quick crashes count toward the
// limit; a child that ran for a while starts the count over.
retries =
Date.now() - started_at >= RETRY_RESET_AFTER_MS ? 1 : retries + 1;
}
timeout = setTimeout(() => {
retries = 0;
}, 10000);
}
+21
View File
@@ -1,6 +1,27 @@
import bunextInit from "../../functions/bunext-init";
import startServer from "../../functions/server/start-server";
import { log } from "../../utils/log";
import writeErrorFile from "../../functions/write-error-file";
// Same crash contract as the dev child: log the real error, record it in the
// error file, and exit non-zero so the supervisor can restart and so the
// bundler pipeline sees the failure.
process.on("uncaughtException", (error) => {
log.error(`Uncaught exception: ${error}`);
writeErrorFile({ error, exitCode: 1, force: true });
process.exit(1);
});
process.on("unhandledRejection", (reason) => {
log.error(`Unhandled rejection: ${reason}`);
writeErrorFile({ error: reason, exitCode: 1, force: true });
process.exit(1);
});
log.info("Starting production server ...");
try {
await bunextInit();
await startServer();
}
catch (error) {
log.error(`Failed to start production server: ${error}`);
writeErrorFile({ error, exitCode: 1, force: true });
process.exit(1);
}
+2
View File
@@ -1,5 +1,6 @@
export declare const AppData: {
readonly DefaultCacheExpiryTimeSeconds: number;
readonly DefaultMaxCacheEntries: 1000;
readonly DefaultCronInterval: 30000;
readonly BunextStaticFilesCacheExpiry: number;
readonly ClientHMRPath: "__bunext_client_hmr__";
@@ -7,4 +8,5 @@ export declare const AppData: {
readonly BunextTmpFileExt: ".bunext_tmp.tsx";
readonly BunextHMRRetryRoute: "/.bunext/hmr-retry";
readonly DefaultMaxLogs: 50;
readonly DefaultSSRRenderTimeoutMs: 30000;
};
+2
View File
@@ -1,5 +1,6 @@
export const AppData = {
DefaultCacheExpiryTimeSeconds: 60 * 60,
DefaultMaxCacheEntries: 1000,
DefaultCronInterval: 30000,
BunextStaticFilesCacheExpiry: 60 * 60 * 24 * 7,
ClientHMRPath: "__bunext_client_hmr__",
@@ -7,4 +8,5 @@ export const AppData = {
BunextTmpFileExt: ".bunext_tmp.tsx",
BunextHMRRetryRoute: "/.bunext/hmr-retry",
DefaultMaxLogs: 50,
DefaultSSRRenderTimeoutMs: 30000,
};
@@ -2,9 +2,19 @@ import type { BundlerCTXMap } from "../../types";
type Params = {
post_build_fn?: (params: {
artifacts: BundlerCTXMap[];
changed_pages?: string[];
}) => Promise<void> | void;
build_only?: boolean;
start?: boolean;
/**
* Re-fetch server props for every HMR client after the build, even if
* only client code changed. Only applies when the context is recreated.
*/
refresh_props?: boolean;
};
/**
* True when `local_path` is a hydration entry of the current esbuild context.
*/
export declare function isHydrationEntryPage(local_path: string): boolean;
export default function allPagesESBuildContextBundler(params?: Params): Promise<void>;
export {};
+62 -8
View File
@@ -7,11 +7,36 @@ import grabClientHydrationScript from "./grab-client-hydration-script";
import path from "path";
import virtualFilesPlugin from "./plugins/virtual-files-plugin";
import esbuildCTXArtifactTracker from "./plugins/esbuild-ctx-artifact-tracker";
import { existsSync } from "fs";
const { HYDRATION_DST_DIR, BUNX_HYDRATION_SRC_DIR, BUNX_BUNDLER_ERROR_EXIT_FILE, } = grabDirNames();
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).
let last_entry_keys = undefined;
let last_root_file_path = undefined;
// Local paths of the pages that made it into the current entry set. Pages
// skipped at build time (e.g. created empty, no default export yet) are
// absent, so the watcher can treat their later edits as structural.
let last_entry_pages = new Set();
/**
* True when `local_path` is a hydration entry of the current esbuild context.
*/
export function isHydrationEntryPage(local_path) {
return last_entry_pages.has(local_path);
}
function arraysEqual(a, b) {
if (a.length !== b.length)
return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i])
return false;
}
return true;
}
export default async function allPagesESBuildContextBundler(params) {
try {
const did_process_exit_because_of_bundler_error = existsSync(BUNX_BUNDLER_ERROR_EXIT_FILE);
const pages = grabAllPages({ exclude_api: true });
global.BUNEXT_PAGE_FILES = pages;
const dev = isDevelopment();
@@ -24,10 +49,22 @@ export default async function allPagesESBuildContextBundler(params) {
continue;
}
const entryFile = path.join(BUNX_HYDRATION_SRC_DIR, `${page.url_path}.tsx`);
// await Bun.write(entryFile, txt, { createPath: true });
entryToPage.set(entryFile, { ...page, tsx });
}
const entryPoints = [...entryToPage.keys()].map((e) => `hydration-virtual:${e}`);
const entry_keys = [...entryToPage.keys()].sort();
const { root_file_path } = grabRootFilePath();
const structure_changed = !global.BUNEXT_BUNDLER_CTX ||
global.BUNEXT_BUNDLER_CTX_DISPOSED ||
!last_entry_keys ||
!arraysEqual(last_entry_keys, entry_keys) ||
last_root_file_path !== root_file_path;
if (structure_changed) {
try {
await global.BUNEXT_BUNDLER_CTX?.dispose();
}
catch (error) { }
global.BUNEXT_BUNDLER_CTX = undefined;
global.BUNEXT_BUNDLER_CTX = await esbuild.context({
entryPoints,
outdir: HYDRATION_DST_DIR,
@@ -51,6 +88,7 @@ export default async function allPagesESBuildContextBundler(params) {
entryToPage,
post_build_fn: params?.post_build_fn,
build_only: params?.build_only || params?.start,
refresh_props_on_first_build: params?.refresh_props,
}),
],
jsx: "automatic",
@@ -64,14 +102,30 @@ export default async function allPagesESBuildContextBundler(params) {
"react/jsx-dev-runtime",
...(global.BUNEXT_CONFIG.page_compiler_excludes || []),
],
logLevel: did_process_exit_because_of_bundler_error
? "silent"
: undefined,
// esbuild's own error logging prints per attempt; the
// artifact-tracker plugin reports errors (deduped, once) itself.
logLevel: "silent",
});
last_entry_keys = entry_keys;
last_root_file_path = root_file_path;
last_entry_pages = new Set([...entryToPage.values()].map((p) => p.local_path));
}
await global.BUNEXT_BUNDLER_CTX.rebuild();
}
catch (error) {
console.log(`ESBUILD Error =>`, error);
global.BUNEXT_BUILD_FAILED = true;
// When the build fails with esbuild errors, the artifact-tracker's
// onEnd already reported them (deduped). "Build failed with N errors"
// is that same failure rethrown by esbuild's API — re-logging it here
// would double every incident.
if (String(error?.message || "").startsWith("Build failed with")) {
return;
}
logBuildErrorsOnce([
{
text: `ESBUILD Error => ${error?.message || String(error)}`,
},
], (line) => log.error(line));
}
}
function forceExternalReact() {
+9 -10
View File
@@ -1,18 +1,17 @@
export default async function buildOnstartErrorHandler(params) {
// const error_msg = `Build Failed. Please check all your components and imports.`;
// log.error(error_msg);
if (global.BUNEXT_BUNDLER_CTX_DISPOSED) {
return;
}
// console.log(`Killing Bundler ...`);
// console.log(`global.BUNEXT_BUNDLER_CTX_DISPOSED`, global.BUNEXT_BUNDLER_CTX_DISPOSED);
global.BUNEXT_BUNDLER_CTX_DISPOSED = true;
global.BUNEXT_RECOMPILING = false;
global.BUNEXT_IS_SERVER_COMPONENT = false;
await Promise.all([
global.BUNEXT_SSR_BUNDLER_CTX?.dispose(),
global.BUNEXT_BUNDLER_CTX?.dispose(),
]);
global.BUNEXT_SSR_BUNDLER_CTX = undefined;
global.BUNEXT_BUNDLER_CTX = undefined;
// NOTE: must NOT dispose the currently-building context here. This
// handler runs from the artifact-tracker plugin's onStart, i.e. while the
// context's own build is in flight. esbuild's `dispose()` waits for the
// in-flight build to finish, so disposing self = deadlock, which left the
// dev server permanently stuck after a failed rebuild.
//
// Marking the context as disposed is enough: the next rebuild attempt
// (watcher / fullRebuild) sees BUNEXT_BUNDLER_CTX_DISPOSED and recreates a
// fresh context, whose cleanup disposes the old (now idle) one.
}
+15
View File
@@ -0,0 +1,15 @@
type Params = {
/**
* Also stop esbuild's service process. Only safe when no other esbuild
* call can be in flight (e.g. before exiting, or right after the startup
* build). The service restarts on the next esbuild call.
*/
stop_service?: boolean;
};
/**
* Disposes the hydration and SSR esbuild contexts, releasing the incremental
* build state esbuild keeps in memory for them. The bundlers re-create the
* contexts on their next run.
*/
export default function disposeBundlerContexts(params?: Params): Promise<void>;
export {};
+27
View File
@@ -0,0 +1,27 @@
import * as esbuild from "esbuild";
/**
* Disposes the hydration and SSR esbuild contexts, releasing the incremental
* build state esbuild keeps in memory for them. The bundlers re-create the
* contexts on their next run.
*/
export default async function disposeBundlerContexts(params) {
try {
await global.BUNEXT_BUNDLER_CTX?.dispose();
}
catch (error) { }
global.BUNEXT_BUNDLER_CTX = undefined;
try {
await global.BUNEXT_SSR_BUNDLER_CTX?.dispose();
}
catch (error) { }
global.BUNEXT_SSR_BUNDLER_CTX = undefined;
if (params?.stop_service) {
// Exiting while the service is still running can leave it orphaned
// with an unanswered ping, which crashes it with "all goroutines are
// asleep - deadlock!".
try {
await esbuild.stop();
}
catch (error) { }
}
}
+44 -2
View File
@@ -2,6 +2,8 @@ import { existsSync } from "fs";
import grabDirNames from "../../utils/grab-dir-names";
import grabConstants from "../../utils/grab-constants";
import grabRootFilePath from "../server/web-pages/grab-root-file-path";
import grabRelativePath from "../../utils/grab-relative-path";
import { log } from "../../utils/log";
const { ROOT_DIR } = grabDirNames();
export default async function grabClientHydrationScript({ page_local_path, }) {
const { ClientRootElementIDName, ClientRootComponentWindowName, ClientWindowPagePropsName, } = grabConstants();
@@ -18,12 +20,19 @@ export default async function grabClientHydrationScript({ page_local_path, }) {
return undefined;
}
const root_content = await Bun.file(root_file_path).text();
if (!root_content.match(/^export default/m)) {
if (!hasBundlableDefaultExport({
file_path: root_file_path,
content: root_content,
is_root: true,
})) {
return undefined;
}
}
const page_content = await Bun.file(page_local_path).text();
if (!page_content.match(/^export default/m)) {
if (!hasBundlableDefaultExport({
file_path: page_local_path,
content: page_content,
})) {
return undefined;
}
let txt = ``;
@@ -54,3 +63,36 @@ export default async function grabClientHydrationScript({ page_local_path, }) {
txt += `}\n`;
return txt;
}
// File path -> content hash of the last warning, so rebuilds don't repeat
// a warning until the file changes.
const warned_files = new Map();
/**
* # Check a page or root file can be bundled
* Files are only bundled when `export default` starts a line. Files with
* no default export at all (e.g. a page still being written) are skipped
* silently; this warns about the confusing case where one exists
* elsewhere, like `import X from "x"; export default X` on one line, or
* `export { Page as default }`.
*/
function hasBundlableDefaultExport({ file_path, content, is_root, }) {
if (/^export default/m.test(content)) {
warned_files.delete(file_path);
return true;
}
const has_default_export = /\bexport\s+default\b/.test(content) ||
/\bas\s+default\b/.test(content);
if (!has_default_export) {
warned_files.delete(file_path);
return false;
}
const content_hash = Bun.hash(content);
if (warned_files.get(file_path) === content_hash)
return false;
warned_files.set(file_path, content_hash);
log.warn(`${grabRelativePath(file_path)} was skipped: ` +
`\`export default\` must be at the start of a line. ` +
(is_root
? `No pages can be bundled until the root layout is fixed.`
: `This page will not be bundled.`));
return false;
}
-7
View File
@@ -1,7 +0,0 @@
type Params = {
post_build_fn?: (params: {
artifacts: any[];
}) => Promise<void> | void;
};
export default function pagesSSRBundler(params?: Params): Promise<void>;
export {};
-94
View File
@@ -1,94 +0,0 @@
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 grabPageReactComponentString from "../server/web-pages/grab-page-react-component-string";
import grabRootFilePath from "../server/web-pages/grab-root-file-path";
import ssrVirtualFilesPlugin from "./plugins/ssr-virtual-files-plugin";
import ssrCTXArtifactTracker from "./plugins/ssr-ctx-artifact-tracker";
import { writeFileSync } from "fs";
import path from "path";
import { log } from "../../utils/log";
const { BUNX_CWD_MODULE_CACHE_DIR, BUNX_TMP_DIR } = grabDirNames();
export default async function pagesSSRBundler(params) {
const pages = grabAllPages({
include_server: true,
});
const dev = isDevelopment();
const config = global.BUNEXT_CONFIG;
try {
writeFileSync(path.join(BUNX_TMP_DIR, "ssr-pages.json"), JSON.stringify(pages, null, 4));
}
catch (error) { }
const entryToPage = new Map();
const { root_file_path } = grabRootFilePath();
for (const page of pages) {
if (page.local_path.match(/\/pages\/api\//) ||
page.local_path.match(/\.server\.tsx?$/)) {
const ts = await Bun.file(page.local_path).text();
if (ts.match(/(export default)|(export \w+ handler)|(export \w+ server)/)) {
entryToPage.set(page.local_path, { ...page, tsx: ts });
}
continue;
}
const tsx = grabPageReactComponentString({
file_path: page.local_path,
root_file_path,
});
if (!tsx)
continue;
if (!tsx.match(/export default/))
continue;
entryToPage.set(page.local_path, { ...page, tsx });
}
const entryPoints = [...entryToPage.keys()].map((e) => `ssr-virtual:${e}`);
try {
writeFileSync(path.join(BUNX_TMP_DIR, "ssr-entry-to-page.json"), JSON.stringify(Object(entryToPage), null, 4));
writeFileSync(path.join(BUNX_TMP_DIR, "ssr-entrypoints.json"), JSON.stringify(entryPoints, null, 4));
}
catch (error) { }
try {
await esbuild.build({
entryPoints,
outdir: BUNX_CWD_MODULE_CACHE_DIR,
bundle: true,
minify: !dev,
format: "esm",
target: "esnext",
platform: "node",
define: {
"process.env.NODE_ENV": JSON.stringify(dev ? "development" : "production"),
},
entryNames: "[dir]/[hash]",
metafile: true,
plugins: [
tailwindEsbuildPlugin,
ssrVirtualFilesPlugin({
entryToPage,
}),
ssrCTXArtifactTracker({
entryToPage,
post_build_fn: params?.post_build_fn,
}),
],
jsx: "automatic",
external: [
"react",
"react-dom",
"react/jsx-runtime",
"react/jsx-dev-runtime",
"bun:*",
"bun",
"sqlite-vec",
"better-sqlite3",
...(config.ssr_compiler_excludes || []),
],
splitting: true,
});
}
catch (error) {
global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED = true;
log.error(`SSR Bundler Error: ${error}`);
}
}
+21 -5
View File
@@ -1,7 +1,23 @@
type Params = {
post_build_fn?: (params: {
artifacts: any[];
}) => Promise<void> | void;
type RebuildParams = {
/**
* Force a context recreation even when the entry set is unchanged.
* Used by `fullRebuild` after it disposed the contexts.
*/
force?: boolean;
};
export default function pagesSSRContextBundler(params?: Params): Promise<void>;
/**
* Single-flight SSR rebuild. Concurrent callers (each successful hydration
* rebuild triggers this, plus watcher/structural paths) join the in-flight
* rebuild instead of spawning overlapping esbuild builds.
*
* A fresh content change that lands while a rebuild is in flight is picked
* up by the next hydration rebuild cycle (each watcher event re-runs the
* hydration context rebuild, which re-enters this queue).
*/
export default function enqueueSSRRebuild(params?: RebuildParams): Promise<void>;
/**
* Resolves once the in-flight SSR rebuild (if any) has finished. Lets page
* requests avoid rendering stale SSR modules against a newer client bundle.
*/
export declare function awaitSSRRebuild(): Promise<void>;
export {};
+147 -15
View File
@@ -2,25 +2,56 @@ 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 { cssStubEsbuildPlugin } from "../server/web-pages/tailwind-esbuild-plugin";
import grabPageReactComponentString from "../server/web-pages/grab-page-react-component-string";
import grabRootFilePath from "../server/web-pages/grab-root-file-path";
import ssrVirtualFilesPlugin from "./plugins/ssr-virtual-files-plugin";
import ssrCTXArtifactTracker from "./plugins/ssr-ctx-artifact-tracker";
const { BUNX_CWD_MODULE_CACHE_DIR } = grabDirNames();
export default async function pagesSSRContextBundler(params) {
const pages = grabAllPages();
const dev = isDevelopment();
if (global.BUNEXT_SSR_BUNDLER_CTX) {
await global.BUNEXT_SSR_BUNDLER_CTX.dispose();
global.BUNEXT_SSR_BUNDLER_CTX = undefined;
import cleanupArtifacts from "../server/cleanup-artifacts";
import { evictDeletedModules } from "../../utils/import-fresh";
import { writeFileSync } from "fs";
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
// picked up without recreating the esbuild context.
let current_entry_map = new Map();
// Entry key set + root file presence of the last *successful* build. When
// this differs from the freshly computed set, the context must be
// recreated (esbuild cannot add/remove entry points on a live context).
let last_entry_keys = undefined;
let last_root_file_path = undefined;
let rebuild_in_flight;
function arraysEqual(a, b) {
if (a.length !== b.length)
return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i])
return false;
}
const entryToPage = new Map();
return true;
}
async function computeEntryToPage() {
const pages = grabAllPages({
include_server: true,
});
const { root_file_path } = grabRootFilePath();
const entryToPage = new Map();
try {
writeFileSync(path.join(BUNX_TMP_DIR, "ssr-pages.json"), JSON.stringify(pages, null, 4));
}
catch (error) { }
for (const page of pages) {
if (page.local_path.match(/\/pages\/api\//)) {
if (page.local_path.match(/\/pages\/api\//) ||
page.local_path.match(/\.server\.tsx?$/)) {
const ts = await Bun.file(page.local_path).text();
if (ts.match(/(export default)|(export \w+ handler)|(export \w+ server)/)) {
entryToPage.set(page.local_path, { ...page, tsx: ts });
}
continue;
}
const tsx = grabPageReactComponentString({
@@ -29,14 +60,84 @@ export default async function pagesSSRContextBundler(params) {
});
if (!tsx)
continue;
if (!tsx.match(/export default/))
continue;
entryToPage.set(page.local_path, { ...page, tsx });
}
return { entryToPage, root_file_path };
}
/**
* Single-flight SSR rebuild. Concurrent callers (each successful hydration
* rebuild triggers this, plus watcher/structural paths) join the in-flight
* rebuild instead of spawning overlapping esbuild builds.
*
* A fresh content change that lands while a rebuild is in flight is picked
* up by the next hydration rebuild cycle (each watcher event re-runs the
* hydration context rebuild, which re-enters this queue).
*/
export default async function enqueueSSRRebuild(params) {
if (rebuild_in_flight) {
return rebuild_in_flight;
}
const run = doSSRRebuild(params).finally(() => {
if (rebuild_in_flight === run) {
rebuild_in_flight = undefined;
}
});
rebuild_in_flight = run;
return run;
}
/**
* Resolves once the in-flight SSR rebuild (if any) has finished. Lets page
* requests avoid rendering stale SSR modules against a newer client bundle.
*/
export async function awaitSSRRebuild() {
try {
await rebuild_in_flight;
}
catch (error) { }
}
async function doSSRRebuild(params) {
const force = Boolean(params?.force);
const dev = isDevelopment();
const config = global.BUNEXT_CONFIG;
try {
const { entryToPage, root_file_path } = await computeEntryToPage();
const entry_keys = [...entryToPage.keys()].sort();
if (entry_keys.length === 0) {
current_entry_map = entryToPage;
last_entry_keys = entry_keys;
last_root_file_path = root_file_path;
return;
}
// Refresh the virtual contents BEFORE rebuilding so the plugin's
// onLoad sees the latest `.server.ts`/api contents.
current_entry_map = entryToPage;
const entryPoints = [...entryToPage.keys()].map((e) => `ssr-virtual:${e}`);
try {
writeFileSync(path.join(BUNX_TMP_DIR, "ssr-entry-to-page.json"), JSON.stringify(Object(entryToPage), null, 4));
writeFileSync(path.join(BUNX_TMP_DIR, "ssr-entrypoints.json"), JSON.stringify(entryPoints, null, 4));
}
catch (error) { }
const structure_changed = !global.BUNEXT_SSR_BUNDLER_CTX ||
global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED ||
force ||
!last_entry_keys ||
!arraysEqual(last_entry_keys, entry_keys) ||
last_root_file_path !== root_file_path;
if (structure_changed) {
try {
await global.BUNEXT_SSR_BUNDLER_CTX?.dispose();
}
catch (error) { }
global.BUNEXT_SSR_BUNDLER_CTX = undefined;
global.BUNEXT_SSR_BUNDLER_CTX = await esbuild.context({
entryPoints,
outdir: BUNX_CWD_MODULE_CACHE_DIR,
bundle: true,
minify: !dev,
minify: shouldMinifySSR({
minify_ssr_bundle: config.minify_ssr_bundle,
}),
format: "esm",
target: "esnext",
platform: "node",
@@ -46,13 +147,14 @@ export default async function pagesSSRContextBundler(params) {
entryNames: "[dir]/[hash]",
metafile: true,
plugins: [
tailwindEsbuildPlugin,
// SSR CSS output is never served (HTML links the
// hydration build's CSS), so skip Tailwind here.
cssStubEsbuildPlugin,
ssrVirtualFilesPlugin({
entryToPage,
getEntryToPage: () => current_entry_map,
}),
ssrCTXArtifactTracker({
entryToPage,
post_build_fn: params?.post_build_fn,
}),
],
jsx: "automatic",
@@ -62,8 +164,38 @@ export default async function pagesSSRContextBundler(params) {
"react/jsx-runtime",
"react/jsx-dev-runtime",
"bun:*",
"bun",
"sqlite-vec",
"better-sqlite3",
...(config.ssr_compiler_excludes || []),
],
// logLevel: "silent",
splitting: true,
logLevel: "silent",
});
}
await global.BUNEXT_SSR_BUNDLER_CTX.rebuild();
// Only record the entry set after a successful build so a failed
// build retries with a fresh context next time.
last_entry_keys = entry_keys;
last_root_file_path = root_file_path;
global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED = false;
global.BUNEXT_BUILD_FAILED = false;
await cleanupArtifacts({ paradigm: "ssr" });
// esbuild has now replaced superseded outputs on disk; release the
// ones the server already imported.
evictDeletedModules(BUNX_CWD_MODULE_CACHE_DIR);
}
catch (error) {
global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED = true;
global.BUNEXT_BUILD_FAILED = true;
// esbuild errors are reported (deduped) by the ssr artifact tracker's
// onEnd; only re-log unexpected failures.
if (!String(error?.message || "").startsWith("Build failed with")) {
logBuildErrorsOnce([
{
text: `SSR Bundler Error: ${error?.message || String(error)}`,
},
], (line) => log.error(line));
}
}
}
@@ -6,8 +6,15 @@ type Params = {
}>;
post_build_fn?: (params: {
artifacts: any[];
changed_pages?: string[];
}) => Promise<void> | void;
build_only?: boolean;
/**
* Always re-fetch server props after this context's first build, even
* when only client code changed (set by `fullRebuild`, e.g. after a
* config change).
*/
refresh_props_on_first_build?: boolean;
};
export default function esbuildCTXArtifactTracker({ entryToPage, post_build_fn, build_only, }: Params): Plugin;
export default function esbuildCTXArtifactTracker({ entryToPage, post_build_fn, build_only, refresh_props_on_first_build, }: Params): Plugin;
export {};
@@ -1,39 +1,35 @@
import {} from "esbuild";
import { log } from "../../../utils/log";
import grabArtifactsFromBundledResults from "../grab-artifacts-from-bundled-result";
import buildOnstartErrorHandler from "../build-on-start-error-handler";
import _ from "lodash";
import pagesSSRBundler from "../pages-ssr-bundler";
import enqueueSSRRebuild from "../pages-ssr-context-bundler";
import grabDirNames from "../../../utils/grab-dir-names";
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "fs";
import { logBuildErrorsOnce, resetBuildErrorLog, } from "../../../utils/build-error-log";
import { cpSync, existsSync, mkdirSync, rmSync } from "fs";
import fullRebuild from "../../server/full-rebuild";
import path from "path";
import cleanupLogsDirs from "../../cleanup-logs-dir";
import grabRelativePath from "../../../utils/grab-relative-path";
const { BUNX_BUNDLER_ERROR_EXIT_FILE, BUNX_ERROR_LOGS_DIR, ROOT_DIR } = grabDirNames();
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;
const MAX_BUILD_STARTS = 2;
export default function esbuildCTXArtifactTracker({ entryToPage, post_build_fn, build_only, }) {
export default function esbuildCTXArtifactTracker({ entryToPage, post_build_fn, build_only, refresh_props_on_first_build, }) {
let force_props_refresh = Boolean(refresh_props_on_first_build);
const artifactTracker = {
name: "artifact-tracker",
setup(build) {
build.onStart(async () => {
global.BUNEXT_MAIN_CTX_BUILD_STARTS++;
build_start = performance.now();
const does_error_file_exist = existsSync(BUNX_BUNDLER_ERROR_EXIT_FILE);
if (global.BUNEXT_MAIN_CTX_BUILD_STARTS >= MAX_BUILD_STARTS &&
!does_error_file_exist) {
await buildOnstartErrorHandler();
}
});
build.onEnd(async (result) => {
if (result.errors.length > 0) {
global.BUNEXT_BUILD_FAILED = true;
global.BUNEXT_RECOMPILING = false;
global.BUNEXT_IS_SERVER_COMPONENT = false;
log.error(`Build errors:`);
for (const err of result.errors) {
log.error(` ${err.text}${err.location ? ` (${err.location.file}:${err.location.line}:${err.location.column})` : ""}`);
}
// Rebuild attempts repeat for every file edit / request
// while the error persists; only log the error once.
logBuildErrorsOnce(result.errors, (line) => log.error(` ${line}`));
for (let i = global.BUNEXT_HMR_CONTROLLERS.length - 1; i >= 0; i--) {
const controller = global.BUNEXT_HMR_CONTROLLERS[i];
try {
@@ -49,26 +45,52 @@ export default function esbuildCTXArtifactTracker({ entryToPage, post_build_fn,
result,
entryToPage,
});
// Pages whose entry output hash changed since the previous
// build. With `splitting`, a change to any shared chunk also
// re-hashes every page entry that imports it, so this set
// covers all pages whose client graph actually changed.
const changed_pages = [];
if (artifacts?.[0] && artifacts.length > 0) {
for (let i = 0; i < artifacts.length; i++) {
const artifact = artifacts[i];
if (artifact?.local_path &&
global.BUNEXT_BUNDLER_CTX_MAP) {
const relative_path = grabRelativePath(artifact.local_path);
const prior = global.BUNEXT_BUNDLER_CTX_MAP[relative_path];
if (!prior?.path ||
prior.path !== artifact.path ||
prior.css_path !== artifact.css_path) {
changed_pages.push(relative_path);
}
global.BUNEXT_BUNDLER_CTX_MAP[relative_path] =
_.merge(global.BUNEXT_BUNDLER_CTX_MAP[relative_path], { ...artifact, local_path: relative_path });
_.merge(prior, {
...artifact,
local_path: relative_path,
});
}
}
}
const elapsed = (performance.now() - build_start).toFixed(0);
log.success(`[Built] in ${elapsed}ms`);
global.BUNEXT_MAIN_CTX_BUILD_STARTS = 0;
// A successful build clears the "last error" so a NEW incident
// with the same signature is reported again.
resetBuildErrorLog();
if (result.warnings.length > 0) {
logBuildErrorsOnce(result.warnings, (line) => log.warn(line));
}
global.BUNEXT_BUILD_FAILED = false;
global.BUNEXT_BUNDLER_CTX_DISPOSED = false;
const props_refresh_forced = force_props_refresh;
force_props_refresh = false;
const does_error_file_exist = existsSync(BUNX_BUNDLER_ERROR_EXIT_FILE);
// SSR must finish before HMR so server props are fresh
// Client-only changes are pushed before the SSR rebuild;
// anything that may change server props waits for SSR so
// props are fresh. The rebuild is single-flighted: concurrent hydration
// rebuilds join the in-flight SSR rebuild instead of
// starting overlapping ones.
if (build_only) {
try {
await pagesSSRBundler();
await enqueueSSRRebuild();
}
catch (error) {
log.error(`SSR Bundler Error: ${error}`);
@@ -81,22 +103,56 @@ export default function esbuildCTXArtifactTracker({ entryToPage, post_build_fn,
cleanupLogsDirs();
await fullRebuild();
}
else {
else if (changed_pages.length > 0 &&
post_build_fn &&
!props_refresh_forced) {
// Client graph changed: push the new bundle right away.
// Server functions live in `.server.ts` files, outside
// the client graph, so the browser's current props are
// still valid and it needn't wait for the SSR rebuild.
pushClientUpdate(changed_pages);
const server_hashes_before = grabServerEntryPaths();
try {
await pagesSSRBundler();
await enqueueSSRRebuild();
}
catch (error) {
log.error(`SSR Bundler Error: ${error}`);
}
if (artifacts?.[0] && artifacts.length > 0) {
// A module shared by a page and its server file changed:
// props may differ, so re-fetch them for every tab.
if (server_hashes_before !== grabServerEntryPaths()) {
try {
await post_build_fn?.({ artifacts });
await post_build_fn({
artifacts: artifacts || [],
changed_pages: [],
});
}
catch (error) {
log.error(`Post-build Error: ${error}`);
}
}
}
else {
try {
await enqueueSSRRebuild();
}
catch (error) {
log.error(`SSR Bundler Error: ${error}`);
}
if (artifacts?.[0] && artifacts.length > 0) {
try {
await post_build_fn?.({
artifacts,
changed_pages,
});
}
catch (error) {
log.error(`Post-build Error: ${error}`);
}
}
}
// Prune orphaned entry outputs from previous rebuilds.
await cleanupArtifacts({ paradigm: "hydration" });
global.BUNEXT_RECOMPILING = false;
global.BUNEXT_IS_SERVER_COMPONENT = false;
});
@@ -104,3 +160,18 @@ export default function esbuildCTXArtifactTracker({ entryToPage, post_build_fn,
};
return artifactTracker;
}
/**
* Fingerprint of the SSR outputs of all `.server.ts(x)` entries. Outputs are
* content-hashed, so a changed fingerprint means some server function's code
* (or a module it imports) changed.
*/
function grabServerEntryPaths() {
const map = global.BUNEXT_SSR_BUNDLER_CTX_MAP;
if (!map)
return "";
return Object.keys(map)
.filter((key) => key.match(/\.server\.tsx?$/))
.sort()
.map((key) => `${key}:${map[key]?.path || ""}`)
.join("\n");
}
@@ -4,9 +4,6 @@ type Params = {
entryToPage: Map<string, PageFiles & {
tsx: string;
}>;
post_build_fn?: (params: {
artifacts: any[];
}) => Promise<void> | void;
};
export default function ssrCTXArtifactTracker({ entryToPage, post_build_fn, }: Params): Plugin;
export default function ssrCTXArtifactTracker({ entryToPage }: Params): Plugin;
export {};
+10 -24
View File
@@ -4,35 +4,22 @@ import { writeFileSync } from "fs";
import path from "path";
import grabDirNames from "../../../utils/grab-dir-names";
import grabRelativePath from "../../../utils/grab-relative-path";
import { logBuildErrorsOnce, resetBuildErrorLog, } from "../../../utils/build-error-log";
import { log } from "../../../utils/log";
let build_start = 0;
let build_starts = 0;
const MAX_BUILD_STARTS = 2;
const { BUNX_TMP_DIR, ROOT_DIR } = grabDirNames();
export default function ssrCTXArtifactTracker({ entryToPage, post_build_fn, }) {
const { BUNX_TMP_DIR } = grabDirNames();
export default function ssrCTXArtifactTracker({ entryToPage }) {
const artifactTracker = {
name: "ssr-artifact-tracker",
setup(build) {
build.onStart(async () => {
build_starts++;
build_start = performance.now();
if (build_starts == MAX_BUILD_STARTS) {
global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED = true;
await global.BUNEXT_SSR_BUNDLER_CTX?.dispose();
global.BUNEXT_SSR_BUNDLER_CTX = undefined;
}
});
build.onEnd(async (result) => {
if (result.errors.length > 0) {
global.BUNEXT_BUILD_FAILED = true;
global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED = true;
try {
await global.BUNEXT_SSR_BUNDLER_CTX?.dispose();
}
catch { }
global.BUNEXT_SSR_BUNDLER_CTX = undefined;
build_starts = 0;
for (const err of result.errors) {
console.error(`SSR Build error: ${err.text}${err.location ? ` (${err.location.file}:${err.location.line}:${err.location.column})` : ""}`);
}
logBuildErrorsOnce(result.errors, (line) => log.error(`SSR Build error: ${line}`));
return;
}
const artifacts = grabArtifactsFromBundledResults({
@@ -52,17 +39,16 @@ export default function ssrCTXArtifactTracker({ entryToPage, post_build_fn, }) {
};
}
}
// post_build_fn?.({ artifacts });
// const elapsed = (performance.now() - build_start).toFixed(
// 0,
// );
// log.success(`SSR [Built] in ${elapsed}ms`);
}
try {
writeFileSync(path.join(BUNX_TMP_DIR, "ctx-map.json"), JSON.stringify(global.BUNEXT_SSR_BUNDLER_CTX_MAP, null, 4));
}
catch (error) { }
// const elapsed = (performance.now() - build_start).toFixed(0);
// log.success(`SSR [Built] in ${elapsed}ms`);
global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED = false;
global.BUNEXT_BUILD_FAILED = false;
resetBuildErrorLog();
});
},
};
@@ -1,9 +1,16 @@
import type { Plugin } from "esbuild";
import type { PageFiles } from "../../../types";
type Params = {
entryToPage: Map<string, PageFiles & {
type SSRVirtualEntry = PageFiles & {
tsx: string;
}>;
};
export default function ssrVirtualFilesPlugin({ entryToPage }: Params): Plugin;
type Params = {
/**
* Lazily-resolved current virtual entry contents. The map is refreshed
* before every rebuild so edits to `.server.ts`/api files (whose virtual
* entries mirror their raw file content) are picked up by a plain
* `ctx.rebuild()` without recreating the esbuild context.
*/
getEntryToPage: () => Map<string, SSRVirtualEntry>;
};
export default function ssrVirtualFilesPlugin({ getEntryToPage }: Params): Plugin;
export {};
+2 -3
View File
@@ -1,6 +1,5 @@
import path from "path";
import { log } from "../../../utils/log";
export default function ssrVirtualFilesPlugin({ entryToPage }) {
export default function ssrVirtualFilesPlugin({ getEntryToPage }) {
const virtualPlugin = {
name: "ssr-virtual-hydration",
setup(build) {
@@ -12,7 +11,7 @@ export default function ssrVirtualFilesPlugin({ entryToPage }) {
};
});
build.onLoad({ filter: /.*/, namespace: "ssr-virtual" }, (args) => {
const target = entryToPage.get(args.path);
const target = getEntryToPage().get(args.path);
if (!target?.tsx)
return null;
const contents = target.tsx;
-6
View File
@@ -1,6 +0,0 @@
type Params = {
txt_code: string;
file_path: string;
};
export default function stripServerSideLogic({ txt_code, file_path }: Params): string;
export {};
-92
View File
@@ -1,92 +0,0 @@
import path from "path";
import ts from "typescript";
export default function stripServerSideLogic({ txt_code, file_path }) {
const file_dir_name = path.dirname(file_path);
// 1. Initial Parse of the source
const sourceFile = ts.createSourceFile("file.tsx", txt_code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const printer = ts.createPrinter();
/**
* PASS 1: Remove 'server' export and resolve absolute paths
*/
const stripTransformer = (context) => (rootNode) => {
const visitor = (node) => {
// Remove 'export const server'
if (ts.isVariableStatement(node) &&
node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) {
if (node.declarationList.declarations.some((d) => ts.isIdentifier(d.name) &&
d.name.text === "server")) {
return undefined;
}
}
// Convert relative imports to absolute
if (ts.isImportDeclaration(node)) {
const specifier = node.moduleSpecifier;
if (ts.isStringLiteral(specifier) &&
specifier.text.startsWith(".")) {
const absolutePath = path.resolve(file_dir_name, specifier.text);
return ts.factory.updateImportDeclaration(node, node.modifiers, node.importClause, ts.factory.createStringLiteral(absolutePath), node.attributes);
}
}
return ts.visitEachChild(node, visitor, context);
};
return ts.visitNode(rootNode, visitor);
};
const step1Result = ts.transform(sourceFile, [stripTransformer]);
const intermediateCode = printer.printFile(step1Result.transformed[0]);
// 2. Re-parse to get a "Clean Slate" AST
const cleanSource = ts.createSourceFile("clean.tsx", intermediateCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
/**
* PASS 2: Collect all used Identifiers and Prune Imports
*/
const usedIdentifiers = new Set();
function walkAndFindUsages(node) {
if (ts.isIdentifier(node)) {
// We only care about identifiers that AREN'T the names in the import statements themselves
const parent = node.parent;
const isImportName = ts.isImportSpecifier(parent) ||
ts.isImportClause(parent) ||
ts.isNamespaceImport(parent);
if (!isImportName) {
usedIdentifiers.add(node.text);
}
}
ts.forEachChild(node, walkAndFindUsages);
}
walkAndFindUsages(cleanSource);
const cleanupTransformer = (context) => (rootNode) => {
const visitor = (node) => {
if (ts.isImportDeclaration(node)) {
const clause = node.importClause;
if (!clause)
return node;
// 1. Clean Named Imports: { A, B }
if (clause.namedBindings &&
ts.isNamedImports(clause.namedBindings)) {
const activeElements = clause.namedBindings.elements.filter((el) => usedIdentifiers.has(el.name.text));
// If no named imports are used and there is no default import, nix the whole line
if (activeElements.length === 0 && !clause.name)
return undefined;
// If we have some named imports left, update the node
return ts.factory.updateImportDeclaration(node, node.modifiers, ts.factory.updateImportClause(clause, clause.isTypeOnly,
// Only keep default import if it's used
clause.name &&
usedIdentifiers.has(clause.name.text)
? clause.name
: undefined, ts.factory.createNamedImports(activeElements)), node.moduleSpecifier, node.attributes);
}
// 2. Clean Default Imports: import X from '...'
if (clause.name && !usedIdentifiers.has(clause.name.text)) {
// If there are no named bindings attached, nix the whole line
if (!clause.namedBindings)
return undefined;
// Otherwise, just strip the default name and keep the named bindings
return ts.factory.updateImportDeclaration(node, node.modifiers, ts.factory.updateImportClause(clause, clause.isTypeOnly, undefined, clause.namedBindings), node.moduleSpecifier, node.attributes);
}
}
return ts.visitEachChild(node, visitor, context);
};
return ts.visitNode(rootNode, visitor);
};
const finalResult = ts.transform(cleanSource, [cleanupTransformer]);
return printer.printFile(finalResult.transformed[0]);
}
+9 -2
View File
@@ -3,7 +3,6 @@ import type { FileSystemRouter, Server } from "bun";
import { type DirNames } from "../utils/grab-dir-names";
import type { BuildContext } from "esbuild";
import grabConstants from "../utils/grab-constants";
import { type FSWatcher } from "fs";
/**
* # Declare Global Variables
*/
@@ -24,7 +23,7 @@ declare global {
[k: string]: BundlerCTXMap;
};
var BUNEXT_BUNDLER_REBUILDS: 0;
var BUNEXT_PAGES_SRC_WATCHER: FSWatcher | undefined;
var BUNEXT_PAGES_SRC_WATCHER: any;
var BUNEXT_CURRENT_VERSION: string | undefined;
var BUNEXT_PAGE_FILES: PageFiles[];
var BUNEXT_ROOT_FILE_UPDATED: boolean;
@@ -44,8 +43,16 @@ declare global {
var BUNEXT_SSR_BUNDLER_CTX_DISPOSED: boolean | undefined;
var BUNEXT_REBUILD_RETRIES: number;
var BUNEXT_IS_404_PAGE: boolean;
var BUNEXT_BUILD_FAILED: boolean;
var BUNEXT_CONSTANTS: ReturnType<typeof grabConstants>;
var BUNEXT_MAIN_CTX_BUILD_STARTS: number;
/**
* Resolves when the one-time dev startup build (hydration + SSR for all
* pages) has completed. The dev server starts listening before this
* resolves; page requests await it so they never render against
* half-built modules.
*/
var BUNEXT_INITIAL_BUILD_PROMISE: Promise<void> | undefined;
}
type Params = {
build_only?: boolean;
+18 -3
View File
@@ -8,6 +8,7 @@ import serverPostBuildFn from "./server/server-post-build-fn";
import reactModulesBundler from "./bundler/react-modules-bundler";
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;
@@ -40,6 +41,7 @@ export default async function bunextInit(params) {
// global.BUNEXT_API_ROUTES_BUNDLER_CTX_MAP = {};
global.BUNEXT_BUNDLER_REBUILDS = 0;
global.BUNEXT_REBUILD_RETRIES = 0;
global.BUNEXT_BUILD_FAILED = false;
global.BUNEXT_PAGE_FILES = [];
global.BUNEXT_SKIPPED_BROWSER_MODULES = new Set();
global.BUNEXT_DIR_NAMES = dirNames;
@@ -61,11 +63,21 @@ export default async function bunextInit(params) {
}
else if (is_dev) {
log.build(`Building Modules ...`);
await allPagesESBuildContextBundler({
post_build_fn: async () => {
await serverPostBuildFn();
// Do NOT block on the full-site build: `Bun.serve` (or the start
// server) opens its port immediately and requests await
// BUNEXT_INITIAL_BUILD_PROMISE, so the site becomes reachable while
// the one-time compile still runs in the background.
const initial_build_promise = allPagesESBuildContextBundler({
post_build_fn: async (params) => {
await serverPostBuildFn(params);
},
});
global.BUNEXT_INITIAL_BUILD_PROMISE = initial_build_promise;
// Errors are logged (deduped) by the build pipeline itself; swallow
// the rejection here so it isn't an unhandled rejection.
initial_build_promise.catch(() => { });
// Start watching immediately so edits made during the initial build
// are not missed (rebuilds coalesce with the in-flight build).
watcherEsbuildCTX();
}
else {
@@ -75,6 +87,9 @@ export default async function bunextInit(params) {
else {
log.build(`Building Modules ...`);
await allPagesESBuildContextBundler({ start: true });
// The server only needs the artifact maps; don't keep
// esbuild's build state alive for the rest of the process.
await disposeBundlerContexts({ stop_service: true });
}
cron();
}
+8 -2
View File
@@ -1,8 +1,14 @@
type Params = {
key: string;
key?: string;
/**
* Hashed key, as found in cache file names. Used when the original
* key is no longer known (e.g. when trimming from a directory listing).
*/
cache_id?: string;
paradigm?: "html" | "json";
};
export default function grabCacheNames({ key, paradigm }: Params): {
export default function grabCacheNames({ key, cache_id, paradigm, }: Params): {
cache_id: string;
cache_name: string;
cache_meta_name: string;
};
+6 -3
View File
@@ -1,6 +1,9 @@
export default function grabCacheNames({ key, paradigm = "html" }) {
const parsed_key = encodeURIComponent(key);
export default function grabCacheNames({ key, cache_id, paradigm = "html", }) {
// Hashing keeps file names short and fixed-length no matter how long
// the request path or query string is.
const parsed_key = cache_id ||
new Bun.CryptoHasher("sha256").update(key || "").digest("hex");
const cache_name = `${parsed_key}.res.${paradigm}`;
const cache_meta_name = `${parsed_key}.meta.json`;
return { cache_name, cache_meta_name };
return { cache_id: parsed_key, cache_name, cache_meta_name };
}
+2 -8
View File
@@ -9,14 +9,8 @@ export default async function trimAllCache() {
const cached_item = cached_items[i];
if (!cached_item.endsWith(`.meta.json`))
continue;
const cache_key = decodeURIComponent(cached_item.replace(/\.meta\.json/, ""));
const trim_key = await trimCacheKey({
key: cache_key,
});
if (trim_key.success) {
cached_items.splice(i, 1);
i--;
}
const cache_id = cached_item.replace(/\.meta\.json$/, "");
await trimCacheKey({ cache_id });
}
}
catch (error) {
+3 -2
View File
@@ -1,6 +1,7 @@
import type { APIResponseObject } from "../../types";
type Params = {
key: string;
key?: string;
cache_id?: string;
};
export default function trimCacheKey({ key, }: Params): Promise<APIResponseObject>;
export default function trimCacheKey({ key, cache_id, }: Params): Promise<APIResponseObject>;
export {};
+11 -8
View File
@@ -1,21 +1,24 @@
import { readFileSync, unlinkSync } from "fs";
import { readFileSync, rmSync } from "fs";
import grabDirNames from "../../utils/grab-dir-names";
import grabCacheNames from "./grab-cache-names";
import path from "path";
import { AppData } from "../../data/app-data";
export default async function trimCacheKey({ key, }) {
export default async function trimCacheKey({ key, cache_id, }) {
try {
const { BUNEXT_CACHE_DIR } = grabDirNames();
const { cache_name, cache_meta_name } = grabCacheNames({
key,
});
const { cache_meta_name } = grabCacheNames({ key, cache_id });
const config = global.BUNEXT_CONFIG;
const default_expiry_time_seconds = config.default_cache_expiry ||
AppData["DefaultCacheExpiryTimeSeconds"];
const default_expiry_time_milliseconds = default_expiry_time_seconds * 1000;
const cache_content_path = path.join(BUNEXT_CACHE_DIR, cache_name);
const cache_meta_path = path.join(BUNEXT_CACHE_DIR, cache_meta_name);
const cache_meta = JSON.parse(readFileSync(cache_meta_path, "utf-8"));
const { cache_name } = grabCacheNames({
key,
cache_id,
paradigm: cache_meta.paradigm,
});
const cache_content_path = path.join(BUNEXT_CACHE_DIR, cache_name);
const expiry_milliseconds = cache_meta.expiry_seconds
? cache_meta.expiry_seconds * 1000
: default_expiry_time_milliseconds;
@@ -25,8 +28,8 @@ export default async function trimCacheKey({ key, }) {
msg: `Cache has not expired yet`,
};
}
unlinkSync(cache_content_path);
unlinkSync(cache_meta_path);
rmSync(cache_content_path, { force: true });
rmSync(cache_meta_path, { force: true });
return {
success: true,
};
+12 -2
View File
@@ -1,7 +1,8 @@
import { existsSync, writeFileSync } from "fs";
import { existsSync, readdirSync, writeFileSync } from "fs";
import grabDirNames from "../../utils/grab-dir-names";
import grabCacheNames from "./grab-cache-names";
import path from "path";
import { AppData } from "../../data/app-data";
export default async function writeCache({ key, value, paradigm = "html", expiry_seconds, }) {
try {
const { BUNEXT_CACHE_DIR } = grabDirNames();
@@ -16,7 +17,16 @@ export default async function writeCache({ key, value, paradigm = "html", expiry
msg: `Cache entry already exists`,
};
}
writeFileSync(path.join(target_path), value);
const max_entries = global.BUNEXT_CONFIG?.max_cache_entries ||
AppData["DefaultMaxCacheEntries"];
const entries_count = readdirSync(BUNEXT_CACHE_DIR).filter((f) => f.endsWith(".meta.json")).length;
if (entries_count >= max_entries) {
return {
success: false,
msg: `Cache entries limit (${max_entries}) reached`,
};
}
writeFileSync(target_path, value);
const cache_file_meta = {
date_created: Date.now(),
paradigm,
+5 -1
View File
@@ -14,6 +14,10 @@ export default async function grabConfig() {
return config;
}
catch (error) {
return undefined;
// A config that fails to load (syntax error, bad export) must not be
// silently replaced by defaults — the app would run with the wrong
// configuration and nothing would explain why.
const { CONFIG_FILE } = grabDirNames();
exitWithError(`Failed to load config file \`${CONFIG_FILE}\`: ${error?.message || error}`);
}
}
-1
View File
@@ -1 +0,0 @@
export {};
-46
View File
@@ -1,46 +0,0 @@
import { spawn } from "bun";
// Only the "supervisor" respawns. The child sets this env var so it won't respawn itself.
const IS_CHILD = process.env.__RESPAWN_CHILD === "1";
let shuttingDown = false;
async function cleanup() {
// Put real cleanup here: close DB handles, servers, file descriptors, timers, etc.
// Must be awaitable — do NOT rely on process.on("exit") for this.
}
function respawn(code) {
const child = spawn({
cmd: [process.execPath, ...process.argv.slice(1)],
stdio: ["inherit", "inherit", "inherit"],
env: { ...process.env, __RESPAWN_CHILD: "1" },
// Detach so the child survives independently and gets its own process group.
// Without this, killing the parent's group can take the child with it.
});
// Let the child live on its own.
child.unref?.();
}
async function shutdown(code) {
if (shuttingDown)
return;
shuttingDown = true;
try {
await cleanup();
}
catch (e) {
console.error("cleanup failed:", e);
}
// Only the supervisor respawns, and only on abnormal exit.
if (!IS_CHILD && code !== 0) {
respawn(code);
}
process.exit(code);
}
// Catch the things that actually fire *before* exit, where async works.
process.on("SIGINT", () => shutdown(130));
process.on("SIGTERM", () => shutdown(143));
process.on("uncaughtException", (err) => {
console.error(err);
shutdown(1);
});
process.on("unhandledRejection", (err) => {
console.error(err);
shutdown(1);
});
+31 -8
View File
@@ -7,20 +7,28 @@ import handleFiles from "./handle-files";
import handleBunextPublicAssets from "./handle-bunext-public-assets";
import checkExcludedPatterns from "../../utils/check-excluded-patterns";
import { AppData } from "../../data/app-data";
import { log } from "../../utils/log";
import fullRebuild from "./full-rebuild";
import grabRouter from "../../utils/grab-router";
import { NoSEORobotsValue } from "../../utils/disable-seo";
import generateRobotsTxt from "../../utils/generate-robots-txt";
const HMR_RETRY_COOLDOWN_MS = 5000;
let lastHmrRetryTime = 0;
export default async function bunextRequestHandler({ req: initial_req, server, }) {
const is_dev = isDevelopment();
let req = initial_req.clone();
const middleware = global.BUNEXT_CONSTANTS.config?.middleware;
// Middleware may consume `initial_req`'s body, so it gets its own copy.
// Cloning tees the body stream and buffers whatever one side doesn't
// read, so only pay for it when middleware is configured.
let req = middleware ? initial_req.clone() : initial_req;
try {
const url = new URL(req.url);
let url = new URL(req.url);
if (checkExcludedPatterns({ path: url.pathname })) {
return Response.json({ success: false, msg: `Invalid Path` });
return Response.json({ success: false, msg: `Invalid Path` }, { status: 404 });
}
let response = undefined;
if (global.BUNEXT_CONSTANTS.config?.middleware) {
const middleware_res = await global.BUNEXT_CONSTANTS.config.middleware({
if (middleware) {
const middleware_res = await middleware({
req: initial_req,
url,
});
@@ -29,6 +37,7 @@ export default async function bunextRequestHandler({ req: initial_req, server, }
}
if (middleware_res instanceof Request) {
req = middleware_res;
url = new URL(req.url);
}
}
if (is_dev && url.pathname == AppData["BunextHMRRetryRoute"]) {
@@ -40,9 +49,15 @@ export default async function bunextRequestHandler({ req: initial_req, server, }
await fullRebuild({ msg: `HMR Retry Rebuild ...` });
return new Response("Modules Rebuilt");
}
const disable_seo = global.BUNEXT_CONSTANTS.config?.disable_seo;
if (url.pathname === "/__hmr" && is_dev) {
return handleHmr({ req });
}
else if (disable_seo && url.pathname == "/robots.txt") {
response = new Response(generateRobotsTxt({ disallow_all: true }), {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
else if (url.pathname.startsWith("/.bunext")) {
response = await handleBunextPublicAssets({ req });
}
@@ -52,8 +67,13 @@ export default async function bunextRequestHandler({ req: initial_req, server, }
else if (url.pathname.startsWith("/public/")) {
response = await handlePublic({ req });
}
else if (url.pathname.match(/\..*$/)) {
else if (url.pathname.match(/\.[^/]+$/)) {
response = await handleFiles({ req });
// Paths like `/users/john.doe` look like files but can be pages.
if (response.status == 404 &&
grabRouter()?.match(url.pathname)?.filePath) {
response = await handleWebPages({ req });
}
}
else {
response = await handleWebPages({ req });
@@ -61,18 +81,21 @@ export default async function bunextRequestHandler({ req: initial_req, server, }
if (!response) {
throw new Error(`No Response generated`);
}
if (disable_seo) {
response.headers.set("X-Robots-Tag", NoSEORobotsValue);
}
if (is_dev) {
response.headers.set("Cache-Control", "no-cache, no-store, must-revalidate");
}
return response;
}
catch (error) {
log.error(`Server Error: ${error?.message || error}\n${error?.stack || ""}`);
if (is_dev) {
return new Response(`Server Error: ${error.message}`, {
return new Response(`Server Error: ${error?.message || error}`, {
status: 500,
});
}
console.error(`Server Error: ${error.message}`, error);
return new Response("Internal Server Error", { status: 500 });
}
}
@@ -1 +0,0 @@
export default function chokadirWatcherEsbuildCTX(): Promise<void>;
-133
View File
@@ -1,133 +0,0 @@
import chokidar from "chokidar";
import path from "path";
import { existsSync } from "fs";
import grabDirNames from "../../utils/grab-dir-names";
import fullRebuild from "./full-rebuild";
import { AppData } from "../../data/app-data";
import checkExcludedPatterns from "../../utils/check-excluded-patterns";
import pagesSSRBundler from "../bundler/pages-ssr-bundler";
import { log } from "../../utils/log";
const { ROOT_DIR, BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames();
export default async function chokadirWatcherEsbuildCTX() {
const watcher = chokidar.watch(ROOT_DIR, {
ignored: [
/(^|[\/\\])\../,
/node_modules/,
/public/,
/\.bunext/,
/\.git/,
/dist/,
/bun\.lockb/,
(path) => path.endsWith(AppData["BunextTmpFileExt"]),
],
persistent: true,
ignoreInitial: true,
depth: 99,
});
const handleEvent = async (event, filePath) => {
let owns_recompile = false;
try {
const filename = path.relative(ROOT_DIR, filePath);
if (existsSync(BUNX_BUNDLER_ERROR_EXIT_FILE)) {
await fullRebuild();
return;
}
if (global.BUNEXT_BUNDLER_CTX_DISPOSED) {
await fullRebuild({ msg: `Restarting Bundler ...` });
}
if (global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED) {
await pagesSSRBundler().catch((error) => {
log.error(`SSR Bundler Error: ${error}`);
});
}
if (filename.match(/\/styles$/) || filename === "styles") {
owns_recompile = true;
global.BUNEXT_RECOMPILING = true;
await Bun.sleep(1000);
await fullRebuild({
msg: `Detected new \`styles\` directory. Rebuilding ...`,
});
return;
}
if (filename.match(/bunext.config\.ts/)) {
await fullRebuild({
msg: `bunext.config.ts file changed. Rebuilding server ...`,
});
return;
}
const target_files_match = /\.(tsx?|jsx?|css)$/;
if (event === "change") {
if (filename.match(target_files_match)) {
if (global.BUNEXT_RECOMPILING)
return;
owns_recompile = true;
global.BUNEXT_RECOMPILING = true;
if (filename.match(/.*\.server\.tsx?/)) {
global.BUNEXT_IS_SERVER_COMPONENT = true;
}
if (global.BUNEXT_BUNDLER_CTX) {
await global.BUNEXT_BUNDLER_CTX.rebuild();
}
if (filename.match(/(404|500)\.tsx?/)) {
for (let i = global.BUNEXT_HMR_CONTROLLERS.length - 1; i >= 0; i--) {
const controller = global.BUNEXT_HMR_CONTROLLERS[i];
try {
controller?.controller?.enqueue(`event: update\ndata: ${JSON.stringify({ reload: true })}\n\n`);
}
catch {
global.BUNEXT_HMR_CONTROLLERS.splice(i, 1);
}
}
}
}
return;
}
if (["add", "unlink", "addDir", "unlinkDir"].includes(event)) {
const is_file_of_interest = !!filename.match(target_files_match) ||
event.includes("Dir");
if (!is_file_of_interest)
return;
if (!filename.match(/^src\/pages\/|\.css$/) ||
checkExcludedPatterns({ path: filename }) ||
filename.includes(" ")) {
return reloadWatcher();
}
if (global.BUNEXT_RECOMPILING)
return;
owns_recompile = true;
const action = event.startsWith("add") ? "created" : "deleted";
const type = filename.match(/\.css$/)
? "Stylesheet"
: event.includes("Dir")
? "Directory"
: filename.match(/\/pages\/api\//)
? "API Route"
: "Page";
await fullRebuild({
msg: `${type} ${action}: ${filename}. Rebuilding ...`,
});
}
}
catch (error) {
log.error(`Watcher rebuild failed: ${error}`);
}
finally {
if (owns_recompile) {
global.BUNEXT_RECOMPILING = false;
global.BUNEXT_IS_SERVER_COMPONENT = false;
}
}
};
watcher
.on("add", (path) => handleEvent("add", path))
.on("change", (path) => handleEvent("change", path))
.on("unlink", (path) => handleEvent("unlink", path))
.on("addDir", (path) => handleEvent("addDir", path))
.on("unlinkDir", (path) => handleEvent("unlinkDir", path));
}
function reloadWatcher() {
if (global.BUNEXT_PAGES_SRC_WATCHER) {
global.BUNEXT_PAGES_SRC_WATCHER.close();
chokadirWatcherEsbuildCTX();
}
}
+11 -3
View File
@@ -1,6 +1,14 @@
import type { BundlerCTXMap } from "../../types";
type Params = {
new_artifacts: BundlerCTXMap[];
paradigm: "hydration" | "ssr";
};
export default function cleanupArtifacts({ new_artifacts }: Params): Promise<void>;
/**
* Deletes orphaned entry outputs left behind by previous rebuilds.
*
* After a rebuild the page/SSR artifact maps only reference the newest
* hashed entry files; every previously hashed entry for the same directory
* is provably unreferenced (generated HTML always uses the current map) and
* is removed. Chunks, non-hashed fallback module files and `map.json` are
* left untouched.
*/
export default function cleanupArtifacts({ paradigm }: Params): Promise<void>;
export {};
+78 -20
View File
@@ -2,32 +2,90 @@ 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_MAP_JSON_FILE_NAME } = grabDirNames();
export default async function cleanupArtifacts({ new_artifacts }) {
const { ROOT_DIR, HYDRATION_DST_DIR, BUNX_CWD_MODULE_CACHE_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
// older chunk (esbuild reuses unchanged chunks across rebuilds), and
// verifying references would require parsing every entry's imports.
const ENTRY_HASH_FILE = /^[A-Za-z0-9]{8}\.(js|css)$/;
/**
* Deletes orphaned entry outputs left behind by previous rebuilds.
*
* After a rebuild the page/SSR artifact maps only reference the newest
* hashed entry files; every previously hashed entry for the same directory
* is provably unreferenced (generated HTML always uses the current map) and
* is removed. Chunks, non-hashed fallback module files and `map.json` are
* left untouched.
*/
export default async function cleanupArtifacts({ paradigm }) {
const is_hydration = paradigm === "hydration";
const root_dir = is_hydration
? HYDRATION_DST_DIR
: BUNX_CWD_MODULE_CACHE_DIR;
const map = is_hydration
? global.BUNEXT_BUNDLER_CTX_MAP
: global.BUNEXT_SSR_BUNDLER_CTX_MAP;
if (!map || !existsSync(root_dir)) {
return;
}
try {
for (let i = 0; i < new_artifacts.length; i++) {
const new_artifact = new_artifacts[i];
const artifact_public_dir = path.dirname(path.join(ROOT_DIR, new_artifact.path));
const dir_content = readdirSync(artifact_public_dir);
for (let d = 0; d < dir_content.length; d++) {
const dir_or_file = dir_content[d];
const full_path = path.join(artifact_public_dir, dir_or_file);
const file_or_path_stats = statSync(full_path);
if (file_or_path_stats.isDirectory() ||
dir_or_file == HYDRATION_DST_DIR_MAP_JSON_FILE_NAME) {
const keep_by_dir = new Map();
for (const entry of Object.values(map)) {
if (!entry?.path)
continue;
const full_path = path.join(ROOT_DIR, entry.path);
const dir_path = path.dirname(full_path);
let keep = keep_by_dir.get(dir_path);
if (!keep) {
keep = new Set();
keep_by_dir.set(dir_path, keep);
}
if (new_artifact.path.includes(dir_or_file) ||
new_artifact.css_path?.includes(dir_or_file)) {
continue;
}
if (existsSync(full_path)) {
unlinkSync(full_path);
}
keep.add(path.basename(full_path));
if (entry.css_path) {
keep.add(path.basename(entry.css_path));
}
}
sweepDir(root_dir, keep_by_dir);
}
catch (error) {
log.error(error);
log.error(`Cleanup Artifacts Error: ${error?.message || error}`);
}
}
function sweepDir(dir_path, keep_by_dir) {
let entries;
try {
entries = readdirSync(dir_path);
}
catch (error) {
return;
}
for (const name of entries) {
const full_path = path.join(dir_path, name);
let is_dir = false;
try {
is_dir = statSync(full_path).isDirectory();
}
catch (error) {
continue;
}
if (is_dir) {
sweepDir(full_path, keep_by_dir);
continue;
}
if (!name.match(ENTRY_HASH_FILE)) {
continue;
}
if (name.startsWith("chunk-")) {
continue;
}
const keep = keep_by_dir.get(path.dirname(full_path));
if (keep?.has(name)) {
continue;
}
try {
unlinkSync(full_path);
}
catch (error) { }
}
}
+37 -15
View File
@@ -1,8 +1,33 @@
import { log } from "../../utils/log";
import allPagesESBuildContextBundler from "../bundler/all-pages-esbuild-context-bundler";
import serverPostBuildFn from "./server-post-build-fn";
import watcherEsbuildCTX from "./watcher-esbuild-ctx";
import { restartWatcher } from "./watcher-esbuild-ctx";
import { logBuildErrorsOnce } from "../../utils/build-error-log";
import disposeBundlerContexts from "../bundler/dispose-bundler-contexts";
import isDevelopment from "../../utils/is-development";
// Single-flight: error recovery paths (grab-page-component retries, the
// watcher, post-build HMR pushes) all call fullRebuild, and each rebuild
// triggers serverPostBuildFn which can re-enter error handling. Without this
// guard one broken page could fan out into cascading, overlapping rebuilds
// that leave the dev server unresponsive. Concurrent callers await the same
// in-flight rebuild instead of starting a new one.
let rebuild_in_progress;
export default async function fullRebuild(params) {
if (rebuild_in_progress) {
if (params?.msg) {
log.watch(`Rebuild already in progress, skipping: ${params.msg}`);
}
return rebuild_in_progress;
}
const run = doFullRebuild(params).finally(() => {
if (rebuild_in_progress === run) {
rebuild_in_progress = undefined;
}
});
rebuild_in_progress = run;
return run;
}
async function doFullRebuild(params) {
try {
const { msg } = params || {};
global.BUNEXT_RECOMPILING = true;
@@ -10,28 +35,25 @@ export default async function fullRebuild(params) {
log.watch(msg);
}
global.BUNEXT_ROUTER.reload();
try {
await global.BUNEXT_BUNDLER_CTX?.dispose();
global.BUNEXT_BUNDLER_CTX = undefined;
await global.BUNEXT_SSR_BUNDLER_CTX?.dispose();
global.BUNEXT_SSR_BUNDLER_CTX = undefined;
}
catch (error) { }
await disposeBundlerContexts();
await allPagesESBuildContextBundler({
post_build_fn: async () => {
await serverPostBuildFn();
refresh_props: true,
post_build_fn: async (params) => {
await serverPostBuildFn(params);
},
});
// Production never rebuilds incrementally; don't keep esbuild's
// build state alive for the rest of the process.
if (!isDevelopment()) {
await disposeBundlerContexts();
}
}
catch (error) {
log.error(error);
logBuildErrorsOnce([{ text: error?.message || String(error) }], (line) => log.error(line));
}
finally {
global.BUNEXT_RECOMPILING = false;
global.BUNEXT_IS_SERVER_COMPONENT = false;
}
if (global.BUNEXT_PAGES_SRC_WATCHER) {
global.BUNEXT_PAGES_SRC_WATCHER.close();
watcherEsbuildCTX();
}
restartWatcher();
}
+1 -1
View File
@@ -8,7 +8,7 @@ export default async function ({ req }) {
try {
const is_dev = isDevelopment();
const url = new URL(req.url);
const file_path = path.join(BUNEXT_PUBLIC_DIR, url.pathname.replace(/\/\.bunext\/public\//, ""));
const file_path = path.join(BUNEXT_PUBLIC_DIR, decodeURIComponent(url.pathname).replace(/\/\.bunext\/public\//, ""));
if (!isSafePath({ filePath: file_path, allowedDir: BUNEXT_PUBLIC_DIR })) {
return new Response("Forbidden", { status: 403 });
}
+1 -1
View File
@@ -8,7 +8,7 @@ export default async function ({ req }) {
try {
const is_dev = isDevelopment();
const url = new URL(req.url);
const file_path = path.join(PUBLIC_DIR, url.pathname);
const file_path = path.join(PUBLIC_DIR, decodeURIComponent(url.pathname));
if (!isSafePath({ filePath: file_path, allowedDir: PUBLIC_DIR })) {
return new Response("Forbidden", { status: 403 });
}
+1 -1
View File
@@ -8,7 +8,7 @@ export default async function ({ req }) {
try {
const is_dev = isDevelopment();
const url = new URL(req.url);
const file_path = path.join(PUBLIC_DIR, url.pathname.replace(/^\/public/, ""));
const file_path = path.join(PUBLIC_DIR, decodeURIComponent(url.pathname).replace(/^\/public/, ""));
if (!isSafePath({ filePath: file_path, allowedDir: PUBLIC_DIR })) {
return new Response("Forbidden", { status: 403 });
}
+74 -36
View File
@@ -2,12 +2,16 @@ import grabRouteParams from "../../utils/grab-route-params";
import grabConstants from "../../utils/grab-constants";
import grabRouter from "../../utils/grab-router";
import isDevelopment from "../../utils/is-development";
import { log } from "../../utils/log";
import _ from "lodash";
import path from "path";
import grabDirNames from "../../utils/grab-dir-names";
import grabRelativePath from "../../utils/grab-relative-path";
import { RequestBodyTooLargeError } from "../../utils/read-request-body";
import importFresh from "../../utils/import-fresh";
const { ROOT_DIR } = grabDirNames();
export default async function ({ req }) {
try {
const url = new URL(req.url);
const is_dev = isDevelopment();
const { MBInBytes, ServerDefaultRequestBodyLimitBytes } = grabConstants();
@@ -25,63 +29,67 @@ export default async function ({ req }) {
},
});
}
const routeParams = await grabRouteParams({
req,
query: match.query,
});
let module;
const now = Date.now();
const relative_match_path = grabRelativePath(match.filePath);
if (is_dev &&
global.BUNEXT_SSR_BUNDLER_CTX_MAP?.[relative_match_path]?.path) {
const target_import = path.join(ROOT_DIR, global.BUNEXT_SSR_BUNDLER_CTX_MAP[relative_match_path].path);
module = await import(`${target_import}?t=${now}`);
module = await importFresh(target_import);
}
else {
const import_path = is_dev
? `${match.filePath}?t=${now}`
: match.filePath;
module = await import(import_path);
module = await importFresh(match.filePath);
}
const config = module.config;
const maxBodyBytes = config?.max_request_body_mb
? config.max_request_body_mb * MBInBytes
: ServerDefaultRequestBodyLimitBytes;
const contentLength = req.headers.get("content-length");
if (contentLength) {
const size = parseInt(contentLength, 10);
if (size > maxBodyBytes) {
return Response.json({
success: false,
msg: "Request Body Too Large!",
}, {
status: 413,
headers: {
"Content-Type": "application/json",
},
if (contentLength && parseInt(contentLength, 10) > maxBodyBytes) {
return bodyTooLargeResponse();
}
let routeParams;
try {
routeParams = await grabRouteParams({
req,
query: match.query,
max_body_bytes: maxBodyBytes,
});
}
catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return bodyTooLargeResponse();
}
else if (req.method !== "GET" && req.method !== "HEAD") {
const body = await req.arrayBuffer();
if (body.byteLength > maxBodyBytes) {
return Response.json({
success: false,
msg: "Request Body Too Large!",
}, {
status: 413,
headers: {
"Content-Type": "application/json",
},
});
}
routeParams.body = JSON.parse(new TextDecoder().decode(body) || "{}");
throw error;
}
const target_module = (module["default"] ||
module["handler"]);
const res = await target_module?.({
if (typeof target_module !== "function") {
log.error(`API Route ${url.pathname} has no default or \`handler\` export`);
return Response.json({
success: false,
msg: is_dev
? `Route ${url.pathname} has no default or \`handler\` export`
: "Internal Server Error",
}, { status: 500 });
}
let res;
try {
res = await target_module({
...routeParams,
});
}
catch (error) {
log.error(`API Route Error (${url.pathname}): ${error?.message}\n${error?.stack || ""}`);
return Response.json({
success: false,
msg: routeErrorMessage(error),
}, {
status: 500,
headers: {
"Content-Type": "application/json",
},
});
}
if (res instanceof Response) {
if (is_dev) {
res.headers.set("Cache-Control", "no-cache, no-store, must-revalidate");
@@ -102,3 +110,33 @@ export default async function ({ req }) {
}
return Response.json({ err: `Route handler error` });
}
catch (error) {
const url = new URL(req.url);
log.error(`API Route Error (${url.pathname}): ${error?.message}\n${error?.stack || ""}`);
return Response.json({
success: false,
msg: routeErrorMessage(error),
}, {
status: 500,
headers: {
"Content-Type": "application/json",
},
});
}
}
function bodyTooLargeResponse() {
return Response.json({
success: false,
msg: "Request Body Too Large!",
}, { status: 413 });
}
/**
* Error details can leak internals, so they are only exposed in
* development. They are always logged server-side.
*/
function routeErrorMessage(error) {
if (!isDevelopment()) {
return "Internal Server Error";
}
return `Route handler error: ${error?.message || "Internal Server Error"}`;
}
+18
View File
@@ -1,5 +1,23 @@
type Params = {
reload_all_controllers?: boolean;
/**
* Relative page paths whose client entry outputs changed in the last
* build. When provided with at least one entry, only controllers
* targeting those pages are re-rendered/re-pushed. When empty or
* omitted, every controller is refreshed (covers server-file-only
* changes, where hydration outputs are untouched but server props must
* still be re-fetched).
*/
changed_pages?: string[];
};
/**
* # Push a client-only HMR update
* Sends the new client bundle to every controller whose page is in
* `changed_pages`, without re-running server functions. The payload carries
* no `page_props`, so the browser keeps its current props and only swaps the
* hydration script/CSS. Used right after the hydration build so the browser
* doesn't wait for the SSR rebuild.
*/
export declare function pushClientUpdate(changed_pages: string[]): void;
export default function serverPostBuildFn(params?: Params): Promise<void>;
export {};
+50 -4
View File
@@ -1,9 +1,46 @@
import _ from "lodash";
import grabPageComponent from "./web-pages/grab-page-component";
/**
* # Push a client-only HMR update
* Sends the new client bundle to every controller whose page is in
* `changed_pages`, without re-running server functions. The payload carries
* no `page_props`, so the browser keeps its current props and only swaps the
* hydration script/CSS. Used right after the hydration build so the browser
* doesn't wait for the SSR rebuild.
*/
export function pushClientUpdate(changed_pages) {
if (!global.BUNEXT_HMR_CONTROLLERS?.[0] || !global.BUNEXT_BUNDLER_CTX_MAP) {
return;
}
const changed_set = new Set(changed_pages);
for (let i = global.BUNEXT_HMR_CONTROLLERS.length - 1; i >= 0; i--) {
const controller = global.BUNEXT_HMR_CONTROLLERS[i];
const local_path = controller?.target_map?.local_path;
if (!controller || !local_path || !changed_set.has(local_path)) {
continue;
}
const target_artifact = global.BUNEXT_BUNDLER_CTX_MAP[local_path];
const payload = target_artifact?.path
? { target_map: target_artifact }
: { reload: true };
try {
controller.controller.enqueue(`event: update\ndata: ${JSON.stringify(payload)}\n\n`);
if (target_artifact?.path) {
controller.target_map = target_artifact;
}
}
catch {
global.BUNEXT_HMR_CONTROLLERS.splice(i, 1);
}
}
}
export default async function serverPostBuildFn(params) {
if (!global.BUNEXT_HMR_CONTROLLERS?.[0] || !global.BUNEXT_BUNDLER_CTX_MAP) {
return;
}
const changed_set = params?.changed_pages && params.changed_pages.length > 0
? new Set(params.changed_pages)
: undefined;
const reload_payload = { reload: true };
const reload_enqueue = `event: update\ndata: ${JSON.stringify(reload_payload)}\n\n`;
for (let i = global.BUNEXT_HMR_CONTROLLERS.length - 1; i >= 0; i--) {
@@ -14,6 +51,10 @@ export default async function serverPostBuildFn(params) {
if (!controller.target_map?.local_path) {
continue;
}
// Only touch controllers whose page actually changed.
if (changed_set && !changed_set.has(controller.target_map.local_path)) {
continue;
}
if (params?.reload_all_controllers) {
try {
controller.controller.enqueue(reload_enqueue);
@@ -33,9 +74,9 @@ export default async function serverPostBuildFn(params) {
}
continue;
}
const mock_req = target_artifact.req_url
? new Request(target_artifact.req_url, {})
: new Request(controller.page_url);
// Re-render with this tab's own URL and cookie, never another
// client's request.
const mock_req = new Request(controller.page_url);
if (controller.page_cookie) {
mock_req.headers.set("cookie", controller.page_cookie);
}
@@ -43,13 +84,18 @@ export default async function serverPostBuildFn(params) {
req: mock_req,
return_server_res_only: true,
is_hydration: true,
// This runs inside serverPostBuildFn (post-build of fullRebuild);
// letting it trigger another rebuild/retry would cascade.
no_retry: true,
});
if (page_component instanceof Response) {
continue;
}
const { serverRes } = page_component || {};
const final_artifact = {
..._.omit(controller, ["controller"]),
// `page_cookie` holds the raw Cookie header (incl. HttpOnly
// cookies), so it must never be sent back to page JS.
..._.omit(controller, ["controller", "page_cookie", "page_url"]),
target_map: target_artifact,
};
if (!target_artifact) {
+4 -7
View File
@@ -2,7 +2,7 @@ import _ from "lodash";
import { log } from "../../utils/log";
import serverParamsGen from "./server-params-gen";
import isDevelopment from "../../utils/is-development";
import watcherEsbuildCTX from "./watcher-esbuild-ctx";
import { watcherWatchdogTick } from "./watcher-esbuild-ctx";
export default async function startServer() {
const serverParams = await serverParamsGen();
const server = Bun.serve(serverParams);
@@ -10,12 +10,9 @@ export default async function startServer() {
global.BUNEXT_SERVER = server;
log.server(`http://${server.hostname}:${server.port}`);
if (is_dev) {
setInterval(() => {
if (global.BUNEXT_PAGES_SRC_WATCHER) {
global.BUNEXT_PAGES_SRC_WATCHER.close();
watcherEsbuildCTX();
}
}, 5000);
// Watchdog: re-create the FS watcher if it went stale and stopped
// receiving events.
setInterval(watcherWatchdogTick, 5000);
}
return server;
}
+16 -1
View File
@@ -1 +1,16 @@
export default function watcherEsbuildCTX(): Promise<void>;
/**
* Closes and re-creates the FS watcher. Deduped: callers that restart the
* watcher around the same time (fullRebuild, reloadWatcher, watchdog) only
* trigger a single re-creation.
*/
export declare function restartWatcher(): void;
/**
* Called periodically by the dev server. Re-creates the watcher only when it
* failed to report the probe written on the previous tick, i.e. when it went
* stale. A healthy watcher is left alone: re-creating it rescans the whole
* project tree.
*/
export declare function watcherWatchdogTick(): void;
export default function watcherEsbuildCTX(params?: {
pages_snapshot?: string;
}): Promise<void>;
+294 -104
View File
@@ -1,94 +1,158 @@
import { watch, existsSync, statSync, glob } from "fs";
import chokidar from "chokidar";
import path from "path";
import { existsSync, mkdirSync, writeFileSync } from "fs";
import grabDirNames from "../../utils/grab-dir-names";
import fullRebuild from "./full-rebuild";
import { AppData } from "../../data/app-data";
import checkExcludedPatterns from "../../utils/check-excluded-patterns";
import pagesSSRBundler from "../bundler/pages-ssr-bundler";
import allPagesESBuildContextBundler, { isHydrationEntryPage, } from "../bundler/all-pages-esbuild-context-bundler";
import grabAllPages from "../../utils/grab-all-pages";
import serverPostBuildFn from "./server-post-build-fn";
import { log } from "../../utils/log";
const { ROOT_DIR, BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames();
export default async function watcherEsbuildCTX() {
const pages_src_watcher = watch(ROOT_DIR, {
recursive: true,
persistent: true,
}, async (event, filename) => {
let owns_recompile = false;
import { logBuildErrorsOnce } from "../../utils/build-error-log";
const { ROOT_DIR, BUNX_CWD_DIR, BUNX_BUNDLER_ERROR_EXIT_FILE } = 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");
// Dedupes near-simultaneous restarts (fullRebuild, watchdog).
let last_watcher_restart = 0;
// The current watcher finished its initial scan; probes written before
// that may not produce events.
let watcher_ready = false;
// A probe was written and the watcher hasn't reported it yet.
let probe_pending = false;
/**
* Closes and re-creates the FS watcher. Deduped: callers that restart the
* watcher around the same time (fullRebuild, reloadWatcher, watchdog) only
* trigger a single re-creation.
*/
export function restartWatcher() {
if (!global.BUNEXT_PAGES_SRC_WATCHER)
return;
if (Date.now() - last_watcher_restart < 1000)
return;
// Files created between close() and the new watcher's initial scan
// completing emit no `add` (ignoreInitial treats them as pre-existing).
// Snapshot the page set so the new watcher can detect them on `ready`.
const pages_snapshot = grabPagesSnapshot();
try {
if (!filename)
global.BUNEXT_PAGES_SRC_WATCHER.close();
}
catch (error) { }
global.BUNEXT_PAGES_SRC_WATCHER = undefined;
last_watcher_restart = Date.now();
watcherEsbuildCTX({ pages_snapshot });
}
function grabPagesSnapshot() {
try {
return grabAllPages()
.map((p) => p.local_path)
.sort()
.join("\n");
}
catch (error) {
return undefined;
}
}
/**
* Called periodically by the dev server. Re-creates the watcher only when it
* failed to report the probe written on the previous tick, i.e. when it went
* stale. A healthy watcher is left alone: re-creating it rescans the whole
* project tree.
*/
export function watcherWatchdogTick() {
if (!global.BUNEXT_PAGES_SRC_WATCHER || !watcher_ready)
return;
const full_file_path = path.join(ROOT_DIR, filename);
if (global.BUNEXT_CONFIG.exclude_watch_patterns) {
for (let i = 0; i < global.BUNEXT_CONFIG.exclude_watch_patterns.length; i++) {
const watch_pattern = global.BUNEXT_CONFIG.exclude_watch_patterns[i];
if (watch_pattern instanceof RegExp) {
const is_path_excluded = watch_pattern.test(filename);
if (is_path_excluded) {
if (probe_pending) {
restartWatcher();
return;
}
try {
mkdirSync(WATCH_PROBE_DIR, { recursive: true });
writeFileSync(WATCH_PROBE_FILE, String(Date.now()));
probe_pending = true;
}
else {
const excluded_path = path.resolve(ROOT_DIR, watch_pattern);
if (excluded_path == full_file_path) {
catch (error) { }
}
function isWatchProbePath(p) {
return (p === WATCH_PROBE_FILE || p === WATCH_PROBE_DIR || p === BUNX_CWD_DIR);
}
const TARGET_FILES_MATCH = /\.(tsx?|jsx?|css)$/;
// Rebuild coalescing: editors emit bursts of events per save (change +
// atomic-swap noise), and edits can land while a rebuild is running.
// Events are debounced into a single runner; the runner loops until the
// event queue drains, so a mid-build edit is never silently dropped.
const REBUILD_DEBOUNCE_MS = 50;
let pending_structural = false;
let pending_content_file = undefined;
let debounce_timer = undefined;
// Guards against overlapping runners: BUNEXT_RECOMPILING alone is not enough
// since the artifact tracker clears it mid-run.
let runner_active = false;
function scheduleRebuild() {
if (debounce_timer) {
clearTimeout(debounce_timer);
}
debounce_timer = setTimeout(async () => {
debounce_timer = undefined;
// Another rebuild (fullRebuild, or a runner already looping) owns the
// bundler. Retry later rather than dropping the pending events.
if (runner_active || global.BUNEXT_RECOMPILING) {
scheduleRebuild();
return;
}
try {
await runRebuilds();
}
catch (error) {
const msg = String(error?.message || error || "");
if (!msg.startsWith("Build failed with")) {
logBuildErrorsOnce([{ text: `Watcher rebuild failed: ${msg}` }], (line) => log.error(line));
}
}
}, REBUILD_DEBOUNCE_MS);
}
if (existsSync(BUNX_BUNDLER_ERROR_EXIT_FILE)) {
await fullRebuild();
return;
}
if (filename.match(/^\.\w+/)) {
return;
}
if (global.BUNEXT_BUNDLER_CTX_DISPOSED) {
await fullRebuild({ msg: `Restarting Bundler ...` });
return;
}
if (global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED) {
await pagesSSRBundler().catch((error) => {
log.error(`SSR Bundler Error: ${error}`);
});
}
if (filename.endsWith(AppData["BunextTmpFileExt"])) {
return;
}
const does_file_exist = existsSync(full_file_path);
const file_stat = does_file_exist
? statSync(full_file_path)
: undefined;
if (full_file_path.match(/\/styles$/)) {
owns_recompile = true;
/**
* Processes the pending event batch. Structural changes go through the
* all-pages bundler (which re-creates esbuild contexts only when the entry
* set really changed, so atomic-swap saves resolve to incremental rebuilds).
* Content changes rebuild the hydration context incrementally; its artifact
* tracker then queues the single-flighted SSR rebuild and pushes HMR updates.
*/
async function runRebuilds() {
runner_active = true;
global.BUNEXT_RECOMPILING = true;
await Bun.sleep(1000);
await fullRebuild({
msg: `Detected new \`styles\` directory. Rebuilding ...`,
global.BUNEXT_IS_SERVER_COMPONENT = false;
try {
while (true) {
const structural = pending_structural;
const content_file = pending_content_file;
pending_structural = false;
pending_content_file = undefined;
if (!structural && !content_file) {
break;
}
if (structural) {
global.BUNEXT_ROUTER.reload();
await allPagesESBuildContextBundler({
post_build_fn: async (params) => {
await serverPostBuildFn(params);
},
});
return;
}
const excluded_match = /node_modules\/|^public\/|^\.bunext\/|^\.git\/|^\.?dist\/|bun\.lockb$/;
if (filename.match(excluded_match))
return;
if (filename.match(/bunext.config\.ts/)) {
await fullRebuild({
msg: `bunext.config.ts file changed. Rebuilding server ...`,
});
return;
}
const target_files_match = /\.(tsx?|jsx?|css)$/;
if (event !== "rename") {
if (filename.match(target_files_match)) {
if (global.BUNEXT_RECOMPILING)
return;
owns_recompile = true;
global.BUNEXT_RECOMPILING = true;
if (filename.match(/.*\.server\.tsx?/)) {
global.BUNEXT_IS_SERVER_COMPONENT = true;
}
if (global.BUNEXT_BUNDLER_CTX) {
else if (content_file) {
if (global.BUNEXT_BUNDLER_CTX &&
!global.BUNEXT_BUNDLER_CTX_DISPOSED) {
await global.BUNEXT_BUNDLER_CTX.rebuild();
}
if (filename.match(/(404|500)\.tsx?/)) {
else if (global.BUNEXT_BUNDLER_CTX_DISPOSED) {
await fullRebuild({
msg: `Restarting Bundler ...`,
});
break;
}
if (content_file.match(/(404|500)\.tsx?$/)) {
for (let i = global.BUNEXT_HMR_CONTROLLERS.length - 1; i >= 0; i--) {
const controller = global.BUNEXT_HMR_CONTROLLERS[i];
try {
@@ -100,49 +164,175 @@ export default async function watcherEsbuildCTX() {
}
}
}
return;
}
const is_file_of_interest = Boolean(filename.match(target_files_match)) ||
file_stat?.isDirectory();
if (!is_file_of_interest) {
return;
}
if (!filename.match(/^src\/pages\/|\.css$/))
return reloadWatcher();
if (checkExcludedPatterns({ path: filename }))
return reloadWatcher();
if (filename.match(/ /))
return reloadWatcher();
if (global.BUNEXT_RECOMPILING)
return;
owns_recompile = true;
const action = does_file_exist ? "created" : "deleted";
const type = filename.match(/\.css$/)
? "Sylesheet"
: file_stat?.isDirectory()
? "Directory"
: filename.match(/\/pages\/api\//)
? "API Route"
: "Page";
await fullRebuild({
msg: `${type} ${action}: ${filename}. Rebuilding ...`,
});
}
catch (error) {
log.error(`Watcher rebuild failed: ${error}`);
}
finally {
if (owns_recompile) {
runner_active = false;
global.BUNEXT_RECOMPILING = false;
global.BUNEXT_IS_SERVER_COMPONENT = false;
}
}
function isPathExcluded(filename, full_file_path) {
const user_patterns = global.BUNEXT_CONFIG?.exclude_watch_patterns;
if (!user_patterns)
return false;
for (let i = 0; i < user_patterns.length; i++) {
const watch_pattern = user_patterns[i];
if (watch_pattern instanceof RegExp) {
if (watch_pattern.test(filename))
return true;
}
else {
const excluded_path = path.resolve(ROOT_DIR, watch_pattern);
if (excluded_path == full_file_path)
return true;
}
}
return false;
}
// 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),
/(^|[\/\\])node_modules([\/\\]|$)/,
/(^|[\/\\])public([\/\\]|$)/,
(p) => !isWatchProbePath(p) && /(^|[\/\\])\.bunext([\/\\]|$)/.test(p),
/(^|[\/\\])\.git([\/\\]|$)/,
/(^|[\/\\])dist([\/\\]|$)/,
/bun\.lockb$/,
(p) => p.endsWith(AppData["BunextTmpFileExt"]),
];
const user_patterns = global.BUNEXT_CONFIG?.exclude_watch_patterns;
if (user_patterns) {
for (let i = 0; i < user_patterns.length; i++) {
const pattern = user_patterns[i];
if (pattern instanceof RegExp) {
ignored.push((p) => pattern.test(path.relative(ROOT_DIR, p)));
}
else {
const excluded_path = path.resolve(ROOT_DIR, pattern);
ignored.push((p) => p === excluded_path);
}
}
}
return ignored;
}
export default async function watcherEsbuildCTX(params) {
try {
mkdirSync(WATCH_PROBE_DIR, { recursive: true });
}
catch (error) { }
watcher_ready = false;
probe_pending = false;
const pages_src_watcher = chokidar.watch(ROOT_DIR, {
ignored: buildIgnoredPatterns(),
persistent: true,
ignoreInitial: true,
});
watcherHandlers(pages_src_watcher, (event, filePath) => handleEvent(event, filePath));
const pages_snapshot = params?.pages_snapshot;
pages_src_watcher.on("ready", () => {
if (global.BUNEXT_PAGES_SRC_WATCHER === pages_src_watcher) {
watcher_ready = true;
}
if (pages_snapshot === undefined)
return;
const current = grabPagesSnapshot();
if (current !== undefined && current !== pages_snapshot) {
pending_structural = true;
scheduleRebuild();
}
});
global.BUNEXT_PAGES_SRC_WATCHER = pages_src_watcher;
}
function reloadWatcher() {
if (global.BUNEXT_PAGES_SRC_WATCHER) {
global.BUNEXT_PAGES_SRC_WATCHER.close();
watcherEsbuildCTX();
function handleEvent(event, filePath) {
if (isWatchProbePath(filePath)) {
if (filePath === WATCH_PROBE_FILE) {
probe_pending = false;
}
return;
}
try {
const filename = path.relative(ROOT_DIR, filePath);
const full_file_path = filePath;
if (isPathExcluded(filename, full_file_path)) {
return;
}
if (existsSync(BUNX_BUNDLER_ERROR_EXIT_FILE)) {
fullRebuild();
return;
}
if (filename.endsWith(AppData["BunextTmpFileExt"])) {
return;
}
if (full_file_path.match(/\/styles$/)) {
pending_structural = false;
pending_content_file = undefined;
if (debounce_timer)
clearTimeout(debounce_timer);
setTimeout(() => {
fullRebuild({
msg: `Detected new \`styles\` directory. Rebuilding ...`,
});
}, 1000);
return;
}
if (filename.match(/bunext\.config\.ts/)) {
pending_structural = false;
pending_content_file = undefined;
if (debounce_timer)
clearTimeout(debounce_timer);
fullRebuild({
msg: `bunext.config.ts file changed. Rebuilding server ...`,
});
return;
}
if (event === "change") {
if (!filename.match(TARGET_FILES_MATCH)) {
return;
}
// A page that isn't a hydration entry yet (e.g. created empty,
// then given its default export) needs the entry set recomputed;
// an incremental rebuild of the existing context would miss it.
if (filename.match(/^src\/pages\//) &&
!filename.match(/^src\/pages\/api\/|\.server\.tsx?$/) &&
!checkExcludedPatterns({ path: filename }) &&
!isHydrationEntryPage(full_file_path)) {
pending_structural = true;
}
else {
pending_content_file = full_file_path;
}
scheduleRebuild();
return;
}
// add / unlink / addDir / unlinkDir
const is_dir_event = event === "addDir" || event === "unlinkDir";
const is_file_of_interest = Boolean(filename.match(TARGET_FILES_MATCH)) || is_dir_event;
if (!is_file_of_interest) {
return;
}
// Only page routes and stylesheets are structural; anything else
// (e.g. a component being added) is covered by the next content
// rebuild and needs no context recreation.
if (!filename.match(/^src\/pages\/|\.css$/) ||
checkExcludedPatterns({ path: filename }) ||
filename.match(/ /)) {
return;
}
pending_structural = true;
scheduleRebuild();
}
catch (error) {
logBuildErrorsOnce([{ text: `Watcher error: ${error?.message || error}` }], (line) => log.error(line));
}
}
function watcherHandlers(watcher, handleEvent) {
watcher
.on("add", (p) => handleEvent("add", p))
.on("change", (p) => handleEvent("change", p))
.on("unlink", (p) => handleEvent("unlink", p))
.on("addDir", (p) => handleEvent("addDir", p))
.on("unlinkDir", (p) => handleEvent("unlinkDir", p));
}
+73 -21
View File
@@ -1,6 +1,6 @@
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
import grabContants from "../../../utils/grab-constants";
import EJSON from "../../../utils/ejson";
import serializeForScript from "../../../utils/serialize-for-script";
import isDevelopment from "../../../utils/is-development";
import grabWebPageHydrationScript from "./grab-web-page-hydration-script";
import grabWebMetaHTML from "./grab-web-meta-html";
@@ -8,6 +8,7 @@ import { log } from "../../../utils/log";
import { AppData } from "../../../data/app-data";
import _ from "lodash";
import grabDirNames from "../../../utils/grab-dir-names";
import { NoSEORobotsValue } from "../../../utils/disable-seo";
const { ROOT_DIR } = grabDirNames();
export default async function genWebHTML({ component: Main, pageProps, bundledMap, module, routeParams, debug, root_module, }) {
const { ClientRootElementIDName, ClientWindowPagePropsName } = grabContants();
@@ -19,7 +20,11 @@ export default async function genWebHTML({ component: Main, pageProps, bundledMa
if (!Main) {
throw new Error(`Main Component not found!`);
}
const serializedProps = (EJSON.stringify(pageProps || {}) || "{}").replace(/<\//g, "<\\/");
let serializedProps = "{}";
try {
serializedProps = serializeForScript(pageProps || {});
}
catch (error) { }
const page_hydration_script = await grabWebPageHydrationScript();
const root_meta = root_module?.meta
? typeof root_module.meta == "function" && routeParams
@@ -42,7 +47,9 @@ export default async function genWebHTML({ component: Main, pageProps, bundledMa
const Head = module?.Head;
const RootHead = root_module?.Head;
const dev = isDevelopment();
const final_meta = _.merge(root_meta, page_meta);
const final_meta = global.BUNEXT_CONFIG.disable_seo
? { ..._.merge(root_meta, page_meta), robots: NoSEORobotsValue }
: _.merge(root_meta, page_meta);
const public_envs = Object.fromEntries(Object.entries(process.env).filter(([k]) => k.startsWith("BUNEXT_PUBLIC_")));
const client_process = {
env: {
@@ -52,9 +59,9 @@ export default async function genWebHTML({ component: Main, pageProps, bundledMa
},
};
let final_component = (_jsxs("html", { ...html_props, children: [_jsxs("head", { children: [_jsx("meta", { charSet: "utf-8", "data-bunext-head": true }), _jsx("meta", { name: "viewport", content: "width=device-width, initial-scale=1.0", "data-bunext-head": true }), final_meta ? grabWebMetaHTML({ meta: final_meta }) : null, bundledMap?.css_path ? (_jsx("link", { rel: "stylesheet", href: `/${bundledMap.css_path}`, "data-bunext-head": true })) : null, _jsx("script", { dangerouslySetInnerHTML: {
__html: `window.${ClientWindowPagePropsName} = ${serializedProps};\nwindow.process = ${JSON.stringify(client_process)}`,
__html: `window.${ClientWindowPagePropsName} = ${serializedProps};\nwindow.process = ${serializeForScript(client_process)}`,
}, "data-bunext-head": true }), RootHead ? (_jsx(RootHead, { serverRes: pageProps, ctx: routeParams })) : null, Head ? _jsx(Head, { serverRes: pageProps, ctx: routeParams }) : null, bundledMap?.path ? (_jsxs(_Fragment, { children: [_jsx("script", { type: "importmap", dangerouslySetInnerHTML: {
__html: JSON.stringify(global.BUNEXT_REACT_IMPORTS_MAP),
__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 }) }) })] }));
@@ -66,7 +73,62 @@ export default async function genWebHTML({ component: Main, pageProps, bundledMa
// },
// });
// const htmlBody = await new Response(stream).text();
const originalConsole = {
silenceConsole();
let htmlBody;
let render_error;
let has_render_error = false;
const render_timeout_ms = global.BUNEXT_CONFIG.ssr_render_timeout_ms ||
AppData["DefaultSSRRenderTimeoutMs"];
// Without a deadline, a Suspense boundary that never settles keeps the
// request (and its whole render tree) alive forever. The timer is
// cleared once the render ends so finished requests don't linger.
const render_abort = new AbortController();
const render_timeout = setTimeout(() => render_abort.abort(new RenderTimeoutError(`Page render timed out after ${render_timeout_ms}ms`)), render_timeout_ms);
try {
const stream = await renderToReadableStream(final_component, {
signal: render_abort.signal,
onError(error) {
if (error?.message?.includes('unique "key" prop'))
return;
if (!has_render_error) {
has_render_error = true;
render_error = error;
}
// A timeout has no useful stack; it's logged upstream.
if (!(error instanceof RenderTimeoutError)) {
original_console.error(error);
}
},
});
htmlBody = await new Response(stream).text();
}
finally {
clearTimeout(render_timeout);
restoreConsole();
}
// A component crash during SSR must surface as a proper error (handled
// upstream as a 500 page). React only reports these to `onError`; without
// rethrowing, the user gets a 200 with broken/blank markup.
if (has_render_error) {
throw render_error;
}
html += htmlBody;
return html;
}
class RenderTimeoutError extends Error {
}
let original_console = console;
let silenced_renders = 0;
/**
* Renders can overlap (each one awaits the stream), so the console is
* swapped only by the first active render and restored by the last one.
* Otherwise a render could save another render's no-ops as the "original"
* console and silence the server for good.
*/
function silenceConsole() {
if (silenced_renders++ > 0)
return;
original_console = {
log: console.log,
warn: console.warn,
error: console.error,
@@ -78,20 +140,10 @@ export default async function genWebHTML({ component: Main, pageProps, bundledMa
console.error = () => { };
console.info = () => { };
console.debug = () => { };
let htmlBody;
try {
const stream = await renderToReadableStream(final_component, {
onError(error) {
if (error.message.includes('unique "key" prop'))
}
function restoreConsole() {
if (--silenced_renders > 0)
return;
originalConsole.error(error);
},
});
htmlBody = await new Response(stream).text();
}
finally {
Object.assign(console, originalConsole);
}
html += htmlBody;
return html;
silenced_renders = 0;
Object.assign(console, original_console);
}
@@ -7,6 +7,19 @@ export default async function generateWebPageGetCachePage({ module, routeParams,
const expiry_seconds = config?.cacheExpiry || serverRes?.cache_expiry;
if (cache_page && routeParams?.url) {
try {
const status = serverRes?.response_options?.status;
if (status && (status < 200 || status >= 300)) {
return false;
}
const res_headers = new Headers(serverRes?.response_options?.headers);
if (res_headers.has("set-cookie")) {
return false;
}
const req_headers = routeParams.req.headers;
if (!global.BUNEXT_CONFIG?.cache_credentialed_requests &&
(req_headers.has("cookie") || req_headers.has("authorization"))) {
return false;
}
const is_cache = typeof cache_page == "boolean"
? cache_page
: await cache_page({ ctx: routeParams, serverRes });
@@ -5,6 +5,11 @@ import genWebHTML from "./generate-web-html";
import generateWebPageGetCachePage from "./generate-web-page-get-cache-page";
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);
}
const html = await genWebHTML({
component,
pageProps: serverRes,
@@ -17,11 +22,6 @@ export default async function generateWebPageResponseFromComponentReturn({ compo
if (debug) {
log.info("html", html);
}
if (serverRes?.redirect?.destination) {
return Response.redirect(serverRes.redirect.destination, serverRes.redirect.permanent
? 301
: serverRes.redirect.status_code || 302);
}
const res_opts = {
...serverRes?.response_options,
headers: {
@@ -1,6 +0,0 @@
type Params = {
file_path: string;
out_file?: string;
};
export default function grabFilePathModule<T extends any = any>({ file_path, out_file, }: Params): Promise<T>;
export {};
@@ -1,30 +0,0 @@
import isDevelopment from "../../../utils/is-development";
import * as esbuild from "esbuild";
import grabDirNames from "../../../utils/grab-dir-names";
import path from "path";
import tailwindEsbuildPlugin from "./tailwind-esbuild-plugin";
export default async function grabFilePathModule({ file_path, out_file, }) {
const dev = isDevelopment();
const { BUNX_CWD_MODULE_CACHE_DIR } = grabDirNames();
const target_cache_file_path = out_file ||
path.join(BUNX_CWD_MODULE_CACHE_DIR, `${path.basename(file_path)}.js`);
await esbuild.build({
entryPoints: [file_path],
bundle: true,
format: "esm",
target: "es2020",
platform: "node",
// external: ["react", "react-dom"],
minify: true,
define: {
"process.env.NODE_ENV": JSON.stringify(dev ? "development" : "production"),
},
metafile: true,
plugins: [tailwindEsbuildPlugin],
jsx: "automatic",
outfile: target_cache_file_path,
});
// Loader.registry.delete(target_cache_file_path);
const module = await import(`${target_cache_file_path}?t=${Date.now()}`);
return module;
}
@@ -5,6 +5,7 @@ import grabRootFilePath from "./grab-root-file-path";
import path from "path";
import grabDirNames from "../../../utils/grab-dir-names";
import grabRelativePath from "../../../utils/grab-relative-path";
import importFresh from "../../../utils/import-fresh";
const { ROOT_DIR } = grabDirNames();
export default async function grabPageBundledReactComponent({ file_path, return_tsx_only, }) {
try {
@@ -12,9 +13,13 @@ export default async function grabPageBundledReactComponent({ file_path, return_
if (global.BUNEXT_SSR_BUNDLER_CTX_MAP?.[relative_path]) {
const abs = path.join(ROOT_DIR, global.BUNEXT_SSR_BUNDLER_CTX_MAP[relative_path].path);
// Loader.registry.delete(abs);
const mod = await import(`${abs}?t=${Date.now()}`);
const mod = await importFresh(abs);
const Main = mod.default;
return { component: Main };
return {
component: Main,
page_module: mod.__bunext_page,
root_module: mod.__bunext_root,
};
}
const { root_file_path } = grabRootFilePath();
let tsx = grabPageReactComponentString({
@@ -35,6 +40,8 @@ export default async function grabPageBundledReactComponent({ file_path, return_
return {
component: Main,
tsx,
page_module: mod.__bunext_page,
root_module: mod.__bunext_root,
};
}
catch (error) {
@@ -6,9 +6,9 @@ import grabPageServerPath from "./grab-page-server-path";
import path from "path";
import grabDirNames from "../../../utils/grab-dir-names";
import grabRelativePath from "../../../utils/grab-relative-path";
import importFresh from "../../../utils/import-fresh";
const { ROOT_DIR } = grabDirNames();
export default async function grabPageCombinedServerRes({ file_path, debug, url, query, routeParams, }) {
const now = Date.now();
const { root_file_path } = grabRootFilePath();
const { server_file_path: root_server_file_path } = root_file_path
? grabPageServerPath({ file_path: root_file_path })
@@ -23,7 +23,7 @@ export default async function grabPageCombinedServerRes({ file_path, debug, url,
// Loader.registry.delete(final_root_server_path);
}
const root_server_module = final_root_server_path
? await import(`${final_root_server_path}?t=${now}`)
? await importFresh(final_root_server_path)
: undefined;
const root_server_fn = root_server_module?.default || root_server_module?.server;
const rootServerRes = await grabPageServerRes({
@@ -46,7 +46,7 @@ export default async function grabPageCombinedServerRes({ file_path, debug, url,
// Loader.registry.delete(final_page_server_path);
}
const server_module = final_page_server_path
? await import(`${final_page_server_path}?t=${now}`)
? await importFresh(final_page_server_path)
: undefined;
const server_fn = server_module?.default || server_module?.server;
const serverRes = await grabPageServerRes({
@@ -4,6 +4,12 @@ type Params = {
file_path?: string;
debug?: boolean;
retry?: boolean;
/**
* Skip the dev auto-rebuild/retry recovery path. Used by internal callers
* (e.g. HMR post-build) where triggering yet another fullRebuild on top
* of the rebuild that's already in progress would cascade.
*/
no_retry?: boolean;
return_server_res_only?: boolean;
skip_server_res?: boolean;
is_hydration?: boolean;
+24 -13
View File
@@ -10,6 +10,8 @@ import isDevelopment from "../../../utils/is-development";
import { existsSync } from "fs";
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;
@@ -19,12 +21,14 @@ class NotFoundError extends Error {
}
}
export default async function grabPageComponent(params) {
const { req, file_path: passed_file_path, debug, return_server_res_only, skip_server_res, is_hydration, } = params;
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;
const is_dev = isDevelopment();
const forwarded_proto = req?.headers.get("x-forwarded-proto");
if (url && forwarded_proto) {
const forwarded_proto = global.BUNEXT_CONFIG?.trust_proxy
? req?.headers.get("x-forwarded-proto")?.split(",")[0]?.trim()
: undefined;
if (url && (forwarded_proto == "http" || forwarded_proto == "https")) {
url.protocol = forwarded_proto;
}
let routeParams = undefined;
@@ -59,7 +63,10 @@ export default async function grabPageComponent(params) {
}
let retries = 0;
const MAX_RETRIES = 2;
while (retries < MAX_RETRIES) {
// Only retry transient failures: if the last rebuild failed
// (BUNEXT_BUILD_FAILED), the file is broken and more rebuilds
// will just repeat the same error.
while (retries < MAX_RETRIES && !global.BUNEXT_BUILD_FAILED) {
await fullRebuild({
msg: `Retrying Bundle map for file \`${relative_file_path}\``,
});
@@ -67,16 +74,13 @@ export default async function grabPageComponent(params) {
bundledMap = global.BUNEXT_BUNDLER_CTX_MAP[relative_file_path];
if (bundledMap?.path)
break;
retries++;
}
if (!bundledMap?.path) {
const errMsg = `No Bundled File Path for this request path!`;
log.error(errMsg);
throw new Error(errMsg);
}
}
if (req && !is_hydration) {
global.BUNEXT_BUNDLER_CTX_MAP[relative_file_path].req_url = req.url;
}
if (debug) {
log.info(`bundledMap:`, bundledMap);
}
@@ -113,11 +117,15 @@ export default async function grabPageComponent(params) {
};
}
catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return new Response("Request Body Too Large", { status: 413 });
}
const is404 = error instanceof NotFoundError ||
error?.name === "NotFoundError" ||
error?.status === 404;
if (!params.retry && is_dev) {
while (global.BUNEXT_REBUILD_RETRIES < 2) {
if (!params.retry && !params.no_retry && is_dev && !is404) {
while (!global.BUNEXT_BUILD_FAILED &&
global.BUNEXT_REBUILD_RETRIES < 2) {
global.BUNEXT_REBUILD_RETRIES =
global.BUNEXT_REBUILD_RETRIES + 1;
await fullRebuild();
@@ -139,9 +147,12 @@ export default async function grabPageComponent(params) {
global.BUNEXT_IS_404_PAGE = true;
}
else {
log.error(`Error Grabbing Page Component: ${error.message}`);
log.error(`Page: ${passed_file_path || url?.pathname}`);
if (is_dev) {
logBuildErrorsOnce([
{
text: `Error Grabbing Page Component: ${error.message}; Page: ${passed_file_path || url?.pathname}`,
},
], (line) => log.error(line));
if (is_dev && !params.no_retry && !global.BUNEXT_BUILD_FAILED) {
fullRebuild();
}
}
@@ -13,6 +13,9 @@ export default async function grabPageErrorComponent({ error, routeParams, is404
const presetComponent = is404
? BUNX_ROOT_404_PRESET_COMPONENT
: BUNX_ROOT_500_PRESET_COMPONENT;
// 404 messages only contain the requested path. Other error messages can
// leak internals, so they are only shown in development.
const error_message = is404 || is_dev ? error?.message : "Internal Server Error";
const default_server_res = {
response_options: {
status: is404 ? 404 : 500,
@@ -24,7 +27,7 @@ export default async function grabPageErrorComponent({ error, routeParams, is404
const default_module = await import(presetComponent);
const Component = default_module.default;
const default_jsx = () => {
return _jsx(Component, { children: _jsx("span", { children: error.message }) });
return _jsx(Component, { children: _jsx("span", { children: error_message }) });
};
return {
component: default_jsx,
@@ -65,7 +68,7 @@ export default async function grabPageErrorComponent({ error, routeParams, is404
alignItems: "center",
justifyContent: "center",
flexDirection: "column",
}, children: [_jsx("h1", { children: is404 ? "404 Not Found" : "500 Internal Server Error" }), _jsx("span", { children: error.message })] }));
}, children: [_jsx("h1", { children: is404 ? "404 Not Found" : "500 Internal Server Error" }), _jsx("span", { children: error_message })] }));
return {
component: DefaultNotFound,
routeParams,
+15 -10
View File
@@ -3,8 +3,8 @@ import _ from "lodash";
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";
export default async function grabPageModules({ file_path, debug, url, query, routeParams, skip_server_res, }) {
const now = Date.now();
const { serverRes } = skip_server_res
? {}
: await grabPageCombinedServerRes({
@@ -19,20 +19,25 @@ export default async function grabPageModules({ file_path, debug, url, query, ro
? 301
: serverRes.redirect.status_code || 302);
}
const { root_file_path } = grabRootFilePath();
const root_module = root_file_path
? await import(`${root_file_path}?t=${now}`)
: undefined;
const module = await import(`${file_path}?t=${now}`);
if (debug) {
log.info(`module:`, module);
}
const { component } = (await grabPageBundledReactComponent({
const { component, page_module, root_module: bundled_root_module, } = (await grabPageBundledReactComponent({
file_path,
})) || {};
if (!component) {
throw new Error(`Couldn't grab page component`);
}
// Prefer the page/root exports re-exported by the SSR bundle. Importing
// the raw source with Bun's loader only cache-busts the page file itself:
// its transitive imports stay in Bun's module registry, so a component
// that failed to resolve once (e.g. imported before it was created)
// keeps failing until the server restarts.
const { root_file_path } = grabRootFilePath();
const root_module = !root_file_path
? undefined
: bundled_root_module || (await importFresh(root_file_path));
const module = page_module || (await importFresh(file_path));
if (debug) {
log.info(`module:`, module);
}
if (debug) {
log.info(`component:`, component);
}
@@ -16,6 +16,12 @@ export default function grabPageReactComponentString({ file_path, root_file_path
}
tsx += ` )\n`;
tsx += `}\n`;
// Expose the page/root's own exports (meta, Head, config, ...) through
// the bundle so the server never has to import the raw source files.
tsx += `export * as __bunext_page from "${file_path}"\n`;
if (root_file_path) {
tsx += `export * as __bunext_root from "${root_file_path}"\n`;
}
return tsx;
}
catch (error) {
+6 -3
View File
@@ -1,5 +1,6 @@
import _ from "lodash";
import { log } from "../../../utils/log";
import isDevelopment from "../../../utils/is-development";
export default async function grabPageServerRes({ url, query, routeParams, server_function, props, }) {
const default_props = {
url: url
@@ -14,9 +15,7 @@ export default async function grabPageServerRes({ url, query, routeParams, serve
"searchParams",
"hash",
"href",
"password",
"protocol",
"username",
]),
}
: null,
@@ -36,8 +35,12 @@ export default async function grabPageServerRes({ url, query, routeParams, serve
}
catch (error) {
log.error(`Page ${url?.pathname} Server Error => ${error.message}\n`, error);
// Error details can leak internals, so only expose them in
// development. They are always logged above.
return _.merge(default_props, {
error: error.message,
error: isDevelopment()
? error.message
: "Internal Server Error",
});
}
}
@@ -1,5 +0,0 @@
type Params = {
tsx: string;
};
export default function grabTsxStringModule<T>({ tsx, }: Params): Promise<T>;
export {};
@@ -1,73 +0,0 @@
import isDevelopment from "../../../utils/is-development";
import * as esbuild from "esbuild";
export default async function grabTsxStringModule({ tsx, }) {
const dev = isDevelopment();
const now = Date.now();
const final_tsx = dev ? tsx + `\n// v_${now}` : tsx;
const result = await esbuild.transform(final_tsx, {
loader: "tsx",
format: "esm",
jsx: "automatic",
minify: !dev,
});
const blob = new Blob([result.code], { type: "text/javascript" });
const url = URL.createObjectURL(blob);
const mod = await import(url);
URL.revokeObjectURL(url);
return mod;
}
// export default async function grabTsxStringModule<T extends any = any>({
// tsx,
// }: Params): Promise<T> {
// const dev = isDevelopment();
// const now = Date.now();
// const { BUNX_CWD_MODULE_CACHE_DIR } = grabDirNames();
// const target_cache_file_path = path.join(
// BUNX_CWD_MODULE_CACHE_DIR,
// `server-render-${now}.js`,
// );
// await esbuild.build({
// stdin: {
// contents: dev ? tsx + `\n// v_${now}` : tsx,
// resolveDir: process.cwd(),
// loader: "tsx",
// },
// bundle: true,
// format: "esm",
// target: "es2020",
// platform: "node",
// external: [
// "react",
// "react-dom",
// "react/jsx-runtime",
// "react/jsx-dev-runtime",
// ],
// minify: !dev,
// define: {
// "process.env.NODE_ENV": JSON.stringify(
// dev ? "development" : "production",
// ),
// },
// jsx: "automatic",
// outfile: target_cache_file_path,
// plugins: [tailwindEsbuildPlugin],
// });
// Loader.registry.delete(target_cache_file_path);
// const mod = await import(`${target_cache_file_path}?t=${now}`);
// return mod as T;
// }
// if (!dev) {
// const now = Date.now();
// const final_tsx = dev ? tsx + `\n// v_${now}` : tsx;
// const result = await esbuild.transform(final_tsx, {
// loader: "tsx",
// format: "esm",
// jsx: "automatic",
// minify: !dev,
// });
// const blob = new Blob([result.code], { type: "text/javascript" });
// const url = URL.createObjectURL(blob);
// const mod = await import(url);
// URL.revokeObjectURL(url);
// return mod as T;
// }
+1 -1
View File
@@ -2,5 +2,5 @@ import type { BunextPageModuleMeta } from "../../../types";
type Params = {
meta: BunextPageModuleMeta;
};
export default function grabWebMetaHTML({ meta }: Params): import("react/jsx-runtime").JSX.Element;
export default function grabWebMetaHTML({ meta }: Params): import("react").JSX.Element;
export {};
+21 -1
View File
@@ -1,9 +1,11 @@
import isDevelopment from "../../../utils/is-development";
import { log } from "../../../utils/log";
import { logBuildErrorsOnce } from "../../../utils/build-error-log";
import getCache from "../../cache/get-cache";
import generateWebPageResponseFromComponentReturn from "./generate-web-page-response-from-component-return";
import grabPageComponent from "./grab-page-component";
import grabPageErrorComponent from "./grab-page-error-component";
import { awaitSSRRebuild } from "../../bundler/pages-ssr-context-bundler";
export default async function handleWebPages({ req, }) {
try {
const is_dev = isDevelopment();
@@ -21,6 +23,20 @@ export default async function handleWebPages({ req, }) {
return new Response(existing_cache, res_opts);
}
}
// The dev server port opens before the one-time startup build
// finishes. First page render waits for it so we never serve HTML
// against half-built hydration/SSR modules.
if (is_dev && global.BUNEXT_INITIAL_BUILD_PROMISE) {
try {
await global.BUNEXT_INITIAL_BUILD_PROMISE;
}
catch (error) { }
}
// HMR pushes client updates before the SSR rebuild finishes; a page
// load in that window waits so SSR HTML matches the new bundle.
if (is_dev) {
await awaitSSRRebuild();
}
const componentRes = await grabPageComponent({
req,
});
@@ -32,7 +48,11 @@ export default async function handleWebPages({ req, }) {
});
}
catch (error) {
log.error(`Error Handling Web Page: ${error.message}`, error);
logBuildErrorsOnce([
{
text: `Error Handling Web Page: ${error?.message || String(error)}`,
},
], (line) => log.error(line));
const componentRes = await grabPageErrorComponent({
error,
});
@@ -1,3 +1,16 @@
import * as esbuild from "esbuild";
type Params = {
/**
* Run `.css` files through Tailwind/PostCSS. When `false`, every `.css`
* import loads as an empty stylesheet (for builds whose CSS output is
* never used, e.g. SSR).
*/
process_css?: boolean;
};
export declare function createTailwindEsbuildPlugin({ process_css, }?: Params): esbuild.Plugin;
/**
* Resolves `.css` imports like the Tailwind plugin but loads them empty.
*/
export declare const cssStubEsbuildPlugin: esbuild.Plugin;
declare const tailwindEsbuildPlugin: esbuild.Plugin;
export default tailwindEsbuildPlugin;
+24 -11
View File
@@ -8,13 +8,26 @@ import grabDirNames from "../../../utils/grab-dir-names";
import { log } from "../../../utils/log";
const { ROOT_DIR } = grabDirNames();
let error_logged = false;
const tailwindEsbuildPlugin = {
name: "tailwindcss",
// One processor for the whole process, so the Tailwind plugin's internal
// state survives across rebuilds instead of being recreated per file load.
let css_processor;
function grabCSSProcessor() {
if (!css_processor) {
css_processor = postcss([tailwindcss()]);
}
return css_processor;
}
export function createTailwindEsbuildPlugin({ process_css = true, } = {}) {
return {
name: process_css ? "tailwindcss" : "css-stub",
setup(build) {
build.onLoad({ filter: /\.css$/ }, async (args) => {
if (!process_css) {
return { contents: "", loader: "css" };
}
try {
const source = await readFile(args.path, "utf-8");
const result = await postcss([tailwindcss()]).process(source, {
const result = await grabCSSProcessor().process(source, {
from: args.path,
});
error_logged = false;
@@ -31,14 +44,6 @@ const tailwindEsbuildPlugin = {
const err_msg = `CSS Error: ${css_path} file does not exist.`;
log.error(err_msg);
error_logged = true;
// return {
// errors: [
// {
// text: err_msg,
// },
// ],
// pluginName: "tailwindcss",
// };
}
return {
path: css_path,
@@ -46,4 +51,12 @@ const tailwindEsbuildPlugin = {
});
},
};
}
/**
* Resolves `.css` imports like the Tailwind plugin but loads them empty.
*/
export const cssStubEsbuildPlugin = createTailwindEsbuildPlugin({
process_css: false,
});
const tailwindEsbuildPlugin = createTailwindEsbuildPlugin();
export default tailwindEsbuildPlugin;
+22 -4
View File
@@ -1,4 +1,22 @@
export default function writeErrorFile({ exitCode, error, }: {
error?: Bun.ErrorLike;
exitCode: number | null;
}): void;
type Params = {
/**
* Only include when the exiting process actually crashed with an error.
* Bun's spawn `onExit` `error` is only set on spawn failures, so callers
* that DO have the error (crash handlers) should pass `force: true`.
*/
force?: boolean;
error?: unknown;
exitCode?: number | null;
};
/**
* Writes the `BUNDLER_EXIT.txt` flag that the bundler pipeline watches to
* detect that the dev/prod child process previously crashed.
*
* Guardrails:
* - Never writes for clean exits (exitCode === 0) or signal deaths where no
* error is available (Ctrl+C must not leave a stale error file behind).
* - The supervisor writes only when the file does not already exist, so the
* crash handler's richer error/stack payload is not clobbered.
*/
export default function writeErrorFile({ force, error, exitCode, }: Params): void;
export {};
+34 -13
View File
@@ -1,21 +1,42 @@
import path from "path";
import { mkdirSync, writeFileSync } from "fs";
import { existsSync, mkdirSync, writeFileSync } from "fs";
import grabDirNames from "../utils/grab-dir-names";
const { BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames();
export default function writeErrorFile({ exitCode, error, }) {
/**
* Writes the `BUNDLER_EXIT.txt` flag that the bundler pipeline watches to
* detect that the dev/prod child process previously crashed.
*
* Guardrails:
* - Never writes for clean exits (exitCode === 0) or signal deaths where no
* error is available (Ctrl+C must not leave a stale error file behind).
* - The supervisor writes only when the file does not already exist, so the
* crash handler's richer error/stack payload is not clobbered.
*/
export default function writeErrorFile({ force, error, exitCode, }) {
const has_error = error != null;
const abnormal_exit = exitCode != null && exitCode !== 0;
if (!has_error && !abnormal_exit)
return;
if (!force && existsSync(BUNX_BUNDLER_ERROR_EXIT_FILE))
return;
const err = error instanceof Error
? error
: typeof error === "string"
? new Error(error)
: undefined;
const message = err?.message || "Unknown error";
const stack = err?.stack || "N/A";
let txt = ``;
txt += `Bunext Error\n`;
txt += `============================================\n`;
txt += `ERROR: ${error?.message}\n`;
txt += `EXIT_CODE: ${exitCode || 0}\n`;
txt += `CALL_STACK: ${error?.stack}\n`;
mkdirSync(path.dirname(BUNX_BUNDLER_ERROR_EXIT_FILE), { recursive: true });
txt += `ERROR: ${message}\n`;
txt += `EXIT_CODE: ${exitCode ?? "N/A"}\n`;
txt += `CALL_STACK:\n${stack}\n`;
try {
mkdirSync(path.dirname(BUNX_BUNDLER_ERROR_EXIT_FILE), {
recursive: true,
});
writeFileSync(BUNX_BUNDLER_ERROR_EXIT_FILE, txt);
}
// log.info("Running development server ...");
// try {
// rmSync(HYDRATION_DST_DIR, { recursive: true });
// rmSync(BUNX_CWD_PAGES_REWRITE_DIR, { recursive: true });
// } catch (error) {}
// await bunextInit();
// await startServer();
catch { }
}
+4
View File
@@ -1,5 +1,7 @@
import bunextInit from "./functions/bunext-init";
import bunextRequestHandler from "./functions/server/bunext-req-handler";
import generateRobotsTxt from "./utils/generate-robots-txt";
import generateSitemap from "./utils/generate-sitemap";
declare const bunext: {
bunextRequestHandler: typeof bunextRequestHandler;
bunextLog: {
@@ -13,5 +15,7 @@ declare const bunext: {
banner: () => void;
};
bunextInit: typeof bunextInit;
bunextGenerateSitemap: typeof generateSitemap;
bunextGenerateRobotsTxt: typeof generateRobotsTxt;
};
export default bunext;
+4
View File
@@ -1,9 +1,13 @@
import bunextInit from "./functions/bunext-init";
import bunextRequestHandler from "./functions/server/bunext-req-handler";
import generateRobotsTxt from "./utils/generate-robots-txt";
import generateSitemap from "./utils/generate-sitemap";
import { log } from "./utils/log";
const bunext = {
bunextRequestHandler,
bunextLog: log,
bunextInit,
bunextGenerateSitemap: generateSitemap,
bunextGenerateRobotsTxt: generateRobotsTxt,
};
export default bunext;
+1 -1
View File
@@ -1,4 +1,4 @@
import type { DetailedHTMLProps, HTMLAttributes, PropsWithChildren } from "react";
type Props = PropsWithChildren<DetailedHTMLProps<HTMLAttributes<HTMLHeadElement>, HTMLHeadElement>>;
export default function Head({ children, ...props }: Props): import("react/jsx-runtime").JSX.Element;
export default function Head({ children, ...props }: Props): import("react").JSX.Element;
export {};
+1 -1
View File
@@ -1,2 +1,2 @@
import type { PropsWithChildren } from "react";
export default function DefaultNotFoundPage({ children }: PropsWithChildren): import("react/jsx-runtime").JSX.Element;
export default function DefaultNotFoundPage({ children }: PropsWithChildren): import("react").JSX.Element;
+1 -1
View File
@@ -1,2 +1,2 @@
import type { PropsWithChildren } from "react";
export default function DefaultServerErrorPage({ children, }: PropsWithChildren): import("react/jsx-runtime").JSX.Element;
export default function DefaultServerErrorPage({ children, }: PropsWithChildren): import("react").JSX.Element;
+190 -1
View File
@@ -46,6 +46,23 @@ export type BunextConfig = {
development?: boolean;
middleware?: (params: BunextConfigMiddlewareParams) => Promise<Response | Request | undefined> | Response | Request | undefined;
default_cache_expiry?: number;
/**
* Maximum number of cached pages kept on disk. New pages are not
* cached once this is reached. Defaults to 1000.
*/
max_cache_entries?: number;
/**
* By default, pages are not written to the cache when the request
* carries a `Cookie` or `Authorization` header, so one user's page is
* never served to everyone. Set this to `true` only if your cached
* pages never render per-user content.
*/
cache_credentialed_requests?: boolean;
/**
* Trust the `X-Forwarded-Proto` header (set this when running behind a
* reverse proxy that sets it). Defaults to `false`.
*/
trust_proxy?: boolean;
websocket?: WebSocketHandler<any>;
server_options?: Omit<Bun.Serve.Options<any>, "fetch" | "routes">;
/**
@@ -76,6 +93,34 @@ export type BunextConfig = {
* Public environment variables to be passed to the client
*/
public_envs?: Record<string, string>;
/**
* Options for the `bunext sitemap` command
*/
sitemap?: BunextSitemapConfig;
/**
* Options for the robots.txt written by `bunext sitemap`. Set to
* `false` to skip generating it.
*/
robots?: BunextRobotsConfig | false;
/**
* Keep the whole site out of search engines, e.g. for preview
* deployments. Every response gets `X-Robots-Tag: noindex, nofollow`,
* pages get a matching robots meta tag (overriding `meta.robots`), and
* `/robots.txt` disallows all crawlers regardless of `public/robots.txt`.
*/
disable_seo?: boolean;
/**
* Maximum time (ms) a page's server render may take before it is
* aborted and answered with a 500. Defaults to 30000.
*/
ssr_render_timeout_ms?: number;
/**
* Minify the SSR bundle. The SSR output is read off local disk by the
* server and is never downloaded by a browser, so minifying it only costs
* build memory. Set to `false` on memory-constrained hosts. Never minified
* in dev; defaults to `true` in production.
*/
minify_ssr_bundle?: boolean;
};
export type BunextConfigMiddlewareParams = {
req: Request;
@@ -174,7 +219,19 @@ export type BunextPageModule = {
export type BunextPageServerModule = {
default?: BunextPageServerFn;
server?: BunextPageServerFn;
sitemap?: BunextPageSitemapFn;
};
/**
* Returns the sitemap entries for a page. Required for dynamic routes
* (`[slug]`) to appear in the sitemap. On a static page, the returned
* entries replace the page's default entry.
*/
export type BunextPageSitemapFn = (params: {
/**
* The page's route pattern, e.g. `/blog/[slug]`
*/
route: string;
}) => BunextSitemapAdditionalPaths | Promise<BunextSitemapAdditionalPaths>;
export type BunextHTMLProps = DetailedHTMLProps<HtmlHTMLAttributes<HTMLHtmlElement>, HTMLHtmlElement>;
export type BunextPageModuleMetaFn = (params: {
ctx: BunxRouteParams;
@@ -301,6 +358,12 @@ export type GrabPageReactBundledComponentRes = {
component?: FC;
server_res?: BunextPageModuleServerReturn;
tsx?: string;
/**
* The page's own exports (`meta`, `Head`, `config`, ...), re-exported
* from the SSR bundle so they share its fresh module graph.
*/
page_module?: BunextPageModule;
root_module?: BunextPageModule;
};
export type PageFiles = {
local_path: string;
@@ -316,7 +379,6 @@ export type BundlerCTXMap = {
url_path: string;
file_name: string;
css_path?: string;
req_url?: string;
};
export type GlobalHMRControllerObject = {
controller: ReadableStreamDefaultController<string>;
@@ -361,3 +423,130 @@ export type BuildDataType = {
build_date_unix: number;
build_date: string;
};
export type BunextSitemapChangeFreq = "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never";
export type BunextSitemapEntry = {
/**
* URL path (e.g. `/blog/my-post`) or absolute URL.
*/
path: string;
lastmod?: Date | string;
changefreq?: BunextSitemapChangeFreq;
/**
* Between `0.0` and `1.0`.
*/
priority?: number;
};
export type BunextSitemapAdditionalPaths = (string | BunextSitemapEntry)[];
export type BunextGenerateSitemapParams = {
/**
* Base URL prepended to every path. Defaults to `config.origin`,
* falling back to `http://localhost:<port>`.
*/
origin?: string;
/**
* Paths to leave out. A string matches a path exactly, or a whole
* subtree when it ends with `/*` (e.g. `/admin/*`). A RegExp is
* tested against the path.
*/
exclude?: (string | RegExp)[];
/**
* Extra entries, typically concrete URLs for dynamic routes
* (`[slug]`), which cannot be discovered from the file system. Can
* also be a function (sync or async) returning them, e.g. to load
* them from a database.
*/
additional_paths?: BunextSitemapAdditionalPaths | (() => BunextSitemapAdditionalPaths | Promise<BunextSitemapAdditionalPaths>);
/**
* Fall back to each page file's modification time as `lastmod` when
* the build record has no date for it. Defaults to `false`: checkouts
* reset modification times, so they are rarely meaningful.
*/
use_file_lastmod?: boolean;
/**
* `lastmod` for entries that have no other date
*/
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
* cached or committed location when builds start from a fresh checkout.
*/
lastmod_file?: string;
/**
* Append a trailing slash to every non-root path. Defaults to `false`.
*/
trailing_slash?: boolean;
default_changefreq?: BunextSitemapChangeFreq;
default_priority?: number;
};
export type BunextSitemapConfig = BunextGenerateSitemapParams & {
/**
* Maximum URLs per sitemap file. Above this, `bunext sitemap` writes
* chunk files to `chunks_dir` and makes sitemap.xml a sitemap index
* referencing them. Defaults to 10,000; capped at 50,000, the protocol
* limit. Chunks are also split at 50MB.
*/
chunk_size?: number;
/**
* Directory for sitemap chunk files, relative to the project root.
* Must be inside `public/` so the files are served. Defaults to
* `public/sitemap-chunks`.
*/
chunks_dir?: string;
/**
* Output file, relative to the project root. Defaults to
* `public/sitemap.xml`, which is served at `/sitemap.xml`.
*/
output?: string;
};
export type BunextRobotsRule = {
/**
* One or more crawler names, e.g. `*` or `["GPTBot", "CCBot"]`
*/
user_agent: string | string[];
allow?: string[];
disallow?: string[];
/**
* Seconds between requests. Not supported by every crawler.
*/
crawl_delay?: number;
};
export type BunextGenerateRobotsTxtParams = {
/**
* Base URL used to turn relative `sitemaps` paths into absolute URLs.
* Defaults to `config.origin`, falling back to `http://localhost:<port>`.
*/
origin?: string;
/**
* Defaults to allowing every crawler everywhere.
*/
rules?: BunextRobotsRule[];
/**
* Sitemap URLs or paths, each written as a `Sitemap:` line.
*/
sitemaps?: string[];
/**
* Block every crawler from the whole site, ignoring `rules` and
* `sitemaps`. Useful for staging deployments.
*/
disallow_all?: boolean;
};
export type BunextRobotsConfig = BunextGenerateRobotsTxtParams & {
/**
* Output file, relative to the project root. Defaults to
* `public/robots.txt`, which is served at `/robots.txt`.
*/
output?: string;
};
/**
* Written by `bunext build`. Keyed by page path relative to the project
* root. `lastmod` is the build date at which `fingerprint` last changed.
*/
export type BunextPageLastmodRecord = {
pages: {
[local_path: string]: {
fingerprint: string;
lastmod: string;
};
};
};
+21
View File
@@ -0,0 +1,21 @@
export type BuildErrorLike = {
text?: string;
message?: string;
id?: string;
location?: {
file?: string;
line?: number;
column?: number;
} | null;
};
/**
* Logs each error once — identical error sets are suppressed until the
* signature changes or `resetBuildErrorLog()` is called (after a successful
* build). Returns true when something was logged.
*/
export declare function logBuildErrorsOnce(errors: BuildErrorLike[], logLine?: (line: string) => void): boolean;
/**
* Clears the dedup state so a new incident with the same signature is logged
* again. Call after a successful build.
*/
export declare function resetBuildErrorLog(): void;
+48
View File
@@ -0,0 +1,48 @@
// Logs build/render errors exactly once while the same error state persists.
//
// Dev recovery paths (watcher, request retries, post-build HMR) re-attempt a
// rebuild for every file edit and request while a syntax error is still in the
// file. Without deduplication that one error is printed again on every
// attempt — an endless spiral of identical log lines. The signature is
// cleared after a successful build, so the same error later (after a fix and
// regression) is logged again as a new incident.
let last_build_error_signature;
function errorSignature(errors) {
return errors
.map((e) => {
const text = e.text || e.message || e.id || "";
const loc = e.location
? `${e.location.file}:${e.location.line}:${e.location.column}`
: "";
return `${text}${loc}`;
})
.join("\n---\n");
}
/**
* Logs each error once — identical error sets are suppressed until the
* signature changes or `resetBuildErrorLog()` is called (after a successful
* build). Returns true when something was logged.
*/
export function logBuildErrorsOnce(errors, logLine = (line) => console.error(line)) {
if (!errors || errors.length === 0)
return false;
const signature = errorSignature(errors);
if (signature === last_build_error_signature)
return false;
last_build_error_signature = signature;
for (const e of errors) {
const text = e.text || e.message || e.id || "Build Error";
const loc = e.location
? ` (${e.location.file}:${e.location.line}:${e.location.column})`
: "";
logLine(`${text}${loc}`);
}
return true;
}
/**
* Clears the dedup state so a new incident with the same signature is logged
* again. Call after a successful build.
*/
export function resetBuildErrorLog() {
last_build_error_signature = undefined;
}
+5
View File
@@ -0,0 +1,5 @@
/**
* `robots` meta / `X-Robots-Tag` value used when `config.disable_seo`
* is set.
*/
export declare const NoSEORobotsValue = "noindex, nofollow";
+5
View File
@@ -0,0 +1,5 @@
/**
* `robots` meta / `X-Robots-Tag` value used when `config.disable_seo`
* is set.
*/
export const NoSEORobotsValue = "noindex, nofollow";
+11
View File
@@ -0,0 +1,11 @@
import type { BunextGenerateRobotsTxtParams } from "../types";
/**
* First line of every generated robots.txt. `bunext sitemap` only
* overwrites an existing robots.txt that starts with it, unless robots
* is configured explicitly.
*/
export declare const RobotsTxtGeneratedMarker = "# Generated by bunext";
/**
* # Generate a robots.txt string
*/
export default function generateRobotsTxt(params?: BunextGenerateRobotsTxtParams): string;
+57
View File
@@ -0,0 +1,57 @@
import grabOrigin from "./grab-origin";
/**
* First line of every generated robots.txt. `bunext sitemap` only
* overwrites an existing robots.txt that starts with it, unless robots
* is configured explicitly.
*/
export const RobotsTxtGeneratedMarker = "# Generated by bunext";
/**
* # Generate a robots.txt string
*/
export default function generateRobotsTxt(params) {
const lines = [RobotsTxtGeneratedMarker, ""];
if (params?.disallow_all) {
lines.push("User-agent: *", "Disallow: /", "");
return lines.join("\n");
}
const rules = params?.rules?.length
? params.rules
: [{ user_agent: "*", allow: ["/"] }];
for (const rule of rules) {
const user_agents = [rule.user_agent].flat();
for (const user_agent of user_agents) {
lines.push(`User-agent: ${sanitizeValue(user_agent)}`);
}
for (const allow of rule.allow || []) {
lines.push(`Allow: ${sanitizeValue(allow)}`);
}
for (const disallow of rule.disallow || []) {
lines.push(`Disallow: ${sanitizeValue(disallow)}`);
}
// A group needs at least one rule; an empty Disallow allows all.
if (!rule.allow?.length && !rule.disallow?.length) {
lines.push("Disallow:");
}
if (typeof rule.crawl_delay == "number") {
lines.push(`Crawl-delay: ${rule.crawl_delay}`);
}
lines.push("");
}
if (params?.sitemaps?.length) {
const origin = (params.origin || grabOrigin()).replace(/\/+$/, "");
for (const sitemap of new Set(params.sitemaps)) {
const url = sitemap.match(/^https?:\/\//)
? sitemap
: `${origin}/${sitemap.replace(/^\/+/, "")}`;
lines.push(`Sitemap: ${sanitizeValue(url)}`);
}
lines.push("");
}
return lines.join("\n");
}
/**
* Line breaks would let a value inject extra directives.
*/
function sanitizeValue(value) {
return value.replace(/[\r\n]+/g, " ").trim();
}
+40
View File
@@ -0,0 +1,40 @@
import type { BunextGenerateSitemapParams, BunextSitemapEntry } from "../types";
/**
* Protocol limits for a single sitemap file
* https://www.sitemaps.org/protocol.html#index
*/
export declare const SitemapMaxUrlsPerFile = 50000;
export declare const SitemapMaxBytesPerFile: number;
export declare const SitemapDefaultChunkSize = 10000;
export declare function grabPendingSitemapTask(): string | undefined;
export type SitemapChunkFile = {
file_name: string;
xml: string;
url_count: number;
};
export type SitemapFiles = {
/**
* A `<urlset>` when everything fits in one file, otherwise a
* `<sitemapindex>` referencing `chunks`.
*/
sitemap: string;
chunks: SitemapChunkFile[];
url_count: number;
};
/**
* # Generate a sitemap.xml string for all pages
* Dynamic routes are only included through a `sitemap` export in their
* `.server.ts` file, or through `additional_paths`. Always returns a
* single `<urlset>`; `bunext sitemap` splits large sitemaps into chunks.
*/
export default function generateSitemap(params?: BunextGenerateSitemapParams): Promise<string>;
/**
* # Generate sitemap.xml, split into chunk files when needed
* Chunks hold at most `chunk_size` URLs and 50MB each. `chunks_url_path`
* is the public URL path the chunk files are served from.
*/
export declare function generateSitemapFiles({ chunk_size, chunks_url_path, ...params }: BunextGenerateSitemapParams & {
chunk_size?: number;
chunks_url_path: string;
}): Promise<SitemapFiles>;
export declare function grabSitemapEntries(params?: BunextGenerateSitemapParams): Promise<BunextSitemapEntry[]>;
+290
View File
@@ -0,0 +1,290 @@
import { statSync } from "fs";
import path from "path";
import grabPageServerPath from "../functions/server/web-pages/grab-page-server-path";
import grabAllPages from "./grab-all-pages";
import grabDirNames from "./grab-dir-names";
import grabOrigin from "./grab-origin";
import grabRelativePath from "./grab-relative-path";
import isDevelopment from "./is-development";
import { log } from "./log";
import { readPageLastmodRecord } from "./page-lastmod-record";
const ErrorPagePaths = ["/404", "/500"];
/**
* Protocol limits for a single sitemap file
* https://www.sitemaps.org/protocol.html#index
*/
export const SitemapMaxUrlsPerFile = 50_000;
export const SitemapMaxBytesPerFile = 50 * 1024 * 1024;
export const SitemapDefaultChunkSize = 10_000;
const SitemapXmlHeader = `<?xml version="1.0" encoding="UTF-8"?>`;
const SitemapXmlns = `xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"`;
/**
* What sitemap generation is currently waiting on, so `bunext sitemap`
* can name it when a `sitemap` export or `additional_paths` never settles.
*/
let pending_sitemap_task;
export function grabPendingSitemapTask() {
return pending_sitemap_task;
}
/**
* # Generate a sitemap.xml string for all pages
* Dynamic routes are only included through a `sitemap` export in their
* `.server.ts` file, or through `additional_paths`. Always returns a
* single `<urlset>`; `bunext sitemap` splits large sitemaps into chunks.
*/
export default async function generateSitemap(params) {
const entries = await grabSitemapEntries(params);
const origin = grabSitemapOrigin(params?.origin);
if (entries.length > SitemapMaxUrlsPerFile) {
log.warn(`Sitemap has ${entries.length} URLs, above the ${SitemapMaxUrlsPerFile} ` +
`per-file limit. Use \`bunext sitemap\` to split it into chunks.`);
}
return renderUrlset(entries.map((entry) => renderUrl(entry, origin)));
}
/**
* # Generate sitemap.xml, split into chunk files when needed
* Chunks hold at most `chunk_size` URLs and 50MB each. `chunks_url_path`
* is the public URL path the chunk files are served from.
*/
export async function generateSitemapFiles({ chunk_size = SitemapDefaultChunkSize, chunks_url_path, ...params }) {
const entries = await grabSitemapEntries(params);
const origin = grabSitemapOrigin(params.origin);
const max_urls = Math.min(Math.max(Math.floor(chunk_size) || 1, 1), SitemapMaxUrlsPerFile);
const urls = entries.map((entry) => renderUrl(entry, origin));
const empty_urlset_bytes = Buffer.byteLength(renderUrlset([]));
const chunks = [];
let chunk;
for (let i = 0; i < urls.length; i++) {
const url_bytes = Buffer.byteLength(urls[i]) + 1;
if (!chunk ||
chunk.urls.length >= max_urls ||
chunk.bytes + url_bytes > SitemapMaxBytesPerFile) {
chunk = { urls: [], bytes: empty_urlset_bytes };
chunks.push(chunk);
}
chunk.urls.push(urls[i]);
chunk.bytes += url_bytes;
const lastmod = parseLastmod(entries[i].lastmod);
if (lastmod && (!chunk.lastmod || lastmod > chunk.lastmod)) {
chunk.lastmod = lastmod;
}
}
if (chunks.length <= 1) {
return {
sitemap: renderUrlset(chunks[0]?.urls || []),
chunks: [],
url_count: urls.length,
};
}
const chunks_dir_path = chunks_url_path.replace(/^\/+|\/+$/g, "");
const chunk_base_url = chunks_dir_path
? `${origin}/${chunks_dir_path}`
: origin;
const chunk_files = chunks.map((c, i) => ({
file_name: `sitemap-${i + 1}.xml`,
xml: renderUrlset(c.urls),
url_count: c.urls.length,
}));
const sitemap = [
SitemapXmlHeader,
`<sitemapindex ${SitemapXmlns}>`,
...chunk_files.map((file, i) => {
const loc = escapeXml(`${chunk_base_url}/${file.file_name}`);
const lines = [` <loc>${loc}</loc>`];
const lastmod = chunks[i].lastmod?.toISOString();
if (lastmod)
lines.push(` <lastmod>${lastmod}</lastmod>`);
return ` <sitemap>\n${lines.join("\n")}\n </sitemap>`;
}),
`</sitemapindex>`,
``,
].join("\n");
return { sitemap, chunks: chunk_files, url_count: urls.length };
}
function grabSitemapOrigin(origin) {
return (origin || grabOrigin()).replace(/\/+$/, "");
}
function renderUrlset(urls) {
return [
SitemapXmlHeader,
`<urlset ${SitemapXmlns}>`,
...urls,
`</urlset>`,
``,
].join("\n");
}
function renderUrl(entry, origin) {
const loc = entry.path.match(/^https?:\/\//)
? entry.path
: `${origin}${entry.path}`;
const lines = [` <loc>${escapeXml(loc)}</loc>`];
if (entry.lastmod) {
const lastmod = entry.lastmod instanceof Date
? entry.lastmod.toISOString()
: entry.lastmod;
lines.push(` <lastmod>${escapeXml(lastmod)}</lastmod>`);
}
if (entry.changefreq) {
lines.push(` <changefreq>${entry.changefreq}</changefreq>`);
}
if (typeof entry.priority == "number") {
const priority = Math.min(Math.max(entry.priority, 0), 1);
lines.push(` <priority>${priority.toFixed(1)}</priority>`);
}
return ` <url>\n${lines.join("\n")}\n </url>`;
}
function parseLastmod(lastmod) {
if (!lastmod)
return;
const date = lastmod instanceof Date ? lastmod : new Date(lastmod);
return isNaN(date.getTime()) ? undefined : date;
}
export async function grabSitemapEntries(params) {
const use_file_lastmod = params?.use_file_lastmod ?? false;
const lastmod_record = readPageLastmodRecord(params?.lastmod_file);
const entries = [];
const pages = grabAllPages({ exclude_api: true });
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
if (!page.local_path)
continue;
const page_path = page.url_path.replace(/\/index$/, "") || "/";
if (ErrorPagePaths.includes(page_path))
continue;
const page_sitemap_paths = await grabPageSitemapPaths({
file_path: page.local_path,
route: page_path,
});
// When the page's client bundle last changed, per `bunext build`
const build_lastmod = lastmod_record?.pages[grabRelativePath(page.local_path)]?.lastmod;
if (page_sitemap_paths) {
for (const page_sitemap_path of page_sitemap_paths) {
const entry = toSitemapEntry(page_sitemap_path);
entries.push({
...entry,
lastmod: entry.lastmod || build_lastmod,
});
}
continue;
}
if (page.url_path.match(/\[/))
continue;
entries.push({
path: page_path,
lastmod: build_lastmod ||
(use_file_lastmod
? statSync(page.local_path).mtime
: undefined),
});
}
let additional_paths;
if (typeof params?.additional_paths == "function") {
pending_sitemap_task = "`sitemap.additional_paths` in the config";
try {
additional_paths = await params.additional_paths();
}
finally {
pending_sitemap_task = undefined;
}
}
else {
additional_paths = params?.additional_paths;
}
entries.push(...(additional_paths || []).map(toSitemapEntry));
const seen = new Set();
const final_entries = [];
for (const entry of entries) {
const path = normalizePath(entry.path, params?.trailing_slash);
if (seen.has(path))
continue;
if (isExcluded(path, params?.exclude))
continue;
seen.add(path);
final_entries.push({
...entry,
path,
lastmod: entry.lastmod || params?.default_lastmod,
changefreq: entry.changefreq || params?.default_changefreq,
priority: entry.priority ?? params?.default_priority,
});
}
return final_entries;
}
/**
* # Run the `sitemap` export of a page's server file, if any
*/
async function grabPageSitemapPaths({ file_path, route, }) {
const { server_file_path } = grabPageServerPath({ file_path });
if (!server_file_path)
return;
const { ROOT_DIR } = grabDirNames();
const server_ctx = global.BUNEXT_SSR_BUNDLER_CTX_MAP?.[grabRelativePath(server_file_path)];
const final_server_path = server_ctx?.local_path
? path.join(ROOT_DIR, server_ctx.path)
: server_file_path;
const relative_server_path = grabRelativePath(server_file_path);
try {
pending_sitemap_task = `importing ${relative_server_path}`;
// Bust the import cache in development so edits to the server file
// are picked up without a restart.
const server_module = await import(isDevelopment()
? `${final_server_path}?t=${Date.now()}`
: final_server_path);
if (typeof server_module.sitemap != "function")
return;
pending_sitemap_task = `the \`sitemap\` export of ${relative_server_path}`;
try {
return await server_module.sitemap({ route });
}
catch (error) {
throw new Error(`\`sitemap\` export of ${relative_server_path} failed: ${error?.message || error}`);
}
}
finally {
pending_sitemap_task = undefined;
}
}
function toSitemapEntry(path) {
return typeof path == "string" ? { path } : path;
}
function normalizePath(path, trailing_slash) {
if (path.match(/^https?:\/\//))
return path;
let new_path = `/${path}`.replace(/\/{2,}/g, "/");
if (new_path != "/")
new_path = new_path.replace(/\/$/, "");
if (trailing_slash && new_path != "/")
new_path += "/";
return new_path;
}
function isExcluded(path, exclude) {
if (!exclude?.length)
return false;
const bare_path = path.replace(/^https?:\/\/[^/]+/, "").replace(/(.)\/$/, "$1") || "/";
for (const pattern of exclude) {
if (pattern instanceof RegExp) {
if (pattern.test(path) || pattern.test(bare_path))
return true;
continue;
}
const normalized = normalizePath(pattern.replace(/\/\*$/, ""));
if (pattern.endsWith("/*")) {
if (bare_path == normalized ||
bare_path.startsWith(`${normalized.replace(/\/$/, "")}/`)) {
return true;
}
}
else if (bare_path == normalized) {
return true;
}
}
return false;
}
function escapeXml(value) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
+1 -1
View File
@@ -7,7 +7,7 @@ export default function grabConstants() {
const ServerDefaultRequestBodyLimitBytes = MB_IN_BYTES * 10;
const MaxBundlerRebuilds = 5;
const RouteIgnorePatterns = [/\/\(/];
if (config.pages_exclude_patterns) {
if (config?.pages_exclude_patterns) {
RouteIgnorePatterns.push(...config.pages_exclude_patterns);
}
return {
+1
View File
@@ -29,5 +29,6 @@ export type DirNames = {
BUNDLER_CTX_MAP_JSON: string;
SSR_BUNDLER_CTX_MAP_JSON: string;
BUILD_INFO_JSON: string;
PAGE_LASTMOD_JSON: string;
};
export default function grabDirNames(): DirNames;
+4 -1
View File
@@ -19,7 +19,8 @@ export default function grabDirNames() {
const BUNEXT_PUBLIC_DIR = path.join(BUNX_CWD_DIR, "public");
const HYDRATION_DST_DIR = path.join(BUNEXT_PUBLIC_DIR, "pages");
const BUNEXT_VENDOR_DIR = path.join(BUNEXT_PUBLIC_DIR, "vendor");
const BUNEXT_CACHE_DIR = path.join(BUNEXT_PUBLIC_DIR, "cache");
// Kept outside BUNEXT_PUBLIC_DIR: that directory is served over HTTP.
const BUNEXT_CACHE_DIR = path.join(BUNX_CWD_DIR, "cache");
const HYDRATION_DST_DIR_MAP_JSON_FILE_NAME = "map.json";
const HYDRATION_DST_DIR_MAP_JSON_FILE = path.join(HYDRATION_DST_DIR, HYDRATION_DST_DIR_MAP_JSON_FILE_NAME);
const BUNX_ROOT_DIR = path.resolve(__dirname, "../../");
@@ -32,6 +33,7 @@ export default function grabDirNames() {
const BUNDLER_CTX_MAP_JSON = path.join(BUNX_CWD_DIR, "bundler-ctx-map.json");
const SSR_BUNDLER_CTX_MAP_JSON = path.join(BUNX_CWD_DIR, "ssr-bundler-ctx-map.json");
const BUILD_INFO_JSON = path.join(BUNX_CWD_DIR, "build-info.json");
const PAGE_LASTMOD_JSON = path.join(BUNX_CWD_DIR, "page-lastmod.json");
return {
ROOT_DIR,
SRC_DIR,
@@ -63,6 +65,7 @@ export default function grabDirNames() {
BUNDLER_CTX_MAP_JSON,
SSR_BUNDLER_CTX_MAP_JSON,
BUILD_INFO_JSON,
PAGE_LASTMOD_JSON,
};
}
// const NODE_MODULES_DIR = path.resolve(
+9 -1
View File
@@ -2,6 +2,14 @@ import type { BunxRouteParams } from "../types";
type Params = {
req: Request;
query?: any;
/**
* Maximum request body size. Defaults to
* `ServerDefaultRequestBodyLimitBytes`.
*/
max_body_bytes?: number;
};
export default function grabRouteParams({ req, query: passed_query, }: Params): Promise<BunxRouteParams>;
/**
* Throws `RequestBodyTooLargeError` if the body exceeds `max_body_bytes`.
*/
export default function grabRouteParams({ req: initial_req, query: passed_query, max_body_bytes, }: Params): Promise<BunxRouteParams>;
export {};
+28 -4
View File
@@ -1,11 +1,35 @@
import _ from "lodash";
import deserializeQuery from "./deserialize-query";
export default async function grabRouteParams({ req, query: passed_query, }) {
const url = new URL(req.url);
import grabConstants from "./grab-constants";
import readRequestBody from "./read-request-body";
/**
* Throws `RequestBodyTooLargeError` if the body exceeds `max_body_bytes`.
*/
export default async function grabRouteParams({ req: initial_req, query: passed_query, max_body_bytes, }) {
const url = new URL(initial_req.url);
const query = deserializeQuery(Object.fromEntries(url.searchParams));
const body = await (async () => {
const raw_body = await readRequestBody({
req: initial_req,
max_bytes: max_body_bytes || grabConstants().ServerDefaultRequestBodyLimitBytes,
});
// The original body stream is consumed above, so hand handlers a fresh
// request over the buffered bytes. This keeps `req.text()`,
// `req.formData()`, etc. usable (e.g. for webhook signatures/uploads).
const req = raw_body
? new Request(initial_req.url, {
method: initial_req.method,
headers: initial_req.headers,
body: raw_body,
signal: initial_req.signal,
})
: initial_req;
const body = (() => {
if (initial_req.method == "GET")
return undefined;
if (!raw_body)
return {};
try {
return req.method == "GET" ? undefined : await req.json();
return JSON.parse(new TextDecoder().decode(raw_body) || "{}");
}
catch (error) {
return {};
+24
View File
@@ -0,0 +1,24 @@
/**
* Imports a module by its stable path.
*
* Dev: when the file changed since it was last imported, its registry
* entry is evicted first so the new version is evaluated and the old one
* can be garbage collected. A cache-busting `?t=` query would instead
* create a new, never-evicted module record on every call.
*
* Prod: plain import, so each module is loaded (and kept) once.
*/
export default function importFresh<T = any>(file_path: string): Promise<T>;
/**
* Drops a module from Bun's registry so its memory can be reclaimed and the
* next import re-evaluates it. Only the module itself is evicted, not the
* modules it imports.
*/
export declare function evictModule(file_path: string): void;
/**
* Evicts every registry entry under `dir` whose file no longer exists. Bundle
* outputs (entries and shared chunks) are content-hashed and esbuild deletes
* the superseded ones on rebuild, so a missing file means no current module
* imports it anymore.
*/
export declare function evictDeletedModules(dir: string): void;

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