Migrate to vite

This commit is contained in:
Rami Bitar
2026-05-03 17:10:49 -04:00
parent b5cca5a6d8
commit 6cbbccf3bf
22 changed files with 661 additions and 3041 deletions

View File

@@ -1,6 +1,6 @@
# Shopify (defaults to mock.shop if unset) # Shopify (defaults to mock.shop if unset)
NEXT_PUBLIC_SHOPIFY_DOMAIN=mock.shop VITE_SHOPIFY_DOMAIN=mock.shop
NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN= VITE_SHOPIFY_STOREFRONT_ACCESS_TOKEN=
# AI Gateway (Vercel) — used by /api/chat. Required for the AI panel. # Anthropic — used by /api/chat. Required for the AI panel.
AI_GATEWAY_API_KEY= ANTHROPIC_API_KEY=

3
.gitignore vendored
View File

@@ -1,8 +1,7 @@
node_modules node_modules
.next dist
.DS_Store .DS_Store
.env .env
.env.local .env.local
.env.*.local .env.*.local
next-env.d.ts
*.log *.log

View File

@@ -1,15 +1,15 @@
# React Editor Demo (Shopify) # React Editor Demo (Shopify)
Standalone Next.js 16 (app router) demo wiring up the Shopify-aware React Editor. Standalone Vite SPA wiring up the Shopify-aware React Editor via `<App />` from `@reacteditor/core`.
## What's here ## What's here
- **`index.html`** — Vite entrypoint.
- **`src/main.tsx`** — mounts `<App />` into `#root`.
- **`src/App.tsx`** — wires `<App />` from `@reacteditor/core` with the schema, plugins, and the Shopify provider.
- **`editor/`** — drop-in copy of the editor scaffold from `fe-shopify-client/app/editor`. Components, config, theme, Shopify client/queries/hooks/contexts, plugin-ai vendor css. - **`editor/`** — drop-in copy of the editor scaffold from `fe-shopify-client/app/editor`. Components, config, theme, Shopify client/queries/hooks/contexts, plugin-ai vendor css.
- **`app.schema.json`** — source of truth for every page in the demo. Shape: `{ "/": { root, content }, "/about": { ... }, ... }`. The save endpoint writes back to this file. - **`app.schema.json`** — source of truth for every page in the demo. Shape: `{ "/": { root, content }, "/about": { ... }, ... }`.
- **`app/[[...path]]/page.tsx`** — view route. Reads the schema and mounts `<Render>`. - **`api/chat.ts`** — `aiPlugin` endpoint exposed in dev via a Vite middleware. Talks to Claude via `@ai-sdk/anthropic` with the editor + Shopify tool surface (`updatePage`, `searchProducts`, etc.).
- **`app/edit/[[...path]]/page.tsx`** — edit route. Reads the schema and mounts `<Editor>`.
- **`app/api/save-schema/route.ts`** — POST `{ route, data }` → patches `app.schema.json` on disk.
- **`app/api/chat/route.ts`** — `aiPlugin` endpoint. Uses the Vercel AI Gateway (`AI_GATEWAY_API_KEY`) to talk to Claude, with the editor + Shopify tool surface (`updatePage`, `searchProducts`, etc.).
## Run ## Run
@@ -19,14 +19,10 @@ yarn install
yarn dev # → http://localhost:3001 yarn dev # → http://localhost:3001
``` ```
- View any route: `http://localhost:3001/`, `/about`, `/products/handle`, etc.
- Edit any route: `http://localhost:3001/edit`, `/edit/about`, etc.
- 404 in view mode means the route isn't in `app.schema.json` yet — add it via the editor's **New page** button.
## Shopify ## Shopify
Defaults to the public `mock.shop` storefront (no token needed) so commerce blocks render demo data immediately. Override via `NEXT_PUBLIC_SHOPIFY_DOMAIN` and `NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN` in `.env.local`. Defaults to the public `mock.shop` storefront (no token needed) so commerce blocks render demo data immediately. Override via `VITE_SHOPIFY_DOMAIN` and `VITE_SHOPIFY_STOREFRONT_ACCESS_TOKEN` in `.env.local`.
## AI ## AI
`/api/chat` mirrors the host app's pattern — pass a string-form model id (`anthropic/claude-sonnet-4.6`) and the AI SDK routes through the Vercel AI Gateway using `AI_GATEWAY_API_KEY`. Tools include the React Editor built-ins plus Shopify search/lookup helpers. `/api/chat` is served in dev by a Vite middleware that loads `api/chat.ts` via `ssrLoadModule`. Set `ANTHROPIC_API_KEY` in `.env.local`. Tools include the React Editor built-ins plus Shopify search/lookup helpers. For production deployment you'll need to host `api/chat.ts` behind your own Node server (the handler exports a standard `POST(req: Request): Promise<Response>`).

