Skip to content
Merged
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
15 changes: 11 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,15 @@ COPY package.json pnpm-lock.yaml ./
# Install all dependencies (including dev for build)
RUN pnpm install --frozen-lockfile

# Copy source code
# Copy source code AND landing page AND build scripts so `pnpm build`
# can produce both the backend dist/ and landing/dist/app.js bundle.
COPY src/ src/
COPY landing/ landing/
COPY scripts/build-landing.mjs scripts/
COPY tsconfig.json tsconfig.build.json tsup.config.ts ./

# Build
# `pnpm build` runs tsup (backend -> dist/) and build-landing
# (landing JSX -> landing/dist/app.js, with React + ReactDOM bundled).
RUN pnpm build

# Stage 2: Production
Expand All @@ -39,8 +43,11 @@ RUN pnpm install --frozen-lockfile --prod --ignore-scripts
# Copy built output from build stage
COPY --from=build /app/dist ./dist

# Copy landing page (served at / by @fastify/static)
COPY landing/ ./landing/
# Copy the landing page WITH its precompiled bundle from the build stage.
# The host-side landing/ doesn't have landing/dist (it's gitignored and only
# materialised during a build), so we pull the whole tree from the build
# stage where the bundle exists at landing/dist/app.js.
COPY --from=build /app/landing ./landing

# Copy agent-discovery surface (served at /SKILL.md by the agent-discovery plugin)
COPY SKILL.md ./SKILL.md
Expand Down
16 changes: 4 additions & 12 deletions landing/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -214,18 +214,10 @@

<div id="root"></div>

<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>

<script type="text/babel" src="hooks.jsx"></script>
<script type="text/babel" src="components/HeroReceipt.jsx"></script>
<script type="text/babel" src="components/HeroTerminal.jsx"></script>
<script type="text/babel" src="components/LiveDemo.jsx"></script>
<script type="text/babel" src="components/HowItWorks.jsx"></script>
<script type="text/babel" src="components/UseCases.jsx"></script>
<script type="text/babel" src="components/MorganBlock.jsx"></script>
<script type="text/babel" src="app.jsx"></script>
<!-- Precompiled by scripts/build-landing.mjs (bundled React 18.3.1 + app). -->
<!-- Source files live in landing/*.jsx and landing/components/*.jsx. -->
<!-- CSP is strict: no inline scripts, no eval, no third-party script src. -->
<script src="/dist/app.js" defer></script>

</body>
</html>
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@
},
"scripts": {
"dev": "tsx watch --env-file-if-exists=.env --inspect src/index.ts",
"build": "tsup",
"build": "tsup && pnpm build:landing",
"build:landing": "node scripts/build-landing.mjs",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"format": "prettier --write \"src/**/*.ts\"",
Expand Down Expand Up @@ -94,12 +95,17 @@
"ioredis": "^5.10.1",
"pino": "^10.3.1",
"prom-client": "^15.1.3",
"react": "18.3.1",
"react-dom": "18.3.1",
"zod": "^4.4.3"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^25.8.0",
"@types/react": "^18.3",
"@types/react-dom": "^18.3",
"@vitest/coverage-v8": "^4.0.18",
"esbuild": "^0.28.0",
"eslint": "^9.39.4",
"eslint-config-airbnb-extended": "^3.1.0",
"eslint-plugin-import": "^2.32.0",
Expand Down
68 changes: 68 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

105 changes: 105 additions & 0 deletions scripts/build-landing.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
#!/usr/bin/env node
// scripts/build-landing.mjs
// Precompile the landing-page JSX so we can drop 'unsafe-eval' from CSP.
//
// Strategy: concatenate the existing 8 .jsx files in the order they were
// loaded via <script type="text/babel"> (hooks first, components, app last),
// prepend an `import React, ...` shim, then bundle with esbuild into a
// single IIFE at landing/dist/app.js.
//
// No source changes needed in landing/*.jsx -- the global React.* references
// resolve cleanly because the bundled module has React in scope, and the
// classic JSX transform (jsxFactory: React.createElement) keeps the
// generated calls identical to what Babel-standalone was producing in the
// browser before.

