Skip to content
Open
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
8 changes: 8 additions & 0 deletions .github/workflows/deploy-runner-authoring.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,15 @@ jobs:
# Build workspace packages, then the authoring app. VITE_API_BASE comes
# from apps/authoring/.env.production (committed) so it targets prod.
- run: pnpm build
# SENTRY_* are only set here, on the deploying build. The `test` job reuses
# ci.yml, which gets no token, so PR builds neither emit source maps nor
# upload a release — see apps/authoring/vite.config.ts.
- run: pnpm --filter @handsontable/demo-authoring build
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
GITHUB_SHA: ${{ github.sha }}

- name: Deploy authoring worker
working-directory: runner/apps/authoring
Expand Down
6 changes: 6 additions & 0 deletions runner/apps/authoring/.env.production
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
# Public prod config for the authoring app (safe to commit — no secrets).
# The Sentry DSN below is one of these: a DSN is a write-only ingest endpoint and
# ships inside the JS bundle by construction, so hiding it buys nothing. Abuse is
# bounded Sentry-side (allowed domains, inbound filters, spike protection).
# src/sentry.ts only initialises on demos.handsontable.com, so this file being
# loaded by PR-CI builds and local `vite preview` sends nothing.
# The API worker and this app share the demos.handsontable.com origin, so the
# app talks to /api on the same host. This MUST be set for prod builds; without
# it the code falls back to http://localhost:8787 and the browser can't reach
# the API ("Failed to fetch"). Dev bypasses (VITE_DEV_USER) stay in .env.local.
VITE_API_BASE=https://demos.handsontable.com
VITE_SENTRY_DSN=https://da4d7c549a5ac69c61bfe44be68873bd@o95873.ingest.us.sentry.io/4511806997135360
2 changes: 2 additions & 0 deletions runner/apps/authoring/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@
"dependencies": {
"@handsontable/demo-editor-shell": "workspace:*",
"@handsontable/demo-runtime": "workspace:*",
"@sentry/react": "^10.68.0",
"fflate": "^0.8.2",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@sentry/vite-plugin": "^5.4.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
Expand Down
73 changes: 69 additions & 4 deletions runner/apps/authoring/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
type FilesMap,
} from "@handsontable/demo-runtime";
import { SandpackRuntime } from "@handsontable/demo-runtime/sandpack";
import { ContainerRuntime, ContainerBootFailure } from "@handsontable/demo-runtime/container";
import { ContainerRuntime, ContainerBootFailure, SessionStartError } from "@handsontable/demo-runtime/container";
import { zipSync, strToU8 } from "fflate";
import { catalog, getEntry, fetchVersions, checkVersionExists, VERSION_OPTIONS, DEFAULT_VERSION } from "./catalog.js";
import {
Expand All @@ -24,6 +24,7 @@ import { DocsCascader, type CascaderLeaf } from "./DocsCascader.js";
import { currentUser, login, logout, getToken, type User } from "./auth.js";
import { MyDemos } from "./MyDemos.js";
import { ShareLinks } from "./ShareLinks.js";
import { reportError, reportingEnabled, Sentry } from "./sentry.js";

const SANDPACK_BUNDLER_URL = import.meta.env.VITE_SANDPACK_BUNDLER_URL || undefined;
const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:8787";
Expand Down Expand Up @@ -93,6 +94,48 @@ function describeRuntimeError(e: unknown, engine: string, version: string): stri
return msg;
}

/**
* Decide whether a preview failure is ours to fix, and report it if so.
*
* `DemoRuntime.onError` is a mixed channel. On the Sandpack engine it carries
* compile and runtime errors from the example code being edited — a typo
* mid-keystroke, an example that was imported broken. That is product output, not
* an application fault, and reporting it would bury the signal (and the quota)
* under one issue per syntax error.
*
* The container engine is the opposite: `SessionStartError` is how the Tier-2
* instance pool refuses a session (the exhaustion class of failure behind PR #87)
* and `ContainerBootFailure` is a dev server that could not install or start.
* Those are exactly what we want to hear about. 410 is excluded — it means the
* client had already navigated away and the server tore the session down, which
* is the designed outcome, not a failure.
*
* Fingerprinted by error class: `ContainerBootFailure` carries the raw boot log,
* which differs per example and would otherwise shard one problem into hundreds
* of issues.
*/
function reportRuntimeError(e: unknown, engine: string): void {
if (!reportingEnabled || engine !== "container") return;
if (e instanceof SessionStartError) {
if (e.status === 410) return;
Sentry.captureException(e, {
tags: { context: "tier2-session-start", session_status: String(e.status) },
fingerprint: ["tier2-session-start", String(e.status)],
});
return;
}
if (e instanceof ContainerBootFailure) {
Sentry.captureException(e, {
tags: { context: "tier2-container-boot" },
fingerprint: ["tier2-container-boot"],
});
return;
}
// Normal teardown: dispose() races a pending message, or the tab is closing.
if (e instanceof Error && e.message === "The session was closed.") return;
Sentry.captureException(e, { tags: { context: "tier2-runtime" } });
}

