chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
**/node_modules
|
||||
.next
|
||||
__pycache__
|
||||
*.pyc
|
||||
.git
|
||||
shared_frontend/
|
||||
shared_python/
|
||||
shared_typescript/
|
||||
@@ -0,0 +1,3 @@
|
||||
.next/
|
||||
tsconfig.tsbuildinfo
|
||||
node_modules/
|
||||
@@ -0,0 +1,42 @@
|
||||
FROM node:20-slim AS builder
|
||||
ARG COMMIT_SHA=unknown
|
||||
ARG BRANCH=unknown
|
||||
WORKDIR /app
|
||||
|
||||
# Copy only what shell-dojo + scripts need
|
||||
COPY showcase/scripts/package.json ./scripts/package.json
|
||||
COPY showcase/shell-dojo/package.json ./shell-dojo/package.json
|
||||
|
||||
# Install deps for both (standalone, no workspace)
|
||||
RUN cd scripts && npm install --silent && cd ../shell-dojo && npm install --silent
|
||||
|
||||
# Copy source
|
||||
COPY showcase/shared/ ./shared/
|
||||
COPY showcase/integrations/ ./integrations/
|
||||
COPY showcase/scripts/ ./scripts/
|
||||
COPY showcase/shell-dojo/ ./shell-dojo/
|
||||
|
||||
# Bake commit info into Next.js build
|
||||
ENV NEXT_PUBLIC_COMMIT_SHA=${COMMIT_SHA}
|
||||
ENV NEXT_PUBLIC_BRANCH=${BRANCH}
|
||||
|
||||
# Generate registry + content, then build Next.js
|
||||
RUN cd scripts && node node_modules/tsx/dist/cli.mjs generate-registry.ts \
|
||||
&& node node_modules/tsx/dist/cli.mjs bundle-demo-content.ts \
|
||||
&& cd ../shell-dojo && npx next build
|
||||
|
||||
FROM node:20-slim AS runner
|
||||
WORKDIR /app
|
||||
ARG COMMIT_SHA=unknown
|
||||
ARG BRANCH=unknown
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=10000
|
||||
ENV NEXT_PUBLIC_COMMIT_SHA=${COMMIT_SHA}
|
||||
ENV NEXT_PUBLIC_BRANCH=${BRANCH}
|
||||
|
||||
COPY --from=builder /app/shell-dojo/.next ./.next
|
||||
COPY --from=builder /app/shell-dojo/node_modules ./node_modules
|
||||
COPY --from=builder /app/shell-dojo/package.json ./
|
||||
|
||||
EXPOSE 10000
|
||||
CMD ["npx", "next", "start", "-p", "10000"]
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8d909c834f8551c70656de69513ebad282fef5b9b93c3828175533c3a15c4c49
|
||||
size 311038
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6dfca9c74b55723db0c44faa36db08277ca4ca19e6c21ddd27eab40fd49ab5eb
|
||||
size 314075
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d0a68f69b8890dea464a2f7b909776f08cee1b6a018a72a6f76c14aec32e1878
|
||||
size 170526
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:afc963b295679c26845855460287a5eff9ae03062223933ce0c4a58442901bfb
|
||||
size 297633
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { NextConfig } from "next";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { localBackendsEnv } from "./src/lib/local-backends-env";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const LOCAL_PORTS_PATH = path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"shared",
|
||||
"local-ports.json",
|
||||
);
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
env: {
|
||||
NEXT_PUBLIC_LOCAL_BACKENDS: localBackendsEnv(LOCAL_PORTS_PATH),
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+2974
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@copilotkit/showcase-shell-dojo",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"generate": "tsx ../scripts/generate-registry.ts && tsx ../scripts/bundle-demo-content.ts",
|
||||
"predev": "tsx ../scripts/generate-registry.ts && tsx ../scripts/bundle-demo-content.ts",
|
||||
"dev": "next dev --turbopack --port 3001",
|
||||
"build": "tsx ../scripts/generate-registry.ts && tsx ../scripts/bundle-demo-content.ts && next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"highlight.js": "^11.11.1",
|
||||
"next": "^15.5.15",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-syntax-highlighter": "^15.6.0",
|
||||
"recharts": "^2.15.0",
|
||||
"zod": "^3.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-syntax-highlighter": "^15.5.0",
|
||||
"postcss": "^8.5.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,47 @@
|
||||
@import "tailwindcss";
|
||||
@import "highlight.js/styles/github.css";
|
||||
|
||||
:root {
|
||||
/* CopilotCloud palette — exact from dojo */
|
||||
--surface-main: #dedee9;
|
||||
--surface-container: #ffffff;
|
||||
--border-default: #ffffff;
|
||||
--border-container: #dbdbe5;
|
||||
--text-primary: #010507;
|
||||
--text-secondary: #57575b;
|
||||
--text-disabled: #838389;
|
||||
--text-invert: #ffffff;
|
||||
|
||||
--sidebar-width: 296px;
|
||||
--font-mono: "Spline Sans Mono", ui-monospace, SFMono-Regular, monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--surface-main);
|
||||
color: var(--text-primary);
|
||||
font-family:
|
||||
"Plus Jakarta Sans",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Scrollbar styling for sidebar */
|
||||
.sidebar-scroll::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.sidebar-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.sidebar-scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--border-container);
|
||||
border-radius: 4px;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import "./globals.css";
|
||||
import type { Metadata } from "next";
|
||||
import { getRuntimeConfig } from "@/lib/runtime-config";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "CopilotKit Interactive Dojo",
|
||||
description: "Interactive showcase of CopilotKit integrations",
|
||||
};
|
||||
|
||||
/**
|
||||
* Serialize the runtime config for inline injection. We must
|
||||
* JSON.stringify-then-escape because the value lands inside a
|
||||
* `<script>...</script>` tag, where three substrings would otherwise
|
||||
* break out of (or corrupt) the parser:
|
||||
*
|
||||
* - `<` — guards against the `</script>` breakout (XSS).
|
||||
* `JSON.stringify` does NOT escape `<` by default, so a URL
|
||||
* containing `</script>` (e.g. a hostile env value) would
|
||||
* terminate the inline script and inject HTML. Escape every
|
||||
* `<` to `<` so the substring `</script>` can never appear.
|
||||
* - `
|
||||
` (LINE SEPARATOR) and `
|
||||
` (PARAGRAPH SEPARATOR) —
|
||||
* legal inside JSON strings, but a syntax error inside a JS
|
||||
* string literal in older engines / when the page is parsed as
|
||||
* `text/javascript`. Escape both.
|
||||
*
|
||||
* Canonical OWASP-recommended escape for inline JSON in HTML.
|
||||
*/
|
||||
function serializeRuntimeConfig(cfg: unknown): string {
|
||||
return JSON.stringify(cfg)
|
||||
.replace(/</g, "\\u003c")
|
||||
.replace(/\u2028/g, "\\u2028")
|
||||
.replace(/\u2029/g, "\\u2029");
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// Server-side: read live env at request time. `unstable_noStore()`
|
||||
// inside getRuntimeConfig opts this segment out of the static cache
|
||||
// so the inline <script> below always reflects the current Railway
|
||||
// env vars. The serialized config carries backendHostPattern, which
|
||||
// the client reader uses to derive each integration's preview backend
|
||||
// URL at request time (see lib/runtime-config.ts).
|
||||
const runtimeConfig = getRuntimeConfig();
|
||||
const injection = `window.__SHOWCASE_CONFIG__=${serializeRuntimeConfig(runtimeConfig)};`;
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
{/* First child of <head> — MUST execute before every other
|
||||
head-level script so client code can read
|
||||
window.__SHOWCASE_CONFIG__ during module init. Keep this
|
||||
ahead of the fonts <link> and any future next/script
|
||||
beforeInteractive blocks. */}
|
||||
<script
|
||||
id="__showcase_config__"
|
||||
dangerouslySetInnerHTML={{ __html: injection }}
|
||||
/>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&family=Spline+Sans+Mono:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import hljs from "highlight.js/lib/core";
|
||||
import typescript from "highlight.js/lib/languages/typescript";
|
||||
import python from "highlight.js/lib/languages/python";
|
||||
import csharp from "highlight.js/lib/languages/csharp";
|
||||
import json from "highlight.js/lib/languages/json";
|
||||
import yaml from "highlight.js/lib/languages/yaml";
|
||||
import css from "highlight.js/lib/languages/css";
|
||||
import markdown from "highlight.js/lib/languages/markdown";
|
||||
|
||||
hljs.registerLanguage("typescript", typescript);
|
||||
hljs.registerLanguage("javascript", typescript);
|
||||
hljs.registerLanguage("python", python);
|
||||
hljs.registerLanguage("csharp", csharp);
|
||||
hljs.registerLanguage("json", json);
|
||||
hljs.registerLanguage("yaml", yaml);
|
||||
hljs.registerLanguage("css", css);
|
||||
hljs.registerLanguage("markdown", markdown);
|
||||
|
||||
const LANGUAGES_SUPPORTED = new Set([
|
||||
"typescript",
|
||||
"javascript",
|
||||
"python",
|
||||
"csharp",
|
||||
"json",
|
||||
"yaml",
|
||||
"css",
|
||||
"markdown",
|
||||
]);
|
||||
|
||||
interface CodeBlockProps {
|
||||
code: string;
|
||||
language: string;
|
||||
/**
|
||||
* 1-based line numbers that should render with a region-highlight
|
||||
* background. Empty / undefined means no highlighting.
|
||||
*/
|
||||
highlightedLines?: ReadonlySet<number>;
|
||||
}
|
||||
|
||||
const SCROLL_CONTAINER_STYLE = {
|
||||
display: "flex",
|
||||
flex: 1,
|
||||
overflow: "auto",
|
||||
background: "#ffffff",
|
||||
fontFamily: "'Spline Sans Mono', 'SF Mono', Menlo, Consolas, monospace",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
} as const;
|
||||
|
||||
const LINES_WRAPPER_STYLE = {
|
||||
display: "inline-block",
|
||||
minWidth: "100%",
|
||||
padding: "16px 0",
|
||||
} as const;
|
||||
|
||||
const LINE_ROW_BASE_STYLE = {
|
||||
display: "flex",
|
||||
} as const;
|
||||
|
||||
const LINE_NUMBER_STYLE = {
|
||||
flexShrink: 0,
|
||||
textAlign: "right",
|
||||
userSelect: "none",
|
||||
color: "#838389",
|
||||
padding: "0 12px 0 16px",
|
||||
whiteSpace: "pre",
|
||||
} as const;
|
||||
|
||||
const LINE_CODE_STYLE = {
|
||||
flex: 1,
|
||||
whiteSpace: "pre",
|
||||
paddingRight: 16,
|
||||
} as const;
|
||||
|
||||
const HIGHLIGHTED_BG = "rgba(250, 204, 21, 0.22)";
|
||||
|
||||
export function CodeBlock({
|
||||
code,
|
||||
language,
|
||||
highlightedLines,
|
||||
}: CodeBlockProps) {
|
||||
// Highlight the whole file once, then split into per-line HTML strings.
|
||||
// Splitting after highlighting means single-line constructs render with
|
||||
// correct token classes; multi-line block comments / template literals
|
||||
// can lose their wrapping span at the line break, but that's a minor
|
||||
// cosmetic glitch in exchange for cheap per-line backgrounds.
|
||||
const lineHtml = useMemo(() => {
|
||||
const lang = language?.toLowerCase();
|
||||
const raw = code.endsWith("\n") ? code.slice(0, -1) : code;
|
||||
if (!lang || !LANGUAGES_SUPPORTED.has(lang)) {
|
||||
return raw.split("\n").map(escapeHtml);
|
||||
}
|
||||
try {
|
||||
return hljs.highlight(raw, { language: lang }).value.split("\n");
|
||||
} catch {
|
||||
return raw.split("\n").map(escapeHtml);
|
||||
}
|
||||
}, [code, language]);
|
||||
|
||||
const pad = String(lineHtml.length).length;
|
||||
|
||||
const highlightedRowStyle = useMemo(
|
||||
() => ({ ...LINE_ROW_BASE_STYLE, background: HIGHLIGHTED_BG }),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={SCROLL_CONTAINER_STYLE}>
|
||||
<div style={LINES_WRAPPER_STYLE}>
|
||||
{lineHtml.map((html, i) => {
|
||||
const lineNum = i + 1;
|
||||
const isHighlighted = highlightedLines?.has(lineNum) ?? false;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={isHighlighted ? highlightedRowStyle : LINE_ROW_BASE_STYLE}
|
||||
>
|
||||
<div style={LINE_NUMBER_STYLE}>
|
||||
{String(lineNum).padStart(pad, " ")}
|
||||
</div>
|
||||
<div
|
||||
style={LINE_CODE_STYLE}
|
||||
// hljs returns sanitized HTML and the highlighted source is
|
||||
// not user input — this content is bundled at build time
|
||||
// from files in `showcase/integrations/`.
|
||||
dangerouslySetInnerHTML={{ __html: html || " " }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
// Runtime derivation of integration backend URLs.
|
||||
//
|
||||
// The registry's `backend_url` is synthesized at Docker BUILD time by
|
||||
// scripts/generate-registry.ts, which bakes the production hostnames
|
||||
// into every image — so a staging shell iframed prod integrations.
|
||||
// These helpers derive the backend host at REQUEST time from the
|
||||
// `backendHostPattern` carried in RuntimeConfig (env var
|
||||
// SHOWCASE_BACKEND_HOST_PATTERN, default = the prod pattern), keeping
|
||||
// registry.json as the source for non-URL metadata only.
|
||||
//
|
||||
// Pattern semantics are IDENTICAL to generate-registry.ts: the pattern
|
||||
// is a bare host with `{slug}` as the only placeholder, and `https://`
|
||||
// is prepended. Keep the two in sync — they consume the same env var.
|
||||
//
|
||||
// This module is import-safe from client components, server components,
|
||||
// and middleware (pure functions, no next/* imports).
|
||||
|
||||
// Matches an explicit URL scheme prefix (e.g. `https://`, `http://`).
|
||||
// Exported as the single source of truth — runtime-config.ts shares it
|
||||
// (this module is import-safe everywhere, so the dependency is free).
|
||||
export const SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
|
||||
|
||||
// Default backend host pattern — reproduces today's baked prod values
|
||||
// exactly, so a deploy with the env var unset (i.e. current prod)
|
||||
// behaves byte-identically. Lives HERE (not runtime-config.ts, which
|
||||
// re-exports it) because normalizeBackendHostPattern falls back to it
|
||||
// for degenerate values and runtime-config already imports this module
|
||||
// — defining it there would create an import cycle.
|
||||
export const DEFAULT_BACKEND_HOST_PATTERN =
|
||||
"showcase-{slug}-production.up.railway.app";
|
||||
|
||||
// Warn once per distinct (pattern, issue) — config is re-read every
|
||||
// request, and per-request warn spam would drown real signal.
|
||||
const patternWarnings = new Set<string>();
|
||||
|
||||
function warnPatternOnce(key: string, message: string): void {
|
||||
if (patternWarnings.has(key)) return;
|
||||
patternWarnings.add(key);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[backend-url] SHOWCASE_BACKEND_HOST_PATTERN ${message}`);
|
||||
}
|
||||
|
||||
// FATAL once per distinct degenerate pattern value — parity with the
|
||||
// FATAL-CONFIG once-guards in runtime-config.ts (readUrl/readDocsHost).
|
||||
const patternFatals = new Set<string>();
|
||||
|
||||
// Same dev-vs-prod branch as validateBaseUrl/readDocsHost
|
||||
// (runtime-config.ts): in production this is a FATAL-CONFIG error with
|
||||
// Railway guidance; in dev it degrades to a warn — Railway guidance is
|
||||
// useless on a laptop, and the dev contract is frictionless iteration.
|
||||
// NODE_ENV is read at CALL time (this module is import-safe everywhere;
|
||||
// Next statically inlines the literal read in client bundles).
|
||||
function fatalPatternOnce(key: string, message: string): void {
|
||||
if (patternFatals.has(key)) return;
|
||||
patternFatals.add(key);
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`[backend-url] FATAL-CONFIG: SHOWCASE_BACKEND_HOST_PATTERN ${message} ` +
|
||||
`Fix the SHOWCASE_BACKEND_HOST_PATTERN env var on the Railway service.`,
|
||||
);
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[backend-url] SHOWCASE_BACKEND_HOST_PATTERN ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Can the normalized pattern actually form a backend URL? Probe by
|
||||
* substituting a registry-shaped slug and parsing the consumer's exact
|
||||
* composition (`https://` + pattern). Catches the degenerate classes
|
||||
* the per-issue normalizations can't fix: empty results ("https://" or
|
||||
* "/" normalize to ""), internal whitespace, and anything else
|
||||
* `new URL` rejects or that parses without a real host.
|
||||
*
|
||||
* Returns `undefined` when usable, otherwise a reason discriminant so
|
||||
* the FATAL can be honest: a probe whose HOST is literally
|
||||
* "http"/"https" (e.g. `https:/host` — one slash short, so SCHEME_RE
|
||||
* never strips it) DOES parse — calling it "unparseable" would hide
|
||||
* the actual problem (a stray scheme fragment in host position).
|
||||
*/
|
||||
function patternUsabilityProblem(
|
||||
normalized: string,
|
||||
): "unparseable" | "stray-scheme-fragment" | undefined {
|
||||
if (normalized.length === 0) return "unparseable";
|
||||
try {
|
||||
const probe = new URL(
|
||||
`https://${normalized.replaceAll("{slug}", "probe")}`,
|
||||
);
|
||||
if (probe.hostname.length === 0) return "unparseable";
|
||||
if (/^https?$/i.test(probe.hostname)) return "stray-scheme-fragment";
|
||||
return undefined;
|
||||
} catch {
|
||||
return "unparseable";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the pattern carry a component no backend base URL may have?
|
||||
* Detected on the LITERAL delimiter characters (see inline comments —
|
||||
* the WHATWG getters return "" for present-but-empty components, so a
|
||||
* probe-based check leaks bare `?`/`#`/`@`).
|
||||
* Returns a human-readable component name for the FATAL log, or
|
||||
* undefined when the pattern is clean. Three rejected classes (same
|
||||
* gates validateBaseUrl has in runtime-config.ts):
|
||||
*
|
||||
* - userinfo credentials: a credentialed pattern yields iframe srcs
|
||||
* that Chromium silently blocks — the integration pane just never
|
||||
* loads, with zero signal;
|
||||
* - query / fragment: consumers concatenate demo routes onto the
|
||||
* composed URL, so `https://host?x=1` + `/route` yields
|
||||
* `https://host?x=1/route` — every backend URL ships corrupted.
|
||||
*/
|
||||
function patternForbiddenComponent(normalized: string): string | undefined {
|
||||
// Query/fragment are detected on the LITERAL characters, not the
|
||||
// probe getters: WHATWG getters return "" for a present-but-EMPTY
|
||||
// component (`https://host?` has search === ""), so a bare trailing
|
||||
// `?`/`#` would slip a getter check while the raw character still
|
||||
// ships in the pattern — and route concatenation then swallows every
|
||||
// demo route into the query/fragment. The literal check is strictly
|
||||
// stronger than the getter check (a non-empty search/hash implies the
|
||||
// literal character), so the getters aren't consulted at all. Which
|
||||
// component a literal introduces follows URL precedence: `#` starts
|
||||
// the fragment; `?` starts a query only when no `#` precedes it.
|
||||
const hashAt = normalized.indexOf("#");
|
||||
const queryAt = normalized.indexOf("?");
|
||||
if (queryAt !== -1 && (hashAt === -1 || queryAt < hashAt)) {
|
||||
return "a query component";
|
||||
}
|
||||
if (hashAt !== -1) return "a fragment component";
|
||||
// Userinfo is a literal check too: `https://@host` and
|
||||
// `https://:@host` parse with username === "" AND password === ""
|
||||
// (present-but-EMPTY userinfo), so the getter check let the raw
|
||||
// `@`-bearing string ship. Any `@` in the AUTHORITY (the part before
|
||||
// the first `/` — query/fragment are already rejected above) is a
|
||||
// userinfo delimiter; it is never valid inside a hostname.
|
||||
const slashAt = normalized.indexOf("/");
|
||||
const authority = slashAt === -1 ? normalized : normalized.slice(0, slashAt);
|
||||
if (authority.includes("@")) return "userinfo credentials";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a backend host pattern read from the environment. The
|
||||
* pattern contract (same as scripts/generate-registry.ts) is a bare
|
||||
* host with `{slug}` as the only placeholder — but env misconfigs are
|
||||
* easy and were previously silent:
|
||||
*
|
||||
* - a scheme-bearing value would yield `https://https://…` (consumer
|
||||
* prepends the scheme) → strip it and warn;
|
||||
* - a trailing slash would yield `host//route` on concat → trim and warn;
|
||||
* - a missing `{slug}` placeholder silently sends EVERY integration to
|
||||
* the same host → warn (can't fix it for the operator).
|
||||
*
|
||||
* Warnings fire once per distinct value, not per request.
|
||||
*/
|
||||
export function normalizeBackendHostPattern(pattern: string): string {
|
||||
let normalized = pattern;
|
||||
if (/\s/.test(normalized)) {
|
||||
// Whitespace was the ONE misconfig class with zero warning — a
|
||||
// pasted ` host` yields an iframe src like `https:// host`. Trim
|
||||
// the ends (fixable) and strip internal tab/CR/LF (the WHATWG URL
|
||||
// parser deletes \t\r\n pre-parse, so a tab-bearing pattern would
|
||||
// pass the usability gate while the RAW control character shipped
|
||||
// in every iframe src — stripping matches what the parser does
|
||||
// anyway). Internal SPACES split by position: a host-position
|
||||
// space makes the pattern unparseable, so the usability gate below
|
||||
// falls back to the DEFAULT (it never ships); a path-position
|
||||
// space parses and genuinely ships broken.
|
||||
warnPatternOnce(
|
||||
`whitespace:${pattern}`,
|
||||
`"${pattern}" contains whitespace — trimming the ends and stripping internal tab/CR/LF (the URL parser ignores them anyway). An internal space in the host cannot form a usable pattern (falls back to the default); a space in a path segment ships in every backend URL.`,
|
||||
);
|
||||
normalized = normalized.replace(/[\t\r\n]/g, "").trim();
|
||||
}
|
||||
// Loop the strip to convergence: a single pass left "https://https://
|
||||
// host" with a scheme that the consumer then double-prepends. Collect
|
||||
// every stripped scheme first so the once-guarded warn can name them
|
||||
// ALL — warning inside the loop swallowed every name after the first.
|
||||
const strippedSchemes: string[] = [];
|
||||
for (
|
||||
let scheme = SCHEME_RE.exec(normalized);
|
||||
scheme;
|
||||
scheme = SCHEME_RE.exec(normalized)
|
||||
) {
|
||||
strippedSchemes.push(scheme[0]);
|
||||
normalized = normalized.slice(scheme[0].length);
|
||||
}
|
||||
if (strippedSchemes.length > 0) {
|
||||
warnPatternOnce(
|
||||
`scheme:${pattern}`,
|
||||
`"${pattern}" includes a scheme — the consumer prepends https://; stripping ${strippedSchemes
|
||||
.map((s) => `"${s}"`)
|
||||
.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
if (/\/+$/.test(normalized)) {
|
||||
warnPatternOnce(
|
||||
`trailing-slash:${pattern}`,
|
||||
`"${pattern}" has a trailing slash — route concatenation would yield "//"; trimming.`,
|
||||
);
|
||||
normalized = normalized.replace(/\/+$/, "");
|
||||
}
|
||||
// Usability gate AFTER the fixable normalizations, BEFORE the
|
||||
// advisory warns ({slug}/path) — a degenerate value ("https://", "/",
|
||||
// whitespace) normalizes to "" or an unparseable host, and previously
|
||||
// flowed out with NO fallback: server-side iframe srcs became
|
||||
// "https://", and the injected "" failed the client reader's
|
||||
// REQUIRED_CONFIG_FIELDS check, crashing every client component with
|
||||
// a message blaming the layout injection instead of this env var.
|
||||
//
|
||||
// DESIGN DECISION — degenerate/forbidden patterns fail OPEN to
|
||||
// DEFAULT_BACKEND_HOST_PATTERN, which is the PROD host pattern. On a
|
||||
// staging deploy, a mistyped SHOWCASE_BACKEND_HOST_PATTERN therefore
|
||||
// silently (FATAL log aside) iframes PROD integrations — the exact
|
||||
// staging→prod leakage this module exists to prevent. The
|
||||
// alternative (fail CLOSED: ship "" and break every integration
|
||||
// pane) was rejected because a hard outage is worse than serving
|
||||
// prod content with a FATAL-CONFIG log; revisit if staging/prod
|
||||
// divergence ever becomes load-bearing for correctness.
|
||||
const forbidden = patternForbiddenComponent(normalized);
|
||||
if (forbidden !== undefined) {
|
||||
fatalPatternOnce(
|
||||
`forbidden:${forbidden}:${pattern}`,
|
||||
`${JSON.stringify(pattern)} carries ${forbidden} — userinfo makes ` +
|
||||
`Chromium silently block every iframe src formed from it, and a ` +
|
||||
`query/fragment corrupts every backend URL when consumers ` +
|
||||
`concatenate demo routes; falling back to the default pattern ` +
|
||||
`${DEFAULT_BACKEND_HOST_PATTERN}.`,
|
||||
);
|
||||
return DEFAULT_BACKEND_HOST_PATTERN;
|
||||
}
|
||||
// Fails OPEN to the prod pattern too — see the DESIGN DECISION note
|
||||
// above the forbidden-component gate.
|
||||
const usabilityProblem = patternUsabilityProblem(normalized);
|
||||
if (usabilityProblem !== undefined) {
|
||||
fatalPatternOnce(
|
||||
`degenerate:${pattern}`,
|
||||
usabilityProblem === "stray-scheme-fragment"
|
||||
? `${JSON.stringify(pattern)} normalizes to ` +
|
||||
`${JSON.stringify(normalized)}, whose host is literally ` +
|
||||
`"http"/"https" — it looks like a stray scheme fragment, not ` +
|
||||
`a backend host; falling back to the default pattern ` +
|
||||
`${DEFAULT_BACKEND_HOST_PATTERN}.`
|
||||
: `${JSON.stringify(pattern)} normalizes to ` +
|
||||
`${JSON.stringify(normalized)}, which cannot form a parseable ` +
|
||||
`backend URL; falling back to the default pattern ` +
|
||||
`${DEFAULT_BACKEND_HOST_PATTERN}.`,
|
||||
);
|
||||
return DEFAULT_BACKEND_HOST_PATTERN;
|
||||
}
|
||||
// Canonicalize the authority for parity with the override path,
|
||||
// which returns the parsed-normalized form (lowercase host, default
|
||||
// port elided) — the pattern path ships this string RAW into iframe
|
||||
// srcs, leaking uppercase hosts and an explicit :443. Hosts are
|
||||
// case-insensitive and the consumer always prepends https://, so
|
||||
// lowercasing the authority and stripping a trailing :443 (the https
|
||||
// default — :80 is a REAL port under https and must survive) matches
|
||||
// what the URL parser does to the composed URL anyway. The path part
|
||||
// (from the first "/") is case-SENSITIVE and stays untouched; the
|
||||
// lowercase {slug} placeholder survives lowercasing verbatim.
|
||||
const pathAt = normalized.indexOf("/");
|
||||
const authority = pathAt === -1 ? normalized : normalized.slice(0, pathAt);
|
||||
const pathPart = pathAt === -1 ? "" : normalized.slice(pathAt);
|
||||
normalized = authority.toLowerCase().replace(/:443$/, "") + pathPart;
|
||||
if (!normalized.includes("{slug}")) {
|
||||
warnPatternOnce(
|
||||
`no-slug:${pattern}`,
|
||||
`"${pattern}" lacks the {slug} placeholder — EVERY integration will resolve to the same backend host.`,
|
||||
);
|
||||
}
|
||||
if (normalized.includes("/")) {
|
||||
// The documented contract is a BARE host — an internal path segment
|
||||
// (`host.app/base/{slug}`) violates it silently: the consumer
|
||||
// prepends https:// and concatenates routes, so the base path lands
|
||||
// in every backend URL. Can't fix it for the operator (the intent
|
||||
// is ambiguous) — flag it.
|
||||
warnPatternOnce(
|
||||
`path:${pattern}`,
|
||||
`"${pattern}" contains a path segment — the contract is a bare host; the path will be embedded in every backend URL.`,
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// Registry slug contract — see scripts/generate-registry.ts manifests.
|
||||
const SLUG_RE = /^[a-z0-9-]+$/;
|
||||
|
||||
/** Substitute `{slug}` into the host pattern and prepend `https://`. */
|
||||
export function backendUrlFromPattern(pattern: string, slug: string): string {
|
||||
// Charset assert at the choke point: every backend URL flows through
|
||||
// here and the slug lands in the HOST of an iframe src — a slug
|
||||
// containing "." or "/" is host/path injection. All registry slugs
|
||||
// match [a-z0-9-]+; anything else is a contract violation upstream
|
||||
// (call sites resolve slugs from the registry), not data.
|
||||
if (!SLUG_RE.test(slug)) {
|
||||
throw new Error(
|
||||
`[backend-url] invalid integration slug ${JSON.stringify(slug)} — ` +
|
||||
`slugs must match ${String(SLUG_RE)} (host-injection guard).`,
|
||||
);
|
||||
}
|
||||
// Function replacer: a plain string replacement is subject to `$`
|
||||
// substitution patterns ("$&", "$'", ...), which would corrupt the
|
||||
// host for any slug containing `$` (defense in depth behind SLUG_RE).
|
||||
return `https://${pattern.replaceAll("{slug}", () => slug)}`;
|
||||
}
|
||||
|
||||
// Warn once per distinct (raw value, issue) — resolveBackendUrl runs on
|
||||
// every render, so per-call warns would spam exactly like the pattern
|
||||
// warnings the patternWarnings set above exists to prevent.
|
||||
const localBackendsWarnings = new Set<string>();
|
||||
|
||||
function warnLocalBackendsOnce(key: string, message: string): void {
|
||||
if (localBackendsWarnings.has(key)) return;
|
||||
localBackendsWarnings.add(key);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[backend-url] NEXT_PUBLIC_LOCAL_BACKENDS ${message}`);
|
||||
}
|
||||
|
||||
// Shared frozen empty map for the unset path — a fresh mutable {} per
|
||||
// call would dodge the freeze guarantee below.
|
||||
const NO_LOCAL_BACKENDS: Record<string, string> = Object.freeze({});
|
||||
|
||||
// Memoized on the raw string: the env value is baked at build time and
|
||||
// effectively constant, so re-running JSON.parse on every render is
|
||||
// pure waste.
|
||||
let lastLocalBackendsRaw: string | undefined;
|
||||
let lastLocalBackends: Record<string, string> = NO_LOCAL_BACKENDS;
|
||||
|
||||
/**
|
||||
* Parse the NEXT_PUBLIC_LOCAL_BACKENDS map (baked at build from
|
||||
* shared/local-ports.json — local-dev only, empty in deployed images).
|
||||
* Tolerant of unset/empty/corrupt values: local dev convenience must
|
||||
* never break rendering. The parse is memoized on the raw string and
|
||||
* warnings fire once per distinct (value, issue), not per call.
|
||||
*
|
||||
* The returned object is FROZEN: the memo is shared across every
|
||||
* caller, so a consumer mutating its "own" map would change the
|
||||
* local-backend overrides process-wide.
|
||||
*/
|
||||
export function parseLocalBackends(
|
||||
raw: string | undefined,
|
||||
): Record<string, string> {
|
||||
if (!raw) return NO_LOCAL_BACKENDS;
|
||||
if (raw !== lastLocalBackendsRaw) {
|
||||
// Compute FIRST, then commit key and value together: committing
|
||||
// the key before the value meant a throw mid-compute (console.warn
|
||||
// is a foreign call — a logging shim CAN throw) left the memo
|
||||
// poisoned, and the next call with the same raw returned the
|
||||
// PREVIOUS raw's value.
|
||||
const backends = Object.freeze(parseLocalBackendsUncached(raw));
|
||||
lastLocalBackendsRaw = raw;
|
||||
lastLocalBackends = backends;
|
||||
}
|
||||
return lastLocalBackends;
|
||||
}
|
||||
|
||||
function parseLocalBackendsUncached(raw: string): Record<string, string> {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
// Validate values instead of an unchecked `as Record<string,
|
||||
// string>` flow-through — a non-string value would otherwise
|
||||
// surface much later as a garbage iframe src.
|
||||
//
|
||||
// Null-prototype accumulator: on a plain `{}`, assigning a
|
||||
// "__proto__" key hits the Object.prototype setter and is a
|
||||
// silent no-op — the entry would vanish with no warning (every
|
||||
// other rejected entry warns). With no prototype it lands as an
|
||||
// ordinary own data property.
|
||||
const backends: Record<string, string> = Object.create(null);
|
||||
for (const [slug, url] of Object.entries(parsed)) {
|
||||
if (typeof url === "string") {
|
||||
backends[slug] = url;
|
||||
} else {
|
||||
warnLocalBackendsOnce(
|
||||
`non-string:${slug}:${raw}`,
|
||||
`value for "${slug}" is not a string — skipping it.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return backends;
|
||||
}
|
||||
warnLocalBackendsOnce(
|
||||
`non-object:${raw}`,
|
||||
"is not a JSON object — ignoring it.",
|
||||
);
|
||||
} catch {
|
||||
// Treat unparseable as unset (local-dev convenience must never
|
||||
// break rendering) — but say so, instead of silently eating it.
|
||||
warnLocalBackendsOnce(
|
||||
`invalid-json:${raw}`,
|
||||
"is not valid JSON — ignoring it.",
|
||||
);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an integration's backend base URL: a local-dev override from
|
||||
* NEXT_PUBLIC_LOCAL_BACKENDS wins (preserving the pre-existing
|
||||
* `SHOWCASE_LOCAL=1` behavior), otherwise derive from the runtime host
|
||||
* pattern.
|
||||
*/
|
||||
export function resolveBackendUrl(slug: string, pattern: string): string {
|
||||
// ASSUMPTION: the server must never SET NEXT_PUBLIC_LOCAL_BACKENDS at
|
||||
// runtime post-build — the client bundle bakes the build-time value,
|
||||
// so a live server-side value would silently diverge from what client
|
||||
// components resolve.
|
||||
const local = parseLocalBackends(process.env.NEXT_PUBLIC_LOCAL_BACKENDS);
|
||||
const rawOverride = local[slug];
|
||||
if (rawOverride !== undefined) {
|
||||
// Trim before validating — the same paste-artifact tolerance the
|
||||
// pattern path applies (normalizeBackendHostPattern trims the
|
||||
// ends): a leading-space override otherwise fails the SCHEME_RE
|
||||
// anchor even though the URL parser accepts the value.
|
||||
const override = rawOverride.trim();
|
||||
// Length-aware: an empty/whitespace-only override (e.g.
|
||||
// `{"mastra": ""}`) is not a usable URL — `??` would accept it and
|
||||
// yield an empty base. The emptiness check lives INSIDE the
|
||||
// override block so it warns like every other bad override:
|
||||
// skipping it in the outer condition ignored the dead override
|
||||
// with zero signal.
|
||||
if (override.length === 0) {
|
||||
warnLocalBackendsOnce(
|
||||
`bad-override:${slug}:empty`,
|
||||
`override for "${slug}" is empty (or whitespace-only) — ignoring it.`,
|
||||
);
|
||||
return backendUrlFromPattern(pattern, slug);
|
||||
}
|
||||
// The override lands verbatim in an iframe src — require a
|
||||
// scheme-bearing, parseable URL (`localhost:4111` without a scheme
|
||||
// parses as scheme "localhost:"!) whose protocol is http(s):
|
||||
// `javascript://...` and `ftp://...` are scheme-bearing AND
|
||||
// parseable, but have no business in an iframe src. Userinfo,
|
||||
// query, and fragment components are rejected too (same gates the
|
||||
// pattern path has): Chromium silently blocks credentialed iframe
|
||||
// srcs, and a query/fragment corrupts every composed URL when
|
||||
// consumers concatenate demo routes. Warn and fall back otherwise.
|
||||
const parsed = parseUrl(override);
|
||||
if (
|
||||
parsed !== undefined &&
|
||||
SCHEME_RE.test(override) &&
|
||||
/^https?:$/i.test(parsed.protocol) &&
|
||||
parsed.username === "" &&
|
||||
parsed.password === "" &&
|
||||
parsed.search === "" &&
|
||||
parsed.hash === ""
|
||||
) {
|
||||
// Return the parsed-normalized form (origin + path), not the raw
|
||||
// string: the parse already happened, and the raw form leaks
|
||||
// un-normalized values (uppercase hosts, explicit default ports)
|
||||
// into iframe srcs. The trailing-slash trim matches the
|
||||
// normalization the pattern path guarantees
|
||||
// (normalizeBackendHostPattern) — an override skipping it yields
|
||||
// `host//route` when consumers concatenate demo routes. Query,
|
||||
// fragment, and userinfo are empty here (gated above), so
|
||||
// origin + pathname IS the whole URL.
|
||||
return (parsed.origin + parsed.pathname).replace(/\/+$/, "");
|
||||
}
|
||||
// Name the actual requirements instead of "not parseable":
|
||||
// `http:localhost:4111` IS parseable (special schemes tolerate
|
||||
// missing slashes), but it fails the explicit `scheme://`
|
||||
// requirement — a "not parseable" claim sends the developer
|
||||
// chasing a parse problem that doesn't exist.
|
||||
warnLocalBackendsOnce(
|
||||
`bad-override:${slug}:${override}`,
|
||||
`override for "${slug}" (${JSON.stringify(override)}) must be an ` +
|
||||
`http(s) URL with an explicit "scheme://" and no userinfo, query, ` +
|
||||
`or fragment — ignoring it.`,
|
||||
);
|
||||
}
|
||||
return backendUrlFromPattern(pattern, slug);
|
||||
}
|
||||
|
||||
function parseUrl(value: string): URL | undefined {
|
||||
// URL.canParse needs Node 18.17+/modern browsers — try/catch keeps
|
||||
// this safe on every runtime the shell targets.
|
||||
try {
|
||||
return new URL(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import fs from "fs";
|
||||
|
||||
/**
|
||||
* Build-time helper for local Dojo runs.
|
||||
*
|
||||
* When SHOWCASE_LOCAL=1, Next bakes a slug -> localhost URL map from
|
||||
* shared/local-ports.json into NEXT_PUBLIC_LOCAL_BACKENDS. Deployed builds leave
|
||||
* this empty and keep using registry backend_url values.
|
||||
*/
|
||||
export function localBackendsEnv(portsPath: string): string {
|
||||
const rawLocal = process.env.SHOWCASE_LOCAL;
|
||||
const showcaseLocal = rawLocal === undefined ? "" : rawLocal.trim();
|
||||
if (showcaseLocal !== "1") {
|
||||
if (showcaseLocal !== "") {
|
||||
console.warn(
|
||||
`[next.config] SHOWCASE_LOCAL is set to ${JSON.stringify(rawLocal)} ` +
|
||||
`but only "1" enables local backend overrides — treating it as off.`,
|
||||
);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
let rawText: string;
|
||||
try {
|
||||
rawText = fs.readFileSync(portsPath, "utf-8");
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
|
||||
console.warn(
|
||||
`[next.config] SHOWCASE_LOCAL=1 but ${portsPath} does not exist — ` +
|
||||
`no local backend overrides will be baked.`,
|
||||
);
|
||||
return "";
|
||||
}
|
||||
throw new Error(`${portsPath} could not be read: ${String(err)}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(rawText);
|
||||
} catch (err) {
|
||||
throw new Error(`${portsPath} is not valid JSON: ${String(err)}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error(`${portsPath} must be a JSON object mapping slug -> port.`);
|
||||
}
|
||||
|
||||
const map: Record<string, string> = Object.create(null);
|
||||
for (const [slug, port] of Object.entries(parsed)) {
|
||||
if (
|
||||
typeof port !== "number" ||
|
||||
!Number.isInteger(port) ||
|
||||
port <= 0 ||
|
||||
port > 65535
|
||||
) {
|
||||
throw new Error(
|
||||
`${portsPath}: port for "${slug}" is not a valid TCP port; got ` +
|
||||
`${JSON.stringify(port)}.`,
|
||||
);
|
||||
}
|
||||
map[slug] = `http://localhost:${port}`;
|
||||
}
|
||||
|
||||
return JSON.stringify(map);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import registryData from "@/data/registry.json";
|
||||
|
||||
export interface Feature {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface FeatureCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Demo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
route: string;
|
||||
animated_preview_url?: string | null;
|
||||
}
|
||||
|
||||
export interface Integration {
|
||||
name: string;
|
||||
slug: string;
|
||||
category: string;
|
||||
language: string;
|
||||
logo?: string;
|
||||
description: string;
|
||||
partner_docs: string | null;
|
||||
repo: string;
|
||||
copilotkit_version: string;
|
||||
backend_url: string;
|
||||
deployed: boolean;
|
||||
sort_order?: number;
|
||||
features: string[];
|
||||
demos: Demo[];
|
||||
}
|
||||
|
||||
export interface Registry {
|
||||
feature_registry: {
|
||||
version: string;
|
||||
categories: FeatureCategory[];
|
||||
features: Feature[];
|
||||
};
|
||||
integrations: Integration[];
|
||||
}
|
||||
|
||||
const registry = registryData as Registry;
|
||||
|
||||
// Hide registry entries with no dojo route. Kept in the registry so
|
||||
// harness/parity/dashboard still see them.
|
||||
const HIDDEN_DOJO_FEATURE_IDS = new Set<string>(["cli-start"]);
|
||||
|
||||
function withVisibleDemos(integration: Integration): Integration {
|
||||
const visible = integration.demos.filter(
|
||||
(d) => !HIDDEN_DOJO_FEATURE_IDS.has(d.id),
|
||||
);
|
||||
if (visible.length === integration.demos.length) return integration;
|
||||
return { ...integration, demos: visible };
|
||||
}
|
||||
|
||||
export function getRegistry(): Registry {
|
||||
return registry;
|
||||
}
|
||||
|
||||
export function getIntegrations(): Integration[] {
|
||||
return registry.integrations.map(withVisibleDemos);
|
||||
}
|
||||
|
||||
export function getIntegration(slug: string): Integration | undefined {
|
||||
const found = registry.integrations.find((i) => i.slug === slug);
|
||||
return found ? withVisibleDemos(found) : undefined;
|
||||
}
|
||||
|
||||
export function getFeatures(): Feature[] {
|
||||
return registry.feature_registry.features;
|
||||
}
|
||||
|
||||
export function getFeature(id: string): Feature | undefined {
|
||||
return registry.feature_registry.features.find((f) => f.id === id);
|
||||
}
|
||||
|
||||
export function getFeatureCategories(): FeatureCategory[] {
|
||||
return registry.feature_registry.categories;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Client-side runtime config reader. Reads from
|
||||
// window.__SHOWCASE_CONFIG__ which the root layout injects via an
|
||||
// inline <script> tag BEFORE React hydrates (see app/layout.tsx). This
|
||||
// is the ONLY public API for these URLs in client code — never read
|
||||
// process.env.NEXT_PUBLIC_* directly (an ESLint rule enforces this).
|
||||
|
||||
import type { RuntimeConfig } from "./runtime-config";
|
||||
|
||||
export type { RuntimeConfig };
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__SHOWCASE_CONFIG__?: RuntimeConfig;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentinel returned during SSR when `window` is unavailable. "use client"
|
||||
* components in the Next.js App Router are server-side rendered on the
|
||||
* initial request (that's how the HTML is streamed before hydration),
|
||||
* which means their function bodies execute on the server too. We can't
|
||||
* throw here without breaking SSR — instead we return a placeholder.
|
||||
*
|
||||
* The {slug} placeholder is preserved so an SSR-phase substitution still
|
||||
* yields a parseable, RFC-2606-unresolvable host (`.invalid`). No iframe
|
||||
* ever renders from this: the page gates its preview iframe on a
|
||||
* client-mounted flag, so backendHostPattern is only ever read after
|
||||
* hydration when window.__SHOWCASE_CONFIG__ carries the real value.
|
||||
* Server components that need the live env values MUST import
|
||||
* getRuntimeConfig from runtime-config.ts (the server variant).
|
||||
*/
|
||||
const SSR_PLACEHOLDER: Readonly<RuntimeConfig> = Object.freeze({
|
||||
backendHostPattern: "showcase-{slug}.ssr-placeholder.invalid",
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the runtime config injected by the root server layout.
|
||||
*
|
||||
* During SSR (no window) returns a sentinel placeholder; the inline
|
||||
* <script> runs before hydration, so every client render — including
|
||||
* the hydration render — sees the real values. If the inline <script>
|
||||
* never ran (a route bypassed the layout) or injected an
|
||||
* empty/incomplete object, this read throws — surfacing the wiring bug
|
||||
* loudly rather than silently rendering empty URLs.
|
||||
*/
|
||||
export function getRuntimeConfig(): Readonly<RuntimeConfig> {
|
||||
if (typeof window === "undefined") {
|
||||
// SSR phase — "use client" component bodies execute here too.
|
||||
// Return placeholder; the hydration render reads the real values.
|
||||
return SSR_PLACEHOLDER;
|
||||
}
|
||||
const cfg = window.__SHOWCASE_CONFIG__;
|
||||
if (!cfg) {
|
||||
// The root layout always emits the <script> tag, so a missing
|
||||
// value here is a wiring bug (e.g. a route bypassed the layout,
|
||||
// or the injection script ran with empty inputs). Surface it
|
||||
// loudly rather than silently returning empty strings.
|
||||
throw new Error(
|
||||
"[runtime-config.client] window.__SHOWCASE_CONFIG__ is missing. " +
|
||||
"The root layout must inject runtime config before client mount.",
|
||||
);
|
||||
}
|
||||
// Field validation: an injection that ran with empty inputs (layout
|
||||
// wired to a broken server read) yields an object that is truthy but
|
||||
// useless — fail loud instead of resolving every preview iframe
|
||||
// against an empty pattern. The typeof check also catches a layout
|
||||
// bug injecting a non-string, which would otherwise explode far from
|
||||
// the cause inside backendUrlFromPattern's replaceAll.
|
||||
const value = cfg.backendHostPattern;
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new Error(
|
||||
`[runtime-config.client] window.__SHOWCASE_CONFIG__ is incomplete: ` +
|
||||
`field "backendHostPattern" is ${
|
||||
typeof value === "string" ? "empty" : `of type ${typeof value}`
|
||||
}. The root layout injection ran with broken inputs — check the ` +
|
||||
`server-side runtime config.`,
|
||||
);
|
||||
}
|
||||
return Object.freeze(cfg);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Server-only runtime config reader. Reads from process.env at REQUEST
|
||||
// time (not at module load) so a single built artifact can serve
|
||||
// different URL values across staging vs prod by changing the Railway
|
||||
// service's env vars — no rebuild required.
|
||||
//
|
||||
// `unstable_noStore()` opts the calling segment out of Next.js's static
|
||||
// cache so reads always reflect the live env. Without it, a server
|
||||
// component that uses this could be statically rendered at build time
|
||||
// and freeze the URLs back into the artifact — defeating the runtime
|
||||
// switch. See Next.js App Router docs on Dynamic Rendering.
|
||||
//
|
||||
// This module MUST NOT be imported from client components. The matching
|
||||
// client-side reader lives in runtime-config.client.ts and reads from
|
||||
// window.__SHOWCASE_CONFIG__ which the root layout injects.
|
||||
|
||||
import { unstable_noStore as noStore } from "next/cache";
|
||||
import {
|
||||
DEFAULT_BACKEND_HOST_PATTERN,
|
||||
normalizeBackendHostPattern,
|
||||
} from "./backend-url";
|
||||
|
||||
export interface RuntimeConfig {
|
||||
/**
|
||||
* Backend host pattern — `{slug}` is the only placeholder. Used to
|
||||
* derive each integration's preview-iframe backend URL at request
|
||||
* time instead of trusting the registry's `backend_url`, which is
|
||||
* baked at Docker build (freezing prod hostnames into every image, so
|
||||
* the staging dojo iframed prod integrations). Same semantics as
|
||||
* SHOWCASE_BACKEND_HOST_PATTERN in scripts/generate-registry.ts and
|
||||
* the shell's runtime config: host only, `https://` is prepended by
|
||||
* the consumer (see lib/backend-url.ts).
|
||||
*/
|
||||
backendHostPattern: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the runtime config for shell-dojo. Called once per request by
|
||||
* the root layout, which injects the result into
|
||||
* window.__SHOWCASE_CONFIG__ for the client reader.
|
||||
*
|
||||
* `opts.noStore` (default `true`) controls whether to call
|
||||
* `unstable_noStore()`. The Node.js server runtime needs the opt-out so
|
||||
* Next.js does not statically prerender callers and freeze the URL
|
||||
* values into the build artifact.
|
||||
*/
|
||||
export function getRuntimeConfig(
|
||||
opts: { noStore?: boolean } = {},
|
||||
): RuntimeConfig {
|
||||
if (opts.noStore !== false) noStore();
|
||||
|
||||
// backendHostPattern is a host *pattern*, not a URL — `{slug}` must
|
||||
// survive untouched. It IS normalized against common env misconfigs
|
||||
// (leading scheme, trailing slash, missing `{slug}`) with warn-once
|
||||
// guards — see normalizeBackendHostPattern in lib/backend-url.ts. An
|
||||
// unset var defaults to the prod pattern, so a deploy with the var
|
||||
// unset behaves byte-identically to the build-baked prod values.
|
||||
const backendHostPattern = normalizeBackendHostPattern(
|
||||
readEnvPair("SHOWCASE_BACKEND_HOST_PATTERN") ??
|
||||
DEFAULT_BACKEND_HOST_PATTERN,
|
||||
);
|
||||
|
||||
return { backendHostPattern };
|
||||
}
|
||||
|
||||
// Env-name tolerance: deploy configs in the wild use either the bare
|
||||
// name (e.g. `SHOWCASE_BACKEND_HOST_PATTERN`) or the `NEXT_PUBLIC_*`-
|
||||
// prefixed name. We accept either — the primary (passed-in) name wins,
|
||||
// with transparent fallback to the alternate so a Railway service
|
||||
// variable set under the "wrong" name still works without redeploy.
|
||||
// Same semantics as the shell's runtime-config readEnvPair.
|
||||
function altEnvName(envKey: string): string {
|
||||
return envKey.startsWith("NEXT_PUBLIC_")
|
||||
? envKey.slice("NEXT_PUBLIC_".length)
|
||||
: `NEXT_PUBLIC_${envKey}`;
|
||||
}
|
||||
|
||||
// Length-aware env coalesce: a deliberately-empty primary (an operator
|
||||
// clearing the var on a Railway service) must NOT mask a populated
|
||||
// alternate. Treat empty-string as "unset" and fall through to the
|
||||
// alternate. Values are .trim()ed — whitespace paste artifacts would
|
||||
// otherwise survive into the pattern; a whitespace-only value counts as
|
||||
// unset. The dynamic `process.env[key]` reads assume a self-hosted Node
|
||||
// runtime (next start / Docker).
|
||||
function readEnvPair(envKey: string): string | undefined {
|
||||
const primary = process.env[envKey]?.trim();
|
||||
if (primary && primary.length > 0) return primary;
|
||||
const alt = process.env[altEnvName(envKey)]?.trim();
|
||||
if (alt && alt.length > 0) return alt;
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user