View File

@@ -5,6 +5,7 @@ import {
tool, tool,
type UIMessage, type UIMessage,
} from "ai"; } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod"; import { z } from "zod";
import { import {
reactEditorTools, reactEditorTools,
@@ -20,9 +21,6 @@ import {
GET_COLLECTION_PRODUCTS_QUERY, GET_COLLECTION_PRODUCTS_QUERY,
} from "@/editor/graphql/collections"; } from "@/editor/graphql/collections";
export const runtime = "nodejs";
export const maxDuration = 60;
type Body = { type Body = {
messages: UIMessage[]; messages: UIMessage[];
editorContext?: Parameters<typeof getEditorContext>[0]; editorContext?: Parameters<typeof getEditorContext>[0];
@@ -48,11 +46,11 @@ export async function POST(req: Request) {
const credentials = { const credentials = {
domain: domain:
process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN ?? process.env.VITE_SHOPIFY_DOMAIN ??
process.env.SHOPIFY_DOMAIN ?? process.env.SHOPIFY_DOMAIN ??
"mock.shop", "mock.shop",
token: token:
process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN ?? process.env.VITE_SHOPIFY_STOREFRONT_ACCESS_TOKEN ??
process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN ?? process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN ??
"", "",
}; };
@@ -211,7 +209,7 @@ export async function POST(req: Request) {
}); });
const result = streamText({ const result = streamText({
model: "anthropic/claude-sonnet-4.6", model: anthropic("claude-sonnet-4-5"),
system: getEditorContext(editorContext), system: getEditorContext(editorContext),
messages: await convertToModelMessages(messages), messages: await convertToModelMessages(messages),
tools: { tools: {

View File

@@ -1,21 +0,0 @@
import { readSchema } from "@/lib/schema.server";
import AppClient from "@/components/AppClient";
export const dynamic = "force-dynamic";
export default async function Page({
params,
}: {
params: Promise<{ path?: string[] }>;
}) {
const { path } = await params;
const segments = (path ?? []).filter(Boolean);
const currentPath = segments.length === 0 ? "/" : "/" + segments.join("/");
const pages = await readSchema();
return (
<div style={{ height: "100vh", width: "100vw" }}>
<AppClient pages={pages} currentPath={currentPath} />
</div>
);
}

View File

@@ -1,22 +0,0 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "React Editor — Shopify Demo",
description: "Standalone Next.js demo wiring the Shopify-aware React Editor.",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<head>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
</head>
<body>{children}</body>
</html>
);
}

View File

@@ -1,78 +0,0 @@
"use client";
import { useCallback, useMemo } from "react";
import {
App,
blocksPlugin,
outlinePlugin,
} from "@reacteditor/core";
import createTailwindCdnPlugin from "@reacteditor/plugin-tailwind-cdn";
import { aiPlugin } from "@reacteditor/plugin-ai";
import "@reacteditor/core/dist/index.css";
import "@reacteditor/plugin-ai/styles.css";
import { createConfig } from "@/editor/config";
import { ShopifyProvider } from "@/editor/contexts/shopify-context";
type Pages = Record<string, { root: any; content: any[] }>;
const SHOPIFY_DOMAIN =
process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN ?? "mock.shop";
const STOREFRONT_TOKEN =
process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN ?? "";
export default function AppClient({
pages,
currentPath,
}: {
pages: Pages;
currentPath: string;
}) {
const config = useMemo(
() =>
createConfig({
domain: SHOPIFY_DOMAIN,
token: STOREFRONT_TOKEN || null,
}),
[],
);
const plugins = useMemo(
() => [
createTailwindCdnPlugin(),
aiPlugin({
api: "/api/chat",
attachments: true,
body: { route: currentPath },
}),
blocksPlugin(),
outlinePlugin(),
],
[currentPath],
);
const handlePublish = useCallback(
(data: any, route?: string) => {
const path = route ?? currentPath;
if (typeof window !== "undefined" && window.parent !== window) {
window.parent.postMessage(
{ type: "PUBLISH", path, data },
"*",
);
}
},
[currentPath],
);
return (
<ShopifyProvider domain={SHOPIFY_DOMAIN} token={STOREFRONT_TOKEN}>
<App
config={config as any}
pages={pages as any}
currentPath={currentPath}
plugins={plugins}
iframe={{ enabled: true }}
onPublish={handlePublish}
/>
</ShopifyProvider>
);
}

View File

@@ -1,149 +0,0 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import {
Editor,
blocksPlugin,
outlinePlugin,
useGetEditor,
type Route,
} from "@reacteditor/core";
import createTailwindCdnPlugin from "@reacteditor/plugin-tailwind-cdn";
import { aiPlugin } from "@reacteditor/plugin-ai";
import "@reacteditor/core/dist/index.css";
import "@reacteditor/plugin-ai/styles.css";
import { createConfig } from "@/editor/config";
import { ShopifyProvider } from "@/editor/contexts/shopify-context";
import { Button } from "@/editor/components/ui/button";
import { resolveSchemaEntry, templateKeyForRoute } from "@/lib/schema-resolver";
type Schema = Record<string, { root: any; content: any[] }>;
const SHOPIFY_DOMAIN =
process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN ?? "mock.shop";
const STOREFRONT_TOKEN =
process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN ?? "";
function SaveButton({
currentPath,
onSave,
}: {
currentPath: string;
onSave: (route: string, data: any) => Promise<void>;
}) {
const getEditor = useGetEditor();
const [saving, setSaving] = useState(false);
return (
<Button
onClick={async () => {
if (saving) return;
setSaving(true);
const data = (getEditor() as any)?.appState?.data;
if (data) await onSave(currentPath, data);
await new Promise((r) => setTimeout(r, 1000));
setSaving(false);
}}
disabled={saving}
>
{saving ? "Saving…" : "Save"}
</Button>
);
}
export default function EditorClient({
initialSchema,
initialPath,
}: {
initialSchema: Schema;
initialPath: string;
}) {
const router = useRouter();
const [schema, setSchema] = useState<Schema>(initialSchema);
const [currentPath, setCurrentPath] = useState<string>(initialPath);
const editKey = useMemo(
() => templateKeyForRoute(currentPath) ?? currentPath,
[currentPath],
);
const data = useMemo(
() =>
resolveSchemaEntry(schema, currentPath)?.data ?? {
root: { props: {} },
content: [],
},
[schema, currentPath],
);
const routes: Route[] = useMemo(
() =>
Object.entries(schema).map(([path, page]) => ({
path,
title:
(page?.root as any)?.props?.title ??
(path === "/" ? "Home" : path),
})),
[schema],
);
const config = useMemo(
() =>
createConfig({
domain: SHOPIFY_DOMAIN,
token: STOREFRONT_TOKEN || null,
}),
[],
);
const persist = useCallback(async (route: string, next: any) => {
setSchema((s) => ({ ...s, [route]: next }));
if (typeof window !== "undefined" && window.parent !== window) {
window.parent.postMessage(
{ type: "PUBLISH", path: route, data: next },
"*",
);
}
}, []);
const plugins = useMemo(
() => [
createTailwindCdnPlugin(),
aiPlugin({
api: "/api/chat",
attachments: true,
body: { route: currentPath },
}),
blocksPlugin(),
outlinePlugin(),
],
[currentPath],
);
return (
<ShopifyProvider domain={SHOPIFY_DOMAIN} token={STOREFRONT_TOKEN}>
<Editor
key={currentPath}
config={config as any}
data={data as any}
plugins={plugins}
headerPath={currentPath}
routes={routes}
currentPath={currentPath}
onRouteChange={(next) => {
setCurrentPath(next);
router.push(next === "/" ? "/edit" : `${next}/edit`);
}}
onPublish={async (next) => {
await persist(editKey, next);
}}
iframe={{ enabled: true }}
overrides={{
headerActions: () => (
<SaveButton currentPath={editKey} onSave={persist} />
),
}}
/>
</ShopifyProvider>
);
}

View File

@@ -1,35 +0,0 @@
"use client";
import { useMemo } from "react";
import { Render } from "@reacteditor/core";
import "@reacteditor/core/dist/index.css";
import { createConfig } from "@/editor/config";
import { ShopifyProvider } from "@/editor/contexts/shopify-context";
const SHOPIFY_DOMAIN =
process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN ?? "mock.shop";
const STOREFRONT_TOKEN =
process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN ?? "";
export default function RenderClient({
data,
route,
}: {
data: { root: any; content: any[] };
route: string;
}) {
const config = useMemo(
() =>
createConfig({
domain: SHOPIFY_DOMAIN,
token: STOREFRONT_TOKEN || null,
}),
[],
);
return (
<ShopifyProvider domain={SHOPIFY_DOMAIN} token={STOREFRONT_TOKEN}>
<Render config={config as any} data={data as any} metadata={{ route }} />
</ShopifyProvider>
);
}

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React Editor — Shopify Demo</title>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,58 +0,0 @@
import type { Schema } from "@/lib/schema.server";
export type ResolvedEntry = {
key: string;
data: { root: any; content: any[] };
params: Record<string, string>;
};
const TEMPLATE_PATTERNS: { key: string; prefix: string; param: string }[] = [
{ key: "/products/*", prefix: "/products/", param: "handle" },
{ key: "/collections/*", prefix: "/collections/", param: "handle" },
];
export function resolveSchemaEntry(
schema: Schema,
route: string,
): ResolvedEntry | null {
if (schema[route]) {
return { key: route, data: schema[route], params: {} };
}
for (const { key, prefix, param } of TEMPLATE_PATTERNS) {
if (route.startsWith(prefix) && route.length > prefix.length && schema[key]) {
const value = route.slice(prefix.length);
return {
key,
data: applyParams(schema[key], { [param]: value }),
params: { [param]: value },
};
}
}
return null;
}
export function templateKeyForRoute(route: string): string | null {
for (const { key, prefix } of TEMPLATE_PATTERNS) {
if (route.startsWith(prefix) && route.length > prefix.length) return key;
}
return null;
}
function applyParams(
data: { root: any; content: any[] },
params: Record<string, string>,
): { root: any; content: any[] } {
const handle = params.handle;
if (!handle) return data;
const content = data.content.map((block) => {
const props = block?.props ?? {};
if ("product" in props && (props.product == null || !props.product?.handle)) {
return { ...block, props: { ...props, product: { handle } } };
}
if ("collection" in props && (props.collection == null || !props.collection?.handle)) {
return { ...block, props: { ...props, collection: { handle } } };
}
return block;
});
return { ...data, content };
}

View File

@@ -1,7 +0,0 @@
import schemaJson from "../app.schema.json";
export type Schema = Record<string, { root: any; content: any[] }>;
export async function readSchema(): Promise<Schema> {
return schemaJson as Schema;
}

View File

@@ -1,24 +0,0 @@
/** @type {import('next').NextConfig} */
module.exports = {
reactStrictMode: true,
typescript: {
// Copied editor components carry a few legacy `any` paths that don't
// affect runtime. Ignoring lets the demo build/run without a port.
ignoreBuildErrors: true,
},
transpilePackages: [
"@reacteditor/core",
"@reacteditor/plugin-ai",
"@reacteditor/plugin-tailwind-cdn",
"@reacteditor/field-shopify",
"@reacteditor/field-google-fonts",
],
images: {
remotePatterns: [
{ protocol: "https", hostname: "images.unsplash.com" },
{ protocol: "https", hostname: "cdn.shopify.com" },
{ protocol: "https", hostname: "logo.clearbit.com" },
{ protocol: "https", hostname: "picsum.photos" },
],
},
};

View File

@@ -2,13 +2,15 @@
"name": "react-editor-demo", "name": "react-editor-demo",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"type": "module",
"scripts": { "scripts": {
"dev": "next dev", "dev": "vite",
"build": "next build", "build": "vite build",
"start": "next start", "preview": "vite preview",
"lint": "next lint" "start": "vite preview"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "^3.0.74",
"@ai-sdk/react": "^2.0.0", "@ai-sdk/react": "^2.0.0",
"@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
@@ -37,7 +39,6 @@
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"framer-motion": "^12.16.0", "framer-motion": "^12.16.0",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"next": "^16.0.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-router": "^7.0.0", "react-router": "^7.0.0",
@@ -51,8 +52,8 @@
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"eslint": "^9.0.0", "@vitejs/plugin-react": "^4.3.4",
"eslint-config-next": "^16.0.0", "typescript": "^5.5.4",
"typescript": "^5.5.4" "vite": "^6.0.0"
} }
} }

87
src/App.tsx Normal file
View File

@@ -0,0 +1,87 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
App as ReactEditorApp,
blocksPlugin,
outlinePlugin,
} from "@reacteditor/core";
import createTailwindCdnPlugin from "@reacteditor/plugin-tailwind-cdn";
import { aiPlugin } from "@reacteditor/plugin-ai";
import "@reacteditor/core/dist/index.css";
import "@reacteditor/plugin-ai/styles.css";
import { createConfig } from "@/editor/config";
import { ShopifyProvider } from "@/editor/contexts/shopify-context";
import schemaJson from "../app.schema.json";
type Pages = Record<string, { root: any; content: any[] }>;
const SHOPIFY_DOMAIN =
(import.meta.env.VITE_SHOPIFY_DOMAIN as string | undefined) ?? "mock.shop";
const STOREFRONT_TOKEN =
(import.meta.env.VITE_SHOPIFY_STOREFRONT_ACCESS_TOKEN as
| string
| undefined) ?? "";
function readPathname() {
if (typeof window === "undefined") return "/";
const p = window.location.pathname;
return p === "" ? "/" : p;
}
export default function App() {
const pages = schemaJson as Pages;
const [currentPath, setCurrentPath] = useState<string>(readPathname);
useEffect(() => {
const onPop = () => setCurrentPath(readPathname());
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
const config = useMemo(
() =>
createConfig({
domain: SHOPIFY_DOMAIN,
token: STOREFRONT_TOKEN || null,
}),
[],
);
const plugins = useMemo(
() => [
createTailwindCdnPlugin(),
aiPlugin({
api: "/api/chat",
attachments: true,
body: { route: currentPath },
}),
blocksPlugin(),
outlinePlugin(),
],
[currentPath],
);
const handlePublish = useCallback(
(data: any, route?: string) => {
const path = route ?? currentPath;
if (typeof window !== "undefined" && window.parent !== window) {
window.parent.postMessage({ type: "PUBLISH", path, data }, "*");
}
},
[currentPath],
);
return (
<div style={{ height: "100vh", width: "100vw" }}>
<ShopifyProvider domain={SHOPIFY_DOMAIN} token={STOREFRONT_TOKEN}>
<ReactEditorApp
config={config as any}
pages={pages as any}
currentPath={currentPath}
plugins={plugins}
iframe={{ enabled: true }}
onPublish={handlePublish}
/>
</ShopifyProvider>
</div>
);
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./globals.css";
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

10
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_SHOPIFY_DOMAIN?: string;
readonly VITE_SHOPIFY_STOREFRONT_ACCESS_TOKEN?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -1,45 +1,27 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"lib": [ "useDefineForClassFields": true,
"dom", "lib": ["ES2022", "DOM", "DOM.Iterable"],
"dom.iterable",
"ES2022"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"incremental": true, "strict": true,
"plugins": [ "skipLibCheck": true,
{ "esModuleInterop": true,
"name": "next" "allowJs": true,
} "types": ["node", "vite/client"],
],
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"~/*": [ "@/*": ["./*"],
"./*" "~/*": ["./*"]
],
"@/*": [
"./*"
]
} }
}, },
"include": [ "include": ["src", "api", "editor", "vite.config.ts"],
"next-env.d.ts", "exclude": ["node_modules", "dist"]
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }

File diff suppressed because one or more lines are too long

72
vite.config.ts Normal file
View File

@@ -0,0 +1,72 @@
import { defineConfig, loadEnv, type Plugin } from "vite";
import react from "@vitejs/plugin-react";
import path from "node:path";
function apiChatPlugin(): Plugin {
return {
name: "api-chat-middleware",
configureServer(server) {
server.middlewares.use("/api/chat", async (req, res, next) => {
if (req.method !== "POST") return next();
try {
const mod = await server.ssrLoadModule("/api/chat.ts");
const handler = (mod as { POST: (req: Request) => Promise<Response> })
.POST;
const chunks: Buffer[] = [];
for await (const c of req) chunks.push(c as Buffer);
const body = chunks.length ? Buffer.concat(chunks) : undefined;
const headers = new Headers();
for (const [k, v] of Object.entries(req.headers)) {
if (Array.isArray(v)) headers.set(k, v.join(", "));
else if (typeof v === "string") headers.set(k, v);
}
const fetchReq = new Request(
`http://localhost${req.url ?? "/api/chat"}`,
{ method: "POST", headers, body },
);
const response = await handler(fetchReq);
res.statusCode = response.status;
response.headers.forEach((value, key) => res.setHeader(key, value));
if (response.body) {
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
res.write(Buffer.from(value));
}
}
res.end();
} catch (err) {
console.error("/api/chat error", err);
res.statusCode = 500;
res.end(err instanceof Error ? err.message : "internal error");
}
});
},
};
}
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
for (const k of Object.keys(env)) {
if (process.env[k] === undefined) process.env[k] = env[k];
}
return {
plugins: [react(), apiChatPlugin()],
resolve: {
alias: {
"@": path.resolve(__dirname, "."),
"~": path.resolve(__dirname, "."),
},
},
server: {
port: 3001,
},
};
});

3011
yarn.lock

File diff suppressed because it is too large Load Diff