@ekanos/harness 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +879 -0
  3. package/dist/app.d.ts +4 -0
  4. package/dist/app.js +11 -0
  5. package/dist/config.d.ts +18 -0
  6. package/dist/config.js +34 -0
  7. package/dist/internal/components/ask-assistant-bridge.d.ts +10 -0
  8. package/dist/internal/components/ask-assistant-bridge.js +50 -0
  9. package/dist/internal/components/dashboard-grid.d.ts +45 -0
  10. package/dist/internal/components/dashboard-grid.js +84 -0
  11. package/dist/internal/components/dev-toolbar.d.ts +15 -0
  12. package/dist/internal/components/dev-toolbar.js +155 -0
  13. package/dist/internal/components/harness-providers.d.ts +16 -0
  14. package/dist/internal/components/harness-providers.js +83 -0
  15. package/dist/internal/components/harness-widget-provider.d.ts +60 -0
  16. package/dist/internal/components/harness-widget-provider.js +84 -0
  17. package/dist/internal/components/i18n-provider.d.ts +9 -0
  18. package/dist/internal/components/i18n-provider.js +9 -0
  19. package/dist/internal/components/row-groups.d.ts +23 -0
  20. package/dist/internal/components/row-groups.js +37 -0
  21. package/dist/internal/components/surface-nav.d.ts +4 -0
  22. package/dist/internal/components/surface-nav.js +70 -0
  23. package/dist/internal/components/viewport-frame.d.ts +14 -0
  24. package/dist/internal/components/viewport-frame.js +27 -0
  25. package/dist/internal/components/widget-boundary.d.ts +25 -0
  26. package/dist/internal/components/widget-boundary.js +44 -0
  27. package/dist/internal/components/widget-surface.d.ts +20 -0
  28. package/dist/internal/components/widget-surface.js +76 -0
  29. package/dist/internal/lib/fonts.d.ts +2 -0
  30. package/dist/internal/lib/fonts.js +22 -0
  31. package/dist/internal/lib/harness-fetch-interceptor.d.ts +89 -0
  32. package/dist/internal/lib/harness-fetch-interceptor.js +101 -0
  33. package/dist/internal/lib/harness-live-fetch.d.ts +66 -0
  34. package/dist/internal/lib/harness-live-fetch.js +121 -0
  35. package/dist/internal/lib/harness-query-client.d.ts +43 -0
  36. package/dist/internal/lib/harness-query-client.js +103 -0
  37. package/dist/internal/lib/http-fixtures.d.ts +145 -0
  38. package/dist/internal/lib/http-fixtures.js +256 -0
  39. package/dist/internal/lib/i18n.d.ts +2 -0
  40. package/dist/internal/lib/i18n.js +17 -0
  41. package/dist/internal/lib/redact.d.ts +33 -0
  42. package/dist/internal/lib/redact.js +43 -0
  43. package/dist/internal/lib/toolbar-context.d.ts +59 -0
  44. package/dist/internal/lib/toolbar-context.js +124 -0
  45. package/dist/internal/registry-context.d.ts +27 -0
  46. package/dist/internal/registry-context.js +51 -0
  47. package/dist/internal/routes/activation-page.d.ts +33 -0
  48. package/dist/internal/routes/activation-page.js +242 -0
  49. package/dist/internal/routes/index-page.d.ts +13 -0
  50. package/dist/internal/routes/index-page.js +62 -0
  51. package/dist/internal/routes/integration-layout.d.ts +9 -0
  52. package/dist/internal/routes/integration-layout.js +84 -0
  53. package/dist/internal/routes/root-layout.d.ts +34 -0
  54. package/dist/internal/routes/root-layout.js +39 -0
  55. package/dist/internal/routes/single-widget-page.d.ts +6 -0
  56. package/dist/internal/routes/single-widget-page.js +30 -0
  57. package/dist/internal/routes/tile-page.d.ts +1 -0
  58. package/dist/internal/routes/tile-page.js +88 -0
  59. package/dist/internal/routes/triggers-page.d.ts +1 -0
  60. package/dist/internal/routes/triggers-page.js +386 -0
  61. package/dist/internal/routes/widgets-page.d.ts +1 -0
  62. package/dist/internal/routes/widgets-page.js +19 -0
  63. package/dist/internal/surfaces.d.ts +25 -0
  64. package/dist/internal/surfaces.js +29 -0
  65. package/dist/mocks/team-account-workspace.d.ts +78 -0
  66. package/dist/mocks/team-account-workspace.js +86 -0
  67. package/dist/registry.d.ts +418 -0
  68. package/dist/registry.js +82 -0
  69. package/dist/routes.d.ts +24 -0
  70. package/dist/routes.js +15 -0
  71. package/dist/styles.css +236 -0
  72. package/package.json +101 -0
