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
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,41 @@ jobs:
npx vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
npx vercel deploy --prebuilt --prod --yes --token=${{ secrets.VERCEL_TOKEN }}

# ── Canonical demo deploy ────────────────────────────────────────────
- name: Check if demo changed
id: demo_changed
run: |
base_sha="${{ github.event.before }}"
head_sha="${{ github.sha }}"
if [ -z "$base_sha" ] || [ "$base_sha" = "0000000000000000000000000000000000000000" ]; then
base_sha="$(git rev-parse "$head_sha^")"
fi
changed_files="$(git diff --name-only "$base_sha" "$head_sha")"
demo_changed=false
if printf '%s\n' "$changed_files" | grep -E '^examples/chat/(angular|python)/' >/dev/null; then
demo_changed=true
fi
if printf '%s\n' "$changed_files" | grep -E '^(vercel\.demo\.json|scripts/(assemble-demo|demo-middleware|langgraph-proxy)\.ts)$' >/dev/null; then
demo_changed=true
fi
if printf '%s\n' "$changed_files" | grep -E '^libs/' >/dev/null; then
demo_changed=true
fi
echo "changed=$demo_changed" >> "$GITHUB_OUTPUT"
- name: Build and assemble canonical demo
if: steps.demo_changed.outputs.changed == 'true'
run: npx tsx scripts/assemble-demo.ts
- name: Deploy canonical demo to Vercel (production)
if: steps.demo_changed.outputs.changed == 'true'
working-directory: deploy/demo
run: |
mkdir -p .vercel
cat > .vercel/project.json <<EOF
{"projectId":"${{ secrets.VERCEL_DEMO_PROJECT_ID }}","orgId":"${{ secrets.VERCEL_ORG_ID }}","projectName":"demo"}
EOF
npx vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
npx vercel deploy --prebuilt --prod --yes --token=${{ secrets.VERCEL_TOKEN }}

production-smoke:
name: Production smoke
needs: [deploy]
Expand All @@ -324,4 +359,5 @@ jobs:
env:
BASE_URL: https://cockpit.cacheplane.ai
EXAMPLES_URL: https://examples.cacheplane.ai
DEMO_URL: https://demo.cacheplane.ai
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
1,059 changes: 1,059 additions & 0 deletions docs/superpowers/plans/2026-05-13-canonical-demo-deploy-phase-2.md

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion examples/chat/angular/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,13 @@
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
"sourceMap": true,
"fileReplacements": [
{
"replace": "examples/chat/angular/src/environments/environment.ts",
"with": "examples/chat/angular/src/environments/environment.development.ts"
}
]
}
},
"defaultConfiguration": "development"
Expand Down
5 changes: 3 additions & 2 deletions examples/chat/angular/src/app/shell/demo-shell.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { PalettePersistence } from './palette-persistence.service';
import { ThreadsService } from './threads.service';
import { ProjectsService } from './projects.service';
import { DEMO_AGENT } from './shell-tokens';
import { environment } from '../../environments/environment';

export type DemoMode = 'embed' | 'popup' | 'sidebar';

