feat(chat): add connecting status during organization bootstrap
This commit is contained in:
@@ -40,6 +40,7 @@
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vitest": "catalog:",
|
||||
"vite-tsconfig-paths": "^6.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ import { useChatComposer } from "@/hooks/chat/use-chat-composer";
|
||||
import type { ChatComposerProps } from "@/lib/chat/types";
|
||||
|
||||
export const ChatComposer = ({ error, onSend, status }: ChatComposerProps) => {
|
||||
const busy = status === "submitted" || status === "streaming";
|
||||
const busy =
|
||||
status === "connecting" || status === "submitted" || status === "streaming";
|
||||
const composer = useChatComposer({ busy, onSend });
|
||||
|
||||
let statusContent: ReactNode = (
|
||||
@@ -23,7 +24,7 @@ export const ChatComposer = ({ error, onSend, status }: ChatComposerProps) => {
|
||||
statusContent = (
|
||||
<>
|
||||
<LoaderCircle className="size-3.5 animate-spin" />
|
||||
Zopu is responding
|
||||
{status === "connecting" ? "Preparing Zopu" : "Zopu is responding"}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
60
apps/web/src/hooks/chat/use-chat-agent.test.ts
Normal file
60
apps/web/src/hooks/chat/use-chat-agent.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { useChatAgent } from "./use-chat-agent";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
agent: {
|
||||
error: undefined,
|
||||
historyReady: true,
|
||||
messages: [],
|
||||
sendMessage: vi.fn(() => Promise.resolve()),
|
||||
status: "idle" as const,
|
||||
},
|
||||
organization: {} as { error?: Error; organizationId?: string },
|
||||
useFlueAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@flue/react", () => ({
|
||||
useFlueAgent: mocks.useFlueAgent,
|
||||
}));
|
||||
vi.mock("@/hooks/use-personal-organization", () => ({
|
||||
usePersonalOrganization: () => mocks.organization,
|
||||
}));
|
||||
|
||||
describe("useChatAgent", () => {
|
||||
beforeEach(() => {
|
||||
mocks.organization = {};
|
||||
mocks.agent.sendMessage.mockClear();
|
||||
mocks.useFlueAgent.mockReset();
|
||||
mocks.useFlueAgent.mockReturnValue(mocks.agent);
|
||||
});
|
||||
|
||||
test("does not use the agent before organization bootstrap completes", async () => {
|
||||
const chat = useChatAgent();
|
||||
|
||||
expect(mocks.useFlueAgent).toHaveBeenCalledWith({
|
||||
id: undefined,
|
||||
live: "sse",
|
||||
name: "zopu",
|
||||
});
|
||||
expect(chat.status).toBe("connecting");
|
||||
await expect(chat.sendMessage("too early")).rejects.toThrow(
|
||||
"Personal organization is still being prepared"
|
||||
);
|
||||
expect(mocks.agent.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("uses the ensured organization id for the agent session", async () => {
|
||||
mocks.organization = { organizationId: "org-a" };
|
||||
|
||||
const chat = useChatAgent();
|
||||
expect(mocks.useFlueAgent).toHaveBeenCalledWith({
|
||||
id: "org-a",
|
||||
live: "sse",
|
||||
name: "zopu",
|
||||
});
|
||||
|
||||
await chat.sendMessage("ready");
|
||||
expect(mocks.agent.sendMessage).toHaveBeenCalledWith("ready");
|
||||
});
|
||||
});
|
||||
@@ -1,36 +1,38 @@
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import { useFlueAgent } from "@flue/react";
|
||||
import { useQuery } from "convex/react";
|
||||
|
||||
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
|
||||
import { CHAT_AGENT } from "@/lib/chat/constants";
|
||||
import type { ChatAgentState } from "@/lib/chat/types";
|
||||
import { setSendRequestId } from "@/root";
|
||||
|
||||
export const useChatAgent = (): ChatAgentState => {
|
||||
// The global Zopu agent instance id is the user's current personal
|
||||
// organization id. This is the hard tenancy boundary: the server rejects an
|
||||
// instance id that is not the caller's own organization.
|
||||
const organization = useQuery(api.organizations.getCurrent);
|
||||
// The agent session is not created until the authenticated user's personal
|
||||
// organization has been ensured and its id is available.
|
||||
const organization = usePersonalOrganization();
|
||||
const agent = useFlueAgent({
|
||||
...CHAT_AGENT,
|
||||
id: organization?._id,
|
||||
id: organization.organizationId,
|
||||
});
|
||||
|
||||
const sendMessage = async (message: string): Promise<void> => {
|
||||
// Generate a stable idempotency request id once per send, immediately
|
||||
// before the message is submitted. It is reused across fetch/stream
|
||||
// retries for this send and reset only on the next send, so the server
|
||||
// can capture normalized evidence keyed by (organization, request id)
|
||||
// without creating duplicates.
|
||||
setSendRequestId(crypto.randomUUID());
|
||||
if (!organization.organizationId) {
|
||||
throw (
|
||||
organization.error ??
|
||||
new Error("Personal organization is still being prepared")
|
||||
);
|
||||
}
|
||||
await agent.sendMessage(message);
|
||||
};
|
||||
|
||||
let { status } = agent;
|
||||
if (!organization.organizationId) {
|
||||
status = organization.error ? "error" : "connecting";
|
||||
}
|
||||
|
||||
return {
|
||||
error: agent.error,
|
||||
error: organization.error ?? agent.error,
|
||||
historyReady: agent.historyReady,
|
||||
messages: agent.messages,
|
||||
sendMessage,
|
||||
status: agent.status,
|
||||
status,
|
||||
};
|
||||
};
|
||||
|
||||
77
apps/web/src/hooks/use-personal-organization.test.ts
Normal file
77
apps/web/src/hooks/use-personal-organization.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { EffectCallback } from "react";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { usePersonalOrganization } from "./use-personal-organization";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
cleanup: undefined as (() => void) | undefined,
|
||||
ensurePersonalOrganization: vi.fn(),
|
||||
session: {
|
||||
data: { session: { id: "session-a" } },
|
||||
} as { data: { session: { id: string } } | null },
|
||||
state: null as {
|
||||
error?: Error;
|
||||
organizationId?: string;
|
||||
sessionId: string;
|
||||
} | null,
|
||||
}));
|
||||
|
||||
vi.mock("@code/auth/web", () => ({
|
||||
authClient: { useSession: () => mocks.session },
|
||||
}));
|
||||
vi.mock("@code/backend/convex/_generated/api", () => ({
|
||||
api: { organizations: { ensurePersonalOrganization: "ensure-org" } },
|
||||
}));
|
||||
vi.mock("convex/react", () => ({
|
||||
useMutation: () => mocks.ensurePersonalOrganization,
|
||||
}));
|
||||
vi.mock("react", () => ({
|
||||
useEffect: (effect: EffectCallback) => {
|
||||
mocks.cleanup?.();
|
||||
const cleanup = effect();
|
||||
mocks.cleanup = typeof cleanup === "function" ? cleanup : undefined;
|
||||
},
|
||||
useState: () => [
|
||||
mocks.state,
|
||||
(next: { error?: Error; organizationId?: string; sessionId: string }) => {
|
||||
mocks.state = next;
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
describe("usePersonalOrganization", () => {
|
||||
beforeEach(() => {
|
||||
mocks.cleanup?.();
|
||||
mocks.cleanup = undefined;
|
||||
mocks.ensurePersonalOrganization.mockReset();
|
||||
mocks.session = { data: { session: { id: "session-a" } } };
|
||||
mocks.state = null;
|
||||
});
|
||||
|
||||
test("an authenticated session ensures its org before exposing the id", async () => {
|
||||
const ensured = Promise.withResolvers<{ _id: string }>();
|
||||
mocks.ensurePersonalOrganization.mockReturnValue(ensured.promise);
|
||||
|
||||
expect(usePersonalOrganization()).toEqual({});
|
||||
expect(mocks.ensurePersonalOrganization).toHaveBeenCalledTimes(1);
|
||||
|
||||
ensured.resolve({ _id: "org-a" });
|
||||
await ensured.promise;
|
||||
await Promise.resolve();
|
||||
|
||||
expect(usePersonalOrganization()).toEqual({
|
||||
error: undefined,
|
||||
organizationId: "org-a",
|
||||
});
|
||||
});
|
||||
|
||||
test("logout cannot expose the prior session organization", async () => {
|
||||
mocks.ensurePersonalOrganization.mockResolvedValue({ _id: "org-a" });
|
||||
usePersonalOrganization();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
mocks.session = { data: null };
|
||||
expect(usePersonalOrganization()).toEqual({});
|
||||
});
|
||||
});
|
||||
70
apps/web/src/hooks/use-personal-organization.ts
Normal file
70
apps/web/src/hooks/use-personal-organization.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { authClient } from "@code/auth/web";
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { useMutation } from "convex/react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface OrganizationBootstrapState {
|
||||
readonly error?: Error;
|
||||
readonly organizationId?: Id<"organizations">;
|
||||
readonly sessionId: string;
|
||||
}
|
||||
|
||||
export interface PersonalOrganizationState {
|
||||
readonly error?: Error;
|
||||
readonly organizationId?: Id<"organizations">;
|
||||
}
|
||||
|
||||
/** Ensure the authenticated session has its personal tenancy boundary. */
|
||||
export const usePersonalOrganization = (): PersonalOrganizationState => {
|
||||
const { data: session } = authClient.useSession();
|
||||
const sessionId = session?.session.id;
|
||||
const ensurePersonalOrganization = useMutation(
|
||||
api.organizations.ensurePersonalOrganization
|
||||
);
|
||||
const [bootstrap, setBootstrap] = useState<OrganizationBootstrapState | null>(
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
const bootstrapOrganization = async (): Promise<void> => {
|
||||
try {
|
||||
const organization = await ensurePersonalOrganization();
|
||||
if (active) {
|
||||
setBootstrap({
|
||||
organizationId: organization._id,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
} catch (caughtError: unknown) {
|
||||
if (active) {
|
||||
setBootstrap({
|
||||
error:
|
||||
caughtError instanceof Error
|
||||
? caughtError
|
||||
: new Error(String(caughtError)),
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
void bootstrapOrganization();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [ensurePersonalOrganization, sessionId]);
|
||||
|
||||
if (!sessionId || bootstrap?.sessionId !== sessionId) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
error: bootstrap.error,
|
||||
organizationId: bootstrap.organizationId,
|
||||
};
|
||||
};
|
||||
@@ -1,14 +1,14 @@
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { useFlueClient } from "@flue/react";
|
||||
import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { flueClient } from "@/root";
|
||||
|
||||
const errorMessage = (error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
export const useProjectWorkspace = () => {
|
||||
const flueClient = useFlueClient();
|
||||
const projects = useQuery(api.projects.list);
|
||||
const [selectedProjectId, setSelectedProjectId] =
|
||||
useState<Id<"projects"> | null>(null);
|
||||
|
||||
115
apps/web/src/lib/flue-transport.test.ts
Normal file
115
apps/web/src/lib/flue-transport.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { createFlueClient } from "@flue/sdk";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createFlueFetch } from "./flue-transport";
|
||||
|
||||
const BASE_URL = new URL("https://flue.example/api/");
|
||||
|
||||
describe("createFlueFetch", () => {
|
||||
test("overlapping SDK agent sends keep distinct request IDs", async () => {
|
||||
const firstResponse = Promise.withResolvers<Response>();
|
||||
const secondResponse = Promise.withResolvers<Response>();
|
||||
const bothStarted = Promise.withResolvers<boolean>();
|
||||
const capturedHeaders: Headers[] = [];
|
||||
let requestCount = 0;
|
||||
let requestId = 0;
|
||||
|
||||
const fetchImpl: typeof fetch = (input, init) => {
|
||||
capturedHeaders.push(new Headers(init?.headers));
|
||||
requestCount += 1;
|
||||
if (requestCount === 2) {
|
||||
bothStarted.resolve(true);
|
||||
}
|
||||
if (requestCount === 1) {
|
||||
return firstResponse.promise;
|
||||
}
|
||||
if (requestCount === 2) {
|
||||
return secondResponse.promise;
|
||||
}
|
||||
throw new Error(`Unexpected request: ${String(input)}`);
|
||||
};
|
||||
const client = createFlueClient({
|
||||
baseUrl: BASE_URL.toString(),
|
||||
fetch: createFlueFetch({
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl,
|
||||
generateRequestId: () => `request-${(requestId += 1)}`,
|
||||
}),
|
||||
headers: { authorization: "Bearer current-jwt" },
|
||||
});
|
||||
|
||||
const firstSend = client.agents.send("zopu", "org-a", {
|
||||
message: "first",
|
||||
});
|
||||
const secondSend = client.agents.send("zopu", "org-a", {
|
||||
message: "second",
|
||||
});
|
||||
await bothStarted.promise;
|
||||
|
||||
expect(capturedHeaders).toHaveLength(2);
|
||||
expect(capturedHeaders[0]?.get("x-zopu-request-id")).toBe("request-1");
|
||||
expect(capturedHeaders[1]?.get("x-zopu-request-id")).toBe("request-2");
|
||||
expect(capturedHeaders[0]?.get("authorization")).toBe("Bearer current-jwt");
|
||||
expect(capturedHeaders[1]?.get("authorization")).toBe("Bearer current-jwt");
|
||||
|
||||
firstResponse.resolve(
|
||||
Response.json(
|
||||
{
|
||||
offset: "1",
|
||||
streamUrl: "http://internal/streams/first",
|
||||
submissionId: "submission-1",
|
||||
},
|
||||
{ status: 202 }
|
||||
)
|
||||
);
|
||||
secondResponse.resolve(
|
||||
Response.json(
|
||||
{
|
||||
offset: "2",
|
||||
streamUrl: "http://internal/streams/second",
|
||||
submissionId: "submission-2",
|
||||
},
|
||||
{ status: 202 }
|
||||
)
|
||||
);
|
||||
|
||||
await expect(Promise.all([firstSend, secondSend])).resolves.toEqual([
|
||||
{
|
||||
offset: "1",
|
||||
streamUrl: "https://flue.example/streams/first",
|
||||
submissionId: "submission-1",
|
||||
},
|
||||
{
|
||||
offset: "2",
|
||||
streamUrl: "https://flue.example/streams/second",
|
||||
submissionId: "submission-2",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("history and stream observation requests remain untagged", async () => {
|
||||
const capturedHeaders: Headers[] = [];
|
||||
const fetchImpl: typeof fetch = (_input, init) => {
|
||||
capturedHeaders.push(new Headers(init?.headers));
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
};
|
||||
const flueFetch = createFlueFetch({
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl,
|
||||
generateRequestId: () => "must-not-be-used",
|
||||
});
|
||||
|
||||
await flueFetch("https://flue.example/api/agents/zopu/org-a?view=history", {
|
||||
headers: { authorization: "Bearer current-jwt" },
|
||||
method: "GET",
|
||||
});
|
||||
await flueFetch("https://flue.example/api/agents/zopu/org-a?view=updates", {
|
||||
headers: { authorization: "Bearer current-jwt" },
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
expect(capturedHeaders).toHaveLength(2);
|
||||
expect(capturedHeaders[0]?.has("x-zopu-request-id")).toBe(false);
|
||||
expect(capturedHeaders[1]?.has("x-zopu-request-id")).toBe(false);
|
||||
});
|
||||
});
|
||||
82
apps/web/src/lib/flue-transport.ts
Normal file
82
apps/web/src/lib/flue-transport.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
interface FlueFetchOptions {
|
||||
readonly baseUrl: URL;
|
||||
readonly fetchImpl?: typeof fetch;
|
||||
readonly generateRequestId?: () => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Flue transport used by the browser client.
|
||||
*
|
||||
* The installed Flue SDK does not expose per-call headers on `agents.send`.
|
||||
* Its custom `fetch` seam does receive the resolved URL and method, so request
|
||||
* IDs are attached here to each agent admission POST. History and Durable
|
||||
* Streams observation requests are GETs and intentionally remain untagged.
|
||||
*/
|
||||
export const createFlueFetch = ({
|
||||
baseUrl,
|
||||
fetchImpl = fetch,
|
||||
generateRequestId = crypto.randomUUID,
|
||||
}: FlueFetchOptions): typeof fetch => {
|
||||
const basePath = baseUrl.pathname.replace(/\/+$/u, "");
|
||||
|
||||
return async (input, init) => {
|
||||
const inputUrl =
|
||||
typeof input === "string" || input instanceof URL ? input : input.url;
|
||||
const requestUrl = new URL(inputUrl, baseUrl);
|
||||
const method = (
|
||||
init?.method ?? (input instanceof Request ? input.method : "GET")
|
||||
).toUpperCase();
|
||||
const relativePath = requestUrl.pathname.startsWith(`${basePath}/`)
|
||||
? requestUrl.pathname.slice(basePath.length)
|
||||
: requestUrl.pathname;
|
||||
const pathSegments = relativePath.split("/").filter(Boolean);
|
||||
const isAgentAdmission =
|
||||
method === "POST" &&
|
||||
pathSegments.length === 3 &&
|
||||
pathSegments[0] === "agents";
|
||||
|
||||
let requestInit = init;
|
||||
if (isAgentAdmission) {
|
||||
const headers = new Headers(
|
||||
input instanceof Request ? input.headers : undefined
|
||||
);
|
||||
for (const [key, value] of new Headers(init?.headers).entries()) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
headers.set("x-zopu-request-id", generateRequestId());
|
||||
requestInit = { ...init, headers };
|
||||
}
|
||||
|
||||
const response = await fetchImpl(input, requestInit);
|
||||
if (
|
||||
!response.ok ||
|
||||
!response.headers.get("content-type")?.includes("application/json")
|
||||
) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const body = (await response.clone().json()) as unknown;
|
||||
if (
|
||||
typeof body !== "object" ||
|
||||
body === null ||
|
||||
!("streamUrl" in body) ||
|
||||
typeof body.streamUrl !== "string"
|
||||
) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const streamUrl = new URL(body.streamUrl);
|
||||
streamUrl.protocol = baseUrl.protocol;
|
||||
streamUrl.host = baseUrl.host;
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set("location", streamUrl.toString());
|
||||
return Response.json(
|
||||
{ ...body, streamUrl: streamUrl.toString() },
|
||||
{
|
||||
headers,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
}
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -1,10 +1,11 @@
|
||||
import { authClient, WebAuthProvider } from "@code/auth/web";
|
||||
import { useConvexAccessToken, WebAuthProvider } from "@code/auth/web";
|
||||
import { env } from "@code/env/web";
|
||||
import { Toaster } from "@code/ui/components/sonner";
|
||||
import { FlueProvider } from "@flue/react";
|
||||
|
||||
import "./index.css";
|
||||
import { createFlueClient } from "@flue/sdk";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
isRouteErrorResponse,
|
||||
Links,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
|
||||
import type { Route } from "./+types/root";
|
||||
import { ThemeProvider } from "./components/theme-provider";
|
||||
import { createFlueFetch } from "./lib/flue-transport";
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
{ href: "https://fonts.googleapis.com", rel: "preconnect" },
|
||||
@@ -31,87 +33,33 @@ export const links: Route.LinksFunction = () => [
|
||||
];
|
||||
|
||||
const flueBaseUrl = new URL(env.VITE_FLUE_URL);
|
||||
const flueFetch = createFlueFetch({ baseUrl: flueBaseUrl });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authenticated Flue request headers.
|
||||
//
|
||||
// The access token is the Better Auth/Convex JWT, resolved once per session
|
||||
// through the installed `convex.token` plugin endpoint and cached. The stable
|
||||
// per-send request id is generated by the chat hook immediately before a user
|
||||
// sends a message and is reused across fetch/stream retries for that send, so
|
||||
// it is a reliable idempotency key for server-side evidence capture. It is
|
||||
// reset only when a new message is sent.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let cachedAccessToken: string | null = null;
|
||||
let currentSendRequestId: string | null = null;
|
||||
|
||||
/** Set the idempotency request id for the next send. Called by the chat hook. */
|
||||
export const setSendRequestId = (id: string): void => {
|
||||
currentSendRequestId = id;
|
||||
};
|
||||
|
||||
const resolveAccessToken = async (): Promise<string | null> => {
|
||||
if (cachedAccessToken) {
|
||||
return cachedAccessToken;
|
||||
}
|
||||
const { data } = await authClient.convex.token({
|
||||
fetchOptions: { throw: false },
|
||||
});
|
||||
cachedAccessToken = data?.token ?? null;
|
||||
return cachedAccessToken;
|
||||
};
|
||||
|
||||
const resolveFlueHeaders = async (): Promise<Record<string, string>> => {
|
||||
const headers: Record<string, string> = {};
|
||||
const accessToken = await resolveAccessToken();
|
||||
if (accessToken) {
|
||||
headers.authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
if (currentSendRequestId) {
|
||||
headers["x-zopu-request-id"] = currentSendRequestId;
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
const flueFetch: typeof fetch = async (input, init) => {
|
||||
const response = await fetch(input, init);
|
||||
if (
|
||||
!response.ok ||
|
||||
!response.headers.get("content-type")?.includes("application/json")
|
||||
) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const body = (await response.clone().json()) as unknown;
|
||||
if (
|
||||
typeof body !== "object" ||
|
||||
body === null ||
|
||||
!("streamUrl" in body) ||
|
||||
typeof body.streamUrl !== "string"
|
||||
) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const streamUrl = new URL(body.streamUrl);
|
||||
streamUrl.protocol = flueBaseUrl.protocol;
|
||||
streamUrl.host = flueBaseUrl.host;
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set("location", streamUrl.toString());
|
||||
return Response.json(
|
||||
{ ...body, streamUrl: streamUrl.toString() },
|
||||
{
|
||||
headers,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
}
|
||||
const AuthenticatedFlueProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const resolveAccessToken = useConvexAccessToken();
|
||||
const client = useMemo(
|
||||
() =>
|
||||
createFlueClient({
|
||||
baseUrl: flueBaseUrl.toString(),
|
||||
fetch: flueFetch,
|
||||
headers: async () => {
|
||||
const accessToken = await resolveAccessToken();
|
||||
const headers: Record<string, string> = {};
|
||||
if (accessToken) {
|
||||
headers.authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
return headers;
|
||||
},
|
||||
}),
|
||||
[resolveAccessToken]
|
||||
);
|
||||
|
||||
return <FlueProvider client={client}>{children}</FlueProvider>;
|
||||
};
|
||||
export const flueClient = createFlueClient({
|
||||
baseUrl: flueBaseUrl.toString(),
|
||||
fetch: flueFetch,
|
||||
headers: resolveFlueHeaders,
|
||||
});
|
||||
export const Layout = ({ children }: { children: React.ReactNode }) => (
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -129,8 +77,8 @@ export const Layout = ({ children }: { children: React.ReactNode }) => (
|
||||
);
|
||||
|
||||
const App = () => (
|
||||
<FlueProvider client={flueClient}>
|
||||
<WebAuthProvider>
|
||||
<WebAuthProvider>
|
||||
<AuthenticatedFlueProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
@@ -142,8 +90,8 @@ const App = () => (
|
||||
</div>
|
||||
<Toaster richColors />
|
||||
</ThemeProvider>
|
||||
</WebAuthProvider>
|
||||
</FlueProvider>
|
||||
</AuthenticatedFlueProvider>
|
||||
</WebAuthProvider>
|
||||
);
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"],
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2024"],
|
||||
"types": ["node", "vite/client"],
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
|
||||
Reference in New Issue
Block a user