Initial commit: KOC LOOP platform

This commit is contained in:
巫凤萍
2026-07-30 12:06:41 +08:00
commit d2cde2c9b2
98 changed files with 44076 additions and 0 deletions

43
koc-portal/.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/.vinext/
/out/
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
.dev.vars
# vercel
.vercel
# typescript
next-env.d.ts
/dist/
/.wrangler/
/outputs/
/work/

View File

@@ -0,0 +1,5 @@
{
"project_id": "appgprj_6a6760b6f5bc8191ae5baf5cfed80804",
"d1": null,
"r2": null
}

21
koc-portal/README.md Normal file
View File

@@ -0,0 +1,21 @@
# KOC LOOP 外部任务门户
独立公开站点,用于把后台分发任务发给外部 KOC。
## MVP 流程
1. KOC 通过带 `task` 参数的任务链接进入。
2. 只填写企微昵称/联系人和领取数量。
3. 领取后只能看到本次领取的笔记。
4. 在单篇笔记详情页查看标题与正文,并一一回填发布账号昵称、发布链接和发布截图。
门户不直接连接数据库。浏览器只调用后台隔离开放的 KOC 领取与回填接口,后台仍是任务、笔记和发布数据的唯一数据源;后台管理页面和管理接口需要管理员登录。
## 本地开发
先在 `3001` 端口启动后台,再运行:
```bash
npm install
npm run dev -- --port 3000
```

View File

@@ -0,0 +1,86 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export type ChatGPTUser = {
displayName: string;
email: string;
fullName: string | null;
};
const USER_EMAIL_HEADER = "oai-authenticated-user-email";
const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name";
const USER_FULL_NAME_ENCODING_HEADER =
"oai-authenticated-user-full-name-encoding";
const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8";
const SIGN_IN_PATH = "/signin-with-chatgpt";
const SIGN_OUT_PATH = "/signout-with-chatgpt";
const CALLBACK_PATH = "/callback";
export async function getChatGPTUser(): Promise<ChatGPTUser | null> {
const requestHeaders = await headers();
const email = requestHeaders.get(USER_EMAIL_HEADER);
if (!email) return null;
const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER);
const fullName =
encodedFullName &&
requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8
? safeDecodeURIComponent(encodedFullName)
: null;
return {
displayName: fullName ?? email,
email,
fullName,
};
}
export async function requireChatGPTUser(
returnTo: string,
): Promise<ChatGPTUser> {
const user = await getChatGPTUser();
if (user) return user;
redirect(chatGPTSignInPath(returnTo));
}
export function chatGPTSignInPath(returnTo: string): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}
export function chatGPTSignOutPath(returnTo = "/"): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}
function safeRelativeReturnPath(value: string): string {
if (!value.startsWith("/") || value.startsWith("//")) return "/";
let url: URL;
try {
url = new URL(value, "https://app.local");
} catch {
return "/";
}
if (url.origin !== "https://app.local") return "/";
if (isReservedAuthPath(url.pathname)) return "/";
return `${url.pathname}${url.search}${url.hash}`;
}
function isReservedAuthPath(pathname: string): boolean {
return (
pathname === SIGN_IN_PATH ||
pathname === SIGN_OUT_PATH ||
pathname === CALLBACK_PATH
);
}
function safeDecodeURIComponent(value: string): string | null {
try {
return decodeURIComponent(value);
} catch {
return null;
}
}

View File

@@ -0,0 +1,26 @@
export function parseStoredDate(value: string) {
const trimmed = value.trim();
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(trimmed)
? `${trimmed}T00:00:00+08:00`
: /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/.test(
trimmed,
)
? `${trimmed.replace(" ", "T")}Z`
: trimmed;
return new Date(normalized);
}
export function formatShanghaiDate(
value?: string | null,
withTime = false,
) {
if (!value) return "—";
const date = parseStoredDate(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
month: "2-digit",
day: "2-digit",
...(withTime ? { hour: "2-digit", minute: "2-digit" } : {}),
}).format(date);
}

1572
koc-portal/app/globals.css Normal file

File diff suppressed because it is too large Load Diff

77
koc-portal/app/layout.tsx Normal file
View File

