FullStacked

Project structure

A FullStacked project is a directory with an entrypoint. There’s no config file, no index.html to write, and no split between frontend and backend: the same file can render the UI and open a socket, read a file or run git, because Node’s APIs and the browser’s are both available.

The smallest project:

hello/
└── index.ts
// index.ts
import os from "node:os";

document.body.innerText = `Hello from ${os.platform()}`;

Run it with fullstacked hello (CLI) or open it in the app.

A typical project

my-app/
├── index.tsx            entrypoint (index.tsx, index.ts, index.jsx or index.js)
├── app.tsx              your modules, imported from the entrypoint
├── style.css            imported from JavaScript: import "./style.css"
├── logo.png             imported from JavaScript: import logo from "./logo.png"
├── worker.ts            built separately with bundleFile (see Workers)
├── public/
│   └── data.json        read at runtime: fetch("/public/data.json") or fs.readFileSync("/public/data.json")
├── package.json         dependencies and scripts
├── package-lock.json
├── tsconfig.json        optional, read by esbuild (jsx, paths, …)
├── .env                 optional, KEY=VALUE pairs for process.env
├── .gitignore           should list out/ and node_modules/
├── node_modules/        installed by `npm install` in the app, or by npm on a desktop
└── out/                 generated by the bundle: never edit
    ├── index.html
    ├── index.tsx.js
    ├── index.tsx.css
    └── logo-HASH.png

The entrypoint

The bundler looks for index.tsx, index.ts, index.jsx and index.js, in that order, at the root of the project. package.json’s main and module fields are not used to find a project’s entry. They only matter for packages in node_modules.

The entrypoint runs as an ES module in the project’s window, after the runtime has installed the bridge and its globals. Top-level await works.

Everything is bundled into one file. The entry and its imports are TypeScript, TSX, JSX or JavaScript, and they can import:

Import Result
./module.ts, ./component.tsx Bundled
react, lodash, … Bundled from node_modules (esbuild’s browser resolution: browser, module, main and exports fields)
node:fs, fs, … The runtime’s Node modules
fullstacked/git, … The runtime API
./style.css Collected into out/<entry>.css, linked from index.html
./image.png (.jpg, .jpeg, .gif, .svg) Copied to out/ under a hashed name. The import’s value is the file’s URL.
./data.json Bundled as a JavaScript object
./style.scss, @import "tailwindcss" With a build plugin
Fonts, .wasm, other files Not handled by the bundler. Keep them as static files and load them by URL.

JSX

esbuild’s default JSX transform is the classic one: React.createElement. Every file that contains JSX needs import React from "react", unless a tsconfig.json selects the automatic runtime:

{
    "compilerOptions": {
        "jsx": "react-jsx"
    }
}

The same tsconfig.json can set jsxImportSource for Preact or Solid, and paths for import aliases.

TypeScript

TypeScript is stripped, not type-checked. For editor support and tsc --noEmit, install the runtime’s types:

npm install --save-dev fullstacked @types/node

A matching tsconfig.json:

{
    "compilerOptions": {
        "target": "esnext",
        "module": "esnext",
        "moduleResolution": "bundler",
        "jsx": "react-jsx",
        "lib": ["dom", "esnext"],
        "types": ["node"],
        "noEmit": true
    }
}

The build: out/

bundle (run by the CLI and by the app before opening a project) writes to out/ next to the entrypoint:

out/ is deleted and recreated on every successful build. Add it to .gitignore. The page’s <body> starts empty, and your code builds the UI.

There’s no watch mode. Rebuild by running the project again.

Static files

Each project is served on its own origin by the host (a custom scheme on Apple and Linux, http://localhost intercepted on Android and Windows, a local HTTP server for the CLI). A request for a path is answered with:

  1. out/<path>, if it exists;
  2. otherwise <project root>/<path>;
  3. a directory resolves to its index.html;
  4. otherwise 404.

The Content-Type comes from the file’s extension, and defaults to text/plain. A few paths are reserved for the bridge: /ctx, /bridge, /call, /sync, /stream, /platform, /version, /open, /exit and /resize (see Requests).

So every file of the project is reachable by URL, and the build output wins when both exist:

const data = await (await fetch("/public/data.json")).json(); // the webview's fetch, same origin
const font = new FontFace("Inter", "url(/fonts/Inter.woff2)");
const wasm = await WebAssembly.instantiateStreaming(fetch("/lib/engine.wasm"));

A relative or root-relative fetch goes to the webview’s own fetch, and everything else to the core’s fetch.

The other way to read files is fs, where / is also the project root.

A hand-written index.html

out/index.html is generated, but the root fallback means a project with no entrypoint, or one opened without bundling, can serve its own index.html and plain scripts. Once a bundle runs, out/index.html takes priority over a root index.html. To control the HTML of a bundled project, build it in your code: document.head.append(…), document.title = ….

Prebuilt projects

A project that needs a build setup FullStacked doesn’t run (other esbuild options, Vite, a framework’s compiler) can build elsewhere and commit the result to out/. Opening it without bundling serves that output: the app shell’s run <dir> command and the run API don’t bundle. The fullstacked command always bundles first and stops on a build error, so it can’t open such a project.

Dependencies

package.json works as it does in npm:

{
    "name": "my-app",
    "scripts": {
        "start": "fullstacked"
    },
    "dependencies": {
        "react": "^19.0.0",
        "react-dom": "^19.0.0"
    },
    "devDependencies": {
        "fullstacked": "^1.0.0-alpha",
        "@fullstacked/tailwindcss": "^4.3.3-patch.1"
    }
}

Install scripts (postinstall, …) don’t run, and native addons (*.node) can’t load. Packages that are pure JavaScript, or that have a browser build, work. See Node.js compatibility for the built-ins those packages can use.

Environment variables

process.env holds the project’s environment:

The environment belongs to the project’s context, and its workers read the same one. It’s read-only at runtime: process.env returns a fresh copy on each access.

WINDOW_SIZE ("w:h:x:y", fullscreen or kiosk) sets the window on desktop hosts (see parentWindow).

Workers

new Worker(url) and worker_threads load a JavaScript file by URL. Bundling follows imports, not worker URLs, so build workers separately with bundleFile, which writes worker.ts.js next to worker.ts:

my-app/
├── index.ts
├── worker.ts
└── build.ts   // run with: fullstacked --file build.ts
// build.ts
import { bundleFile, bundle } from "fullstacked/bundle";

await bundleFile("worker.ts"); // → worker.ts.js
await bundle("."); // → out/
// index.ts
const worker = new Worker("/worker.ts.js", { type: "module" });

worker.ts.js is served through the root fallback. Ignore *.ts.js in git. Workers share the page’s context, so they can use fs, net, crypto and the other core modules.

Per-project settings

config stores device-local settings in .git/config.json. One key matters to the runtime: initialDirectory makes the project start in a subdirectory, for repositories that keep their app below the root. parentWindow stores the window size in .git/window-size.txt.

Multiple projects

A project can open others: bundle a directory, run it, and fullstacked.open(ctx). Each project gets its own context, sandbox and window. This is how the FullStacked app works: its shell is a FullStacked project (shell/ in the repository) that clones, installs, bundles and runs the projects in its directory.

Summary of generated files

File Created by Commit it?
out/ bundle No
*.ts.js, *.js.js bundleFile (workers, --file scripts, which delete theirs) No
node_modules/ npm install No
package-lock.json npm install Yes
.git/config.json, .git/window-size.txt config, parentWindow Never committed (inside .git)