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.
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 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. |
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 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
}
}
bundle (run by the CLI and by the app before opening a project) writes to out/ next to the entrypoint:
out/<entry>.js: the bundle, an ES module.out/<entry>.css: the CSS imported from JavaScript.out/index.html: generated with a viewport meta, a <link> for each CSS file and a <script type="module"> for each JS file.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.
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:
out/<path>, if it exists;<project root>/<path>;index.html;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.
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 = ….
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.
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"
}
}
npm install (or npm i <pkg>, npm uninstall, npm audit), which uses the core’s package installer. npm run <script> and npm start run package.json scripts in the shell, with their pre hooks.npm install works just as well. The output is the same node_modules layout and lockfile.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.
process.env holds the project’s environment:
.env at the project root (KEY=VALUE, export KEY=…, quotes, # comments), read by the fullstacked command (from a terminal or the app’s shell) when it starts the project;-e KEY=VALUE on the CLI;env passed to run.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).
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.
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.
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.
| 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) |