Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Check realtime generated types
run: npm run check:realtime-types

- name: Run ESLint
run: npm run lint

Expand Down
12 changes: 12 additions & 0 deletions openapi/refract-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,18 @@ components:
type: string
enum: [low, medium, high, triggered]
message: { type: string }
RealtimeEnvelope:
type: object
required: [topic, payload]
properties:
topic: { type: string }
payload:
type: object
additionalProperties: true
metadata:
type: object
nullable: true
additionalProperties: true
ClaimRecord:
type: object
required: [policyId, holder, coverageType, triggered, payout, reason, processedAt]
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
"size": "size-limit",
"size:why": "size-limit --why",
"tokens:check": "node scripts/check-tokens.mjs",
"gen:realtime-types": "node scripts/gen-realtime-types.mjs",
"check:realtime-types": "node scripts/gen-realtime-types.mjs --check",
"realtime:benchmark": "node scripts/realtime-benchmark.mjs",
"mock:realtime": "node scripts/mock-realtime-server.mjs"
},
Expand Down
5 changes: 5 additions & 0 deletions scripts/fixtures/malformed-openapi.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
openapi: 3.0.3
components:
schemas:
Unrelated:
type: object
128 changes: 128 additions & 0 deletions scripts/gen-realtime-types.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";

const root = process.cwd();
const specPath = resolve(root, process.env.REALTIME_OPENAPI_SPEC ?? "openapi/refract-api.yaml");
const outPath = resolve(root, "src/lib/realtime/generated/schemas.ts");
const indexPath = resolve(root, "src/lib/realtime/generated/index.ts");
const check = process.argv.includes("--check");
const spec = readFileSync(specPath, "utf8");
if (
!/^openapi:\s*3\./m.test(spec) ||
!/^components:\s*$/m.test(spec) ||
!/^ schemas:\s*$/m.test(spec)
)
throw new Error("Invalid OpenAPI document: expected OpenAPI 3 and components.schemas");
const wanted = ["OracleReading", "ClaimRecord", "PoolStats", "RealtimeEnvelope"];
const schemas = {};
for (const name of wanted) {
const start = spec.indexOf(` ${name}:`);
if (start < 0) throw new Error(`Required realtime schema '${name}' is missing from ${specPath}`);
const boundary = spec.slice(start + 5).search(/\n [A-Z][A-Za-z0-9]*:/);
const next = boundary < 0 ? -1 : start + 5 + boundary;
const block = spec.slice(start, next < 0 ? undefined : next);
const requiredLine = block.match(/^ required:\s*\[([^\]]*)\]/m)?.[1];
if (!requiredLine) throw new Error(`Schema '${name}' has no required field declaration`);
const required = new Set(
requiredLine
.split(",")
.map((v) => v.trim())
.filter(Boolean)
);
const propsStart = block.indexOf(" properties:");
if (propsStart < 0) throw new Error(`Schema '${name}' has no properties`);
const propsText = block.slice(propsStart + 7);
const props = [];
const lines = propsText.split("\n");
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
const field = line.match(/^ ([A-Za-z][\w]*):(?:\s*\{\s*(.*?)\s*\})?\s*$/);
if (!field) continue;
let descriptor = field[2] ?? "";
if (!descriptor) {
const nested = lines.slice(i + 1, i + 5).join(" ");
descriptor = nested;
}
const type = descriptor.match(
/(?:^|[,\s])type:\s*(string|number|integer|boolean|object|array)/
)?.[1];
const enumBody = descriptor.match(/enum:\s*\[([^\]]*)\]/)?.[1];
const nullable = /nullable:\s*true/.test(descriptor);
if (!type && !enumBody) throw new Error(`Unsupported or malformed field '${name}.${field[1]}'`);
let tsType =
type === "integer" || type === "number"
? "number"
: type === "boolean"
? "boolean"
: type === "object"
? "Record<string, unknown>"
: type === "array"
? "unknown[]"
: "string";
let zod =
type === "number" || type === "integer"
? "z.number()"
: type === "boolean"
? "z.boolean()"
: type === "object"
? "z.record(z.string(), z.unknown())"
: "z.string()";
if (enumBody) {
const vals = enumBody
.split(",")
.map((v) => v.trim())
.filter(Boolean);
tsType = vals
.map((v) => v.replace(/^['"]|['"]$/g, ""))
.map((v) => JSON.stringify(v))
.join(" | ");
zod = `z.enum([${vals.map((v) => JSON.stringify(v.replace(/^['"]|['"]$/g, ""))).join(", ")}])`;
}
if (nullable) {
tsType += " | null";
zod += ".nullable()";
}
const optional = !required.has(field[1]);
props.push({ key: field[1], tsType, zod: optional ? `${zod}.optional()` : zod, optional });
}
props.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
for (const field of required)
if (!props.some((p) => p.key === field))
throw new Error(`Required field '${name}.${field}' is missing from properties`);
schemas[name] = props;
}
const lines = [
"// Generated by scripts/gen-realtime-types.mjs. Do not edit.",
'import { z } from "zod";',
"",
];
for (const [name, props] of Object.entries(schemas)) {
lines.push(`export interface ${name} {`);
for (const p of props) lines.push(` ${p.key}${p.optional ? "?" : ""}: ${p.tsType};`);
lines.push("}", `export const ${name}Schema = z.object({`);
for (const p of props) lines.push(` ${p.key}: ${p.zod},`);
lines.push("});", "");
}
const output = `${lines.join("\n").trimEnd()}\n`;
const indexOutput = `// Generated by scripts/gen-realtime-types.mjs. Do not edit.\nexport { ${wanted.map((n) => `${n}Schema`).join(", ")} } from "./schemas";\nexport type { ${wanted.join(", ")} } from "./schemas";\n`;
if (check) {
let current = "";
try {
current = readFileSync(outPath, "utf8");
} catch {
/* drift */
}
let currentIndex = "";
try {
currentIndex = readFileSync(indexPath, "utf8");
} catch {
/* drift */
}
if (current !== output || currentIndex !== indexOutput) {
console.error("Realtime generated files are stale. Run npm run gen:realtime-types.");
process.exitCode = 1;
}
} else {
writeFileSync(outPath, output);
writeFileSync(indexPath, indexOutput);
}
16 changes: 16 additions & 0 deletions scripts/gen-realtime-types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { spawnSync } from "node:child_process";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";

describe("realtime type generator", () => {
it("fails loudly when the OpenAPI document omits a realtime schema", () => {
const script = resolve(process.cwd(), "scripts/gen-realtime-types.mjs");
const fixture = resolve(process.cwd(), "scripts/fixtures/malformed-openapi.yaml");
const result = spawnSync(process.execPath, [script], {
env: { ...process.env, REALTIME_OPENAPI_SPEC: fixture },
encoding: "utf8",
});
expect(result.status).not.toBe(0);
expect(result.stderr).toMatch(/Required realtime schema/);
});
});
32 changes: 10 additions & 22 deletions src/app/globals.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
/* Realtime freshness status stays static when reduced motion is enabled. */
.data-freshness { display: inline-flex; align-items: center; gap: .4rem; min-width: 8rem; font-size: .75rem; line-height: 1.25rem; color: var(--pm-text-muted, #a1a1aa); }
.data-freshness__dot { width: .4rem; height: .4rem; flex: 0 0 .4rem; border-radius: 50%; background: #a1a1aa; }
.data-freshness--live { color: #34d399; }
.data-freshness--live .data-freshness__dot { background: #34d399; }
.data-freshness--muted .data-freshness__dot { background: #fbbf24; }
.data-freshness__dot--pulse { animation: freshness-pulse 1.8s ease-in-out infinite; }
@keyframes freshness-pulse { 50% { opacity: .4; transform: scale(.8); } }
@media (prefers-reduced-motion: reduce) { .data-freshness__dot--pulse { animation: none; } }

@import url("https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap");

@tailwind base;
Expand Down Expand Up @@ -327,28 +337,6 @@ input:focus-visible,

/* ── Loading skeleton ────────────────────────────────────────────────────── */

x 18px;
border-radius: var(--pm-r);
font-size: 13px;
font-weight: 600;
text-decoration: none;
transition: top 0.15s ease;
}
.pm-skip-link:focus {
top: 16px;
}

@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

/* ── Loading skeleton ────────────────────────────────────────────────────── */

.pm-skeleton {
background: linear-gradient(90deg, var(--pm-surface-2) 25%, var(--pm-surface-3) 50%, var(--pm-surface-2) 75%);
background-size: 200% 100%;
Expand Down
3 changes: 3 additions & 0 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { formatUsd, formatRelativeTime, fromStroops } from "@/lib/format";
import { truncateAddress } from "@/lib/wallet/WalletProvider";
import { ACTIVE_NETWORK } from "@/lib/network";
import { HowItWorks } from "@/components/HowItWorks";
import { DataFreshness } from "@/components/DataFreshness";
import { RealtimeBoundary } from "@/components/realtime/RealtimeBoundary";
import { RealtimeProfiler } from "@/lib/realtime/devProfiler";

Expand Down Expand Up @@ -188,6 +189,7 @@ export default function Home() {
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs uppercase tracking-wide text-pm-text/60">Live Oracle Status</span>
<DataFreshness updatedAt={oracleStatus.lastUpdated?.getTime()} source={oracleStatus.dataSource} />
<button
type="button"
onClick={() => void oracleStatus.refresh()}
Expand Down Expand Up @@ -365,6 +367,7 @@ export default function Home() {
<h2 id="payouts-heading" className="font-display text-2xl font-extrabold tracking-tight text-pm-text sm:text-[26px]">Recent payouts</h2>
<button type="button" onClick={() => void recentClaims.refresh()} disabled={recentClaims.isRefreshing} className="text-[11px] text-pm-text/40 hover:text-pm-text disabled:opacity-50">Refresh ↻</button>
</div>
<div className="-mt-4 mb-5"><DataFreshness updatedAt={recentClaims.lastUpdated?.getTime()} source={recentClaims.dataSource} /></div>
{recentClaims.isFixture && (
<p className="mb-4 inline-flex items-center gap-1.5 text-[11px] text-pm-amber">
⚠ Showing example data — the Refract API isn&apos;t reachable from this environment.
Expand Down
Loading