function pinHandsontableFiles(files: FilesMap, version: string): FilesMap {
const validated = validateHandsontableVersion(version);
if (!validated.ok || files["/package.json"] === undefined) return files;
Expand Down Expand Up @@ -313,7 +356,10 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) {
}
loadWorkspace(getEntry(src.framework), src.files, savedId);
setSourceLoaded(true);
} catch {
} catch (error) {
// A share/edit link that no longer resolves: missing D1 row, unreadable R2
// snapshot, or a framework the catalog no longer has.
reportError(error, "saved-demo-load");
if (!cancelled) { setErrorMessage("This demo is unavailable."); setSourceLoaded(true); }
}
})();
Expand All @@ -335,7 +381,10 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) {
}
setVersionsResolved(true);
})
.catch(() => {
.catch((error) => {
// Fails open onto the hardcoded VERSION_OPTIONS, so the picker silently
// goes stale rather than breaking — worth knowing about.
reportError(error, "versions-fetch");
if (!cancelled) setVersionsResolved(true); // release buckets can still resolve without dist-tags.next
});
return () => { cancelled = true; };
Expand Down Expand Up @@ -461,10 +510,16 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) {
setSourceLoaded(true);
sourceLoadedRef.current = true;
} catch (error) {
// The DEV-2130 class: a docs page links an example the runner can't
// open. Tagged by which step failed so a missing artifact (docs linking
// an example that was never imported) is distinguishable from a
// transient fetch.
reportError(error, `docs-example-load:${isMissingDocsResource(error) ? "path" : "fetch"}`);
failOpenDocs(isMissingDocsResource(error) ? "path" : "fetch");
}
})
.catch((error) => {
reportError(error, `docs-bucket-resolve:${isMissingDocsResource(error) ? "bucket" : "fetch"}`);
failOpenDocs(isMissingDocsResource(error) ? "bucket" : "fetch");
});
return () => { cancelled = true; };
Expand Down Expand Up @@ -534,7 +589,8 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) {
loadWorkspace(e, nextFiles, `docs:${bucket}:${dp}`);
setVersionWarning(null);
setDocsRuntimeBlocked(false);
} catch {
} catch (error) {
reportError(error, "docs-example-switch");
// Unlike the deep-link (`?docs=`) load path, a working workspace is already
// open here, so a toolbar note is enough — no full-screen not-found takeover.
if (docsRequestSeqRef.current === requestSeq) {
Expand Down Expand Up @@ -625,13 +681,17 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) {
if (cancelled) return;
setStatus("error");
setErrorMessage(describeRuntimeError(e, entry.engine, v.value.ref));
reportRuntimeError(e, entry.engine);
});
runtimeRef.current = runtime;
runtime.mount(filesRef.current).catch((e: unknown) => {
if (!cancelled) {
setStatus("error");
setErrorMessage(describeRuntimeError(e, entry.engine, v.value.ref));
}
// Reported even when cancelled: a session the pool refused still failed,
// and the unmount that set `cancelled` is often the user giving up on it.
reportRuntimeError(e, entry.engine);
});
return () => {
cancelled = true;
Expand Down Expand Up @@ -741,6 +801,7 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) {
setLinksId(id);
setShareLinksOpen(true);
} catch (e) {
reportError(e, "demo-embed");
setErrorMessage(e instanceof Error ? e.message : String(e));
} finally {
setEmbedding(false);
Expand Down Expand Up @@ -772,6 +833,7 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) {
const { id } = (await res.json()) as { id: string };
location.href = `/edit/${id}`; // boot into the edit page for the new demo
} catch (e) {
reportError(e, "demo-fork");
setErrorMessage(e instanceof Error ? e.message : String(e));
setForking(false);
}
Expand Down Expand Up @@ -801,6 +863,9 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) {
setDirty(false);
dirtyRef.current = false;
} catch (e) {
// Losing a save is the worst outcome in the app — the user's edits are only
// in this tab's memory until the PATCH lands.
reportError(e, "demo-save");
setErrorMessage(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
Expand Down
17 changes: 15 additions & 2 deletions runner/apps/authoring/src/MyDemos.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { theme } from "@handsontable/demo-editor-shell";
import { reportError } from "./sentry.js";

interface DemoListItem {
id: string;
Expand Down Expand Up @@ -32,17 +33,29 @@ export function MyDemos({ apiBase, token, onClose }: MyDemosProps) {
const data = (await r.json()) as { demos: DemoListItem[] };
setDemos(data.demos);
})
.catch((e) => setError(String(e)));
.catch((e) => {
reportError(e, "my-demos-list");
setError(String(e));
});
}, [apiBase, token]);

