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
27 changes: 27 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,24 @@
import type { Metadata, Viewport } from "next"
import { Rajdhani, Plus_Jakarta_Sans, Fira_Code } from "next/font/google"
import { TooltipProvider } from "@/components/ui/tooltip"
import {
RUNTIME_CONFIG_GLOBAL,
readRuntimeConfig,
serializeRuntimeConfig,
} from "@/lib/runtime-config"
import "./globals.css"

/**
* Required for runtime configuration to mean anything.
*
* Next prerenders a layout with no dynamic inputs at build time, which would
* evaluate readRuntimeConfig() during `next build` and bake that value into the
* HTML — reintroducing exactly the build-time freezing this replaces. Rendering
* per request is what lets GRAPH_MINDSET_API_URL be read from the live
* environment on every boot.
*/
export const dynamic = "force-dynamic"

const rajdhani = Rajdhani({
variable: "--font-heading",
subsets: ["latin"],
Expand Down Expand Up @@ -51,6 +67,17 @@ export default function RootLayout({
className={`${rajdhani.variable} ${jakarta.variable} ${firaCode.variable} h-full antialiased dark`}
>
<body className="h-full overflow-hidden">
{/*
Inline and first in the document so it runs during parse, before the
deferred application bundles — getApiUrl() therefore always sees it.
*/}
<script
dangerouslySetInnerHTML={{
__html: `window.${RUNTIME_CONFIG_GLOBAL}=${serializeRuntimeConfig(
readRuntimeConfig()
)}`,
}}
/>
<div style={{ display: "none" }} data-ai-discovery="true">
<h1>GraphMindset</h1>
<p>
Expand Down
4 changes: 2 additions & 2 deletions src/lib/agent-api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client"

import { API_URL } from "./api"
import { getApiUrl } from "./api"
import { getSignedMessage, getL402, payL402 } from "./sphinx"
import { useModalStore } from "@/stores/modal-store"
import { isMocksEnabled } from "./mock-data"
Expand Down Expand Up @@ -60,7 +60,7 @@ function unwrapEnvelope(raw: string): { answer: string; cited_ref_ids: string[]

// Builds a signed URL for a given API path
async function buildSignedUrl(path: string): Promise<string> {
const url = new URL(`${API_URL}${path}`)
const url = new URL(`${getApiUrl()}${path}`)
const signed = await getSignedMessage()
if (signed.signature) {
url.searchParams.append("sig", signed.signature)
Expand Down
43 changes: 37 additions & 6 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,27 @@
import { getSignedMessage, getL402 } from "./sphinx"
import { injectedApiUrl } from "./runtime-config"

function resolveApiUrl(): string {
if (typeof window === "undefined") {
return process.env.NEXT_PUBLIC_API_URL || "https://bitcoin.sphinx.chat/api"
// Server render: read the live environment directly. GRAPH_MINDSET_API_URL
// is not inlined at build time, so this reflects the running container.
return (
process.env.GRAPH_MINDSET_API_URL?.trim() ||
process.env.NEXT_PUBLIC_API_URL ||
"https://bitcoin.sphinx.chat/api"
)
}

// Runtime value injected into the document by the root layout. Takes
// precedence over NEXT_PUBLIC_API_URL, which is only ever what happened to be
// set when the image was built.
const injected = injectedApiUrl()

if (injected) {
return injected
}

// Still honoured for builds that bake the URL in via --build-arg.
if (process.env.NEXT_PUBLIC_API_URL) {
return process.env.NEXT_PUBLIC_API_URL
}
Expand All @@ -25,7 +42,21 @@ function resolveApiUrl(): string {
return `${origin}/api`
}

export const API_URL = resolveApiUrl()
let cachedApiUrl: string | undefined

/**
* Resolved on first use rather than at module load, so the runtime value the
* root layout injects into the document is guaranteed to be in place by the
* time anything asks for it. Import this instead of a module-level constant.
*/
export function getApiUrl(): string {
return (cachedApiUrl ??= resolveApiUrl())
}

/** Test seam — forces the next getApiUrl() call to re-resolve. */
export function resetApiUrlCache(): void {
cachedApiUrl = undefined
}

async function request<Res>(
url: string,
Expand Down Expand Up @@ -74,7 +105,7 @@ export const api = {
endpoint: string,
headers?: RequestInit["headers"],
signal?: AbortSignal
) => request<Res>(`${API_URL}${endpoint}`, headers ? { headers } : undefined, signal),
) => request<Res>(`${getApiUrl()}${endpoint}`, headers ? { headers } : undefined, signal),

post: <Res>(
endpoint: string,
Expand All @@ -83,7 +114,7 @@ export const api = {
signal?: AbortSignal
) =>
request<Res>(
`${API_URL}${endpoint}`,
`${getApiUrl()}${endpoint}`,
{
body: JSON.stringify(body),
headers: { ...headers, "Content-Type": "application/json" },
Expand All @@ -99,7 +130,7 @@ export const api = {
signal?: AbortSignal
) =>
request<Res>(
`${API_URL}${endpoint}`,
`${getApiUrl()}${endpoint}`,
{
body: JSON.stringify(body),
headers: { ...headers, "Content-Type": "application/json" },
Expand All @@ -114,7 +145,7 @@ export const api = {
signal?: AbortSignal
) =>
request<Res>(
`${API_URL}${endpoint}`,
`${getApiUrl()}${endpoint}`,
{
headers: { ...headers, "Content-Type": "application/json" },
method: "DELETE",
Expand Down
8 changes: 4 additions & 4 deletions src/lib/graph-api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { api, API_URL } from "./api"
import { api, getApiUrl } from "./api"
import { getSignedMessage, getL402 } from "./sphinx"
import { isMocksEnabled, MOCK_REVIEWS } from "./mock-data"

Expand Down Expand Up @@ -216,7 +216,7 @@ export async function uploadImageToNode(
file: File,
signal?: AbortSignal
): Promise<{ url: string; ref_id: string; status?: string }> {
const url = new URL(`${API_URL}/v2/nodes/${refId}/image`)
const url = new URL(`${getApiUrl()}/v2/nodes/${refId}/image`)

// Sphinx-signed admin path piggybacks on query params (matches api.ts).
const signed = await getSignedMessage()
Expand Down Expand Up @@ -284,7 +284,7 @@ export async function addImageContent(
status_messages: string[]
temp_url?: string
}> {
const url = new URL(`${API_URL}/v2/content/image`)
const url = new URL(`${getApiUrl()}/v2/content/image`)

const signed = await getSignedMessage()
if (signed.signature) {
Expand Down Expand Up @@ -1219,7 +1219,7 @@ export async function addLegalDocumentFile(
file: File,
signal?: AbortSignal
): Promise<{ status: string; nodes: Array<Record<string, unknown>>; status_messages: string[] }> {
const url = new URL(`${API_URL}/v2/legal/upload`)
const url = new URL(`${getApiUrl()}/v2/legal/upload`)

const signed = await getSignedMessage()
if (signed.signature) {
Expand Down
64 changes: 64 additions & 0 deletions src/lib/runtime-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Runtime configuration.
*
* NEXT_PUBLIC_* variables are inlined by Next at build time, so they freeze
* whatever value was present when the image was built. That makes a published
* image unusable in any deployment with a different API host — which is why
* the container could be handed the correct URL at runtime and still call the
* compiled-in default.
*
* GRAPH_MINDSET_API_URL carries no NEXT_PUBLIC_ prefix, so it is never inlined:
* the server reads it from the real environment on each boot and hands it to
* the browser through a <script> tag in the document head. One image then works
* in every environment, and changing the URL is a restart rather than a rebuild.
*/

export const RUNTIME_CONFIG_GLOBAL = "__GRAPH_MINDSET_CONFIG__"

export type RuntimeConfig = {
apiUrl: string
}

declare global {
interface Window {
[RUNTIME_CONFIG_GLOBAL]?: Partial<RuntimeConfig>
}
}

/**
* Server-side only — reads the live environment. Called while rendering the
* document so the value is embedded in the HTML the browser receives.
*/
export function readRuntimeConfig(): RuntimeConfig {
return {
// GRAPH_MINDSET_API_URL is read from the live environment on every render.
//
// The NEXT_PUBLIC_API_URL fallback only ever yields the value baked in at
// build time — verified: starting this build with NEXT_PUBLIC_API_URL set to
// a fresh value still produced the compiled-in one, because Next inlines
// NEXT_PUBLIC_* in server code as well as client code. It is kept so images
// built with --build-arg keep working; it cannot carry a runtime value.
// Backwards compatibility for a swarm still setting the old name is handled
// in sphinx-swarm, which forwards it as GRAPH_MINDSET_API_URL.
apiUrl:
process.env.GRAPH_MINDSET_API_URL?.trim() ||
process.env.NEXT_PUBLIC_API_URL?.trim() ||
"",
}
}

/** The value injected into the document, or undefined when running server-side. */
export function injectedApiUrl(): string | undefined {
if (typeof window === "undefined") {
return undefined
}

return window[RUNTIME_CONFIG_GLOBAL]?.apiUrl || undefined
}

/** Serialised for embedding in a <script> tag. */
export function serializeRuntimeConfig(config: RuntimeConfig): string {
// JSON.stringify escapes quotes; guard the one sequence that could close the
// surrounding <script> element early.
return JSON.stringify(config).replace(/</g, "\\u003c")
}
4 changes: 2 additions & 2 deletions src/lib/socket.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { io, Socket } from 'socket.io-client'
import { API_URL } from './api'
import { getApiUrl } from './api'

let socket: Socket | null = null

export function getSocket(): Socket {
if (typeof window === 'undefined') throw new Error('Socket not available in SSR')
if (!socket) {
const url = API_URL.replace(/\/api$/, '')
const url = getApiUrl().replace(/\/api$/, '')
socket = io(url, { transports: ['websocket'], autoConnect: true })
}
return socket
Expand Down
Loading