@@ -0,0 +1,84 @@
1
+ "use client";
2
+ import { jsx } from "react/jsx-runtime";
3
+ import { useCallback, useMemo, useState } from "react";
4
+ import {
5
+ WidgetContext
6
+ } from "@ekanos/sdk/components";
7
+ function HarnessWidgetProvider({
8
+ widgetId,
9
+ title,
10
+ subtitle,
11
+ description,
12
+ className,
13
+ state: renderState,
14
+ isEnabled = true,
15
+ isCollapsible = false,
16
+ isPinnable = false,
17
+ defaultCollapsed = false,
18
+ defaultPinned = false,
19
+ maxContentHeight,
20
+ askContext,
21
+ aiFooterEnabled = false,
22
+ health,
23
+ children
24
+ }) {
25
+ const [toggles, setToggles] = useState({
26
+ collapsed: defaultCollapsed,
27
+ pinned: defaultPinned
28
+ });
29
+ const toggleCollapsed = useCallback(() => {
30
+ setToggles((current) => ({ ...current, collapsed: !current.collapsed }));
31
+ }, []);
32
+ const togglePinned = useCallback(() => {
33
+ setToggles((current) => ({ ...current, pinned: !current.pinned }));
34
+ }, []);
35
+ const baseRenderState = renderState !== "active" ? renderState : isEnabled ? "active" : "disabled";
36
+ const effectiveRenderState = baseRenderState === "active" && health?.status === "unhealthy" && health?.lastSuccessAt ? "disabled" : baseRenderState;
37
+ const value = useMemo(
38
+ () => ({
39
+ state: {
40
+ state: effectiveRenderState,
41
+ collapsed: toggles.collapsed,
42
+ pinned: toggles.pinned,
43
+ pendingCollapse: false,
44
+ pendingPin: false
45
+ },
46
+ actions: { toggleCollapsed, togglePinned },
47
+ meta: {
48
+ widgetId,
49
+ title,
50
+ subtitle,
51
+ description,
52
+ maxContentHeight,
53
+ isCollapsible,
54
+ isPinnable,
55
+ askContext,
56
+ aiFooterEnabled,
57
+ className,
58
+ health
59
+ }
60
+ }),
61
+ [
62
+ effectiveRenderState,
63
+ toggles.collapsed,
64
+ toggles.pinned,
65
+ toggleCollapsed,
66
+ togglePinned,
67
+ widgetId,
68
+ title,
69
+ subtitle,
70
+ description,
71
+ maxContentHeight,
72
+ isCollapsible,
73
+ isPinnable,
74
+ askContext,
75
+ aiFooterEnabled,
76
+ className,
77
+ health
78
+ ]
79
+ );
80
+ return /* @__PURE__ */ jsx(WidgetContext, { value, children });
81
+ }
82
+ export {
83
+ HarnessWidgetProvider
84
+ };
@@ -0,0 +1,9 @@
1
+ import type { ReactNode } from 'react';
2
+ import '../lib/i18n.js';
3
+ /**
4
+ * Initialization happens as a module side effect on import, so it runs once,
5
+ * synchronously, before anything below renders.
6
+ */
7
+ export declare function I18nProvider({ children }: {
8
+ children: ReactNode;
9
+ }): import("react").JSX.Element;
@@ -0,0 +1,9 @@
1
+ "use client";
2
+ import { Fragment, jsx } from "react/jsx-runtime";
3
+ import "../lib/i18n.js";
4
+ function I18nProvider({ children }) {
5
+ return /* @__PURE__ */ jsx(Fragment, { children });
6
+ }
7
+ export {
8
+ I18nProvider
9
+ };
@@ -0,0 +1,23 @@
1
+ import { WidgetConfig } from '@ekanos/sdk';
2
+ export type RowGroup = {
3
+ type: 'full';
4
+ widget: WidgetConfig;
5
+ } | {
6
+ type: 'half';
7
+ widgets: WidgetConfig[];
8
+ };
9
+ /**
10
+ * Build row groups that preserve widget order. Contiguous runs of half-width
11
+ * widgets are grouped together; full-width widgets each get their own row.
12
+ */
13
+ export declare function buildRowGroups(widgets: WidgetConfig[]): RowGroup[];
14
+ export declare function getColSpan(widget: WidgetConfig): 1 | 2;
15
+ /**
16
+ * Splits a run of half-width widgets into two balanced columns, alternating
17
+ * into whichever column is currently shorter (left wins ties) — preserves
18
+ * the exact alternation `MasonryGrid` rendered inline before extraction.
19
+ */
20
+ export declare function splitBalanced(widgets: WidgetConfig[]): {
21
+ left: WidgetConfig[];
22
+ right: WidgetConfig[];
23
+ };
@@ -0,0 +1,37 @@
1
+ function buildRowGroups(widgets) {
2
+ const groups = [];
3
+ let halfBatch = [];
4
+ for (const widget of widgets) {
5
+ if (getColSpan(widget) === 2) {
6
+ if (halfBatch.length > 0) {
7
+ groups.push({ type: "half", widgets: halfBatch });
8
+ halfBatch = [];
9
+ }
10
+ groups.push({ type: "full", widget });
11
+ } else {
12
+ halfBatch.push(widget);
13
+ }
14
+ }
15
+ if (halfBatch.length > 0) {
16
+ groups.push({ type: "half", widgets: halfBatch });
17
+ }
18
+ return groups;
19
+ }
20
+ function getColSpan(widget) {
21
+ const w = widget.layouts?.lg?.w ?? 6;
22
+ return w > 6 ? 2 : 1;
23
+ }
24
+ function splitBalanced(widgets) {
25
+ const left = [];
26
+ const right = [];
27
+ for (const w of widgets) {
28
+ if (left.length <= right.length) left.push(w);
29
+ else right.push(w);
30
+ }
31
+ return { left, right };
32
+ }
33
+ export {
34
+ buildRowGroups,
35
+ getColSpan,
36
+ splitBalanced
37
+ };
@@ -0,0 +1,4 @@
1
+ import type { HarnessIntegration } from '../../registry.js';
2
+ export declare function SurfaceNav({ integration, }: {
3
+ integration: HarnessIntegration;
4
+ }): import("react").JSX.Element;
@@ -0,0 +1,70 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import Link from "next/link";
4
+ import { usePathname } from "next/navigation";
5
+ import { Icon } from "@ekanos/ui/icon";
6
+ import { cn } from "@ekanos/ui/utils";
7
+ import { HARNESS_SURFACES } from "../surfaces.js";
8
+ function SurfaceNav({
9
+ integration
10
+ }) {
11
+ const pathname = usePathname();
12
+ return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-5 border-b px-6 py-5", children: [
13
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-baseline gap-3", children: [
14
+ /* @__PURE__ */ jsxs(
15
+ Link,
16
+ {
17
+ href: "/",
18
+ className: "text-muted-foreground hover:text-foreground text-sm",
19
+ children: [
20
+ /* @__PURE__ */ jsx(Icon, { name: "fa-solid fa-arrow-left", className: "mr-2 h-4 w-4" }),
21
+ "All integrations"
22
+ ]
23
+ }
24
+ ),
25
+ /* @__PURE__ */ jsx("h1", { className: "font-heading text-2xl tracking-tight", children: integration.name }),
26
+ /* @__PURE__ */ jsx("code", { className: "text-muted-foreground text-xs", children: integration.slug })
27
+ ] }),
28
+ /* @__PURE__ */ jsx("nav", { className: "flex flex-wrap gap-1", children: HARNESS_SURFACES.map((surface) => {
29
+ const href = `/${integration.slug}/${surface.segment}`;
30
+ const active = pathname.startsWith(href);
31
+ return /* @__PURE__ */ jsxs(
32
+ Link,
33
+ {
34
+ href,
35
+ className: cn(
36
+ "flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors",
37
+ active ? "bg-accent text-accent-foreground font-medium" : "text-muted-foreground hover:bg-accent/50"
38
+ ),
39
+ children: [
40
+ /* @__PURE__ */ jsx(Icon, { name: surface.icon, className: "h-5 w-5" }),
41
+ surface.label
42
+ ]
43
+ },
44
+ surface.segment
45
+ );
46
+ }) }),
47
+ integration.widgets.length > 0 ? /* @__PURE__ */ jsxs("nav", { className: "flex flex-wrap gap-1", children: [
48
+ /* @__PURE__ */ jsx("span", { className: "text-muted-foreground px-3 py-2 text-xs", children: "Focus one widget:" }),
49
+ integration.widgets.map((widget) => {
50
+ const href = `/${integration.slug}/widgets/${widget.id}`;
51
+ const active = pathname === href;
52
+ return /* @__PURE__ */ jsx(
53
+ Link,
54
+ {
55
+ href,
56
+ className: cn(
57
+ "rounded-md px-3 py-2 text-sm transition-colors",
58
+ active ? "bg-accent text-accent-foreground font-medium" : "text-muted-foreground hover:bg-accent/50"
59
+ ),
60
+ children: widget.title
61
+ },
62
+ widget.id
63
+ );
64
+ })
65
+ ] }) : null
66
+ ] });
67
+ }
68
+ export {
69
+ SurfaceNav
70
+ };
@@ -0,0 +1,14 @@
1
+ import type { ReactNode } from 'react';
2
+ /**
3
+ * Constrains the preview to a viewport preset.
4
+ *
5
+ * This narrows a real container rather than rendering into an iframe, which is
6
+ * the right trade for widgets: the whole dashboard grid is built on Tailwind
7
+ * `@container` queries (`@4xl/dashboard:grid-cols-2`), so container width — not
8
+ * window width — is what actually drives the responsive behaviour a partner
9
+ * needs to check. Media-query breakpoints still read the real window; see the
10
+ * "Known gaps" section of the README.
11
+ */
12
+ export declare function ViewportFrame({ children }: {
13
+ children: ReactNode;
14
+ }): import("react").JSX.Element;
@@ -0,0 +1,27 @@
1
+ "use client";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import { VIEWPORTS, useToolbar } from "../lib/toolbar-context.js";
4
+ function ViewportFrame({ children }) {
5
+ const { state } = useToolbar();
6
+ const { width, label } = VIEWPORTS[state.viewport];
7
+ if (width === null) return /* @__PURE__ */ jsx(Fragment, { children });
8
+ return /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center gap-2", children: [
9
+ /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground text-xs", children: [
10
+ label,
11
+ " \xB7 ",
12
+ width,
13
+ "px"
14
+ ] }),
15
+ /* @__PURE__ */ jsx(
16
+ "div",
17
+ {
18
+ className: "w-full overflow-hidden rounded-lg border p-5",
19
+ style: { maxWidth: width },
20
+ children
21
+ }
22
+ )
23
+ ] });
24
+ }
25
+ export {
26
+ ViewportFrame
27
+ };
@@ -0,0 +1,25 @@
1
+ import { Component, type ReactNode } from 'react';
2
+ interface Props {
3
+ widgetId: string;
4
+ /** Changing this remounts the boundary, clearing a caught error. */
5
+ resetKey?: string;
6
+ children: ReactNode;
7
+ }
8
+ interface State {
9
+ error: Error | null;
10
+ }
11
+ /**
12
+ * `Widget.Active` already wraps its children in an error boundary, but only its
13
+ * children — a widget that throws during its own render, before reaching the
14
+ * compound components (a bad hook call, a fixture shaped wrongly), escapes it
15
+ * and takes the page with it. This catches that outer case so one broken widget
16
+ * degrades to one broken card.
17
+ */
18
+ export declare class WidgetBoundary extends Component<Props, State> {
19
+ state: State;
20
+ static getDerivedStateFromError(error: Error): State;
21
+ componentDidUpdate(previous: Props): void;
22
+ componentDidCatch(error: Error): void;
23
+ render(): string | number | bigint | boolean | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | import("react").JSX.Element | null | undefined;
24
+ }
25
+ export {};
@@ -0,0 +1,44 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { Component } from "react";
4
+ import { Icon } from "@ekanos/ui/icon";
5
+ class WidgetBoundary extends Component {
6
+ constructor() {
7
+ super(...arguments);
8
+ this.state = { error: null };
9
+ }
10
+ static getDerivedStateFromError(error) {
11
+ return { error };
12
+ }
13
+ componentDidUpdate(previous) {
14
+ if (previous.resetKey !== this.props.resetKey && this.state.error) {
15
+ this.setState({ error: null });
16
+ }
17
+ }
18
+ componentDidCatch(error) {
19
+ console.error(`[harness] Widget "${this.props.widgetId}" crashed:`, error);
20
+ }
21
+ render() {
22
+ const { error } = this.state;
23
+ if (!error) return this.props.children;
24
+ return /* @__PURE__ */ jsxs("div", { className: "bg-card flex flex-col gap-3 rounded-lg border p-6", children: [
25
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
26
+ /* @__PURE__ */ jsx(
27
+ Icon,
28
+ {
29
+ name: "fa-solid fa-triangle-exclamation",
30
+ className: "text-destructive h-5 w-5"
31
+ }
32
+ ),
33
+ /* @__PURE__ */ jsxs("p", { className: "text-sm font-medium", children: [
34
+ this.props.widgetId,
35
+ " threw during render"
36
+ ] })
37
+ ] }),
38
+ /* @__PURE__ */ jsx("pre", { className: "bg-muted text-destructive overflow-x-auto rounded-md p-3 text-xs whitespace-pre-wrap", children: error.message })
39
+ ] });
40
+ }
41
+ }
42
+ export {
43
+ WidgetBoundary
44
+ };
@@ -0,0 +1,20 @@
1
+ import type { WidgetConfig } from '@ekanos/sdk';
2
+ import { HARNESS_ACCOUNT_ID, HARNESS_SOURCE_ID, type HarnessWidget } from '../../registry.js';
3
+ /**
4
+ * Adapts the harness registry's widget entries into the `WidgetConfig` shape
5
+ * the real dashboard grid consumes.
6
+ *
7
+ * Note `health` is only set when the toolbar's "Unhealthy" switch is on.
8
+ * Leaving it undefined the rest of the time matters: `DashboardWidgetProvider`
9
+ * downgrades an active widget to `disabled` whenever health is `unhealthy` AND
10
+ * `lastSuccessAt` is set, which would silently override the render state the
11
+ * toolbar is asking for. Behind the switch, it becomes something you can
12
+ * exercise deliberately instead of trip over.
13
+ */
14
+ export declare function toWidgetConfig(widget: HarnessWidget, productSlug: string, unhealthy: boolean): WidgetConfig;
15
+ export declare function WidgetSurface({ widgets, productSlug, layout, }: {
16
+ widgets: HarnessWidget[];
17
+ productSlug: string;
18
+ layout?: 'masonry' | 'two-column';
19
+ }): import("react").JSX.Element;
20
+ export { HARNESS_ACCOUNT_ID, HARNESS_SOURCE_ID };
@@ -0,0 +1,76 @@
1
+ "use client";
2
+ import { jsx } from "react/jsx-runtime";
3
+ import { useMemo } from "react";
4
+ import {
5
+ HARNESS_ACCOUNT_ID,
6
+ HARNESS_SOURCE_ID
7
+ } from "../../registry.js";
8
+ import { useToolbar } from "../lib/toolbar-context.js";
9
+ import { DashboardGrid } from "./dashboard-grid.js";
10
+ import { ViewportFrame } from "./viewport-frame.js";
11
+ function toWidgetConfig(widget, productSlug, unhealthy) {
12
+ return {
13
+ id: widget.id,
14
+ name: widget.title,
15
+ component: widget.component,
16
+ // `w > 6` is what the grid reads as full-width.
17
+ widgetState: "active",
18
+ layouts: { lg: { x: 0, y: 0, w: widget.width === "full" ? 12 : 6, h: 4 } },
19
+ // Passed through ONLY when the registry entry declared them, so the
20
+ // default lives in exactly one place: `dashboard-grid.tsx`, which is a copy
21
+ // of the host's own grid and applies the host's own `?? false`.
22
+ //
23
+ // This used to default `isCollapsible` to `true` here, which silently
24
+ // shadowed that copy — `?? false` one layer down never saw `undefined`, so
25
+ // every widget grew a collapse control whether it asked for one or not, and
26
+ // the harness showed chrome production would not. Two defaults where one is
27
+ // unreachable is also the shape of thing that gets "fixed" later by
28
+ // deleting the wrong one.
29
+ ...widget.isCollapsible === void 0 ? {} : { isCollapsible: widget.isCollapsible },
30
+ ...widget.isPinnable === void 0 ? {} : { isPinnable: widget.isPinnable },
31
+ aiFooterEnabled: widget.aiFooterEnabled,
32
+ integrationMetadata: {
33
+ productId: productSlug,
34
+ productSlug,
35
+ integrationName: productSlug,
36
+ description: "",
37
+ category: "harness",
38
+ version: "0.0.0"
39
+ },
40
+ health: unhealthy ? {
41
+ status: "unhealthy",
42
+ lastSuccessAt: "2026-08-27T10:00:00.000Z",
43
+ errorMessage: "Simulated by the harness toolbar."
44
+ } : void 0
45
+ };
46
+ }
47
+ function WidgetSurface({
48
+ widgets,
49
+ productSlug,
50
+ layout = "masonry"
51
+ }) {
52
+ const { state } = useToolbar();
53
+ const configs = useMemo(
54
+ () => widgets.map(
55
+ (widget) => toWidgetConfig(widget, productSlug, state.unhealthy)
56
+ ),
57
+ [widgets, productSlug, state.unhealthy]
58
+ );
59
+ return /* @__PURE__ */ jsx(ViewportFrame, { children: /* @__PURE__ */ jsx(
60
+ DashboardGrid,
61
+ {
62
+ widgets: configs,
63
+ accountId: HARNESS_ACCOUNT_ID,
64
+ layout,
65
+ stateOverride: state.renderState,
66
+ collapsedOverride: state.collapsed,
67
+ resetKey: `${state.variant}-${String(state.unhealthy)}`
68
+ }
69
+ ) });
70
+ }
71
+ export {
72
+ HARNESS_ACCOUNT_ID,
73
+ HARNESS_SOURCE_ID,
74
+ WidgetSurface,
75
+ toWidgetConfig
76
+ };
@@ -0,0 +1,2 @@
1
+ /** Space-joined next/font variable classes to apply to `<html>`. */
2
+ export declare const fontVariables: string;
@@ -0,0 +1,22 @@
1
+ import {
2
+ Bricolage_Grotesque as HeadingFont,
3
+ Figtree as SansFont
4
+ } from "next/font/google";
5
+ const sans = SansFont({
6
+ subsets: ["latin"],
7
+ variable: "--font-sans-fallback",
8
+ fallback: ["system-ui", "Helvetica Neue", "Helvetica", "Arial"],
9
+ preload: true,
10
+ weight: ["300", "400", "500", "600", "700"]
11
+ });
12
+ const heading = HeadingFont({
13
+ subsets: ["latin"],
14
+ variable: "--font-heading-fallback",
15
+ fallback: ["system-ui", "Helvetica Neue", "Helvetica", "Arial"],
16
+ preload: true,
17
+ weight: ["200", "300", "400", "500", "600", "700", "800"]
18
+ });
19
+ const fontVariables = [sans, heading].map((f) => f.variable).join(" ");
20
+ export {
21
+ fontVariables
22
+ };
@@ -0,0 +1,89 @@
1
+ import type { HttpFixture } from '../../registry.js';
2
+ import { type CompiledFixture, NoRecordedResponseError } from './http-fixtures.js';
3
+ /**
4
+ * ─────────────────────────────────────────────────────────────────────────────
5
+ * THE ONE PLACE A REQUEST CAN LEAVE THE HARNESS
6
+ * ─────────────────────────────────────────────────────────────────────────────
7
+ *
8
+ * `globalThis.fetch` is patched, once, at module import. That is a heavy
9
+ * instrument and it is the only one that works, for a reason worth stating
10
+ * plainly:
11
+ *
12
+ * AN INTERCEPTION A PARTNER CAN DECLINE TO WIRE IS NOT REALLY AN INTERCEPTION.
13
+ *
14
+ * The previous design had two interception points — a forced react-query
15
+ * `queryFn`, and a fetch handed to the partner's own `FetchProvider`. Both are
16
+ * sound where they apply, and between them they left a third path completely
17
+ * open: a widget using neither, a `useEffect` calling the global `fetch`,
18
+ * reached the real network in fixtures mode. The provider was mounted only
19
+ * when the partner supplied one, so omitting it was an escape hatch.
20
+ *
21
+ * Worse, that third path is the SAME population the old query-key fixture
22
+ * format shut out: a partner not using react-query had no working fixture
23
+ * surface *and* no interception. The one least served by the design was the
24
+ * one whose requests escaped. Patching the global closes both at once.
25
+ *
26
+ * ── What is deliberately NOT intercepted ─────────────────────────────────────
27
+ *
28
+ * Same-origin traffic passes through untouched — with ONE exception.
29
+ *
30
+ * Measured against a running harness, same-origin traffic is `/_next/static`,
31
+ * the document, and the `?_rsc=` payloads client navigation fetches: the
32
+ * framework's own plumbing, none of it the integration talking to an API.
33
+ * Intercepting it would break the app to no purpose.
34
+ *
35
+ * `/api/` is the exception, because it is the integration's own namespace. A
36
+ * first-party integration's widgets call `/api/integrations/<slug>/…` rather
37
+ * than the vendor directly — the vendor call happens server-side, where the
38
+ * credential lives — and our own Acme example is shaped exactly that way. The
39
+ * harness serves no backend, so passing those through guarantees a 404; the
40
+ * useful answer is a fixture, or a refusal that names the URL.
41
+ *
42
+ * Scoped to `/api/` rather than "everything except `/_next/`" deliberately: a
43
+ * rule that has to enumerate the framework's internals is a rule that breaks
44
+ * the next time Next adds one.
45
+ *
46
+ * ── Fail closed ──────────────────────────────────────────────────────────────
47
+ *
48
+ * The route table starts REFUSING EVERYTHING and only opens once a provider
49
+ * publishes one. react-query begins fetching during the mount commit, so a
50
+ * table installed from an effect would arrive after the first requests; the
51
+ * safe direction for that race is to refuse, never to allow.
52
+ */
53
+ /** How the interceptor should answer third-party requests right now. */
54
+ export interface HarnessRoutingTable {
55
+ mode: 'fixtures' | 'live';
56
+ /** Compiled fixtures for the active variant. */
57
+ fixtures: readonly CompiledFixture[];
58
+ /** Origins live mode may reach. Empty in fixtures mode. */
59
+ egress: readonly string[];
60
+ /** Notified for every third-party call. Never throws into the caller. */
61
+ onCall?: (call: {
62
+ url: string;
63
+ method: string;
64
+ outcome: 'fixture' | 'network' | 'refused';
65
+ }) => void;
66
+ }
67
+ /**
68
+ * Publish the table the interceptor answers from.
69
+ *
70
+ * Called synchronously during render, which is a deliberate impurity: the
71
+ * alternative is an effect, and an effect runs AFTER react-query has begun
72
+ * fetching. The write is idempotent, so StrictMode's double invocation is
73
+ * harmless. Do not "fix" this into a `useEffect` — that reopens the race the
74
+ * fail-closed default exists to survive.
75
+ */
76
+ export declare function publishHarnessRouting(table: HarnessRoutingTable): void;
77
+ /** Restore the fail-closed default — for unmount, and for tests. */
78
+ export declare function closeHarnessRouting(): void;
79
+ /**
80
+ * Patch `globalThis.fetch`. Idempotent; safe to call from module scope.
81
+ *
82
+ * Returns the original, which is what same-origin traffic is passed through
83
+ * to — captured once so a second patch cannot chain onto our own wrapper.
84
+ */
85
+ export declare function installHarnessFetch(): typeof globalThis.fetch;
86
+ /** Test seam: undo the patch and reset the table. */
87
+ export declare function uninstallHarnessFetch(original: typeof globalThis.fetch): void;
88
+ export { NoRecordedResponseError };
89
+ export type { HttpFixture };
@@ -0,0 +1,101 @@
1
+ "use client";
2
+ import { EgressDeniedError, isEgressAllowed } from "@ekanos/sdk/context";
3
+ import {
4
+ NoRecordedResponseError,
5
+ fixtureResponse,
6
+ matchHttpFixture
7
+ } from "./http-fixtures.js";
8
+ const CLOSED = {
9
+ mode: "fixtures",
10
+ fixtures: [],
11
+ egress: []
12
+ };
13
+ let active = CLOSED;
14
+ let installed = false;
15
+ let originalFetch;
16
+ let patchedFetch;
17
+ function redact(url) {
18
+ const safe = new URL(url.toString());
19
+ safe.username = "";
20
+ safe.password = "";
21
+ const hadQuery = safe.search !== "";
22
+ safe.search = "";
23
+ return `${safe.toString()}${hadQuery ? "?\u2026" : ""}`;
24
+ }
25
+ function notify(url, method, outcome) {
26
+ try {
27
+ active.onCall?.({ url: redact(url), method, outcome });
28
+ } catch {
29
+ }
30
+ }
31
+ function publishHarnessRouting(table) {
32
+ active = table;
33
+ }
34
+ function closeHarnessRouting() {
35
+ active = CLOSED;
36
+ }
37
+ function installHarnessFetch() {
38
+ if (installed && originalFetch !== void 0 && globalThis.fetch === patchedFetch) {
39
+ return originalFetch;
40
+ }
41
+ const original = globalThis.fetch.bind(globalThis);
42
+ installed = true;
43
+ originalFetch = original;
44
+ const intercept = async (input, init) => {
45
+ const target = typeof input === "string" || input instanceof URL ? String(input) : input.url;
46
+ const method = (init?.method ?? (typeof input === "object" && "method" in input ? input.method : "GET") ?? "GET").toUpperCase();
47
+ let url;
48
+ try {
49
+ url = new URL(target, globalThis.location?.href);
50
+ } catch {
51
+ throw new NoRecordedResponseError(method, target, true);
52
+ }
53
+ if (globalThis.location !== void 0 && url.origin === globalThis.location.origin && !url.pathname.startsWith("/api/")) {
54
+ return original(input, init);
55
+ }
56
+ if (active.mode === "live") {
57
+ if (!isEgressAllowed(url.toString(), active.egress)) {
58
+ notify(url, method, "refused");
59
+ throw new EgressDeniedError(url.origin);
60
+ }
61
+ notify(url, method, "network");
62
+ return original(input, {
63
+ ...init,
64
+ // An allowlist is a promise about ONE url; following a redirect would
65
+ // silently make it a promise about wherever that points next, and a
66
+ // 307/308 carries the method and body along. A browser cannot re-vet
67
+ // hops — `redirect: 'manual'` yields an opaque response with no
68
+ // readable Location — so refusing loudly is the only sound choice.
69
+ redirect: "error"
70
+ });
71
+ }
72
+ const fixture = matchHttpFixture(method, url.toString(), active.fixtures);
73
+ if (!fixture) {
74
+ notify(url, method, "refused");
75
+ throw new NoRecordedResponseError(
76
+ method,
77
+ redact(url),
78
+ globalThis.location === void 0 && url.pathname.startsWith("/api/")
79
+ );
80
+ }
81
+ notify(url, method, "fixture");
82
+ return fixtureResponse(fixture);
83
+ };
84
+ globalThis.fetch = intercept;
85
+ patchedFetch = intercept;
86
+ return original;
87
+ }
88
+ function uninstallHarnessFetch(original) {
89
+ globalThis.fetch = original;
90
+ installed = false;
91
+ originalFetch = void 0;
92
+ patchedFetch = void 0;
93
+ closeHarnessRouting();
94
+ }
95
+ export {
96
+ NoRecordedResponseError,
97
+ closeHarnessRouting,
98
+ installHarnessFetch,
99
+ publishHarnessRouting,
100
+ uninstallHarnessFetch
101
+ };