diff --git a/services/cross-runtime-queues/.gitignore b/services/cross-runtime-queues/.gitignore new file mode 100644 index 0000000000..84aa34b3c2 --- /dev/null +++ b/services/cross-runtime-queues/.gitignore @@ -0,0 +1,29 @@ +# frontend +frontend/.next/ +frontend/node_modules/ +frontend/*.log +frontend/*.tsbuildinfo +frontend/next-env.d.ts +frontend/package-lock.json + +# python +backend/.venv/ +backend/**/__pycache__/ +backend/**/*.py[cod] +backend/**/*.egg-info/ +backend/.pytest_cache/ +backend/.queue-results.sqlite3 +backend/.queue-results.sqlite3-* + +# environment files +.env +.env*.local +frontend/.env +frontend/.env*.local +backend/.env +backend/.env*.local + +# common +.vercel/ +.DS_Store +.vercel diff --git a/services/cross-runtime-queues/README.md b/services/cross-runtime-queues/README.md new file mode 100644 index 0000000000..831aa76ce8 --- /dev/null +++ b/services/cross-runtime-queues/README.md @@ -0,0 +1,65 @@ +# Next.js + FastAPI Queues + +Minimal example showing [Vercel Services](https://vercel.com/docs/services) +with [Vercel Queues](https://vercel.com/docs/queues): + +- `frontend` (Next.js) mounted at `/` +- `backend` (FastAPI) mounted at `/api/python` + +It demonstrates: + +1. Next.js publishing a message for a Python subscriber +2. Python publishing a message for a Next.js callback +3. Next.js publishing one message to both runtimes + +## How it works + +Each producer creates a task before publishing a message. The queue delivers +the message to the configured subscriber, which stores its result in +[Vercel Runtime Cache](https://vercel.com/docs/runtime-cache). The frontend +polls the FastAPI service until every expected subscriber has completed. + +The fanout example uses separate consumer groups for the Next.js callback and +Python subscriber, so both receive a copy of the same message. + +## Project structure + +```txt +cross-runtime-queues/ +├── backend/ # FastAPI producer, subscribers, and result store +├── frontend/ # Next.js UI, producers, and queue callbacks +└── vercel.json # Service routing and Next.js queue triggers +``` + +## Services config + +Configuration in `vercel.json`: + +- routes `/(.*)` to `frontend` +- routes `/api/python/(.*)` to `backend` +- binds `backend` to `frontend` as `BACKEND_URL` +- registers the Next.js callbacks as `queue/v2beta` triggers + +Python subscribers are configured in `backend/pyproject.toml`. + +## Run locally + +Install the frontend and Python dependencies: + +```bash +cd frontend +npm install +cd ../backend +python -m venv .venv +source .venv/bin/activate +pip install -e . +cd .. +``` + +Start the development server: + +```bash +npx vercel dev +``` + +The application is now available at `http://localhost:3000`. diff --git a/services/cross-runtime-queues/backend/main.py b/services/cross-runtime-queues/backend/main.py new file mode 100644 index 0000000000..5c83a7f13f --- /dev/null +++ b/services/cross-runtime-queues/backend/main.py @@ -0,0 +1,109 @@ +from datetime import datetime, timezone +from typing import Any, Literal +from uuid import uuid4 + +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel +from vercel.queue import send + +from store import result_store + + +class DemoMessage(BaseModel): + text: str + sentAt: str | None = None + + +class TaskRecord(BaseModel): + taskId: str + expectedConsumers: list[Literal["nextjs", "python"]] + messageId: str | None = None + + +class Completion(BaseModel): + result: dict[str, Any] + + +app = FastAPI(title="Cross-runtime Vercel Queues demo") + + +def task_key(task_id: str) -> str: + return f"task:{task_id}" + + +def completion_key(task_id: str, consumer: str) -> str: + return f"task:{task_id}:completion:{consumer}" + + +@app.put("/api/python/tasks/{task_id}", status_code=204) +async def put_task(task_id: str, task: TaskRecord) -> None: + if task.taskId != task_id: + raise HTTPException(status_code=400, detail="Task ID mismatch") + await result_store.set(task_key(task_id), task.model_dump()) + + +@app.delete("/api/python/tasks/{task_id}", status_code=204) +async def delete_task(task_id: str) -> None: + await result_store.delete(task_key(task_id)) + + +@app.put( + "/api/python/tasks/{task_id}/completions/{consumer}", + status_code=204, +) +async def put_completion( + task_id: str, + consumer: Literal["nextjs", "python"], + completion: Completion, +) -> None: + await result_store.set( + completion_key(task_id, consumer), + completion.model_dump(), + ) + + +@app.get("/api/python/tasks/{task_id}") +async def get_task(task_id: str) -> dict[str, Any]: + task = await result_store.get(task_key(task_id)) + if task is None: + raise HTTPException(status_code=404, detail="Task not found") + + completions: dict[str, Any] = {} + for consumer in task["expectedConsumers"]: + completion = await result_store.get(completion_key(task_id, consumer)) + if completion is not None: + completions[consumer] = completion + + return { + **task, + "status": ( + "completed" + if len(completions) == len(task["expectedConsumers"]) + else "pending" + ), + "completions": completions, + } + + +@app.post("/api/python/messages/python-to-next") +async def send_to_next(message: DemoMessage) -> TaskRecord: + task_id = str(uuid4()) + payload = message.model_dump() + payload["sentAt"] = payload["sentAt"] or datetime.now(timezone.utc).isoformat() + payload["taskId"] = task_id + task = TaskRecord( + taskId=task_id, + expectedConsumers=["nextjs"], + ) + + await result_store.set(task_key(task_id), task.model_dump()) + try: + message_id = await send("demo-python-to-next", payload) + except Exception: + await result_store.delete(task_key(task_id)) + raise + + task.messageId = message_id + await result_store.set(task_key(task_id), task.model_dump()) + + return task diff --git a/services/cross-runtime-queues/backend/pyproject.toml b/services/cross-runtime-queues/backend/pyproject.toml new file mode 100644 index 0000000000..039f28e73a --- /dev/null +++ b/services/cross-runtime-queues/backend/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "queues-python-backend" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "fastapi", + "pydantic", + "vercel-cache", + "vercel-queue", +] + +[tool.vercel] +entrypoint = "main:app" + +[[tool.vercel.subscribers]] +entrypoint = "subscribers" diff --git a/services/cross-runtime-queues/backend/store.py b/services/cross-runtime-queues/backend/store.py new file mode 100644 index 0000000000..07b740d27f --- /dev/null +++ b/services/cross-runtime-queues/backend/store.py @@ -0,0 +1,102 @@ +import asyncio +import json +import os +import sqlite3 +import time +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Iterator + +from vercel.cache import AsyncRuntimeCache + +RESULT_TTL_SECONDS = 3600 +LOCAL_DATABASE_PATH = Path(__file__).with_name(".queue-results.sqlite3") + + +class ResultStore: + def __init__(self) -> None: + self._runtime_cache = AsyncRuntimeCache(namespace="cross-runtime-queues") + self._use_runtime_cache = bool( + os.getenv("VERCEL") and os.getenv("VERCEL_ENV") != "development" + ) + + async def set(self, key: str, value: dict[str, Any]) -> None: + if self._use_runtime_cache: + await self._runtime_cache.set( + key, + value, + {"ttl": RESULT_TTL_SECONDS}, + ) + return + + await asyncio.to_thread(self._set_local, key, value) + + async def get(self, key: str) -> dict[str, Any] | None: + if self._use_runtime_cache: + return await self._runtime_cache.get(key) + + return await asyncio.to_thread(self._get_local, key) + + async def delete(self, key: str) -> None: + if self._use_runtime_cache: + await self._runtime_cache.delete(key) + return + + await asyncio.to_thread(self._delete_local, key) + + @contextmanager + def _connect(self) -> Iterator[sqlite3.Connection]: + connection = sqlite3.connect(LOCAL_DATABASE_PATH, timeout=5) + try: + connection.execute( + """ + CREATE TABLE IF NOT EXISTS queue_results ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + expires_at REAL NOT NULL + ) + """ + ) + yield connection + connection.commit() + finally: + connection.close() + + def _set_local(self, key: str, value: dict[str, Any]) -> None: + with self._connect() as connection: + connection.execute( + """ + INSERT INTO queue_results (key, value, expires_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + expires_at = excluded.expires_at + """, + (key, json.dumps(value), time.time() + RESULT_TTL_SECONDS), + ) + + def _get_local(self, key: str) -> dict[str, Any] | None: + with self._connect() as connection: + row = connection.execute( + "SELECT value, expires_at FROM queue_results WHERE key = ?", + (key,), + ).fetchone() + if row is None: + return None + + value, expires_at = row + if expires_at <= time.time(): + connection.execute( + "DELETE FROM queue_results WHERE key = ?", + (key,), + ) + return None + + return json.loads(value) + + def _delete_local(self, key: str) -> None: + with self._connect() as connection: + connection.execute("DELETE FROM queue_results WHERE key = ?", (key,)) + + +result_store = ResultStore() diff --git a/services/cross-runtime-queues/backend/subscribers.py b/services/cross-runtime-queues/backend/subscribers.py new file mode 100644 index 0000000000..fea1413506 --- /dev/null +++ b/services/cross-runtime-queues/backend/subscribers.py @@ -0,0 +1,50 @@ +from typing import Any + +from vercel.queue import Message, subscribe + +from store import result_store + + +async def complete_task( + message: Message[dict[str, Any]], + consumer: str, +) -> None: + task_id = str(message.payload["taskId"]) + await result_store.set( + f"task:{task_id}:completion:{consumer}", + { + "result": { + "messageId": message.message_id, + "deliveryCount": message.metadata.delivery_count, + "received": message.payload, + }, + }, + ) + + +@subscribe(topic="demo-next-to-python") +async def receive_from_next(message: Message[dict[str, Any]]) -> None: + await complete_task(message, "python") + print( + "[python consumer] received from Next.js", + { + "payload": message.payload, + "message_id": message.message_id, + "delivery_count": message.metadata.delivery_count, + "topic": message.metadata.topic, + }, + ) + + +@subscribe(topic="demo-fanout") +async def receive_fanout(message: Message[dict[str, Any]]) -> None: + await complete_task(message, "python") + print( + "[python fanout consumer] received a copy", + { + "payload": message.payload, + "message_id": message.message_id, + "consumer_group": message.metadata.consumer_group, + "delivery_count": message.metadata.delivery_count, + }, + ) diff --git a/services/cross-runtime-queues/frontend/eslint.config.mjs b/services/cross-runtime-queues/frontend/eslint.config.mjs new file mode 100644 index 0000000000..6407a8442c --- /dev/null +++ b/services/cross-runtime-queues/frontend/eslint.config.mjs @@ -0,0 +1,9 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTypeScript from "eslint-config-next/typescript"; + +export default defineConfig([ + ...nextVitals, + ...nextTypeScript, + globalIgnores([".next/**", "next-env.d.ts"]), +]); diff --git a/services/cross-runtime-queues/frontend/package.json b/services/cross-runtime-queues/frontend/package.json new file mode 100644 index 0000000000..59692f67d5 --- /dev/null +++ b/services/cross-runtime-queues/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "frontend", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint .", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@vercel/queue": "^0.4.0", + "next": "^16.3.1", + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "eslint": "^9.39.5", + "eslint-config-next": "^16.3.1", + "typescript": "^5.9.3" + } +} diff --git a/services/cross-runtime-queues/frontend/public/nextjs.svg b/services/cross-runtime-queues/frontend/public/nextjs.svg new file mode 100644 index 0000000000..06a2bd6eed --- /dev/null +++ b/services/cross-runtime-queues/frontend/public/nextjs.svg @@ -0,0 +1 @@ +Next.js \ No newline at end of file diff --git a/services/cross-runtime-queues/frontend/public/python.svg b/services/cross-runtime-queues/frontend/public/python.svg new file mode 100644 index 0000000000..30587d8164 --- /dev/null +++ b/services/cross-runtime-queues/frontend/public/python.svg @@ -0,0 +1 @@ +Python \ No newline at end of file diff --git a/services/cross-runtime-queues/frontend/src/app/api/next/fanout/route.ts b/services/cross-runtime-queues/frontend/src/app/api/next/fanout/route.ts new file mode 100644 index 0000000000..4bcd951a78 --- /dev/null +++ b/services/cross-runtime-queues/frontend/src/app/api/next/fanout/route.ts @@ -0,0 +1,27 @@ +import { send } from '@vercel/queue' +import { deleteTask, putTask, type TaskRecord } from '@/lib/task-store' + +export async function POST(request: Request) { + const payload = await request.json() + const taskId = crypto.randomUUID() + const task: TaskRecord = { + taskId, + expectedConsumers: ['nextjs', 'python'], + messageId: null, + } + + await putTask(task) + try { + const { messageId } = await send('demo-fanout', { + ...payload, + taskId, + }) + task.messageId = messageId + await putTask(task) + + return Response.json(task) + } catch (error) { + await deleteTask(taskId) + throw error + } +} diff --git a/services/cross-runtime-queues/frontend/src/app/api/next/next-to-python/route.ts b/services/cross-runtime-queues/frontend/src/app/api/next/next-to-python/route.ts new file mode 100644 index 0000000000..332b281a84 --- /dev/null +++ b/services/cross-runtime-queues/frontend/src/app/api/next/next-to-python/route.ts @@ -0,0 +1,27 @@ +import { send } from '@vercel/queue' +import { deleteTask, putTask, type TaskRecord } from '@/lib/task-store' + +export async function POST(request: Request) { + const payload = await request.json() + const taskId = crypto.randomUUID() + const task: TaskRecord = { + taskId, + expectedConsumers: ['python'], + messageId: null, + } + + await putTask(task) + try { + const { messageId } = await send('demo-next-to-python', { + ...payload, + taskId, + }) + task.messageId = messageId + await putTask(task) + + return Response.json(task) + } catch (error) { + await deleteTask(taskId) + throw error + } +} diff --git a/services/cross-runtime-queues/frontend/src/app/api/queues/fanout/route.ts b/services/cross-runtime-queues/frontend/src/app/api/queues/fanout/route.ts new file mode 100644 index 0000000000..ce84fd87b4 --- /dev/null +++ b/services/cross-runtime-queues/frontend/src/app/api/queues/fanout/route.ts @@ -0,0 +1,22 @@ +import { handleCallback } from '@vercel/queue' +import { completeTask } from '@/lib/task-store' + +type DemoMessage = { + taskId: string + text: string + sentAt: string +} + +export const POST = handleCallback(async (message, metadata) => { + await completeTask(message.taskId, 'nextjs', { + messageId: metadata.messageId, + deliveryCount: metadata.deliveryCount, + received: message, + }) + console.log('[nextjs fanout consumer] received a copy', { + message, + messageId: metadata.messageId, + consumerGroup: metadata.consumerGroup, + deliveryCount: metadata.deliveryCount, + }) +}) diff --git a/services/cross-runtime-queues/frontend/src/app/api/queues/from-python/route.ts b/services/cross-runtime-queues/frontend/src/app/api/queues/from-python/route.ts new file mode 100644 index 0000000000..4f65b02027 --- /dev/null +++ b/services/cross-runtime-queues/frontend/src/app/api/queues/from-python/route.ts @@ -0,0 +1,22 @@ +import { handleCallback } from '@vercel/queue' +import { completeTask } from '@/lib/task-store' + +type DemoMessage = { + taskId: string + text: string + sentAt: string +} + +export const POST = handleCallback(async (message, metadata) => { + await completeTask(message.taskId, 'nextjs', { + messageId: metadata.messageId, + deliveryCount: metadata.deliveryCount, + received: message, + }) + console.log('[nextjs consumer] received from Python', { + message, + messageId: metadata.messageId, + deliveryCount: metadata.deliveryCount, + topic: metadata.topicName, + }) +}) diff --git a/services/cross-runtime-queues/frontend/src/app/layout.tsx b/services/cross-runtime-queues/frontend/src/app/layout.tsx new file mode 100644 index 0000000000..5e928a37ed --- /dev/null +++ b/services/cross-runtime-queues/frontend/src/app/layout.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' +import './styles.css' + +export const metadata: Metadata = { + title: 'Cross-runtime Queues', + description: 'Vercel Queues with Next.js and Python', +} + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/services/cross-runtime-queues/frontend/src/app/page.tsx b/services/cross-runtime-queues/frontend/src/app/page.tsx new file mode 100644 index 0000000000..38330ac201 --- /dev/null +++ b/services/cross-runtime-queues/frontend/src/app/page.tsx @@ -0,0 +1,372 @@ +'use client' + +import Image from 'next/image' +import { useRef, useState } from 'react' + +type Consumer = 'nextjs' | 'python' + +type Demo = { + title: string + description: string + producer: 'Next.js' | 'Python' + topic: string + expectedConsumers: Consumer[] + endpoint: string +} + +type Completion = { + result: { + received: { text?: string } + } +} + +type TaskResult = { + taskId: string + messageId: string | null + status: 'pending' | 'completed' | 'error' + expectedConsumers: Consumer[] + completions: Record + detail?: string +} + +type ActiveRun = { + title: string + expectedConsumers: Consumer[] + taskId?: string + messageId?: string | null +} + +const demos: Demo[] = [ + { + title: 'Next.js → Python', + description: 'Send in Next.js, consume in Python.', + producer: 'Next.js', + topic: 'demo-next-to-python', + expectedConsumers: ['python'], + endpoint: '/api/next/next-to-python', + }, + { + title: 'Python → Next.js', + description: 'Send in Python, consume in Next.js.', + producer: 'Python', + topic: 'demo-python-to-next', + expectedConsumers: ['nextjs'], + endpoint: '/api/python/messages/python-to-next', + }, + { + title: 'Fan out', + description: 'Send once, consume in both runtimes.', + producer: 'Next.js', + topic: 'demo-fanout', + expectedConsumers: ['nextjs', 'python'], + endpoint: '/api/next/fanout', + }, +] + +export default function Home() { + const [pending, setPending] = useState() + const [activeRun, setActiveRun] = useState() + const [results, setResults] = useState>({}) + const runIds = useRef>({}) + + async function pollTask( + title: string, + taskId: string, + runId: string, + attempt = 0 + ) { + if (runIds.current[title] !== runId) return + + try { + const response = await fetch( + `/api/python/tasks/${encodeURIComponent(taskId)}`, + { cache: 'no-store' } + ) + if (!response.ok) { + throw new Error(`Result lookup failed (${response.status})`) + } + + const task = (await response.json()) as TaskResult + if (runIds.current[title] !== runId) return + setResults((current) => ({ ...current, [title]: task })) + + if (task.status !== 'completed' && attempt < 120) { + window.setTimeout( + () => pollTask(title, taskId, runId, attempt + 1), + 1000 + ) + } + } catch (error) { + if (runIds.current[title] !== runId) return + setResults((current) => ({ + ...current, + [title]: { + ...current[title], + taskId, + status: 'error', + expectedConsumers: current[title]?.expectedConsumers ?? [], + completions: current[title]?.completions ?? {}, + messageId: current[title]?.messageId ?? null, + detail: + error instanceof Error ? error.message : 'Unable to read result', + }, + })) + } + } + + async function enqueue(demo: Demo) { + const runId = crypto.randomUUID() + runIds.current[demo.title] = runId + setPending(demo.title) + setActiveRun({ + title: demo.title, + expectedConsumers: demo.expectedConsumers, + }) + setResults((current) => { + const next = { ...current } + delete next[demo.title] + return next + }) + + try { + const response = await fetch(demo.endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + text: `Hello from ${demo.producer}`, + sentAt: new Date().toISOString(), + }), + }) + const result = (await response.json()) as { + taskId?: string + messageId?: string | null + detail?: string + expectedConsumers?: Consumer[] + } + + if (!response.ok) { + throw new Error(result.detail ?? `Request failed (${response.status})`) + } + if (!result.taskId) { + throw new Error('Producer did not return a task ID') + } + if (!result.expectedConsumers) { + throw new Error('Producer did not return expected consumers') + } + const taskId = result.taskId + const expectedConsumers = result.expectedConsumers + setActiveRun((current) => + current?.title === demo.title + ? { + ...current, + taskId, + messageId: result.messageId ?? null, + } + : current + ) + + setResults((current) => ({ + ...current, + [demo.title]: { + taskId, + messageId: result.messageId ?? null, + status: 'pending', + expectedConsumers, + completions: {}, + }, + })) + void pollTask(demo.title, taskId, runId) + } catch (error) { + setResults((current) => ({ + ...current, + [demo.title]: { + taskId: '', + messageId: null, + status: 'error', + expectedConsumers: demo.expectedConsumers, + completions: {}, + detail: + error instanceof Error + ? error.message + : 'Unable to enqueue message', + }, + })) + } finally { + setPending(undefined) + } + } + + const activeTask = activeRun ? results[activeRun.title] : undefined + + function runtimeState(runtime: Consumer) { + if (!activeRun?.expectedConsumers.includes(runtime)) return 'idle' + if (!activeTask) return 'processing' + if (activeTask.completions[runtime]) return 'completed' + if (activeTask.status === 'error') return 'error' + return 'processing' + } + + return ( +
+
+