@@ -0,0 +1,77 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { headers } from "next/headers";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export async function generateMetadata(): Promise<Metadata> {
const requestHeaders = await headers();
const host = requestHeaders.get("x-forwarded-host") || requestHeaders.get("host");
const protocol =
requestHeaders.get("x-forwarded-proto") ||
(host?.startsWith("localhost") ? "http" : "https");
const metadataBase = host
? new URL(`${protocol}://${host}`)
: new URL("https://koc-task.example.com");
const description =
"领取KOC内容任务逐篇查看笔记详情并一一回填发布账号、链接与截图。";
return {
metadataBase,
title: "KOC LOOP外部任务领取",
description,
robots: {
index: false,
follow: false,
noarchive: true,
nosnippet: true,
},
referrer: "no-referrer",
icons: {
icon: "/favicon.svg",
shortcut: "/favicon.svg",
},
openGraph: {
title: "KOC LOOP外部任务领取",
description,
type: "website",
images: [
{
url: new URL("/og.png", metadataBase).toString(),
width: 1200,
height: 630,
alt: "KOC LOOP 外部任务领取与逐篇回填",
},
],
},
twitter: {
card: "summary_large_image",
title: "KOC LOOP外部任务领取",
description,
images: [new URL("/og.png", metadataBase).toString()],
},
};
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<body className={`${geistSans.variable} ${geistMono.variable}`}>
{children}
</body>
</html>
);
}

1363
koc-portal/app/page.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
import { access, cp, mkdir, rm } from "node:fs/promises";
import { resolve } from "node:path";
import type { Plugin } from "vite";
async function exists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return false;
}
throw error;
}
}
// Packages Sites metadata and migrations after Vite finishes compiling.
export function sites(): Plugin {
let root = process.cwd();
return {
name: "sites",
apply: "build",
configResolved(config) {
root = config.root;
},
async closeBundle() {
const outputDirectory = resolve(root, "dist", ".openai");
const hostingConfig = resolve(root, ".openai", "hosting.json");
const drizzleSource = resolve(root, "drizzle");
await rm(outputDirectory, { recursive: true, force: true });
await mkdir(outputDirectory, { recursive: true });
if (await exists(hostingConfig)) {
await cp(hostingConfig, resolve(outputDirectory, "hosting.json"));
}
if (await exists(drizzleSource)) {
await cp(drizzleSource, resolve(outputDirectory, "drizzle"), {
recursive: true,
});
}
},
};
}

13
koc-portal/db/index.ts Normal file
View File

@@ -0,0 +1,13 @@
import { env } from "cloudflare:workers";
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";
export function getDb() {
if (!env.DB) {
throw new Error(
"Cloudflare D1 binding `DB` is unavailable. Set the `d1` field in .openai/hosting.json to `DB` or let your control plane inject the real binding values before using the database."
);
}
return drizzle(env.DB, { schema });
}

4
koc-portal/db/schema.ts Normal file
View File

@@ -0,0 +1,4 @@
// Intentionally empty by default.
// Add Drizzle tables here when the site actually needs a database.
// See examples/d1/db/schema.ts for an opt-in example.
export {};

View File

@@ -0,0 +1,7 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
out: "./drizzle",
schema: "./db/schema.ts",
dialect: "sqlite",
});

View File

@@ -0,0 +1,5 @@
{
"version": "7",
"dialect": "sqlite",
"entries": []
}

View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View File

