Build IP Sentinel operations console
This commit is contained in:
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnpm-store/
|
||||
/.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*
|
||||
.dev-output.log
|
||||
.dev-error.log
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
/dist/
|
||||
/.wrangler/
|
||||
/outputs/
|
||||
/work/
|
||||
5
.openai/hosting.json
Normal file
5
.openai/hosting.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"project_id": "appgprj_6a787035642c8191b105d60a8f8b111d",
|
||||
"d1": null,
|
||||
"r2": null
|
||||
}
|
||||
100
README.md
Normal file
100
README.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# vinext-starter
|
||||
|
||||
A clean full-stack starter running on
|
||||
[vinext](https://github.com/cloudflare/vinext), with optional Cloudflare D1 and
|
||||
Drizzle support.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js `>=22.13.0`
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
npm run build
|
||||
```
|
||||
|
||||
This starter does not use `wrangler.jsonc`.
|
||||
|
||||
## Included Shape
|
||||
|
||||
- edit site code under `app/`
|
||||
- `.openai/hosting.json` declares optional Sites D1 and R2 bindings
|
||||
- `vite.config.ts` simulates declared bindings for local development
|
||||
- `db/schema.ts` starts intentionally empty
|
||||
- `examples/d1/` contains an optional D1 example surface
|
||||
- `drizzle.config.ts` supports local migration generation when needed
|
||||
|
||||
## Workspace Auth Headers
|
||||
|
||||
Signed-in visitors receive both `oai-authenticated-user-id` and `oai-authenticated-user-email`. Private Sites require every visitor to sign in; public Sites may also have anonymous visitors, for whom neither header is present.
|
||||
|
||||
The user ID is stable for the same user on the same Site and different across Sites. Email and name are intended for display or contact purposes.
|
||||
|
||||
SIWC-authenticated workspace sites may also receive
|
||||
`oai-authenticated-user-full-name` when the user's SIWC profile has a non-empty
|
||||
`name` claim. The full-name value is percent-encoded UTF-8 and is accompanied by
|
||||
`oai-authenticated-user-full-name-encoding: percent-encoded-utf-8`.
|
||||
|
||||
Treat the full name as optional and fall back to email when it is absent:
|
||||
|
||||
```tsx
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export default async function Home() {
|
||||
const requestHeaders = await headers();
|
||||
const userId = requestHeaders.get("oai-authenticated-user-id");
|
||||
const email = requestHeaders.get("oai-authenticated-user-email");
|
||||
const encodedFullName = requestHeaders.get("oai-authenticated-user-full-name");
|
||||
const fullName =
|
||||
encodedFullName &&
|
||||
requestHeaders.get("oai-authenticated-user-full-name-encoding") ===
|
||||
"percent-encoded-utf-8"
|
||||
? decodeURIComponent(encodedFullName)
|
||||
: null;
|
||||
|
||||
const displayName = fullName ?? email;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Optional Dispatch-Owned ChatGPT Sign-In
|
||||
|
||||
Import the ready-to-use helpers from `app/chatgpt-auth.ts` when the site needs
|
||||
optional or required ChatGPT sign-in:
|
||||
|
||||
- Use `getChatGPTUser()` for optional signed-in UI.
|
||||
- Use `requireChatGPTUser(returnTo)` for server-rendered pages that should send
|
||||
anonymous visitors through Sign in with ChatGPT.
|
||||
- Use `chatGPTSignInPath(returnTo)` and `chatGPTSignOutPath(returnTo)` for
|
||||
browser links or actions.
|
||||
- Pass a same-origin relative `returnTo` path for the destination after sign-in
|
||||
or sign-out. The helper validates and safely encodes it.
|
||||
- Mark protected pages with `export const dynamic = "force-dynamic"` because
|
||||
they depend on per-request identity headers.
|
||||
|
||||
Dispatch owns `/signin-with-chatgpt`, `/signout-with-chatgpt`, `/callback`, the
|
||||
OAuth cookies, and identity header injection. Do not implement app routes for
|
||||
those reserved paths. Routes that do not import and call the helper remain
|
||||
anonymous-compatible.
|
||||
|
||||
SIWC establishes identity only; it does not prove workspace membership. Use the
|
||||
Sites hosting platform's access policy controls for workspace-wide restrictions,
|
||||
or enforce explicit server-side membership or allowlist checks.
|
||||
|
||||
Use SIWC for account pages, user-specific dashboards, saved records, and write
|
||||
actions tied to the current ChatGPT user. Leave public content anonymous.
|
||||
|
||||
## Useful Commands
|
||||
|
||||
- `npm run dev`: start local development
|
||||
- `npm run build`: verify the vinext build output
|
||||
- `npm test`: build the starter and verify its rendered loading skeleton
|
||||
- `npm run db:generate`: generate Drizzle migrations after schema changes
|
||||
|
||||
## Learn More
|
||||
|
||||
- [vinext Documentation](https://github.com/cloudflare/vinext)
|
||||
- [Drizzle D1 Guide](https://orm.drizzle.team/docs/get-started/d1-new)
|
||||
90
app/chatgpt-auth.ts
Normal file
90
app/chatgpt-auth.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export type ChatGPTUser = {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
fullName: string | null;
|
||||
};
|
||||
|
||||
const USER_ID_HEADER = "oai-authenticated-user-id";
|
||||
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 userId = requestHeaders.get(USER_ID_HEADER);
|
||||
const email = requestHeaders.get(USER_EMAIL_HEADER);
|
||||
if (!userId || !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 {
|
||||
userId,
|
||||
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;
|
||||
}
|
||||
}
|
||||
51
app/globals.css
Normal file
51
app/globals.css
Normal file
@@ -0,0 +1,51 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root { --ink:#18202b; --muted:#6f7885; --line:#e6e8ec; --navy:#172139; --blue:#2764e7; --canvas:#f5f6f8; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; background:var(--canvas); color:var(--ink); font-family:var(--font-geist-sans),Arial,sans-serif; }
|
||||
button,input { font:inherit; }
|
||||
button { cursor:pointer; }
|
||||
.topbar { height:68px; display:flex; align-items:center; padding:0 34px; background:#fff; border-bottom:1px solid var(--line); position:sticky; top:0; z-index:10; }
|
||||
.brand { display:flex; align-items:center; gap:10px; font-size:17px; font-weight:760; letter-spacing:-.03em; width:225px; }
|
||||
.brandMark { display:grid; place-items:center; width:31px; height:31px; background:var(--navy); color:#fff; border-radius:8px; font-size:11px; letter-spacing:.02em; }
|
||||
nav { display:flex; align-self:stretch; gap:30px; }
|
||||
nav button { border:0; background:none; color:#7a818b; font-size:13px; font-weight:600; position:relative; padding:0; }
|
||||
nav button:hover, nav .navActive { color:var(--ink); }
|
||||
nav .navActive:after { content:""; position:absolute; height:2px; background:var(--blue); bottom:0; left:0; right:0; }
|
||||
.topActions { margin-left:auto; display:flex; align-items:center; gap:10px; font-size:12px; }
|
||||
.topActions small { display:block; color:#8a919a; margin-top:2px; }
|
||||
.iconButton { border:0; background:transparent; color:#c2c7ce; font-size:9px; margin-right:8px; }
|
||||
.avatar { width:33px; height:33px; display:grid; place-items:center; background:#dce8ff; color:#2350a5; border-radius:50%; font-size:11px; font-weight:750; }
|
||||
.workspace { max-width:1360px; margin:0 auto; padding:35px 34px 55px; }
|
||||
.hero { display:flex; align-items:flex-end; justify-content:space-between; margin-bottom:24px; }
|
||||
.eyebrow { color:#476386!important; letter-spacing:.13em; font-size:10px!important; font-weight:750; margin-bottom:9px!important; }
|
||||
.hero h1 { margin:0; font-size:30px; line-height:1.15; letter-spacing:-.045em; }
|
||||
.hero p { color:var(--muted); margin:8px 0 0; font-size:13px; }
|
||||
.health { display:flex; align-items:center; gap:11px; background:#fff; padding:11px 15px; border:1px solid var(--line); border-radius:10px; font-size:11px; box-shadow:0 3px 12px #1f293708; }
|
||||
.health small { display:block; color:#8b929c; margin-top:3px; }.pulse { width:8px; height:8px; background:#29a36a; border-radius:50%; box-shadow:0 0 0 5px #29a36a18; }
|
||||
.lookup { display:flex; align-items:center; gap:15px; background:var(--navy); color:#fff; padding:20px 22px; border-radius:12px; box-shadow:0 9px 24px #1721391f; }
|
||||
.lookupIcon { width:36px; height:36px; border:1px solid #ffffff26; border-radius:8px; display:grid; place-items:center; font-size:24px; }
|
||||
.lookupCopy { min-width:235px; }.lookupCopy label { display:block; font-size:13px; font-weight:680; }.lookupCopy span { display:block; font-size:10px; color:#aab3c5; margin-top:4px; }
|
||||
.lookup input { min-width:220px; flex:1; height:42px; background:#273149; border:1px solid #ffffff1b; color:#fff; border-radius:7px; padding:0 15px; outline:none; font-family:var(--font-geist-mono); font-size:12px; }
|
||||
.lookup input:focus { border-color:#6c93f0; box-shadow:0 0 0 3px #2764e730; }.lookup input::placeholder { color:#8290a8; }
|
||||
.primary { height:42px; border:0; padding:0 18px; border-radius:7px; background:#fff; color:#172139; font-weight:700; font-size:12px; }.primary span { margin-left:14px; color:var(--blue); }
|
||||
.metrics { display:grid; grid-template-columns:repeat(4,1fr); gap:12px; margin:17px 0; }
|
||||
.metrics article { background:#fff; border:1px solid var(--line); border-radius:10px; padding:16px 17px; }
|
||||
.metricTop { display:flex; justify-content:space-between; align-items:center; color:#78818d; font-size:9px; letter-spacing:.09em; font-weight:700; }
|
||||
.metricTop i { font-style:normal; display:grid; place-items:center; width:26px; height:26px; border-radius:7px; font-size:14px; }.blue{color:#3374e8;background:#eaf1ff}.violet{color:#6c55c7;background:#f0edff}.red{color:#dc504f;background:#fff0ef}.amber{color:#bb7917;background:#fff5e4}
|
||||
.metrics article>strong { display:block; font-size:25px; letter-spacing:-.045em; margin:9px 0 5px; }.metrics article p { margin:0; color:#9299a2; font-size:10px; }.metrics b { color:#35946b; }.metrics em { color:#dc5c59; font-style:normal; font-weight:700; }
|
||||
.mainGrid { display:grid; grid-template-columns:minmax(0,1fr) 320px; gap:14px; align-items:start; }
|
||||
.panel { background:#fff; border:1px solid var(--line); border-radius:11px; overflow:hidden; }
|
||||
.panelHead { min-height:67px; padding:15px 18px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); }.panel h2 { margin:0; font-size:14px; }.panelHead p { margin:5px 0 0; font-size:10px; color:#89919b; }
|
||||
.filters { background:#f5f6f8; padding:3px; border-radius:7px; display:flex; }.filters button { border:0; background:transparent; color:#777f8b; border-radius:5px; padding:6px 10px; font-size:9px; }.filters button.active { background:#fff; color:#202936; box-shadow:0 1px 4px #00000012; font-weight:700; }
|
||||
.tableWrap { overflow-x:auto; } table { width:100%; border-collapse:collapse; } th { padding:10px 14px; background:#fafbfc; text-align:left; color:#8a929d; font-size:8px; letter-spacing:.08em; } td { padding:12px 14px; border-top:1px solid #eff0f2; font-size:11px; } tbody tr { transition:.15s; cursor:pointer; } tbody tr:hover, tbody tr.selected { background:#f7f9fd; } td code,.detail code { font-family:var(--font-geist-mono); font-weight:650; font-size:11px; } td small { display:block; margin-top:4px; color:#949ba4; font-size:9px; } td strong { font-size:10px; }
|
||||
.score { display:inline-grid; place-items:center; width:27px; height:27px; border-radius:50%; font-weight:750; font-size:10px; border:3px solid; }.score.critical{color:#cf4545;border-color:#f0a2a2}.score.elevated{color:#b2761b;border-color:#f2c779}.score.low{color:#28865d;border-color:#8fd2b4}
|
||||
.status { display:inline-flex; align-items:center; gap:5px; border-radius:20px; padding:5px 8px; font-size:8px; font-weight:750; }.status:before { content:""; width:5px; height:5px; border-radius:50%; }.status.review{color:#9a691e;background:#fff3dd}.status.review:before{background:#d69832}.status.blocked{color:#b63b3c;background:#ffeded}.status.blocked:before{background:#d64d4e}.status.allowed{color:#247e59;background:#e9f7f0}.status.allowed:before{background:#35a574}
|
||||
.viewAll,.fullReport { width:100%; border:0; border-top:1px solid var(--line); background:#fff; color:#46628e; padding:12px; font-size:9px; font-weight:700; }.viewAll:hover,.fullReport:hover { background:#f7f9fc; }
|
||||
.detailHead { padding:16px 17px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); }.detailHead p { margin:0 0 6px; font-size:8px; color:#8f97a2; letter-spacing:.1em; font-weight:700; }
|
||||
.riskBlock { display:flex; align-items:center; gap:15px; padding:18px 17px; border-bottom:1px solid var(--line); }.riskRing { width:63px; height:63px; border-radius:50%; border:5px solid; display:flex; align-items:baseline; justify-content:center; padding-top:16px; }.riskRing strong{font-size:20px}.riskRing span{font-size:8px;color:#8a929c}.riskRing.critical{border-color:#ef807a;color:#c43d3a}.riskRing.elevated{border-color:#e6b155;color:#a46a13}.riskRing.low{border-color:#64ba94;color:#277853}.riskBlock p{margin:0;color:#8b929c;font-size:9px}.riskBlock h3{margin:3px 0;font-size:14px}.riskBlock small{font-size:8px;color:#969da6}
|
||||
.signal { display:flex; justify-content:space-between; align-items:center; margin:0 17px; padding:12px 0; border-bottom:1px solid #eef0f2; }.signal span { font-size:8px; color:#969da6; letter-spacing:.08em; }.signal strong { font-size:9px; max-width:160px; text-align:right; }
|
||||
.actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:16px 17px; }.actions button { border:1px solid #dfe2e6; background:#fff; border-radius:6px; padding:9px 5px; font-size:9px; font-weight:700; }.actions button:hover { background:#f7f8fa; }.actions .danger { background:#c94747; color:#fff; border-color:#c94747; }.actions .danger:hover { background:#b63e3e; }
|
||||
.toast { position:fixed; right:28px; bottom:28px; background:#182235; color:#fff; border-radius:9px; padding:12px 17px; box-shadow:0 10px 28px #17213942; font-size:11px; display:flex; gap:9px; align-items:center; }.toast span { color:#62d49e; }
|
||||
@media(max-width:900px){.topbar{padding:0 18px}.brand{width:auto;margin-right:28px}.topActions>div:last-child{display:none}.workspace{padding:26px 18px}.lookup{flex-wrap:wrap}.lookupCopy{min-width:calc(100% - 55px)}.lookup input{min-width:0}.metrics{grid-template-columns:1fr 1fr}.mainGrid{grid-template-columns:1fr}.detail{order:-1}nav{gap:18px}.health{display:none}}
|
||||
@media(max-width:620px){nav{display:none}.hero h1{font-size:25px}.lookup input,.primary{width:100%;flex:auto}.metrics{grid-template-columns:1fr 1fr}.metrics article{padding:13px}.metrics article>strong{font-size:21px}.panelHead{align-items:flex-start;gap:12px;flex-direction:column}.filters{width:100%;overflow:auto}.filters button{flex:1;white-space:nowrap}.mainGrid{display:flex;flex-direction:column}.detail,.activity{width:100%}th:nth-child(2),td:nth-child(2),th:nth-child(3),td:nth-child(3){display:none}.topbar{height:60px}.workspace{padding-top:22px}.hero{margin-bottom:18px}}
|
||||
16
app/layout.tsx
Normal file
16
app/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
|
||||
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "IP Sentinel — Network Intelligence",
|
||||
description: "Internal IP reputation and network operations console.",
|
||||
icons: { icon: "/favicon.svg", shortcut: "/favicon.svg" },
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return <html lang="en"><body className={`${geistSans.variable} ${geistMono.variable}`}>{children}</body></html>;
|
||||
}
|
||||
93
app/page.tsx
Normal file
93
app/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
type Event = { ip: string; country: string; city: string; network: string; score: number; status: "Review" | "Blocked" | "Allowed"; seen: string; requests: string };
|
||||
|
||||
const initialEvents: Event[] = [
|
||||
{ ip: "185.220.101.42", country: "Germany", city: "Falkenstein", network: "AS60729 · Zwiebelfreunde", score: 96, status: "Review", seen: "12 sec ago", requests: "842" },
|
||||
{ ip: "45.134.26.91", country: "Netherlands", city: "Amsterdam", network: "AS9009 · M247 Europe", score: 88, status: "Blocked", seen: "1 min ago", requests: "391" },
|
||||
{ ip: "104.28.208.87", country: "United States", city: "San Francisco", network: "AS13335 · Cloudflare", score: 18, status: "Allowed", seen: "3 min ago", requests: "126" },
|
||||
{ ip: "193.32.162.14", country: "Romania", city: "Bucharest", network: "AS209605 · UAB Host Baltic", score: 73, status: "Review", seen: "7 min ago", requests: "214" },
|
||||
{ ip: "34.160.111.145", country: "United States", city: "Mountain View", network: "AS396982 · Google Cloud", score: 7, status: "Allowed", seen: "11 min ago", requests: "98" },
|
||||
];
|
||||
|
||||
function riskLabel(score: number) { return score >= 80 ? "Critical" : score >= 60 ? "Elevated" : "Low"; }
|
||||
|
||||
export default function Home() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [filter, setFilter] = useState("All traffic");
|
||||
const [events, setEvents] = useState(initialEvents);
|
||||
const [selected, setSelected] = useState<Event>(initialEvents[0]);
|
||||
const [notice, setNotice] = useState("");
|
||||
const visible = useMemo(() => events.filter((e) => filter === "All traffic" || e.status === filter), [events, filter]);
|
||||
|
||||
const inspect = () => {
|
||||
const value = query.trim();
|
||||
if (!value) return;
|
||||
const found = events.find((e) => e.ip === value);
|
||||
if (found) setSelected(found);
|
||||
else setSelected({ ip: value, country: "Unknown", city: "Unresolved", network: "No network record", score: 42, status: "Review", seen: "Just now", requests: "1" });
|
||||
setNotice(`Inspection opened for ${value}`);
|
||||
setTimeout(() => setNotice(""), 2400);
|
||||
};
|
||||
|
||||
const decide = (status: "Blocked" | "Allowed") => {
|
||||
setSelected((current) => ({ ...current, status }));
|
||||
setEvents((current) => current.map((event) => event.ip === selected.ip ? { ...event, status } : event));
|
||||
setNotice(`${selected.ip} ${status.toLowerCase()}`);
|
||||
setTimeout(() => setNotice(""), 2400);
|
||||
};
|
||||
|
||||
return (
|
||||
<main>
|
||||
<header className="topbar">
|
||||
<div className="brand"><span className="brandMark">IP</span><span>Sentinel</span></div>
|
||||
<nav aria-label="Primary navigation">
|
||||
<button className="navActive">Overview</button><button>Investigations</button><button>Rules</button><button>Reports</button>
|
||||
</nav>
|
||||
<div className="topActions"><button className="iconButton" aria-label="Notifications">●</button><div className="avatar">MK</div><div><strong>Maya Kim</strong><small>Security Ops</small></div></div>
|
||||
</header>
|
||||
|
||||
<div className="workspace">
|
||||
<section className="hero">
|
||||
<div><p className="eyebrow">NETWORK INTELLIGENCE</p><h1>Good morning, Maya.</h1><p>Here’s what your perimeter has seen in the last 24 hours.</p></div>
|
||||
<div className="health"><span className="pulse"/><div><strong>All systems operational</strong><small>Last sync 18 seconds ago</small></div></div>
|
||||
</section>
|
||||
|
||||
<section className="lookup" aria-label="IP address lookup">
|
||||
<div className="lookupIcon">⌕</div><div className="lookupCopy"><label htmlFor="ip">Inspect an IP address</label><span>Reputation, location, network and recent activity</span></div>
|
||||
<input id="ip" value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => e.key === "Enter" && inspect()} placeholder="Enter IPv4 or IPv6 address" />
|
||||
<button className="primary" onClick={inspect}>Inspect IP <span>→</span></button>
|
||||
</section>
|
||||
|
||||
<section className="metrics" aria-label="Traffic summary">
|
||||
<article><div className="metricTop"><span>REQUESTS SCANNED</span><i className="blue">↗</i></div><strong>2.48M</strong><p><b>+12.4%</b> from yesterday</p></article>
|
||||
<article><div className="metricTop"><span>UNIQUE ADDRESSES</span><i className="violet">◎</i></div><strong>18,294</strong><p><b>+3.1%</b> from yesterday</p></article>
|
||||
<article><div className="metricTop"><span>THREATS BLOCKED</span><i className="red">!</i></div><strong>1,847</strong><p><em>−8.2%</em> from yesterday</p></article>
|
||||
<article><div className="metricTop"><span>REVIEW QUEUE</span><i className="amber">⌁</i></div><strong>24</strong><p><b>7 critical</b> need attention</p></article>
|
||||
</section>
|
||||
|
||||
<div className="mainGrid">
|
||||
<section className="panel activity">
|
||||
<div className="panelHead"><div><h2>Recent activity</h2><p>Addresses with notable behavior</p></div><div className="filters">{["All traffic","Review","Blocked","Allowed"].map((name) => <button key={name} className={filter === name ? "active" : ""} onClick={() => setFilter(name)}>{name}</button>)}</div></div>
|
||||
<div className="tableWrap"><table><thead><tr><th>IP ADDRESS</th><th>LOCATION / NETWORK</th><th>REQUESTS</th><th>RISK</th><th>STATUS</th><th></th></tr></thead><tbody>{visible.map((event) => <tr key={event.ip} className={selected.ip === event.ip ? "selected" : ""} onClick={() => setSelected(event)}><td><code>{event.ip}</code><small>{event.seen}</small></td><td><strong>{event.city}, {event.country}</strong><small>{event.network}</small></td><td>{event.requests}</td><td><span className={`score ${riskLabel(event.score).toLowerCase()}`}>{event.score}</span></td><td><span className={`status ${event.status.toLowerCase()}`}>{event.status}</span></td><td>›</td></tr>)}</tbody></table></div>
|
||||
<button className="viewAll">View all investigations →</button>
|
||||
</section>
|
||||
|
||||
<aside className="panel detail">
|
||||
<div className="detailHead"><div><p>SELECTED ADDRESS</p><code>{selected.ip}</code></div><span className={`status ${selected.status.toLowerCase()}`}>{selected.status}</span></div>
|
||||
<div className="riskBlock"><div className={`riskRing ${riskLabel(selected.score).toLowerCase()}`}><strong>{selected.score}</strong><span>/100</span></div><div><p>Risk assessment</p><h3>{riskLabel(selected.score)} risk</h3><small>Based on 14 intelligence signals</small></div></div>
|
||||
<div className="signal"><span>TOR EXIT NODE</span><strong>{selected.score > 80 ? "Detected" : "Not detected"}</strong></div>
|
||||
<div className="signal"><span>ABUSE REPORTS</span><strong>{selected.score > 80 ? "47 in 30 days" : "None recent"}</strong></div>
|
||||
<div className="signal"><span>NETWORK</span><strong>{selected.network.split(" · ")[0]}</strong></div>
|
||||
<div className="signal"><span>LOCATION</span><strong>{selected.city}, {selected.country}</strong></div>
|
||||
<div className="actions"><button className="danger" onClick={() => decide("Blocked")}>Block address</button><button onClick={() => decide("Allowed")}>Add to allowlist</button></div>
|
||||
<button className="fullReport">Open full investigation →</button>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
{notice && <div className="toast" role="status"><span>✓</span>{notice}</div>}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
45
build/sites-vite-plugin.ts
Normal file
45
build/sites-vite-plugin.ts
Normal 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
db/index.ts
Normal file
13
db/index.ts
Normal 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
db/schema.ts
Normal file
4
db/schema.ts
Normal 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 {};
|
||||
7
drizzle.config.ts
Normal file
7
drizzle.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
out: "./drizzle",
|
||||
schema: "./db/schema.ts",
|
||||
dialect: "sqlite",
|
||||
});
|
||||
5
drizzle/meta/_journal.json
Normal file
5
drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": []
|
||||
}
|
||||
41
eslint.config.mjs
Normal file
41
eslint.config.mjs
Normal file
@@ -0,0 +1,41 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import eslint from "@eslint/js";
|
||||
import next from "@next/eslint-plugin-next";
|
||||
import jsxA11y from "eslint-plugin-jsx-a11y";
|
||||
import react from "eslint-plugin-react";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import globals from "globals";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
globalIgnores([
|
||||
".next/**",
|
||||
"dist/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
react.configs.flat.recommended,
|
||||
react.configs.flat["jsx-runtime"],
|
||||
reactHooks.configs.flat["recommended-latest"],
|
||||
jsxA11y.flatConfigs.recommended,
|
||||
next.configs["core-web-vitals"],
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
...globals.serviceworker,
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
58
examples/d1/app/api/notes/route.ts
Normal file
58
examples/d1/app/api/notes/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
9
examples/d1/db/schema.ts
Normal file
9
examples/d1/db/schema.ts
Normal 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`),
|
||||
});
|
||||
5
next-env.d.ts
vendored
Normal file
5
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
import "vinext/types";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
46
package.json
Normal file
46
package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "site-creator-vinext-starter",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.13.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vinext dev",
|
||||
"build": "vinext build",
|
||||
"start": "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",
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vite-plugin": "1.37.1",
|
||||
"@eslint/js": "9.39.4",
|
||||
"@next/eslint-plugin-next": "16.2.6",
|
||||
"@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-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"globals": "16.4.0",
|
||||
"react-server-dom-webpack": "19.2.6",
|
||||
"tailwindcss": "4.2.1",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.59.3",
|
||||
"vinext": "1.0.0-beta.2",
|
||||
"vite": "8.0.13",
|
||||
"wrangler": "4.92.0"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
6188
pnpm-lock.yaml
generated
Normal file
6188
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
6
public/favicon.svg
Normal file
6
public/favicon.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M22 19.2727C22 20.779 20.779 22 19.2727 22H14.7273C13.221 22 12 20.779 12 19.2727V12H19.2727C20.779 12 22 13.221 22 14.7273V19.2727Z" fill="#68C4FF"/>
|
||||
<path d="M20 2C21.1046 2 22 2.89543 22 4V7C22 8.10457 21.1046 9 20 9H17C15.8954 9 15 8.10457 15 7V4C15 2.89543 15.8954 2 17 2H20Z" fill="#0C79D8"/>
|
||||
<path d="M7 15C8.10457 15 9 15.8954 9 17V20C9 21.1046 8.10457 22 7 22H4C2.89543 22 2 21.1046 2 20V17C2 15.8954 2.89543 15 4 15H7Z" fill="#0C79D8"/>
|
||||
<path d="M12 12H4.72727C3.22104 12 2 10.779 2 9.27273V4.72727C2 3.22104 3.22104 2 4.72727 2H9.27273C10.779 2 12 3.22104 12 4.72727V12Z" fill="#2E9EFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 712 B |
1
public/file.svg
Normal file
1
public/file.svg
Normal 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: 392 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal 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 |
1
public/window.svg
Normal file
1
public/window.svg
Normal 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: 386 B |
91
tests/rendered-html.test.mjs
Normal file
91
tests/rendered-html.test.mjs
Normal file
@@ -0,0 +1,91 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { access, readFile, readdir } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
const developmentPreviewMeta =
|
||||
/<meta(?=[^>]*\bname=["']codex-preview["'])(?=[^>]*\bcontent=["']development["'])[^>]*>/i;
|
||||
const templateRoot = new URL("../", import.meta.url);
|
||||
const previewRoot = new URL("../app/_sites-preview/", import.meta.url);
|
||||
|
||||
async function render() {
|
||||
const workerUrl = new URL("../dist/server/index.js", import.meta.url);
|
||||
workerUrl.searchParams.set("test", `${process.pid}-${Date.now()}`);
|
||||
const { default: worker } = await import(workerUrl.href);
|
||||
|
||||
return worker.fetch(
|
||||
new Request("http://localhost/", {
|
||||
headers: { accept: "text/html" },
|
||||
}),
|
||||
{
|
||||
ASSETS: {
|
||||
fetch: async () => new Response("Not found", { status: 404 }),
|
||||
},
|
||||
},
|
||||
{
|
||||
waitUntil() {},
|
||||
passThroughOnException() {},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test("server-renders the starter loading skeleton", async () => {
|
||||
const response = await render();
|
||||
assert.equal(response.status, 200);
|
||||
assert.match(response.headers.get("content-type") ?? "", /^text\/html\b/i);
|
||||
|
||||
const html = await response.text();
|
||||
assert.match(html, developmentPreviewMeta);
|
||||
assert.match(html, /<title>Your site is taking shape<\/title>/i);
|
||||
assert.match(html, /Building your site/);
|
||||
assert.match(html, /Your site is taking shape/);
|
||||
assert.match(
|
||||
html,
|
||||
/Your first version will appear here automatically when it’s ready\./,
|
||||
);
|
||||
assert.doesNotMatch(html, /Codex/);
|
||||
assert.match(html, /react-loading-skeleton/);
|
||||
assert.match(html, /role="status"/);
|
||||
});
|
||||
|
||||
test("keeps the loading skeleton scoped and disposable", async () => {
|
||||
const [preview, css, page, layout, packageJson, files] = await Promise.all([
|
||||
readFile(new URL("SkeletonPreview.tsx", previewRoot), "utf8"),
|
||||
readFile(new URL("preview.css", previewRoot), "utf8"),
|
||||
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"),
|
||||
readdir(previewRoot),
|
||||
]);
|
||||
|
||||
assert.deepEqual(files.sort(), ["SkeletonPreview.tsx", "preview.css"]);
|
||||
assert.match(preview, /from "react-loading-skeleton"/);
|
||||
assert.match(preview, /baseColor="#eceae7"/);
|
||||
assert.match(preview, /highlightColor="#f9f8f6"/);
|
||||
assert.match(preview, /duration=\{2\.8\}/);
|
||||
assert.match(preview, /sites-skeleton-search-placeholder/);
|
||||
assert.match(packageJson, /"react-loading-skeleton": "3\.5\.0"/);
|
||||
|
||||
const shellIndex = preview.indexOf('className="sites-skeleton-shell"');
|
||||
const statusIndex = preview.indexOf('className="sites-skeleton-status"');
|
||||
assert.ok(shellIndex >= 0 && statusIndex > shellIndex);
|
||||
assert.match(css, /position:\s*fixed/);
|
||||
assert.match(css, /inset:\s*0/);
|
||||
assert.match(css, /opacity:\s*0\.52/);
|
||||
assert.match(css, /prefers-reduced-motion:\s*reduce/);
|
||||
assert.doesNotMatch(css, /#020617|canvas|pets|progress/i);
|
||||
assert.doesNotMatch(
|
||||
preview,
|
||||
/loading-spinner|status-mark|status-progress|canvas|cookie|random/i,
|
||||
);
|
||||
|
||||
assert.match(page, /export const metadata:\s*Metadata/);
|
||||
assert.match(page, /"codex-preview": "development"/);
|
||||
assert.match(page, /<SkeletonPreview \/>/);
|
||||
assert.match(layout, /title:\s*"Starter Project"/);
|
||||
assert.doesNotMatch(layout, /codex-preview|_sites-preview|themeColor|\bViewport\b/);
|
||||
assert.doesNotMatch(css, /(^|\s)(html|body)\s*\{/m);
|
||||
|
||||
await assert.rejects(
|
||||
access(new URL("public/_sites-preview", templateRoot)),
|
||||
);
|
||||
});
|
||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"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,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
59
vite.config.ts
Normal file
59
vite.config.ts
Normal 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,
|
||||
}),
|
||||
],
|
||||
};
|
||||
});
|
||||
47
worker/index.ts
Normal file
47
worker/index.ts
Normal 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;
|
||||
Reference in New Issue
Block a user