Expand Down Expand Up @@ -327,8 +328,8 @@ export class DemoShell {
*/
readonly agent = (() => {
const a = agent({
apiUrl: 'http://localhost:2024',
assistantId: 'chat',
apiUrl: environment.langGraphApiUrl,
assistantId: environment.assistantId,
threadId: this.threadIdSignal,
onThreadId: (id: string) => {
this.threadIdSignal.set(id);
Expand Down
3 changes: 2 additions & 1 deletion examples/chat/angular/src/app/shell/threads.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
import { Injectable, signal } from '@angular/core';
import { Client, type Thread as SdkThread } from '@langchain/langgraph-sdk';
import type { Thread } from '@ngaf/chat';
import { environment } from '../../environments/environment';

const API_URL = 'http://localhost:2024';
const API_URL = environment.langGraphApiUrl;

@Injectable({ providedIn: 'root' })
export class ThreadsService {
Expand Down
12 changes: 12 additions & 0 deletions examples/chat/angular/src/environments/environment.development.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: MIT
/**
* Development environment configuration for the canonical demo.
*
* Points to a local LangGraph server started with:
* cd examples/chat/python && langgraph dev
*/
export const environment = {
production: false,
langGraphApiUrl: 'http://localhost:2024',
assistantId: 'chat',
};
14 changes: 14 additions & 0 deletions examples/chat/angular/src/environments/environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: MIT
/**
* Production environment configuration for the canonical demo.
*
* Uses a relative /api URL — Vercel routes /api/* to the
* langgraph-proxy serverless function (scripts/demo-middleware.ts),
* which injects x-api-key server-side and proxies to the shared
* cockpit-dev LangGraph Cloud assistant.
*/
export const environment = {
production: true,
langGraphApiUrl: '/api',
assistantId: 'chat',
};
81 changes: 81 additions & 0 deletions scripts/assemble-demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#!/usr/bin/env npx tsx
// scripts/assemble-demo.ts
// SPDX-License-Identifier: MIT
/**
* Build the canonical-demo Angular app and assemble it into the Vercel
* deploy directory at deploy/demo/.
*
* Output structure:
* deploy/demo/ (Angular SPA static files)
* deploy/demo/.vercel/output/
* ├── config.json (route table: /api/* → function, else SPA fallback)
* ├── static/ (mirrors the SPA files)
* └── functions/api/[[...path]].func/
* ├── index.js (bundled scripts/demo-middleware.ts)
* └── .vc-config.json
*
* Usage:
* npx tsx scripts/assemble-demo.ts
* npx tsx scripts/assemble-demo.ts --skip-build
*/
import { execSync } from 'child_process';
import { cpSync, mkdirSync, rmSync, existsSync, writeFileSync } from 'fs';
import { resolve } from 'path';

const root = resolve(__dirname, '..');
const deployDir = resolve(root, 'deploy/demo');
const skipBuild = process.argv.includes('--skip-build');

if (!skipBuild) {
console.log('Building examples-chat-angular (production)...');
execSync('npx nx build examples-chat-angular --configuration=production --skip-nx-cache', {
cwd: root,
stdio: 'inherit',
});
}

if (existsSync(deployDir)) rmSync(deployDir, { recursive: true });

const src = resolve(root, 'dist/examples/chat/angular');
if (!existsSync(src)) {
console.error(`❌ Missing build output: ${src}`);
process.exit(1);
}

mkdirSync(deployDir, { recursive: true });
cpSync(src, deployDir, { recursive: true });
console.log(`✅ Copied SPA to ${deployDir}`);

const outputDir = resolve(deployDir, '.vercel/output');
const staticDir = resolve(outputDir, 'static');
const funcDir = resolve(outputDir, 'functions/api/[[...path]].func');

mkdirSync(staticDir, { recursive: true });
// Copy from the original dist (not deployDir) — Node's cpSync rejects
// copying a directory to a subdirectory of itself, filter or no filter.
cpSync(src, staticDir, { recursive: true });

mkdirSync(funcDir, { recursive: true });
execSync(`npx esbuild scripts/demo-middleware.ts --bundle --format=cjs --platform=node --outfile=${funcDir}/index.js`, {
cwd: root,
stdio: 'inherit',
});

writeFileSync(resolve(funcDir, '.vc-config.json'), JSON.stringify({
runtime: 'nodejs20.x',
handler: 'index.js',
launcherType: 'Nodejs',
shouldAddHelpers: true,
}, null, 2));

writeFileSync(resolve(outputDir, 'config.json'), JSON.stringify({
version: 3,
routes: [
{ src: '^/api/(.*)', dest: '/api/[[...path]]', check: true },
{ handle: 'filesystem' },
{ src: '.*', dest: '/index.html' },
],
}, null, 2));

console.log('✅ .vercel/output/ (Build Output API with serverless proxy)');
console.log(`\nAssembled canonical demo to ${deployDir}`);
10 changes: 10 additions & 0 deletions scripts/demo-middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// scripts/demo-middleware.ts
// SPDX-License-Identifier: MIT
/**
* Vercel Serverless Function proxy for the canonical-demo deployment
* (demo.cacheplane.ai). Five-line wrapper around the shared
* scripts/langgraph-proxy.ts factory using defaults — single backend,
* no Referer-based fan-out.
*/
import { createProxyHandler } from './langgraph-proxy';
module.exports = createProxyHandler({});
133 changes: 14 additions & 119 deletions scripts/examples-middleware.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,14 @@
/**
* Vercel Serverless Function proxy for LangGraph Cloud.
* Vercel Serverless Function proxy for the cockpit-examples deployment.
*
* Deployed as api/[...path].js - catches all /api/* requests.
* Injects x-api-key header from LANGSMITH_API_KEY env var.
* Routes active product paths to the shared cockpit dev backend based on the
* Referer header.
*/
// Types only - Vercel provides these at runtime
type VercelRequest = {
method?: string;
headers: Record<string, string | undefined>;
body: unknown;
url?: string;
query: Record<string, string | string[]>;
};
type VercelResponse = {
setHeader(k: string, v: string): void;
status(code: number): VercelResponse;
json(body: unknown): void;
write(chunk: string): void;
end(): void;
send(body: string): void;
};
* Thin wrapper around scripts/langgraph-proxy.ts that adds the
* examples-specific Referer-based backend resolution. Today there's
* a single shared backend, but the resolver pattern keeps the door
* open for future fan-out.
*
* Deployed as api/[[...path]].js by scripts/assemble-examples.ts.
*/
import { createProxyHandler } from './langgraph-proxy';

const SHARED_DEPLOYMENT_URL = 'https://cockpit-dev-219a15942c545a00a03a9a41905d7fc2.us.langgraph.app';

Expand Down Expand Up @@ -55,111 +42,19 @@ const ACTIVE_PRODUCT_PATHS = new Set([

function isActiveProductPath(pathname: string): boolean {
const segments = pathname.split('/').filter(Boolean);
if (segments.length < 2) {
return false;
}

if (segments.length < 2) return false;
return ACTIVE_PRODUCT_PATHS.has(`${segments[0]}/${segments[1]}`);
}

function resolveBackend(referer: string | undefined): string {
if (!referer) {
return SHARED_DEPLOYMENT_URL;
}

if (!referer) return SHARED_DEPLOYMENT_URL;
try {
const url = new URL(referer);
if (isActiveProductPath(url.pathname)) {
return SHARED_DEPLOYMENT_URL;
}
if (isActiveProductPath(url.pathname)) return SHARED_DEPLOYMENT_URL;
} catch {
// Ignore invalid referers and fall back to the shared deployment.
// Ignore invalid referers and fall back.
}

return SHARED_DEPLOYMENT_URL;
}

module.exports = async function handler(req: VercelRequest, res: VercelResponse) {
// CORS preflight
res.setHeader('access-control-allow-origin', '*');
res.setHeader('access-control-allow-methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('access-control-allow-headers', 'content-type, x-api-key, authorization');

if (req.method === 'OPTIONS') {
res.status(204).end();
return;
}

const apiKey = process.env['LANGSMITH_API_KEY'];
if (!apiKey) {
res.status(500).json({ error: 'LANGSMITH_API_KEY not configured' });
return;
}

const backendUrl = resolveBackend(req.headers.referer);

// Build target URL - extract path from req.url, stripping /api prefix.
const parsedUrl = new URL(req.url ?? '', `https://${req.headers.host ?? 'localhost'}`);
const apiPath = parsedUrl.pathname.replace(/^\/api/, '') || '/';
// Strip the Vercel catch-all query param, keep any real query params.
parsedUrl.searchParams.delete('[...path]');
parsedUrl.searchParams.delete('[[...path]]');
const cleanSearch = parsedUrl.searchParams.toString() ? `?${parsedUrl.searchParams.toString()}` : '';
const targetUrl = `${backendUrl}${apiPath}${cleanSearch}`;

// Debug endpoint.
if (apiPath === '/_proxy_debug') {
return res.status(200).json({
method: req.method,
url: req.url,
apiPath,
targetUrl,
backendUrl,
sharedDeployment: SHARED_DEPLOYMENT_URL,
referer: req.headers.referer,
query: req.query,
hasApiKey: !!apiKey,
apiKeyPrefix: apiKey?.substring(0, 10),
});
}

console.log(`[proxy] ${req.method} ${req.url} → ${targetUrl}`);

// Forward headers, inject API key.
const headers: Record<string, string> = {
'x-api-key': apiKey,
'content-type': req.headers['content-type'] ?? 'application/json',
};

try {
const response = await fetch(targetUrl, {
method: req.method ?? 'GET',
headers,
body: req.method !== 'GET' && req.method !== 'HEAD' ? JSON.stringify(req.body) : undefined,
});

// Stream the response back.
const contentType = response.headers.get('content-type') ?? 'application/json';
res.setHeader('content-type', contentType);
res.status(response.status);

if (contentType.includes('text/event-stream')) {
// SSE streaming - pipe the response body.
const reader = response.body?.getReader();
if (reader) {
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
res.write(decoder.decode(value, { stream: true }));
}
}
res.end();
} else {
const text = await response.text();
res.send(text);
}
} catch (err) {
res.status(502).json({ error: 'Proxy error', message: (err as Error).message });
}
};
module.exports = createProxyHandler({ resolveBackend, backendUrl: SHARED_DEPLOYMENT_URL });
Loading
Loading