Next.js + Python queues

+

Send a message and watch each consumer run.

+
+ +
+ {demos.map((demo, index) => ( +
+
+ 0{index + 1} + {demo.producer} +
+
+

{demo.title}

+

{demo.description}

+
+
+
+ + {demo.producer === 'Python' ? 'main.py' : 'route.ts'} + + {demo.topic} +
+
+                
+                  await{' '}
+                  send
+                  ("{demo.topic}", payload)
+                
+              
+
+ + + {results[demo.title] && ( + <> +
+ + {results[demo.title].status} +
+ {results[demo.title].detail && ( + {results[demo.title].detail} + )} + + )} +
+
+ ))} +
+ +
+
+

Consumers

+
+ +
+
+
+
+ + + +
+ Next.js callback + route.ts +
+
+ + + {runtimeState('nextjs')} + +
+
+              
+                export const POST ={' '}
+                handleCallback(
+                async (message) =>{' '}
+                {'{'}
+                {'\n  '}
+                await{' '}
+                completeTask(message)
+                {'\n'}
+                {'}'})
+              
+            
+
+ Latest result + + {activeTask?.completions.nextjs?.result.received.text ?? + 'Waiting for a delivery…'} + +
+
+ +
+
+
+ + + +
+ Python subscriber + subscribers.py +
+
+ + + {runtimeState('python')} + +
+
+              
+                @subscribe
+                (topic="demo-*"
+                ){'\n'}
+                async def{' '}
+                handle(message):
+                {'\n  '}
+                await{' '}
+                complete_task(message)
+              
+            
+
+ Latest result + + {activeTask?.completions.python?.result.received.text ?? + 'Waiting for a delivery…'} + +
+
+
+ + {activeRun && ( +
+ Current task + {activeRun.taskId ?? 'Creating task…'} + + {activeRun.taskId + ? activeRun.messageId ?? 'ingestion deferred' + : ' '} + +
+ )} +
+
+ ) +} diff --git a/services/cross-runtime-queues/frontend/src/app/styles.css b/services/cross-runtime-queues/frontend/src/app/styles.css new file mode 100644 index 0000000000..8f695dbd4b --- /dev/null +++ b/services/cross-runtime-queues/frontend/src/app/styles.css @@ -0,0 +1,439 @@ +:root { + color-scheme: dark; + --background: #000; + --panel: #0a0a0a; + --border: #262626; + --muted: #888; + --foreground: #ededed; + --green: #50e3c2; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--background); + color: var(--foreground); + font-family: 'Geist Sans', 'Helvetica Neue', Arial, sans-serif; + -webkit-font-smoothing: antialiased; +} + +main { + width: min(1200px, calc(100% - 48px)); + margin: 0 auto; +} + +.hero { + padding: 72px 0 48px; +} + +.hero h1 { + margin: 0 0 12px; + font-size: clamp(38px, 5vw, 56px); + font-weight: 600; + letter-spacing: -0.055em; + line-height: 1; +} + +.hero p { + margin: 0; + color: var(--muted); + font-size: 15px; + line-height: 1.6; +} + +.demo-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--border); +} + +.demo-card { + display: flex; + min-height: 440px; + flex-direction: column; + background: var(--panel); + transition: background 180ms ease; +} + +.demo-card.selected { + background: #0e0e0e; + box-shadow: inset 0 2px 0 #fff; +} + +.card-topline { + display: flex; + justify-content: space-between; + padding: 18px 20px; + border-bottom: 1px solid var(--border); + color: #777; + font-family: monospace; + font-size: 11px; + text-transform: uppercase; +} + +.card-copy { + min-height: 150px; + padding: 26px 24px 22px; +} + +.card-copy h2 { + margin: 0 0 10px; + font-size: 22px; + font-weight: 550; + letter-spacing: -0.035em; +} + +.card-copy p { + margin: 0; + color: var(--muted); + font-size: 14px; + line-height: 1.55; +} + +.mini-code { + margin: 0 16px 16px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 8px; + background: #050505; +} + +.code-chrome { + display: flex; + justify-content: space-between; + padding: 8px 11px; + border-bottom: 1px solid #1c1c1c; + color: #666; + font-family: monospace; + font-size: 9px; +} + +.mini-code pre { + margin: 0; + padding: 18px 14px; + overflow-x: auto; + color: #d4d4d4; + font-size: 13px; + line-height: 1.5; +} + +.syntax-keyword { + color: #c586c0; +} + +.syntax-fn { + color: #dcdcaa; +} + +.syntax-decorator { + color: #4ec9b0; +} + +.syntax-string { + color: #ce9178; +} + +.demo-card > button { + display: flex; + width: auto; + min-height: 42px; + align-items: center; + justify-content: space-between; + margin: auto 16px 16px; + padding: 0 14px; + border: 1px solid #fff; + border-radius: 7px; + background: #fff; + color: #000; + cursor: pointer; + font-size: 13px; + font-weight: 550; + transition: background 150ms ease, color 150ms ease; +} + +.demo-card > button:hover:not(:disabled) { + background: #000; + color: #fff; +} + +.demo-card > button:disabled { + cursor: wait; + opacity: 0.55; +} + +.task-output { + display: block; + min-height: 16px; + margin: -8px 16px 16px; + color: var(--muted); + font-family: monospace; + font-size: 10px; + overflow-wrap: anywhere; +} + +.task-state { + display: flex; + align-items: center; + gap: 7px; + text-transform: capitalize; +} + +.status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #666; +} + +.status-dot.pending { + background: #f5a623; + box-shadow: 0 0 8px #f5a623; + animation: pulse 1.2s ease-in-out infinite; +} + +.status-dot.completed { + background: var(--green); +} + +.status-dot.error { + background: #e00; +} + +.consumers-section { + margin-top: 72px; + padding-bottom: 72px; +} + +.section-heading { + margin-bottom: 28px; +} + +.section-heading h2 { + margin: 0; + font-size: 28px; + font-weight: 550; + letter-spacing: -0.045em; +} + +.handler-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.handler-card { + overflow: hidden; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--panel); + transition: border-color 250ms ease, box-shadow 250ms ease, + transform 250ms ease; +} + +.handler-card[data-state='processing'] { + border-color: #666; + box-shadow: 0 0 0 1px #444, 0 0 45px rgb(255 255 255 / 10%); + transform: translateY(-2px); +} + +.handler-card[data-state='completed'] { + border-color: rgb(80 227 194 / 55%); + box-shadow: 0 0 40px rgb(80 227 194 / 8%); +} + +.handler-card[data-state='error'] { + border-color: #8f2020; +} + +.handler-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + border-bottom: 1px solid var(--border); +} + +.handler-header > div { + display: flex; + align-items: center; + gap: 11px; +} + +.runtime-icon { + display: grid; + width: 30px; + height: 30px; + place-items: center; + border: 1px solid #303030; + border-radius: 6px; + background: #111; +} + +.runtime-icon img { + filter: invert(1); +} + +.handler-header strong, +.handler-header small { + display: block; +} + +.handler-header strong { + margin-bottom: 3px; + font-size: 13px; + font-weight: 550; +} + +.handler-header small { + color: #666; + font-family: monospace; + font-size: 10px; +} + +.handler-state { + display: flex; + align-items: center; + gap: 7px; + color: #777; + font-family: monospace; + font-size: 9px; + text-transform: uppercase; +} + +.handler-state i { + width: 6px; + height: 6px; + border-radius: 50%; + background: #444; +} + +[data-state='processing'] .handler-state { + color: #fff; +} + +[data-state='processing'] .handler-state i { + background: #fff; + box-shadow: 0 0 10px #fff; + animation: pulse 1s ease-in-out infinite; +} + +[data-state='completed'] .handler-state { + color: var(--green); +} + +[data-state='completed'] .handler-state i { + background: var(--green); + box-shadow: 0 0 8px var(--green); +} + +.handler-code { + min-height: 155px; + margin: 0; + padding: 28px 24px; + overflow-x: auto; + background: linear-gradient( + 90deg, + transparent 39px, + #161616 40px, + transparent 41px + ), + #070707; + color: #d4d4d4; + font-size: 13px; + line-height: 1.8; +} + +.handler-result { + display: grid; + grid-template-columns: auto 1fr; + gap: 16px; + padding: 13px 16px; + border-top: 1px solid var(--border); + color: #666; + font-size: 10px; +} + +.handler-result code { + overflow: hidden; + color: #aaa; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +.run-meta { + display: flex; + align-items: center; + gap: 14px; + margin-top: 14px; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 8px; + color: #666; + font-family: monospace; + font-size: 9px; +} + +.run-meta code { + color: #aaa; +} + +.run-meta span:last-child { + margin-left: auto; +} + +@keyframes pulse { + 50% { + opacity: 0.4; + } +} + +@media (max-width: 860px) { + main { + width: min(100% - 32px, 640px); + } + + .hero { + padding: 56px 0 40px; + } + + .demo-grid, + .handler-grid { + grid-template-columns: 1fr; + } + + .demo-card { + min-height: 410px; + } + + .consumers-section { + margin-top: 56px; + } +} + +@media (max-width: 520px) { + .hero h1 { + font-size: 46px; + } + + .handler-code { + padding: 22px 16px; + font-size: 11px; + } + + .run-meta { + align-items: flex-start; + flex-direction: column; + } + + .run-meta span:last-child { + margin-left: 0; + } +} diff --git a/services/cross-runtime-queues/frontend/src/lib/task-store.ts b/services/cross-runtime-queues/frontend/src/lib/task-store.ts new file mode 100644 index 0000000000..6667b7a016 --- /dev/null +++ b/services/cross-runtime-queues/frontend/src/lib/task-store.ts @@ -0,0 +1,55 @@ +type Consumer = 'nextjs' | 'python' + +export type TaskRecord = { + taskId: string + expectedConsumers: Consumer[] + messageId: string | null +} + +function backendUrl(path: string) { + if (!process.env.BACKEND_URL) { + throw new Error('BACKEND_URL service binding is not configured') + } + return new URL(path, process.env.BACKEND_URL).toString() +} + +async function assertSuccessful(response: Response) { + if (response.ok) return + + const detail = await response.text() + throw new Error(`Result store request failed (${response.status}): ${detail}`) +} + +export async function putTask(task: TaskRecord) { + const response = await fetch(backendUrl(`/api/python/tasks/${task.taskId}`), { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(task), + }) + await assertSuccessful(response) +} + +export async function deleteTask(taskId: string) { + const response = await fetch(backendUrl(`/api/python/tasks/${taskId}`), { + method: 'DELETE', + }) + await assertSuccessful(response) +} + +export async function completeTask( + taskId: string, + consumer: Consumer, + result: Record +) { + const response = await fetch( + backendUrl(`/api/python/tasks/${taskId}/completions/${consumer}`), + { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + result, + }), + } + ) + await assertSuccessful(response) +} diff --git a/services/cross-runtime-queues/frontend/tsconfig.json b/services/cross-runtime-queues/frontend/tsconfig.json new file mode 100644 index 0000000000..4b89c2fedb --- /dev/null +++ b/services/cross-runtime-queues/frontend/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + }, + "allowJs": true, + "resolveJsonModule": true + }, + "include": [ + "next-env.d.ts", + ".next/types/**/*.ts", + "**/*.ts", + "**/*.tsx", + ".next/dev/types/**/*.ts" + ], + "exclude": ["node_modules"] +} diff --git a/services/cross-runtime-queues/vercel.json b/services/cross-runtime-queues/vercel.json new file mode 100644 index 0000000000..6c727521c3 --- /dev/null +++ b/services/cross-runtime-queues/vercel.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "services": { + "frontend": { + "root": "frontend/", + "bindings": [ + { + "type": "service", + "service": "backend", + "format": "url", + "env": "BACKEND_URL" + } + ], + "functions": { + "src/app/api/queues/from-python/route.ts": { + "experimentalTriggers": [ + { + "type": "queue/v2beta", + "topic": "demo-python-to-next" + } + ] + }, + "src/app/api/queues/fanout/route.ts": { + "experimentalTriggers": [ + { + "type": "queue/v2beta", + "topic": "demo-fanout" + } + ] + } + } + }, + "backend": { + "root": "backend/", + "entrypoint": "pyproject.toml" + } + }, + "rewrites": [ + { + "source": "/api/python/(.*)", + "destination": { + "service": "backend" + } + }, + { + "source": "/(.*)", + "destination": { + "service": "frontend" + } + } + ] +}