import { build } from 'esbuild';
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(__dirname, '..');
const LANDING = resolve(REPO_ROOT, 'landing');
const DIST = resolve(LANDING, 'dist');

// Concatenation order matches the original <script type="text/babel"> tags.
// hooks first, then components in their previous load order, then app last
// (since app.jsx calls ReactDOM.createRoot on the App component).
const SOURCE_ORDER = [
'hooks.jsx',
'components/HeroReceipt.jsx',
'components/HeroTerminal.jsx',
'components/LiveDemo.jsx',
'components/HowItWorks.jsx',
'components/UseCases.jsx',
'components/MorganBlock.jsx',
'app.jsx',
];

const BANNER = `// AUTO-GENERATED. Do not edit landing/dist/*. Run \`pnpm build:landing\`.
// Source: concatenated from landing/*.jsx and landing/components/*.jsx.

import React, { useState, useEffect } from 'react';
import { createRoot } from 'react-dom/client';

// The original Babel-standalone build expected React and ReactDOM as
// globals. We satisfy that surface without changing the source files.
const ReactDOM = { createRoot };

`;

function makeVirtualEntry() {
const parts = [BANNER];
for (const rel of SOURCE_ORDER) {
const full = resolve(LANDING, rel);
const body = readFileSync(full, 'utf-8');
parts.push(`// ===== FILE: ${rel} =====\n`);
parts.push(body);
parts.push('\n');
}
return parts.join('\n');
}

async function main() {
mkdirSync(DIST, { recursive: true });

const virtual = makeVirtualEntry();
const entryPath = resolve(DIST, '.entry.jsx');
writeFileSync(entryPath, virtual);

try {
const result = await build({
entryPoints: [entryPath],
outfile: resolve(DIST, 'app.js'),
bundle: true,
format: 'iife',
loader: { '.jsx': 'jsx' },
jsxFactory: 'React.createElement',
jsxFragment: 'React.Fragment',
define: {
'process.env.NODE_ENV': '"production"',
},
minify: true,
sourcemap: true,
target: 'es2020',
logLevel: 'info',
metafile: true,
});

// Report bundle size for visibility
const out = result.metafile.outputs[Object.keys(result.metafile.outputs).find((k) =>
k.endsWith('app.js')
)];
const kb = (out.bytes / 1024).toFixed(1);
console.log(`\nLanding bundle: landing/dist/app.js (${kb} KiB)`);
} finally {
// Clean up the virtual entry so the dist dir only contains the compiled output
rmSync(entryPath, { force: true });
}
}

main().catch((err) => {
console.error('build-landing failed:', err);
process.exit(1);
});
20 changes: 11 additions & 9 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,15 +84,17 @@ export async function createServer(options: CreateServerOptions): Promise<Fastif
: {
useDefaults: true,
directives: {
'script-src': [
"'self'",
"'unsafe-inline'",
// Babel standalone compiles JSX in the browser via new Function()
"'unsafe-eval'",
'https://static.cloudflareinsights.com',
// React, ReactDOM, Babel standalone CDN
'https://unpkg.com',
],
// Strict script policy: no inline, no eval, no third-party scripts
// except the Cloudflare Web Analytics beacon. The landing app is
// precompiled to landing/dist/app.js (see scripts/build-landing.mjs)
// and bundles React + ReactDOM, so Babel-standalone and unpkg.com
// are no longer needed.
'script-src': ["'self'", 'https://static.cloudflareinsights.com'],
// 'unsafe-inline' on style-src remains for now: React's inline
// style={{...}} pattern and the inline <style> block in landing
// both depend on it. A future PR can extract the inline <style>
// to an external CSS file, but the React inline-style usage is
// pervasive and not worth refactoring for marginal hardening.
'style-src': ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
'font-src': ["'self'", 'https://fonts.gstatic.com', 'data:'],
'img-src': ["'self'", 'data:'],
Expand Down
Loading
Loading