From 65d19d460966f881b09a2eb862e6fba585ede362 Mon Sep 17 00:00:00 2001 From: beeman Date: Fri, 10 Apr 2026 04:33:47 +0700 Subject: [PATCH] feat: create TanStack Start Bun server library Create the initial tanstack-start-bun-server library. This adds the Bun server package for running a TanStack Start app in production, along with docs, tests, and release metadata for the 1.0.0 launch. --- .changeset/blue-snails-judge.md | 5 + README.md | 128 ++++++- bun.lock | 4 +- package.json | 13 +- src/index.ts | 80 +++- src/logger.ts | 19 + src/server-handler.ts | 38 ++ src/static-assets.ts | 500 +++++++++++++++++++++++++ test/index.test.ts | 10 - test/logger.test.ts | 71 ++++ test/tanstack-start-bun-server.test.ts | 295 +++++++++++++++ 11 files changed, 1123 insertions(+), 40 deletions(-) create mode 100644 .changeset/blue-snails-judge.md create mode 100644 src/logger.ts create mode 100644 src/server-handler.ts create mode 100644 src/static-assets.ts delete mode 100644 test/index.test.ts create mode 100644 test/logger.test.ts create mode 100644 test/tanstack-start-bun-server.test.ts diff --git a/.changeset/blue-snails-judge.md b/.changeset/blue-snails-judge.md new file mode 100644 index 0000000..8c984bc --- /dev/null +++ b/.changeset/blue-snails-judge.md @@ -0,0 +1,5 @@ +--- +'tanstack-start-bun-server': major +--- + +Create the initial tanstack-start-bun-server library for running TanStack Start apps on Bun, with static asset handling, caching support, documentation, and tests. diff --git a/README.md b/README.md index ba0b5c8..de6071d 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,124 @@ -# tanstack-start-bun-server +# `tanstack-start-bun-server` -TypeScript library built with [Bun](https://bun.sh/), [tsdown](https://tsdown.js.org/), [Biome](https://biomejs.dev/), and [Changesets](https://github.com/changesets/changesets). +Bun-only helpers for serving a TanStack Start production build with: -## Features +- a loaded `server/server.js` handler +- static asset routes from `client/**/*` +- in-memory preloading for small assets +- on-demand disk reads for filtered or oversized assets +- optional ETag and gzip support -- Bun-first dependency management, scripts, and tests -- TypeScript source with dual ESM/CJS output and generated types -- Biome for linting and formatting -- Example library export -- Changesets and GitHub Actions for release automation +## Install -## Installation +```sh +bun add tanstack-start-bun-server +``` + +## Basic usage + +```ts +import { createTanStackStartBunServeConfig } from 'tanstack-start-bun-server' -```bash -bun install +const { fetchHandler, routes } = await createTanStackStartBunServeConfig({ + webDistPath: './dist', +}) + +const server = Bun.serve({ + port: 3000, + routes: { + ...routes, + '/*': (request) => fetchHandler(request), + }, +}) + +console.log(server.url) ``` -## Usage +## Use the default console logger explicitly ```ts -import { greet } from 'tanstack-start-bun-server' +import { createConsoleLogger, createTanStackStartBunServeConfig } from 'tanstack-start-bun-server' -console.log(greet('Seed')) +const { fetchHandler, routes } = await createTanStackStartBunServeConfig({ + logger: createConsoleLogger(), + webDistPath: './dist', +}) +``` + +## Use a custom logger + +```ts +import { createTanStackStartBunServeConfig, type Logger } from 'tanstack-start-bun-server' + +const logger: Logger = { + debug(message, ...args) { + console.debug(message, ...args) + }, + error(message, ...args) { + console.error(message, ...args) + }, + info(message, ...args) { + console.info(message, ...args) + }, +} + +const { fetchHandler, routes } = await createTanStackStartBunServeConfig({ + logger, + maxPreloadBytes: 5 * 1024 * 1024, + webDistPath: './dist', +}) +``` + +## Customize gzip mime types + +```ts +import { DEFAULT_GZIP_MIME_TYPES, createTanStackStartBunServeConfig } from 'tanstack-start-bun-server' + +const { fetchHandler, routes } = await createTanStackStartBunServeConfig({ + gzipMimeTypes: [...DEFAULT_GZIP_MIME_TYPES, 'application/wasm'].sort(), + webDistPath: './dist', +}) +``` + +```ts +import { DEFAULT_GZIP_MIME_TYPES, createTanStackStartBunServeConfig } from 'tanstack-start-bun-server' + +const { fetchHandler, routes } = await createTanStackStartBunServeConfig({ + gzipMimeTypes: DEFAULT_GZIP_MIME_TYPES.filter((type) => type !== 'image/svg+xml'), + webDistPath: './dist', +}) +``` + +## Use alongside application routes + +```ts +import { createTanStackStartBunServeConfig } from 'tanstack-start-bun-server' + +const app = { + fetch(request: Request) { + return new Response(`api:${new URL(request.url).pathname}`) + }, +} + +const { fetchHandler, routes } = await createTanStackStartBunServeConfig({ + includePatterns: ['*.css', '*.js', '*.woff2'], + webDistPath: './dist', +}) + +Bun.serve({ + port: 3000, + routes: { + '/api': (request) => app.fetch(request), + '/api/*': (request) => app.fetch(request), + ...routes, + '/*': (request) => fetchHandler(request), + }, +}) ``` ## Development -```bash +```sh bun run build bun run check-types bun run lint @@ -37,9 +129,7 @@ bun run test:watch ## Publishing -Use Changesets to manage versioning and releases. - -```bash +```sh bun run changeset bun run version bun run release @@ -47,4 +137,4 @@ bun run release ## License -MIT – see [LICENSE](./LICENSE). +MIT; see [LICENSE](./LICENSE). diff --git a/bun.lock b/bun.lock index 297983d..29cda8b 100644 --- a/bun.lock +++ b/bun.lock @@ -12,9 +12,7 @@ "pkg-pr-new": "0.0.62", "publint": "0.3.18", "tsdown": "0.21.7", - }, - "peerDependencies": { - "typescript": "^5.0.0 || ^6.0.0", + "typescript": "6.0.2", }, }, }, diff --git a/package.json b/package.json index 92c2d46..1cfaddd 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "url": "https://github.com/beeman/tanstack-start-bun-server/issues" }, "dependencies": {}, - "description": "TypeScript library built with Bun, tsdown, Biome, and Changesets.", + "description": "Bun server helpers for serving TanStack Start production builds", "devDependencies": { "@biomejs/biome": "2.4.6", "@changesets/cli": "2.30.0", @@ -11,10 +11,12 @@ "@types/bun": "1.3.11", "pkg-pr-new": "0.0.62", "publint": "0.3.18", - "tsdown": "0.21.7" + "tsdown": "0.21.7", + "typescript": "6.0.2" }, "exports": { ".": { + "bun": "./src/index.ts", "import": { "types": "./dist/index.d.mts", "default": "./dist/index.mjs" @@ -26,16 +28,14 @@ } }, "files": [ - "dist" + "dist", + "src" ], "homepage": "https://github.com/beeman/tanstack-start-bun-server#readme", "license": "MIT", "main": "./dist/index.cjs", "module": "./dist/index.mjs", "name": "tanstack-start-bun-server", - "peerDependencies": { - "typescript": "^5.0.0 || ^6.0.0" - }, "repository": { "type": "git", "url": "git+https://github.com/beeman/tanstack-start-bun-server.git" @@ -53,6 +53,7 @@ "test:watch": "bun test --watch", "version": "changeset version && bun lint:fix" }, + "sideEffects": false, "type": "module", "types": "./dist/index.d.mts", "version": "0.0.0" diff --git a/src/index.ts b/src/index.ts index 5694d5d..f4321c3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,79 @@ -export function greet(name: string = 'World'): string { - return `Hello, ${name} from tanstack-start-bun-server!` +import { createConsoleLogger, type Logger } from './logger.ts' +import { initializeServerHandler } from './server-handler.ts' +import { initializeStaticRoutes, type StaticAssetRouteHandler } from './static-assets.ts' + +export const DEFAULT_GZIP_MIME_TYPES = [ + 'application/javascript', + 'application/json', + 'application/xml', + 'image/svg+xml', + 'text/', +] as const + +export type { Logger } from './logger.ts' +export { createConsoleLogger } + +export interface TanStackStartBunServeConfig { + fetchHandler: (request: Request) => Promise + routes: Record +} + +export interface TanStackStartBunServeOptions { + enableEtag?: boolean + enableGzip?: boolean + excludePatterns?: string[] + gzipMimeTypes?: string[] + gzipMinBytes?: number + includePatterns?: string[] + logger?: Logger + maxPreloadBytes?: number + verbose?: boolean + webDistPath: string +} + +export async function createTanStackStartBunServeConfig({ + enableEtag = true, + enableGzip = true, + excludePatterns = [], + gzipMimeTypes = [...DEFAULT_GZIP_MIME_TYPES], + gzipMinBytes = 1024, + includePatterns = [], + logger = createConsoleLogger(), + maxPreloadBytes = 5 * 1024 * 1024, + verbose = false, + webDistPath, +}: TanStackStartBunServeOptions): Promise { + logger.debug('Starting web server') + + const handler = await initializeServerHandler({ + logger, + webDistPath, + }) + + logger.debug('TanStack Start application handler initialized') + + const { routes } = await initializeStaticRoutes({ + enableEtag, + enableGzip, + excludePatterns, + gzipMimeTypes, + gzipMinBytes, + includePatterns, + logger, + maxPreloadBytes, + verbose, + webDistPath, + }) + + return { + fetchHandler: async (request: Request) => { + try { + return await handler.fetch(request) + } catch (error) { + logger.error(error as Error) + return new Response('Internal Server Error', { status: 500 }) + } + }, + routes, + } } diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..61f2c89 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,19 @@ +export interface Logger { + debug(message: unknown, ...args: unknown[]): void + error(message: unknown, ...args: unknown[]): void + info(message: unknown, ...args: unknown[]): void +} + +export function createConsoleLogger(): Logger { + return { + debug(message, ...args) { + console.log('DBG', message, ...args) + }, + error(message, ...args) { + console.error('ERR', message, ...args) + }, + info(message, ...args) { + console.log('INF', message, ...args) + }, + } +} diff --git a/src/server-handler.ts b/src/server-handler.ts new file mode 100644 index 0000000..12973b6 --- /dev/null +++ b/src/server-handler.ts @@ -0,0 +1,38 @@ +import { join, relative } from 'node:path' +import type { Logger } from './logger.ts' + +export interface ServerHandler { + fetch: (request: Request) => Response | Promise +} + +export interface InitializeServerHandlerOptions { + logger: Logger + webDistPath: string +} + +export async function initializeServerHandler({ + logger, + webDistPath, +}: InitializeServerHandlerOptions): Promise { + const serverEntryPoint = join(webDistPath, 'server/server.js') + const displayPath = relative(process.cwd(), serverEntryPoint) || serverEntryPoint + + if (!(await Bun.file(serverEntryPoint).exists())) { + logger.info(`Skipping web bootstrap because ${displayPath} is missing`) + return { + fetch: async () => new Response('Not Found', { status: 404 }), + } + } + + logger.info(`Loading application handler ${displayPath}`) + + try { + const serverModule = (await import(serverEntryPoint)) as { + default: ServerHandler + } + + return serverModule.default + } catch (error) { + throw new Error(`Failed to load server handler: ${String(error)}`) + } +} diff --git a/src/static-assets.ts b/src/static-assets.ts new file mode 100644 index 0000000..33e00d6 --- /dev/null +++ b/src/static-assets.ts @@ -0,0 +1,500 @@ +import { join, posix, relative, sep } from 'node:path' +import type { Logger } from './logger.ts' + +export interface AssetMetadata { + route: string + size: number + type: string +} + +interface InMemoryAsset { + etag?: string + gz?: Uint8Array + immutable: boolean + raw: Uint8Array + size: number + type: string +} + +export interface InitializeStaticRoutesOptions { + enableEtag: boolean + enableGzip: boolean + excludePatterns: string[] + gzipMimeTypes: string[] + gzipMinBytes: number + includePatterns: string[] + logger: Logger + maxPreloadBytes: number + verbose: boolean + webDistPath: string +} + +export interface PreloadResult { + loaded: AssetMetadata[] + routes: Record + skipped: AssetMetadata[] +} + +export type StaticAssetRouteHandler = (request: Request) => Response | Promise + +const ASSET_GLOB = new Bun.Glob('**/*') + +function compressDataIfAppropriate({ + data, + enableGzip, + gzipMimeTypes, + gzipMinBytes, + mimeType, +}: { + data: Uint8Array + enableGzip: boolean + gzipMimeTypes: string[] + gzipMinBytes: number + mimeType: string +}): Uint8Array | undefined { + if (!enableGzip) { + return undefined + } + + if (data.byteLength < gzipMinBytes) { + return undefined + } + + if (!isMimeTypeCompressible(gzipMimeTypes, mimeType)) { + return undefined + } + + try { + return Bun.gzipSync(data.buffer as ArrayBuffer) + } catch { + return undefined + } +} + +function computeEtag(data: Uint8Array): string { + const hash = Bun.hash(data) + return `W/"${hash.toString(16)}-${data.byteLength.toString()}"` +} + +function convertGlobToRegExp(globPattern: string): RegExp { + const escapedPattern = globPattern.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&').replace(/\*/g, '.*') + return new RegExp(`^${escapedPattern}$`, 'i') +} + +function createAssetResponse({ + asset, + enableEtag, + enableGzip, + request, +}: { + asset: InMemoryAsset + enableEtag: boolean + enableGzip: boolean + request: Request +}): Response { + const headers: Record = { + 'Cache-Control': asset.immutable ? 'public, max-age=31536000, immutable' : 'public, max-age=3600', + 'Content-Type': asset.type, + } + const shouldVaryOnEncoding = enableGzip && !!asset.gz + + if (shouldVaryOnEncoding) { + headers.Vary = 'Accept-Encoding' + } + + if (enableEtag && asset.etag) { + const ifNoneMatch = request.headers.get('if-none-match') + + if (requestMatchesEtag(asset.etag, ifNoneMatch)) { + const notModifiedHeaders: Record = { + ETag: asset.etag, + } + + if (shouldVaryOnEncoding) { + notModifiedHeaders.Vary = 'Accept-Encoding' + } + + return new Response(null, { + headers: notModifiedHeaders, + status: 304, + }) + } + + headers.ETag = asset.etag + } + + if (enableGzip && asset.gz && requestAcceptsGzip(request.headers.get('accept-encoding'))) { + headers['Content-Encoding'] = 'gzip' + headers['Content-Length'] = String(asset.gz.byteLength) + return new Response(new Uint8Array(asset.gz), { + headers, + status: 200, + }) + } + + headers['Content-Length'] = String(asset.raw.byteLength) + return new Response(new Uint8Array(asset.raw), { + headers, + status: 200, + }) +} + +function createResponseHandler({ + asset, + enableEtag, + enableGzip, +}: { + asset: InMemoryAsset + enableEtag: boolean + enableGzip: boolean +}): StaticAssetRouteHandler { + return (request) => + createAssetResponse({ + asset, + enableEtag, + enableGzip, + request, + }) +} + +function createStreamingAssetResponse({ + filepath, + relativePath, + type, +}: { + filepath: string + relativePath: string + type: string +}): Response { + return new Response(Bun.file(filepath), { + headers: { + 'Cache-Control': isVersionedAsset(relativePath) ? 'public, max-age=31536000, immutable' : 'public, max-age=3600', + 'Content-Type': type, + }, + status: 200, + }) +} + +function formatFileSize(bytes: number, gzBytes?: number) { + const kb = bytes / 1024 + const size = kb < 100 ? kb.toFixed(2) : kb.toFixed(1) + + if (gzBytes !== undefined) { + const gzipKb = gzBytes / 1024 + return { + gzip: gzipKb < 100 ? gzipKb.toFixed(2) : gzipKb.toFixed(1), + size, + } + } + + const estimatedGzipKb = kb * 0.35 + return { + gzip: estimatedGzipKb < 100 ? estimatedGzipKb.toFixed(2) : estimatedGzipKb.toFixed(1), + size, + } +} + +export async function initializeStaticRoutes({ + enableEtag, + enableGzip, + excludePatterns: excludePatternValues, + gzipMimeTypes, + gzipMinBytes, + includePatterns: includePatternValues, + logger, + maxPreloadBytes, + verbose, + webDistPath, +}: InitializeStaticRoutesOptions): Promise { + const clientDirectory = join(webDistPath, 'client') + const displayPath = relative(process.cwd(), clientDirectory) || clientDirectory + const excludePatterns = excludePatternValues.map((pattern) => convertGlobToRegExp(pattern)) + const includePatterns = includePatternValues.map((pattern) => convertGlobToRegExp(pattern)) + const loaded: AssetMetadata[] = [] + const routes: Record = {} + const skipped: AssetMetadata[] = [] + + logger.info(`Serving web app from ${displayPath}`) + logger.info(`Loading static assets from ${displayPath}...`) + + if (verbose) { + logger.debug(`Max preload size: ${(maxPreloadBytes / 1024 / 1024).toFixed(2)} MB`) + + if (includePatternValues.length > 0) { + logger.debug(`Include patterns: ${includePatternValues.join(',')}`) + } + + if (excludePatternValues.length > 0) { + logger.debug(`Exclude patterns: ${excludePatternValues.join(',')}`) + } + } + + let totalPreloadedBytes = 0 + + try { + for await (const relativePath of ASSET_GLOB.scan({ cwd: clientDirectory })) { + const filepath = join(clientDirectory, relativePath) + const route = `/${relativePath.split(sep).join(posix.sep)}` + + try { + const file = Bun.file(filepath) + + if (!(await file.exists()) || file.size === 0) { + continue + } + + const metadata: AssetMetadata = { + route, + size: file.size, + type: file.type || 'application/octet-stream', + } + const matchesPattern = isFileEligibleForPreloading(relativePath, excludePatterns, includePatterns) + const withinSizeLimit = file.size <= maxPreloadBytes + + if (matchesPattern && withinSizeLimit) { + const asset = await loadAssetFromFile({ + enableEtag, + enableGzip, + filepath, + gzipMimeTypes, + gzipMinBytes, + relativePath, + type: metadata.type, + }) + + loaded.push({ ...metadata, size: asset.size }) + routes[route] = createResponseHandler({ + asset, + enableEtag, + enableGzip, + }) + totalPreloadedBytes += asset.size + continue + } + + routes[route] = withinSizeLimit + ? async (request) => + createAssetResponse({ + asset: await loadAssetFromFile({ + enableEtag, + enableGzip, + filepath, + gzipMimeTypes, + gzipMinBytes, + relativePath, + type: metadata.type, + }), + enableEtag, + enableGzip, + request, + }) + : () => + createStreamingAssetResponse({ + filepath, + relativePath, + type: metadata.type, + }) + + skipped.push(metadata) + } catch (error: unknown) { + if (error instanceof Error && (error as NodeJS.ErrnoException).code !== 'EISDIR') { + logger.error(`Failed to load ${filepath}: ${error.message}`) + } + } + } + + if (verbose && (loaded.length > 0 || skipped.length > 0)) { + const allFiles = [...loaded, ...skipped].sort((left, right) => left.route.localeCompare(right.route)) + const maxPathLength = Math.min(Math.max(...allFiles.map((file) => file.route.length)), 60) + + if (loaded.length > 0) { + logger.debug('\nPreloaded into memory:') + logger.debug('Path │ Size │ Gzip Size') + + loaded + .sort((left, right) => left.route.localeCompare(right.route)) + .forEach((file) => { + const { gzip, size } = formatFileSize(file.size) + const gzipSize = `${gzip.padStart(7)} kB` + const paddedPath = file.route.padEnd(maxPathLength) + const sizeValue = `${size.padStart(7)} kB` + + logger.debug(`${paddedPath} │ ${sizeValue} │ ${gzipSize}`) + }) + } + + if (skipped.length > 0) { + logger.debug('\nServed on-demand:') + logger.debug('Path │ Size │ Gzip Size') + + skipped + .sort((left, right) => left.route.localeCompare(right.route)) + .forEach((file) => { + const { gzip, size } = formatFileSize(file.size) + const gzipSize = `${gzip.padStart(7)} kB` + const paddedPath = file.route.padEnd(maxPathLength) + const sizeValue = `${size.padStart(7)} kB` + + logger.debug(`${paddedPath} │ ${sizeValue} │ ${gzipSize}`) + }) + } + } + + if (verbose) { + if (loaded.length > 0 || skipped.length > 0) { + const allFiles = [...loaded, ...skipped].sort((left, right) => left.route.localeCompare(right.route)) + + logger.debug('\nDetailed file information:') + logger.debug('Status │ Path │ MIME Type │ Reason') + + allFiles.forEach((file) => { + const isPreloaded = loaded.includes(file) + const reason = + !isPreloaded && file.size > maxPreloadBytes ? 'too large' : !isPreloaded ? 'filtered' : 'preloaded' + const route = file.route.length > 30 ? `${file.route.slice(0, 27)}...` : file.route + const status = isPreloaded ? 'MEMORY' : 'ON-DEMAND' + + logger.debug(`${status.padEnd(12)} │ ${route.padEnd(30)} │ ${file.type.padEnd(28)} │ ${reason.padEnd(10)}`) + }) + } else { + logger.debug('\nNo files found to display') + } + } + + if (loaded.length > 0) { + logger.info( + `Preloaded ${String(loaded.length)} files (${(totalPreloadedBytes / 1024 / 1024).toFixed(2)} MB) into memory`, + ) + } else { + logger.info('No files preloaded into memory') + } + + if (skipped.length > 0) { + const tooLarge = skipped.filter((file) => file.size > maxPreloadBytes).length + const filtered = skipped.length - tooLarge + + logger.info( + `${String(skipped.length)} files will be served on-demand (${String(tooLarge)} too large, ${String(filtered)} filtered)`, + ) + } + } catch (error) { + logger.error(`Failed to load static files from ${clientDirectory}: ${String(error)}`) + } + + return { + loaded, + routes, + skipped, + } +} + +function isFileEligibleForPreloading(relativePath: string, excludePatterns: RegExp[], includePatterns: RegExp[]) { + const fileName = relativePath.split(/[/\\]/).pop() ?? relativePath + + if (includePatterns.length > 0 && !includePatterns.some((pattern) => pattern.test(fileName))) { + return false + } + + if (excludePatterns.some((pattern) => pattern.test(fileName))) { + return false + } + + return true +} + +function isMimeTypeCompressible(gzipMimeTypes: string[], mimeType: string): boolean { + return gzipMimeTypes.some((type) => (type.endsWith('/') ? mimeType.startsWith(type) : mimeType === type)) +} + +function isVersionedAsset(relativePath: string): boolean { + const fileName = relativePath.split(/[/\\]/).pop() ?? relativePath + return /[-.](?=[A-Za-z0-9]{8,}\.[^.]+$)(?=[A-Za-z0-9]*\d)[A-Za-z0-9]+\.[^.]+$/i.test(fileName) +} + +async function loadAssetFromFile({ + enableEtag, + enableGzip, + filepath, + gzipMimeTypes, + gzipMinBytes, + relativePath, + type, +}: { + enableEtag: boolean + enableGzip: boolean + filepath: string + gzipMimeTypes: string[] + gzipMinBytes: number + relativePath: string + type: string +}): Promise { + const raw = new Uint8Array(await Bun.file(filepath).arrayBuffer()) + + return { + etag: enableEtag ? computeEtag(raw) : undefined, + gz: compressDataIfAppropriate({ + data: raw, + enableGzip, + gzipMimeTypes, + gzipMinBytes, + mimeType: type, + }), + immutable: isVersionedAsset(relativePath), + raw, + size: raw.byteLength, + type, + } +} + +function normalizeEtag(etag: string): string { + return etag.trim().replace(/^W\//i, '') +} + +function requestAcceptsGzip(acceptEncoding: string | null): boolean { + if (!acceptEncoding) { + return false + } + + const encodings = acceptEncoding + .split(',') + .map((token) => token.trim()) + .filter(Boolean) + .map((token) => { + const [coding = '', ...parameters] = token.split(';').map((part) => part.trim()) + const quality = parameters.find((parameter) => parameter.startsWith('q=')) + const value = quality ? Number(quality.slice(2)) : 1 + + return { + coding: coding.toLowerCase(), + value: Number.isFinite(value) ? value : 0, + } + }) + + if (encodings.some(({ coding, value }) => coding === 'gzip' && value > 0)) { + return true + } + + return ( + !encodings.some(({ coding, value }) => coding === 'gzip' && value === 0) && + encodings.some(({ coding, value }) => coding === '*' && value > 0) + ) +} + +function requestMatchesEtag(etag: string, ifNoneMatch: string | null): boolean { + if (!ifNoneMatch) { + return false + } + + const trimmed = ifNoneMatch.trim() + + if (trimmed === '*') { + return true + } + + const normalizedEtag = normalizeEtag(etag) + return trimmed + .split(',') + .map((candidate) => candidate.trim()) + .filter(Boolean) + .some((candidate) => normalizeEtag(candidate) === normalizedEtag) +} diff --git a/test/index.test.ts b/test/index.test.ts deleted file mode 100644 index db1a976..0000000 --- a/test/index.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { expect, test } from 'bun:test' -import { greet } from '../src/index.ts' - -test('greet function returns correct greeting', () => { - expect(greet('Alice')).toBe('Hello, Alice from tanstack-start-bun-server!') -}) - -test('greet function defaults to World if no name is provided', () => { - expect(greet()).toBe('Hello, World from tanstack-start-bun-server!') -}) diff --git a/test/logger.test.ts b/test/logger.test.ts new file mode 100644 index 0000000..e9fbc2a --- /dev/null +++ b/test/logger.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from 'bun:test' +import { createConsoleLogger, DEFAULT_GZIP_MIME_TYPES, type Logger } from '../src/index.ts' + +const customLogger: Logger = { + debug() {}, + error() {}, + info() {}, +} +const consoleLogger: Logger = createConsoleLogger() + +test('Logger accepts structural and console implementations', () => { + customLogger.info('custom logger is assignable') + consoleLogger.info('console logger is assignable') + + expect(typeof customLogger.info).toBe('function') + expect(typeof consoleLogger.info).toBe('function') +}) + +test('createConsoleLogger prefixes debug, error, and info output', () => { + const calls: Array<{ args: unknown[]; method: 'error' | 'log' }> = [] + const originalConsoleError = console.error + const originalConsoleLog = console.log + const logger = createConsoleLogger() + + console.log = (...args: unknown[]) => { + calls.push({ + args, + method: 'log', + }) + } + console.error = (...args: unknown[]) => { + calls.push({ + args, + method: 'error', + }) + } + + try { + logger.debug('debug message', 1) + logger.error('error message', 2) + logger.info('info message', 3) + } finally { + console.error = originalConsoleError + console.log = originalConsoleLog + } + + expect(calls).toEqual([ + { + args: ['DBG', 'debug message', 1], + method: 'log', + }, + { + args: ['ERR', 'error message', 2], + method: 'error', + }, + { + args: ['INF', 'info message', 3], + method: 'log', + }, + ]) +}) + +test('DEFAULT_GZIP_MIME_TYPES is exported for composition', () => { + expect(DEFAULT_GZIP_MIME_TYPES).toEqual([ + 'application/javascript', + 'application/json', + 'application/xml', + 'image/svg+xml', + 'text/', + ]) +}) diff --git a/test/tanstack-start-bun-server.test.ts b/test/tanstack-start-bun-server.test.ts new file mode 100644 index 0000000..1a03bb3 --- /dev/null +++ b/test/tanstack-start-bun-server.test.ts @@ -0,0 +1,295 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { createTanStackStartBunServeConfig, type Logger } from '../src/index.ts' + +const DEFAULT_SERVER_MODULE = ` +export default { + fetch(request) { + const url = new URL(request.url) + return new Response(\`server:\${url.pathname}\`, { + headers: { + 'Content-Type': 'text/plain', + }, + status: 200, + }) + }, +} +` + +const tempDirectories = new Set() + +afterEach(() => { + for (const tempDirectory of tempDirectories) { + rmSync(tempDirectory, { + force: true, + recursive: true, + }) + } + + tempDirectories.clear() +}) + +function getRoute(config: Awaited>, route: string) { + const handler = config.routes[route] + + expect(handler).toBeDefined() + + return handler as NonNullable +} + +function createFixtureDist({ + assets, + includeServer = true, + serverModule = DEFAULT_SERVER_MODULE, +}: { + assets: Record + includeServer?: boolean + serverModule?: string +}) { + const webDistPath = mkdtempSync(join(tmpdir(), 'tanstack-start-bun-server-')) + tempDirectories.add(webDistPath) + + for (const [relativePath, contents] of Object.entries(assets).sort(([left], [right]) => left.localeCompare(right))) { + writeFixtureFile({ + contents, + filepath: join(webDistPath, 'client', relativePath), + }) + } + + if (includeServer) { + writeFixtureFile({ + contents: serverModule, + filepath: join(webDistPath, 'server', 'server.js'), + }) + } + + return webDistPath +} + +function createSilentLogger(): Logger { + return { + debug() {}, + error() {}, + info() {}, + } +} + +function writeFixtureFile({ contents, filepath }: { contents: string; filepath: string }) { + mkdirSync(dirname(filepath), { + recursive: true, + }) + writeFileSync(filepath, contents) +} + +describe('createTanStackStartBunServeConfig', () => { + test('defaults to the console logger when logger is omitted', async () => { + const webDistPath = createFixtureDist({ + assets: { + 'app.js': 'console-default', + }, + }) + + const config = await createTanStackStartBunServeConfig({ + webDistPath, + }) + const response = await config.fetchHandler(new Request('https://example.com/')) + + expect(await response.text()).toBe('server:/') + }) + + test('preloads small assets into memory and serves oversized assets on demand', async () => { + const webDistPath = createFixtureDist({ + assets: { + 'large.txt': 'abcdefghijklmnopqrstuvwxyz', + 'small.txt': 'small-original', + }, + }) + + const config = await createTanStackStartBunServeConfig({ + logger: createSilentLogger(), + maxPreloadBytes: 16, + webDistPath, + }) + + writeFixtureFile({ + contents: 'large-updated', + filepath: join(webDistPath, 'client', 'large.txt'), + }) + writeFixtureFile({ + contents: 'small-updated', + filepath: join(webDistPath, 'client', 'small.txt'), + }) + + const largeResponse = await getRoute(config, '/large.txt')(new Request('https://example.com/large.txt')) + const smallResponse = await getRoute(config, '/small.txt')(new Request('https://example.com/small.txt')) + + expect(await largeResponse.text()).toBe('large-updated') + expect(await smallResponse.text()).toBe('small-original') + }) + + test('applies include and exclude patterns to filenames only', async () => { + const webDistPath = createFixtureDist({ + assets: { + 'nested/app.js': 'app-original', + 'nested/keep.css': 'keep-original', + 'nested/skip.css': 'skip-original', + }, + }) + + const config = await createTanStackStartBunServeConfig({ + excludePatterns: ['skip.css'], + includePatterns: ['*.css'], + logger: createSilentLogger(), + maxPreloadBytes: 1024, + webDistPath, + }) + + writeFixtureFile({ + contents: 'app-updated', + filepath: join(webDistPath, 'client', 'nested', 'app.js'), + }) + writeFixtureFile({ + contents: 'keep-updated', + filepath: join(webDistPath, 'client', 'nested', 'keep.css'), + }) + writeFixtureFile({ + contents: 'skip-updated', + filepath: join(webDistPath, 'client', 'nested', 'skip.css'), + }) + + const appResponse = await getRoute(config, '/nested/app.js')(new Request('https://example.com/nested/app.js')) + const keepResponse = await getRoute(config, '/nested/keep.css')(new Request('https://example.com/nested/keep.css')) + const skipResponse = await getRoute(config, '/nested/skip.css')(new Request('https://example.com/nested/skip.css')) + + expect(await appResponse.text()).toBe('app-updated') + expect(await keepResponse.text()).toBe('keep-original') + expect(await skipResponse.text()).toBe('skip-updated') + }) + + test('returns immutable and short-lived cache headers based on versioned filenames', async () => { + const webDistPath = createFixtureDist({ + assets: { + 'app-1a2b3c4d.js': 'versioned', + 'app.js': 'plain', + }, + }) + + const config = await createTanStackStartBunServeConfig({ + logger: createSilentLogger(), + webDistPath, + }) + + const plainResponse = await getRoute(config, '/app.js')(new Request('https://example.com/app.js')) + const versionedResponse = await getRoute( + config, + '/app-1a2b3c4d.js', + )(new Request('https://example.com/app-1a2b3c4d.js')) + + expect(plainResponse.headers.get('Cache-Control')).toBe('public, max-age=3600') + expect(versionedResponse.headers.get('Cache-Control')).toBe('public, max-age=31536000, immutable') + }) + + test('supports gzip negotiation and etag revalidation for preloaded assets', async () => { + const webDistPath = createFixtureDist({ + assets: { + 'bundle.js': 'const value = "gzip me please";\n'.repeat(8), + }, + }) + + const config = await createTanStackStartBunServeConfig({ + gzipMinBytes: 1, + logger: createSilentLogger(), + webDistPath, + }) + + const gzipResponse = await getRoute( + config, + '/bundle.js', + )( + new Request('https://example.com/bundle.js', { + headers: { + 'accept-encoding': 'br, gzip;q=1', + }, + }), + ) + const etag = gzipResponse.headers.get('ETag') + const gunzippedBody = Bun.gunzipSync(await gzipResponse.arrayBuffer()) + + expect(etag).toBeTruthy() + expect(gzipResponse.headers.get('Content-Encoding')).toBe('gzip') + expect(gzipResponse.headers.get('Vary')).toBe('Accept-Encoding') + expect(new TextDecoder().decode(gunzippedBody)).toContain('gzip me please') + + const notModifiedResponse = await getRoute( + config, + '/bundle.js', + )( + new Request('https://example.com/bundle.js', { + headers: { + 'if-none-match': etag ?? '', + }, + }), + ) + + expect(notModifiedResponse.status).toBe(304) + expect(notModifiedResponse.headers.get('ETag')).toBe(etag) + }) + + test('treats wildcard accept-encoding as gzip unless gzip is explicitly disabled', async () => { + const webDistPath = createFixtureDist({ + assets: { + 'bundle.js': 'const value = "wildcard gzip";\n'.repeat(8), + }, + }) + + const config = await createTanStackStartBunServeConfig({ + gzipMinBytes: 1, + logger: createSilentLogger(), + webDistPath, + }) + + const wildcardResponse = await getRoute( + config, + '/bundle.js', + )( + new Request('https://example.com/bundle.js', { + headers: { + 'accept-encoding': 'br;q=0.5, *;q=1', + }, + }), + ) + const disabledResponse = await getRoute( + config, + '/bundle.js', + )( + new Request('https://example.com/bundle.js', { + headers: { + 'accept-encoding': 'gzip;q=0, *;q=1', + }, + }), + ) + + expect(wildcardResponse.headers.get('Content-Encoding')).toBe('gzip') + expect(disabledResponse.headers.get('Content-Encoding')).toBeNull() + }) + + test('returns a not found handler when server/server.js is missing', async () => { + const webDistPath = createFixtureDist({ + assets: { + 'app.js': 'no-server', + }, + includeServer: false, + }) + + const config = await createTanStackStartBunServeConfig({ + logger: createSilentLogger(), + webDistPath, + }) + const response = await config.fetchHandler(new Request('https://example.com/')) + + expect(response.status).toBe(404) + expect(await response.text()).toBe('Not Found') + }) +})