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
25 changes: 23 additions & 2 deletions apps/petrinaut-website/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
---
layer: website
role: Demo site and embed host for the Petrinaut editor
---

# Petrinaut Website

A website for demoing Petrinaut (libs/@hashintel/petrinaut).

A SPA plus a single API function that proxies AI requests to OpenAI.
A SPA with API functions for AI assistance and JSON oEmbed discovery.

## Quickstart

Expand All @@ -15,7 +20,23 @@ turbo run dev

The dev server runs at [http://localhost:5173](http://localhost:5173). A plugin in `vite.config.ts` loads the API function.

In production, the function in the `api` folder is automatically deployed as a Vercel Serverless Function.
In production, the functions in the `api` folder are automatically deployed as
Vercel Functions.

## Example embeds and oEmbed

Canonical example pages live below `/examples`. The JSON oEmbed endpoint at
`/api/oembed` accepts their production URLs and returns an
`/embed/examples/...` iframe. Canonical pages send both CSP `frame-ancestors
'none'` and `X-Frame-Options: DENY`; only the dedicated embed routes permit
third-party framing. The returned iframe is sandboxed with
`allow-scripts allow-same-origin` and does not send a referrer.

Because this is a client-rendered SPA, a static `index.html` discovery link
cannot include the current example URL. `FullExamplePage` adds the standard
`application/json+oembed` link to the document head after the route mounts.
Consumers that do not execute JavaScript must call `/api/oembed` directly or
use provider-pattern discovery instead.

### Optimization demo with Petrinaut Opt

Expand Down
263 changes: 263 additions & 0 deletions apps/petrinaut-website/api/oembed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
/**
* @layerRoot website.api
* @role Server functions: JSON oEmbed discovery and the AI chat proxy
* @talksTo website.routes via the embed URL it returns to consumers
*/

import {
getExampleCatalogEntry,
isExampleSlug,
PETRINAUT_DEMO_ORIGIN,
type ExampleSlug,
} from "../src/examples/catalog-metadata";
import {
canonicalSearchString,
validateSharedExampleSearch,
} from "../src/examples/example-search";

const DEFAULT_WIDTH = 800;
const DEFAULT_HEIGHT = 450;
const SUCCESS_CACHE_CONTROL =
"public, max-age=300, s-maxage=86400, stale-while-revalidate=604800";

type OEmbedResponse = Readonly<{
type: "rich";
version: "1.0";
title: string;
provider_name: "Petrinaut";
provider_url: typeof PETRINAUT_DEMO_ORIGIN;
width: number;
height: number;
html: string;
}>;

const corsHeaders = (): Headers =>
new Headers({
"Access-Control-Allow-Headers": "Accept",
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
"Access-Control-Allow-Origin": "*",
});

const jsonResponse = (
body: unknown,
init: ResponseInit & { cacheable?: boolean } = {},
): Response => {
const { cacheable = false, ...responseInit } = init;
const headers = corsHeaders();
for (const [name, value] of new Headers(responseInit.headers)) {
headers.set(name, value);
}
headers.set("Cache-Control", cacheable ? SUCCESS_CACHE_CONTROL : "no-store");
headers.set("Content-Type", "application/json; charset=utf-8");

return new Response(JSON.stringify(body), { ...responseInit, headers });
};

const errorResponse = (
status: 400 | 404 | 405 | 501,
error: string,
): Response => jsonResponse({ error }, { status });

const readPositiveFiniteMaximum = (
searchParams: URLSearchParams,
name: "maxheight" | "maxwidth",
): number | undefined | null => {
if (!searchParams.has(name)) {
return undefined;
}

const rawValue = searchParams.get(name);
// A consumer that always appends the optional oEmbed params sends them
// empty when the user set no size. That is "no maximum", not a bad request.
if (rawValue === null || rawValue.trim() === "") {
return undefined;
}

// oEmbed defines these as integers. `Number` would also accept `0x10`,
// `1e3` and ` 400 `, which are typos rather than sizes.
if (!/^[0-9]+$/u.test(rawValue.trim())) {
return null;
}

const value = Number(rawValue.trim());
return value > 0 ? value : null;
};

const fitDimensions = (
maxWidth: number | undefined,
maxHeight: number | undefined,
): { width: number; height: number } => {
const scale = Math.min(
1,
maxWidth === undefined ? 1 : maxWidth / DEFAULT_WIDTH,
maxHeight === undefined ? 1 : maxHeight / DEFAULT_HEIGHT,
);

// Height follows from the clamped width, so the aspect ratio survives.
// Flooring each axis independently turns `?maxwidth=1` into a 1x1 embed.
const width = Math.max(1, Math.floor(DEFAULT_WIDTH * scale));
return {
width,
height: Math.max(1, Math.round((width * DEFAULT_HEIGHT) / DEFAULT_WIDTH)),
};
};

const escapeHtmlAttribute = (value: string): string =>
value
.replaceAll("&", "&amp;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");

type ParsedExampleUrl = Readonly<{
slug: ExampleSlug;
embedSearch: URLSearchParams;
}>;

/**
* Carry over only state the embed page understands, by decoding the canonical
* URL through the shared contract and re-encoding it. One embed-specific rule:
* embeds always show a named scenario, so an explicit `none` (valid on the
* canonical page) resolves to the model's first scenario instead.
*/
const sanitizeEmbedSearch = (source: URLSearchParams): URLSearchParams => {
const search = validateSharedExampleSearch(Object.fromEntries(source));

return new URLSearchParams(
canonicalSearchString(
search.scenario === "none" ? { ...search, scenario: undefined } : search,
),
);
};

const parseExampleUrl = (
rawUrl: string,
): ParsedExampleUrl | { error: string; status: 400 | 404 } => {
let sourceUrl: URL;
try {
sourceUrl = new URL(rawUrl);
} catch {
return { error: "The url parameter must be a valid URL", status: 400 };
}

// A well-formed URL this provider cannot embed is 404 rather than 400.
// oEmbed 1.0 section 2.3.1 lists 404 for "no response for this url", and
// consumers branch on it to fall back to a plain link.
if (sourceUrl.origin !== PETRINAUT_DEMO_ORIGIN) {
return {
error: `The url parameter must use ${PETRINAUT_DEMO_ORIGIN}`,
status: 404,
};
}

const pathMatch = sourceUrl.pathname.match(/^\/examples\/([^/]+?)\/?$/u);
if (!pathMatch) {
return {
error: "The url parameter is not a canonical example URL",
status: 404,
};
}

const slug = pathMatch[1]!;
if (!isExampleSlug(slug)) {
return { error: `Unknown example: ${slug}`, status: 404 };
}

return {
slug,
embedSearch: sanitizeEmbedSearch(sourceUrl.searchParams),
};
};

const respond = async (request: Request): Promise<Response> => {
if (request.method === "OPTIONS") {
const headers = corsHeaders();
headers.set("Cache-Control", "public, max-age=86400");
return new Response(null, { headers, status: 204 });
}

const isHead = request.method === "HEAD";
if (request.method !== "GET" && !isHead) {
const response = errorResponse(405, "Method not allowed");
response.headers.set("Allow", "GET, HEAD, OPTIONS");
return response;
}

const endpointUrl = new URL(request.url);
// An empty `format=` means the consumer expressed no preference, and the
// parameter is case-insensitive in practice.
const format =
endpointUrl.searchParams.get("format")?.trim().toLowerCase() || null;
if (format !== null && format !== "json") {
// oEmbed 1.0 section 2.3.1: a format the provider cannot return is 501.
return errorResponse(501, "Only the json oEmbed format is supported");
}

const rawSourceUrl = endpointUrl.searchParams.get("url");
if (!rawSourceUrl) {
return errorResponse(400, "Missing required url parameter");
}

const parsedExampleUrl = parseExampleUrl(rawSourceUrl);
if ("error" in parsedExampleUrl) {
return errorResponse(parsedExampleUrl.status, parsedExampleUrl.error);
}

const maxWidth = readPositiveFiniteMaximum(
endpointUrl.searchParams,
"maxwidth",
);
if (maxWidth === null) {
return errorResponse(400, "maxwidth must be a positive integer");
}
const maxHeight = readPositiveFiniteMaximum(
endpointUrl.searchParams,
"maxheight",
);
if (maxHeight === null) {
return errorResponse(400, "maxheight must be a positive integer");
}

const { width, height } = fitDimensions(maxWidth, maxHeight);
const embedUrl = new URL(
`/embed/examples/${parsedExampleUrl.slug}`,
PETRINAUT_DEMO_ORIGIN,
);
embedUrl.search = parsedExampleUrl.embedSearch.toString();

const catalogEntry = getExampleCatalogEntry(parsedExampleUrl.slug)!;
const response: OEmbedResponse = {
type: "rich",
version: "1.0",
title: catalogEntry.title,
provider_name: "Petrinaut",
provider_url: PETRINAUT_DEMO_ORIGIN,
width,
height,
html: `<iframe src="${escapeHtmlAttribute(embedUrl.href)}" title="${escapeHtmlAttribute(catalogEntry.title)}" width="${width}" height="${height}" style="border:0" loading="lazy" sandbox="allow-scripts allow-same-origin" referrerpolicy="no-referrer" allowfullscreen></iframe>`,
};

return jsonResponse(response, { cacheable: true });
};

/**
* Serve Petrinaut example embeds using the JSON oEmbed 1.0 contract.
*
* Exported only through the default `{ fetch }` object, matching `chat.ts`, so
* Vercel's Node.js runtime treats this as a Web fetch handler and hands us a
* `Request`. Without that opt-in the default export is invoked with a Node.js
* `IncomingMessage`, which has no `Request` API.
*
* See https://vercel.com/changelog/node-js-vercel-functions-now-support-fetch-web-handlers
*/
const fetch = async (request: Request): Promise<Response> => {
const response = await respond(request);
// Strip the body at the single exit, so error replies to HEAD are bodiless
// too rather than only the 200.
return request.method === "HEAD"
? new Response(null, { headers: response.headers, status: response.status })
: response;
};

export default { fetch };
2 changes: 2 additions & 0 deletions apps/petrinaut-website/src/examples/catalog-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
* functions that only need to validate a public URL should not bundle every
* example model and generated simulation artifact.
*/
export const PETRINAUT_DEMO_ORIGIN = "https://demo.petrinaut.org";

export const exampleSlugs = [
"gases-1-pn-consumption-trigger",
"gases-1-pn",
Expand Down
5 changes: 5 additions & 0 deletions apps/petrinaut-website/src/examples/catalog.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
/**
* @layerRoot website.examples
* @role Publishes example models and the URL contract every example surface speaks
*/

import {
parseSDCPNFile,
type HirArtifacts,
Expand Down
13 changes: 13 additions & 0 deletions apps/petrinaut-website/src/examples/full-example-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useEffect } from "react";
import { css } from "@hashintel/ds-helpers/css";
import { Petrinaut } from "@hashintel/petrinaut/ui";

import { getOEmbedDiscoveryUrl } from "./oembed-discovery";
import { getReadonlyExampleHandle } from "./readonly-example-handle";
import { useSharedSearchNavigation } from "./use-shared-search-navigation";

Expand Down Expand Up @@ -53,8 +54,20 @@ export const FullExamplePage = ({
};
}, [example.catalog.title]);

// The website is a client-rendered SPA, so the oEmbed discovery link cannot
// be baked into index.html; React 19 hoists this <link> into document.head.
// Consumers that execute the page's JavaScript can then discover the same
// production oEmbed endpoint used by server integrations.
const discoveryUrl = getOEmbedDiscoveryUrl(example.catalog.slug, search);

return (
<main className={pageStyle}>
<link
href={discoveryUrl}
rel="alternate"
title={`${example.catalog.title} oEmbed profile`}
type="application/json+oembed"
/>
<Petrinaut
handle={handle}
hideNetManagementControls="all"
Expand Down
45 changes: 45 additions & 0 deletions apps/petrinaut-website/src/examples/oembed-discovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";

import { getOEmbedDiscoveryUrl } from "./oembed-discovery";

describe("getOEmbedDiscoveryUrl", () => {
it("uses the production origin without leaking a development port", () => {
const endpoint = new URL(
getOEmbedDiscoveryUrl("gases-1-pn", { scenario: "steady" }),
);

expect(endpoint.origin).toBe("https://demo.petrinaut.org");
expect(endpoint.searchParams.get("format")).toBe("json");
expect(endpoint.searchParams.get("url")).toBe(
"https://demo.petrinaut.org/examples/gases-1-pn?scenario=steady",
);
});

it("advertises one endpoint URL per location, whatever the address bar holds", () => {
// A foreign parameter is not part of the contract, so it must not produce
// a second endpoint URL for a response that is byte-identical.
const withoutForeignParam = getOEmbedDiscoveryUrl("gases-1-pn", {
scenario: "steady",
});
const withForeignParam = getOEmbedDiscoveryUrl("gases-1-pn", {
scenario: "steady",
...({ utm_source: "twitter" } as Record<string, string>),
});

expect(withForeignParam).toBe(withoutForeignParam);
});

it("orders the contract parameters canonically", () => {
expect(
getOEmbedDiscoveryUrl("gases-2-spn", {
subnet: "subnet-a",
scenario: "scenario__drawing",
}),
).toBe(
getOEmbedDiscoveryUrl("gases-2-spn", {
scenario: "scenario__drawing",
subnet: "subnet-a",
}),
);
});
});
Loading
Loading