async function remove(id: string) {
const res = await fetch(`${apiBase}/api/demos/${id}`, {
method: "DELETE",
headers: token ? { Authorization: `Bearer ${token}` } : {},
}).catch(() => null);
}).catch((e: unknown) => {
reportError(e, "demo-revoke");
return null;
});
Comment thread
cursor[bot] marked this conversation as resolved.
if (res && (res.status === 204 || res.ok)) {
setDemos((cur) => (cur ? cur.map((d) => (d.id === id ? { ...d, revoked: 1 } : d)) : cur));
return;
}
// Every failure path leaves the list exactly as it was, with nothing in the UI
// to say why, so the revoke simply looks like it didn't happen. A non-OK
// response is as silent as a network error and needs reporting just the same —
// `res === null` was already reported in the catch above.
if (res) reportError(new Error(`revoke failed (${res.status})`), "demo-revoke");
}

return (
Expand Down
4 changes: 4 additions & 0 deletions runner/apps/authoring/src/ShareDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState } from "react";
import { theme } from "@handsontable/demo-editor-shell";
import type { FilesMap } from "@handsontable/demo-runtime";
import { reportError } from "./sentry.js";

export interface ShareResult {
id: string;
Expand Down Expand Up @@ -64,6 +65,9 @@ export function ShareDialog(props: ShareDialogProps) {
setResult(r);
props.onResult(r);
} catch (e) {
// POST /api/demos also runs the real framework build in the builder
// container, so this covers share-build failures, not just network errors.
reportError(e, "demo-share");
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
Expand Down
7 changes: 6 additions & 1 deletion runner/apps/authoring/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// the broker rejects non-@handsontable.com accounts. The token is per-user,
// per-session; kept in sessionStorage, never persisted or logged.

import { reportError } from "./sentry.js";

const BROKER = import.meta.env.VITE_LOGIN_BROKER_URL || "https://mcp-auth-proxy-j0tb.onrender.com";
const TOKEN_KEY = "hot_token";

Expand Down Expand Up @@ -44,7 +46,10 @@ export async function currentUser(): Promise<User | null> {
return null;
}
return (await res.json()) as User;
} catch {
} catch (error) {
// The broker being unreachable presents as "signed out" with no explanation,
// and every write endpoint then rejects.
reportError(error, "broker-userinfo");
return null;
}
}
Expand Down
18 changes: 17 additions & 1 deletion runner/apps/authoring/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,25 @@
// Must stay first: initialises error reporting before any other module runs, so a
// throw during module evaluation is still captured.
import { Sentry } from "./sentry.js";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App.js";

// A render crash in the editor shell used to leave a blank page and no record of
// why. The boundary keeps the failure visible to the user and reports it.
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
<Sentry.ErrorBoundary
fallback={
<div style={{ padding: 24, fontFamily: "system-ui, sans-serif" }}>
<h1 style={{ fontSize: 18, margin: "0 0 8px" }}>Something went wrong</h1>
<p style={{ margin: 0, color: "#555" }}>
Reload the page. If it keeps happening, the error has been reported.
</p>
</div>
}
>
<App />
</Sentry.ErrorBoundary>
</StrictMode>,
);
Loading
Loading