Bun: Zero-Dependency Server-Side JSX
I'm building a little, zero-dependency application with Bun. Bun comes with JSX support out of the box, but it took a bit of digging to get things working with no dependencies.
I figured I'd explain the process.
tsconfig
First, we need to tell Bun / TypeScript where our JSX rendering logic lives. We'll put everything in lib/jsx:
Add the following compilerOptions to tsconfig.json:
"jsx": "react-jsx",
"jsxImportSource": "lib/jsx",
And modify your paths to have an entry like this:
"lib/*": ["./lib/*"]
So, with unrelated stuff omitted, your tsconfig.json should be a superset of this:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "lib/jsx",
"baseUrl": ".",
"paths": {
"lib/*": ["./lib/*"]
}
}
}
lib/jsx
We'll put all of our logic into lib/jsx/index.ts, so go ahead and create that.
Bun and other transpilers expect a few other files which we'll need to create:
lib/jsx/jsx-runtime.tslib/jsx/jsx-dev-runtime.ts
And they both look like this:
export * from './index';
Now, let's write index.ts:
The bulk of the logic will be in our h function:
export function h(args) {
console.log(args);
return 'TODO...';
}
export const jsxDEV = h;
export const jsx = h;
You can test that by creating test.tsx:
console.log(<h1>It is now {new Date().toISOString()}</h1>);
If you run that using bun test.tsx, you'll see the shape of the args passed to the h function.
Escaping user input
Before we jump into the code, there's something worth noting. Bun comes with a handy escapeHTML function which I use in my JSX rendering logic. If you want to use any templates from the client, you'll want to use your own escape logic. It might look something like this:
const chars: Record<string, string> = {
[`"`]: '"',
[`&`]: '&',
[`'`]: ''',
[`<`]: '<',
[`>`]: '>',
};
const htmlEscapeRegex = /["&'<>]/g;
function esc(s: string) {
return s.replaceAll(htmlEscapeRegex, (ch) => chars[ch]);
}
Source
Here's a look at the final implementation:
// Bun / other transpilers expect these to be defined if jsx mode is
// react-jsx and jsxImportSource is used.
export const jsxDEV = h;
export const jsx = h;
// We'll use this to identify when a child is the result of a nested JSX
// expression. In that case, we don't want to escape the resulting HTML.
const $jsx = Symbol();
// This is what we'll return from our h function. It allows us to get access
// to the raw HTML. The $jsx property helps us identify JSX output when
// dealing with nested components.
export type JSXResult = {
$jsx: Symbol;
value: string;
};
// These are self-closing tags such as <img /> <br />, and need special
// treatment so that we avoid generating something invalid like <img></img>.
const voidElementNames = new Set([
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'link',
'meta',
'source',
'track',
'wbr',
]);
// Determine if arg is a potentially dangerous href value.
function isPotentiallyDangerousURL(arg: string) {
return (
// If the href contains a : (58 is the unicode value for :)
(arg.includes(':') || arg.includes(':')) &&
// And it doesn't start with http:// or https://
!fullyQualifiedURLRegex.test(arg)
);
}
// Determine if a value is a JSXResult
function isJSXResult(o: any): o is JSXResult {
return o?.$jsx === $jsx;
}
// Convert a child / children to HTML. We may get various shapes of
// data as children: strings / literal values / arrays / nested
// JSX values, etc.
function stringifyChild(child: any): string {
if (Array.isArray(child)) {
return child.map(stringifyChild).join('');
}
if (typeof child === 'string') {
return Bun.escapeHTML(child);
}
if (isJSXResult(child)) {
return child.value;
}
if (child != null && child !== false) {
return Bun.escapeHTML(`${child}`);
}
return '';
}
// Convert an object to HTML attributes. We'll handle functions specially
// so that we can write simple event handlers on the server, and have the
// code execute on the client.
function stringifyAttrs(attrs: Record<string, any>) {
let result = '';
for (const k in attrs) {
let value = attrs[k];
// We want to specifically handle boolean attributes and treat them
// as an add / remove operation. For example:
//
// <input checked={true} /> -> <input checked />
// <input checked={false} /> -> <input />
if (typeof value === 'boolean') {
value && (result += ${k});
continue;
}
// Convert functions to strings, presumably as an event-handler.
// This would take something like this:
//
// onClick={(e) => { document.title = e.target.textContent; }}>
//
// And convert it to this:
//
// onClick="((e) => { document.title = e.target.textContent; })(event)"
if (typeof value === 'function' && k.startsWith('on')) {
value = (${value})(event);
}
// It can be handy to embed view HTML as an attribute (e.g. so that client
// scripts can then make use of conditional HTML). This allows us to do
// something like this:
//
// data-moon-icon={<svg>...</svg>}
if (isJSXResult(value)) {
value = value.value;
}
// We want to disallow dangerous href values like javascript:alert("hi").
if (typeof value === 'string' && k === 'href' && isPotentiallyDangerousURL(value)) {
// We have a potentially dangerous href value, so we'll
// make it blank.
value = '';
}
result += ${k}="${Bun.escapeHTML(value)}";
}
return result;
}
// Fragments are how React and friends represent JSX results that contain
// multiple leafs without having a wrapper element.
export function Fragment(...args: Array<{ children: any[] }>) {
return { $jsx, value: args.flatMap((arg) => stringifyChild(arg.children)).join('') };
}
// The first argument is either a tagName or a function. That is, it is either
// something like "div" or "h1", or it is a function which is itself a JSX
// component such as:
//
// const Hello(props) => <h1>Hello {props.name}</h1>
export function h(tagOrFn: string | ((props: any) => any), props: any): JSXResult {
// We have a function component
if (typeof tagOrFn === 'function') {
const result = tagOrFn(props);
// If a functioni component returns anything other than a JSXResult,
// we don't want its output to show up in our final result.
return isJSXResult(result) ? result : { $jsx, value: '' };
}
// We're dealing with a tagName like "h1", etc
const { children, dangerouslySetInnerHTML, ...attrs } = props;
const content = dangerouslySetInnerHTML
? dangerouslySetInnerHTML.__html
: stringifyChild(children);
// We have a self-closing tag
if (voidElementNames.has(tagOrFn)) {
return { $jsx, value: `<${tagOrFn}${stringifyAttrs(attrs)} />` };
}
return { $jsx, value: `<${tagOrFn}${stringifyAttrs(attrs)}>${content}</${tagOrFn}>` };
}
// This helper lets us change the shape of JSXResult without breaking
// callers.
export function renderToString(result: JSXResult) {
return result.value;
}