@@ -0,0 +1,58 @@
import { desc } from "drizzle-orm";
import { getDb } from "../../../../../db";
import { notes } from "../../../db/schema";
function toRouteErrorMessage(error: unknown) {
const message = error instanceof Error ? error.message : "Unexpected error";
const detail =
error instanceof Error && error.cause instanceof Error ? error.cause.message : "";
const combined = `${message}\n${detail}`;
if (combined.includes("no such table") || combined.includes('from "notes"')) {
return "The notes table is unavailable. Generate the migration locally with `npm run db:generate`, then deploy so the platform can apply the generated SQL to the real D1 database.";
}
return message;
}
export async function GET() {
try {
const db = getDb();
const rows = await db
.select()
.from(notes)
.orderBy(desc(notes.createdAt), desc(notes.id))
.limit(20);
return Response.json({ notes: rows });
} catch (error) {
return Response.json(
{ error: toRouteErrorMessage(error) },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const payload = (await request.json()) as {
title?: string;
content?: string;
};
const title = payload.title?.trim() ?? "";
const content = payload.content?.trim() ?? "";
if (!title) {
return Response.json({ error: "title is required" }, { status: 400 });
}
const db = getDb();
const [note] = await db.insert(notes).values({ title, content }).returning();
return Response.json({ note }, { status: 201 });
} catch (error) {
return Response.json(
{ error: toRouteErrorMessage(error) },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,9 @@
import { sql } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const notes = sqliteTable("notes", {
id: integer("id").primaryKey({ autoIncrement: true }),
title: text("title").notNull(),
content: text("content").notNull().default(""),
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
});

View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

11169
koc-portal/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
koc-portal/package.json Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "koc-task-portal",
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=22.13.0"
},
"scripts": {
"dev": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext dev",
"build": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext build",
"start": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext start",
"test": "npm run build && node --test tests/rendered-html.test.mjs",
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next",
"db:generate": "drizzle-kit generate"
},
"dependencies": {
"drizzle-orm": "0.45.2",
"fflate": "0.7.4",
"next": "16.2.6",
"react": "19.2.6",
"react-dom": "19.2.6"
},
"devDependencies": {
"@cloudflare/vite-plugin": "1.37.1",
"@tailwindcss/postcss": "4.2.1",
"@types/node": "22.19.19",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.2",
"@vitejs/plugin-rsc": "0.5.26",
"drizzle-kit": "0.31.10",
"eslint": "9.39.4",
"eslint-config-next": "16.2.6",
"react-server-dom-webpack": "19.2.6",
"tailwindcss": "4.2.1",
"typescript": "5.9.3",
"vinext": "0.0.50",
"vite": "8.0.13",
"wrangler": "4.92.0"
},
"type": "module"
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@@ -0,0 +1,5 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="9" fill="#172D27"/>
<path d="M9 8V24M9 16L20 8M9 16L21 24" stroke="#68D0A6" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="23.5" cy="9.5" r="2.5" fill="#F39A70"/>
</svg>

After

Width:  |  Height:  |  Size: 338 B

View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
koc-portal/public/og.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import { access, readFile } from "node:fs/promises";
import test from "node:test";
import {
formatShanghaiDate,
parseStoredDate,
} from "../app/date-utils.ts";
test("builds the branded external task shell", async () => {
const [page, layout] = await Promise.all([
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"),
]);
assert.match(layout, /KOC LOOP外部任务领取/);
assert.match(layout, /og\.png/);
assert.match(page, /正在打开任务/);
await access(new URL("../dist/server/index.js", import.meta.url));
});
test("keeps claiming minimal and backfill one-to-one", async () => {
const [page, layout, packageJson, hosting] =
await Promise.all([
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"),
readFile(new URL("../package.json", import.meta.url), "utf8"),
readFile(new URL("../.openai/hosting.json", import.meta.url), "utf8"),
]);
assert.match(page, /企微昵称\s*\/\s*联系人/);
assert.match(page, /const \[quantity, setQuantity\] = useState\(1\)/);
assert.match(page, /distributionId:\s*selected\.id/);
assert.match(page, /发布账号昵称/);
assert.match(page, /发布链接/);
assert.match(page, /inputMode="url"/);
assert.match(page, /长链、短链或整段分享文案/);
assert.doesNotMatch(page, /type="url"/);
assert.match(page, /发布截图/);
assert.match(page, /发布配图/);
assert.match(page, /复制标题/);
assert.match(page, /复制文案/);
assert.match(page, /下载原图/);
assert.match(page, /批量保存图片/);
assert.match(page, /navigator\.share/);
assert.match(page, /zipSync/);
assert.match(page, /navigator\.clipboard\.writeText/);
assert.match(page, /URL\.createObjectURL/);
assert.match(page, /\/api\/partner-image/);
assert.match(page, /找回领取记录/);
assert.match(page, /action:\s*"recover"/);
assert.match(page, /同一任务多次领取会分批展示/);
assert.match(page, /这篇笔记已回填,不会与其他笔记错配/);
assert.doesNotMatch(page, /批量回填/);
assert.doesNotMatch(page, /复制标题和正文/);
assert.match(page, /PRODUCTION_ADMIN_ORIGIN/);
assert.match(page, /\/api\/partner-upload/);
assert.match(page, /"X-KOC-Distribution":\s*selectedItem\.id/);
assert.match(page, /"X-KOC-Upload-Kind":\s*kind/);
assert.match(page, /上传截图并填写数据/);
assert.match(page, /提交创作者数据/);
assert.match(page, /submit_creator_metrics/);
assert.match(page, /creatorExposure/);
assert.match(page, /creatorViews/);
assert.match(page, /截图仅用于运营核对不再自动OCR/);
assert.match(page, /曝光量/);
assert.match(page, /阅读量/);
assert.doesNotMatch(page, /recognizeCreatorMetrics/);
assert.match(page, /note-index \$\{item\.publish_url \? "done" : ""\}/);
assert.match(packageJson, /"fflate":\s*"0\.7\.4"/);
assert.doesNotMatch(packageJson, /tesseract\.js/);
assert.match(layout, /逐篇查看笔记详情并一一回填/);
assert.doesNotMatch(packageJson, /react-loading-skeleton/);
const hostingConfig = JSON.parse(hosting);
assert.equal(hostingConfig.d1, null);
assert.equal(hostingConfig.r2, null);
await access(new URL("../public/og.png", import.meta.url));
await access(new URL("../public/favicon.svg", import.meta.url));
});
test("shows D1 timestamps in Beijing time", () => {
const stored = "2026-07-29 05:36:00";
assert.equal(parseStoredDate(stored).toISOString(), "2026-07-29T05:36:00.000Z");
assert.match(formatShanghaiDate(stored, true), /07\/29.*13:36/);
});
test("creates anonymous delegation bundles and reuses one-to-one backfill", async () => {
const [page, layout, styles] = await Promise.all([
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
]);
assert.match(page, /选择笔记并分享/);
assert.match(page, /生成并复制分享链接/);
assert.match(page, /action:\s*"create_delegation"/);
assert.match(page, /action:\s*"revoke_delegation"/);
assert.match(page, /合作社转派 · 无需登录/);
assert.match(page, /"X-KOC-Delegation"/);
assert.match(page, /url\.searchParams\.set\("share", shareToken\)/);
assert.match(page, /请保存当前分享链接/);
assert.doesNotMatch(page, /底层KOC手机号|底层KOC企微|底层KOC微信/);
assert.match(layout, /index:\s*false/);
assert.match(layout, /referrer:\s*"no-referrer"/);
assert.match(styles, /\.share-checkbox/);
assert.match(styles, /\.delegation-history/);
});

34
koc-portal/tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"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": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

59
koc-portal/vite.config.ts Normal file
View File

@@ -0,0 +1,59 @@
import vinext from "vinext";
import { defineConfig } from "vite";
import hostingConfig from "./.openai/hosting.json";
import { sites } from "./build/sites-vite-plugin";
const SITE_CREATOR_PLACEHOLDER_DATABASE_ID =
"00000000-0000-4000-8000-000000000000";
const { d1, r2 } = hostingConfig;
// macOS Seatbelt blocks FSEvents, so Codex previews need polling for HMR.
const isCodexSeatbeltSandbox = process.env.CODEX_SANDBOX === "seatbelt";
const localBindingConfig = {
main: "./worker/index.ts",
compatibility_flags: ["nodejs_compat"],
d1_databases: d1
? [
{
binding: d1,
database_name: "site-creator-d1",
database_id: SITE_CREATOR_PLACEHOLDER_DATABASE_ID,
},
]
: [],
r2_buckets: r2
? [
{
binding: r2,
bucket_name: "site-creator-r2",
},
]
: [],
};
export default defineConfig(async () => {
// Keep Wrangler and Miniflare state project-local. These are non-secret tool
// settings; application environment belongs in ignored `.env*` files.
process.env.WRANGLER_WRITE_LOGS ??= "false";
process.env.WRANGLER_LOG_PATH ??= ".wrangler/logs";
process.env.MINIFLARE_REGISTRY_PATH ??= ".wrangler/registry";
// Wrangler snapshots its log path while the Cloudflare plugin is imported.
const { cloudflare } = await import("@cloudflare/vite-plugin");
return {
server: isCodexSeatbeltSandbox
? { watch: { useFsEvents: false, usePolling: true } }
: undefined,
plugins: [
vinext(),
sites(),
cloudflare({
viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] },
config: localBindingConfig,
}),
],
};
});

View File

@@ -0,0 +1,47 @@
/** Cloudflare Worker entry point for the vinext-starter template. */
import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } from "vinext/server/image-optimization";
import handler from "vinext/server/app-router-entry";
interface Env {
ASSETS: Fetcher;
DB: D1Database;
IMAGES: {
input(stream: ReadableStream): {
transform(options: Record<string, unknown>): {
output(options: { format: string; quality: number }): Promise<{ response(): Response }>;
};
};
};
}
interface ExecutionContext {
waitUntil(promise: Promise<unknown>): void;
passThroughOnException(): void;
}
// Image security config. SVG sources with .svg extension auto-skip the
// optimization endpoint on the client side (served directly, no proxy).
// To route SVGs through the optimizer (with security headers), set
// dangerouslyAllowSVG: true in next.config.js and uncomment below:
// const imageConfig: ImageConfig = { dangerouslyAllowSVG: true };
const worker = {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/_vinext/image") {
const allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES];
return handleImageOptimization(request, {
fetchAsset: (path) => env.ASSETS.fetch(new Request(new URL(path, request.url))),
transformImage: async (body, { width, format, quality }) => {
const result = await env.IMAGES.input(body).transform(width > 0 ? { width } : {}).output({ format, quality });
return result.response();
},
}, allowedWidths);
}
return handler.fetch(request, env, ctx);
},
};
export default worker;