Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,4 @@
# Deployment artifacts
.fastapps-deploy-*.tar.gz

# examples will be uploaded soon
examples/
# (examples subdir now tracked; keep ignoring per-example build artifacts via nested .gitignore)
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,12 @@ export default function MyWidget() {

---

## Examples

- `examples/pizzaz` – port of the OpenAI Apps SDK “Pizzaz” gallery built with FastApps (multiple widgets, Tailwind styling). See the example README for setup instructions.

---

## Contributing

We welcome contributions! Please see our contributing guidelines:
Expand Down
41 changes: 41 additions & 0 deletions examples/pizzaz/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
venv/
ENV/
env/
.venv

# JavaScript
node_modules/
npm-debug.log*
*.log
dist/
.cache/

# Build outputs
assets/

# IDEs
.vscode/
.idea/
*.swp
.DS_Store
80 changes: 80 additions & 0 deletions examples/pizzaz/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Pizzaz FastApps Example

End-to-end port of the [OpenAI Apps SDK “Pizzaz” gallery](https://github.com/openai/openai-apps-sdk-examples) built entirely with FastApps.

This example demonstrates:

- Multiple widgets + tools in a single FastApps app (map, carousel, albums, list, shop)
- Shared React utilities (routing, widget state, Tailwind 4 styling)
- Custom build pipeline (`build-all.mts`) with Tailwind CSS 4 (`@tailwindcss/vite`)

## Prerequisites

- Python 3.11+ (for FastApps CLI)
- Node.js 18+ / npm 8+
- `fastapps` CLI installed globally (`uv tool install fastapps` or `pipx install fastapps`)

## Quick Start

```bash
cd examples/pizzaz
npm install # install React/Tailwind deps
npm run build # or: fastapps build
fastapps dev # starts MCP server + Cloudflare tunnel
```

`fastapps dev` prints the public tunnel URL (e.g. `https://xxx.trycloudflare.com/mcp`). Add that URL to ChatGPT Connectors or use [MCPJam Inspector](https://www.npmjs.com/package/@mcpjam/inspector) to view each widget.

> **Note:** Mapbox GL uses the demo token baked into `widgets/pizza-map/index.jsx`. Replace `mapboxgl.accessToken` with your own for production use.

## Scripts

| Command | Description |
| ------------------ | -------------------------------------- |
| `npm run build` | Runs `build-all.mts` to bundle widgets |
| `fastapps build` | Same as above (calls the script) |
| `fastapps dev` | Dev server + Cloudflare tunnel |

## Widget/Tool Mapping

| Tool identifier | Widget path | Description |
| ---------------- | ---------------------------------- | ------------------------------- |
| `pizza-map` | `widgets/pizza-map/` | Mapbox map + inspector sidebar |
| `pizza-carousel` | `widgets/pizza-carousel/` | Embla carousel of places |
| `pizza-albums` | `widgets/pizza-albums/` | Photo albums + fullscreen view |
| `pizza-list` | `widgets/pizza-list/` | Ranked list UI |
| `pizza-shop` | `widgets/pizza-shop/` | Cart/checkout demo |

Python backend lives in `server/tools/*.py` (one `BaseWidget` per identifier). Shared inputs/constants are in `server/tools/pizzaz_common.py`.

## Project Structure

```
examples/pizzaz
├── build-all.mts # Vite build orchestrator (with Tailwind plugin)
├── package.json # npm deps (Tailwind, mapbox-gl, etc.)
├── server/
│ ├── main.py # FastApps auto-discovery server
│ └── tools/ # Backend widgets (pizzaz_common, pizza_*_tool.py)
├── widgets/
│ ├── pizza-map/ # Mapbox widget + inspector/sidebar
│ ├── pizza-carousel/ # Carousel widget
│ ├── pizza-albums/ # Albums widget
│ ├── pizza-list/ # List widget
│ ├── pizza-shop/ # Cart widget
│ ├── shared/ # Shared JSON data (markers)
│ └── styles/index.css # Tailwind 4 entrypoint
└── tailwind.config.ts # Tailwind content configuration
```

Generated assets are ignored (`assets/`)—run `npm run build` whenever widgets change.

## Learn More

- **FastApps Framework**: https://pypi.org/project/fastapps/
- **FastApps React hooks**: https://www.npmjs.com/package/fastapps
- **Docs**: https://docs.fastapps.org/

## License

MIT (same as FastApps)
177 changes: 177 additions & 0 deletions examples/pizzaz/build-all.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { build, type InlineConfig, type Plugin } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import fg from "fast-glob";
import path from "path";
import fs from "fs";
import crypto from "crypto";

// Read package.json from current working directory (project root)
const pkgPath = path.join(process.cwd(), "package.json");
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));

// Find all widget directories with index.{tsx,jsx}
const widgetDirs = fg.sync("widgets/*/", { onlyDirectories: true });
const entries = widgetDirs
.map((dir) => {
const dirPath = dir.endsWith("/") ? dir : dir + "/";
const indexFiles = fg.sync(`${dirPath}index.{tsx,jsx}`);
return indexFiles[0];
})
.filter(Boolean);
const outDir = "assets";

function wrapEntryPlugin(
virtualId: string,
entryFile: string,
widgetName: string,
): Plugin {
return {
name: `virtual-entry-wrapper:${entryFile}`,
resolveId(id) {
if (id === virtualId) return id;
},
load(id) {
if (id !== virtualId) {
return null;
}

// Automatically add mounting logic - no _app.jsx needed!
return `
import React from 'react';
import { createRoot } from 'react-dom/client';
import Component from ${JSON.stringify(entryFile)};

// Auto-mount the component
const rootElement = document.getElementById('${widgetName}-root');
if (rootElement) {
const root = createRoot(rootElement);
root.render(React.createElement(Component));
} else {
console.error('Root element #${widgetName}-root not found!');
}
`;
},
};
}

fs.rmSync(outDir, { recursive: true, force: true });
fs.mkdirSync(outDir, { recursive: true });

const builtNames: string[] = [];

for (const file of entries) {
const name = path.basename(path.dirname(file));

const entryAbs = path.resolve(file);

const virtualId = `\0virtual-entry:${entryAbs}`;

const createConfig = (): InlineConfig => ({
plugins: [
wrapEntryPlugin(virtualId, entryAbs, name),
tailwindcss(),
react(),
{
name: "remove-manual-chunks",
outputOptions(options) {
if ("manualChunks" in options) {
delete (options as any).manualChunks;
}
return options;
},
},
],
esbuild: {
jsx: "automatic",
jsxImportSource: "react",
target: "es2022",
},
build: {
target: "es2022",
outDir,
emptyOutDir: false,
chunkSizeWarningLimit: 2000,
minify: "esbuild",
cssCodeSplit: false,
rollupOptions: {
input: virtualId,
output: {
format: "es",
entryFileNames: `${name}.js`,
inlineDynamicImports: true,
assetFileNames: (info) =>
(info.name || "").endsWith(".css")
? `${name}.css`
: `[name]-[hash][extname]`,
},
preserveEntrySignatures: "allow-extension",
treeshake: true,
},
},
});

console.group(`Building ${name} (react)`);
await build(createConfig());
console.groupEnd();
builtNames.push(name);
console.log(`Built ${name}`);
}

const outputs = fs
.readdirSync("assets")
.filter((f) => f.endsWith(".js") || f.endsWith(".css"))
.map((f) => path.join("assets", f))
.filter((p) => fs.existsSync(p));

const renamed = [];

const h = crypto
.createHash("sha256")
.update(pkg.version, "utf8")
.digest("hex")
.slice(0, 4);

console.group("Hashing outputs");
for (const out of outputs) {
const dir = path.dirname(out);
const ext = path.extname(out);
const base = path.basename(out, ext);
const newName = path.join(dir, `${base}-${h}${ext}`);

fs.renameSync(out, newName);
renamed.push({ old: out, neu: newName });
console.log(`${out} -> ${newName}`);
}
console.groupEnd();

console.log("new hash: ", h);

for (const name of builtNames) {
const dir = outDir;
const htmlPath = path.join(dir, `${name}-${h}.html`);
const cssPath = path.join(dir, `${name}-${h}.css`);
const jsPath = path.join(dir, `${name}-${h}.js`);

const css = fs.existsSync(cssPath)
? fs.readFileSync(cssPath, { encoding: "utf8" })
: "";
const js = fs.existsSync(jsPath)
? fs.readFileSync(jsPath, { encoding: "utf8" })
: "";

const cssBlock = css ? `\n <style>\n${css}\n </style>\n` : "";
const jsBlock = js ? `\n <script type="module">\n${js}\n </script>` : "";

const html = [
"<!doctype html>",
"<html>",
`<head>${cssBlock}</head>`,
"<body>",
` <div id="${name}-root"></div>${jsBlock}`,
"</body>",
"</html>",
].join("\n");
fs.writeFileSync(htmlPath, html, { encoding: "utf8" });
console.log(`${htmlPath} (generated)`);
}
Loading
Loading