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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

# environment variables
.env*
!.env.example

# MacOS
.DS_Store
74 changes: 74 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# StackOne Hub — Claude instructions

`@stackone/hub` is a React component library that ships an embeddable integration picker. It's bundled with Rollup and consumed in three forms: ESM, CJS, and a self-contained web component IIFE.

## Commands

| Task | Command |
|------|---------|
| Build all bundles | `npm run build` |
| Vite dev sandbox (port 3001) | `npm run dev` |
| Next.js SSR sandbox (port 3002) | `npm run dev:nextjs` |
| First-time Next sandbox setup | `npm run dev:nextjs:setup` |
| Lint | `npm run lint` (Biome) |
| Auto-fix lint/format | `npm run lint:fix` and `npm run code:format:fix` |
| Regenerate Relay artifacts | `npm run relay` |

Always run `npm run lint` before committing. Biome is the only linter — don't add ESLint/Prettier configs.

## Project layout

- `src/index.ts` — public entry, exports `StackOneHub`
- `src/StackOneHub.tsx` — top-level component (carries the `'use client'` directive)
- `src/Hub.tsx` — inner component, switches on `mode`
- `src/modules/integration-picker/` — main feature
- `src/shared/` — error boundary, http client, feature flags, queries
- `src/WebComponentWrapper.tsx` — separate entry built into `dist/webcomponent.js`
- `dev/vite/` — Vite-based dev sandbox (own `package.json`, hub linked via `file:../..`)
- `dev/nextjs/` — Next.js 15 + React 19 App Router SSR sandbox (own `package.json`, hub linked via `file:../..`)
- `rollup.config.mjs` — three bundle outputs (ESM main, CJS main, IIFE webcomponent) plus a `.d.ts` rollup
- `dist/` — build output, gitignored, the only thing published

## SSR / Next.js constraints

The package is consumed by SSR frameworks (Next.js App Router). When changing `src/`, keep these invariants intact:

1. **`'use client'` directive on the bundle.** It's injected by `output.banner` in `rollup.config.mjs` and survives minification because terser is configured with `compress: { directives: false }`. If you change the rollup config, verify `head -c 30 dist/index.esm.js` still starts with `"use client";`.
2. **No `window`/`document`/`localStorage` access during render.** Anything that touches the DOM must live inside `useEffect`, an event handler, or be guarded with `typeof window !== 'undefined'`. Render-time access (including `useMemo` and module-scope code) breaks SSR.
3. **`customElements.define` is module-scoped in `WebComponentWrapper.tsx`** and is guarded with `typeof window !== 'undefined' && typeof customElements !== 'undefined'` plus a `customElements.get` check. Keep the guards if you edit that file.
4. **Theme application mutates `<html>`.** `applyTheme`/`applyLightTheme`/`applyDarkTheme` from `@stackone/malachite` set CSS custom properties on `document.documentElement`. This requires consumers to add `suppressHydrationWarning` to their `<html>` tag (documented in README). Don't move these calls out of `useEffect`.
5. **`package.json` `sideEffects` field** marks only `./dist/webcomponent.js` as side-effecting so bundlers can tree-shake the React entry. Keep it that way.
6. **Single React instance.** `react`/`react-dom`/`react-hook-form` are peer deps and the bundle imports them at runtime — two copies in the consumer's tree breaks hooks (`Invalid hook call`). Standard `npm install` hoists React and is fine. Monorepos, pnpm without hoist, and `file:`/`link:` deps may require explicit deduping by the consumer. The README's "Invalid hook call — duplicate React" section documents fixes.

## Build output

| File | Format | Notes |
|------|--------|-------|
| `dist/index.esm.js` | ESM | Has `'use client'` banner |
| `dist/index.js` | CJS | Has `'use client'` banner |
| `dist/index.d.ts` | TS declarations | Generated by `rollup-plugin-dts` |
| `dist/webcomponent.js` | IIFE | React + ReactDOM bundled in, registers `<stackone-hub>` |

Pre-existing build warnings about `crypto` / `vm` Node built-ins (from `@stackone/utils` and `jsonpath-plus`) are noisy but harmless for the React bundles — only the webcomponent IIFE actually needs them and they're never reached at runtime in a browser.

## Dev sandboxes

Both sandboxes are now their own npm packages and consume the hub via `"@stackone/hub": "file:../.."`, so they exercise the actual built `dist/` (including the `'use client'` banner). Edit hub source, run `npm run build` from the repo root, and both sandboxes pick up the new bundle automatically — no reinstall needed.

- **Vite sandbox** (`dev/vite/`): port 3001. Run via `npm run dev` from the repo root. First-time setup is `npm run dev:setup` (builds the hub, then `npm install` inside `dev/vite/`).
- **Next.js sandbox** (`dev/nextjs/`): port 3002. Run via `npm run dev:nextjs`. First-time setup is `npm run dev:nextjs:setup`. Use this one to validate SSR behaviour.

**Vite sandbox needs `resolve.dedupe`** for `react`, `react-dom`, `react-hook-form` because the symlinked hub at `node_modules/@stackone/hub` resolves to a directory with its own `node_modules` — two React copies otherwise. The `dev/vite/vite.config.ts` is already set up correctly. If you add a new peer dep to the hub, add it to the dedupe list.

If you add a new public prop to `StackOneHub`, update both `dev/vite/main.tsx` and `dev/nextjs/app/HubWrapper.tsx` so the props stay testable in both sandboxes.

## Versioning / publishing

Releases use `release-please-config.json`. Don't bump `version` in `package.json` manually — release-please handles it.

## Style conventions

- No comments in code unless the *why* is genuinely non-obvious. Names should carry the intent.
- TypeScript strict mode is on; don't reach for `any`. Internal types live in `src/types/`, feature-specific types live in their feature folder (`src/modules/integration-picker/types.ts`).
- 4-space indent (Biome enforces).
- Imports are sorted by Biome — let the formatter do it.
162 changes: 149 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,14 @@ StackOne HUB is a React-based integration component library that provides a web
- [Setup](#setup)
- [🛠️ Development](#️-development)
- [Environment Setup](#environment-setup)
- [Next.js SSR Sandbox](#nextjs-ssr-sandbox)
- [🏗️ Build](#️-build)
- [Build Output](#build-output)
- [📖 Usage](#-usage)
- [🌐 Web Component Integration](#-web-component-integration)
- [⚛️ React Component Integration](#️-react-component-integration)
- [▲ Next.js (App Router) Integration](#-nextjs-app-router-integration)
- [⚠️ "Invalid hook call" — duplicate React](#️-invalid-hook-call--duplicate-react)
- [💻 Local Development Usage](#-local-development-usage)
- [Web Component (Local)](#web-component-local)
- [React Component (Local)](#react-component-local)
Expand Down Expand Up @@ -83,7 +86,25 @@ npm run dev
npm run dev
```

The development server will start at [http://localhost:3000](http://localhost:3000) (default port).
The Vite dev server starts at [http://localhost:3001](http://localhost:3001).

### Next.js SSR Sandbox

A second sandbox lives in `dev/nextjs/` and runs the hub inside a Next.js 15 + React 19 App Router app. Use it to verify server-side rendering behaviour.

From the repo root:

```bash
# First time (builds the hub and installs sandbox deps)
npm run dev:nextjs:setup

# Subsequent runs
npm run dev:nextjs
```

The Next.js sandbox runs at [http://localhost:3002](http://localhost:3002). Set `STACKONE_API_KEY` in `dev/nextjs/.env` (see `dev/nextjs/.env.example`) to have the page fetch a connect-session token server-side, or paste one into the input on the page.
Comment thread
adefreitas marked this conversation as resolved.

After editing hub source, rerun `npm run build` from the repo root — the sandbox is linked via `file:../..` so it picks up the new `dist/` automatically.

## 🏗️ Build

Expand All @@ -99,9 +120,10 @@ The build generates multiple bundles in the `dist/` directory:

| File | Description | Use Case |
|------|-------------|----------|
| `StackOneHub.esm.js` | ES module bundle | Modern React applications |
| `StackOneHub.cjs.js` | CommonJS module | Node.js/legacy environments |
| `StackOneHub.web.js` | Web component bundle | Vanilla HTML/JS integration |
| `dist/index.esm.js` | ES module bundle (with `'use client'` banner) | Modern React apps, Next.js, Vite |
| `dist/index.js` | CommonJS module (with `'use client'` banner) | Node.js / legacy environments |
| `dist/index.d.ts` | TypeScript declarations | Type-checking |
| `dist/webcomponent.js` | Web component bundle (IIFE, React inlined) | Vanilla HTML/JS integration |
Comment thread
adefreitas marked this conversation as resolved.

## 📖 Usage

Expand All @@ -116,45 +138,159 @@ For vanilla HTML/JavaScript applications:
<title>StackOne HUB Integration</title>
</head>
<body>
<script src="<TBD>/StackOneHub.web.js"></script>
<my-component></my-component>
<script src="<TBD>/webcomponent.js"></script>
<stackone-hub token="..."></stackone-hub>
</body>
</html>
```

### ⚛️ React Component Integration

For React applications:
For React applications (CSR — Vite, CRA, etc.):

```tsx
import StackOneHub from "@stackone/StackOneHub";
import { StackOneHub } from "@stackone/hub";

function App() {
return (
<div className="app">
<h1>My Application</h1>
<StackOneHub />
<StackOneHub token={token} />
</div>
);
}

export default App;
```

`StackOneHub` is a client-side component — it ships with a `'use client'` directive and is safe to import directly in any framework that supports server-side rendering.

### ▲ Next.js (App Router) Integration

`StackOneHub` is annotated with `'use client'` so you can import it directly from any Server Component. The token can be created server-side (recommended — keeps your API key off the client and avoids the CORS-protected `/connect_sessions` endpoint), and passed as a prop to a small Client Component that renders the hub.

**Important:** Add `suppressHydrationWarning` to the `<html>` tag in your root layout. The hub applies its theme CSS custom properties to `document.documentElement` after hydration, which would otherwise trigger a hydration warning on the `<html>` element (the warning only suppresses the `<html>` tag itself, not its children):

```tsx
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<body>{children}</body>
</html>
);
}
```

**`app/page.tsx`** (Server Component):

```tsx
import HubWrapper from "./HubWrapper";

export default async function Page() {
const res = await fetch("https://api.stackone.com/connect_sessions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Basic ${Buffer.from(process.env.STACKONE_API_KEY!).toString("base64")}`,
},
body: JSON.stringify({
origin_owner_id: "your_customer_id",
origin_owner_name: "Your Customer",
origin_username: "your_username",
}),
cache: "no-store",
});
const { token } = await res.json();

return <HubWrapper token={token} />;
}
```

**`app/HubWrapper.tsx`** (Client Component):

```tsx
"use client";

import { StackOneHub } from "@stackone/hub";

export default function HubWrapper({ token }: { token: string }) {
return (
<StackOneHub
token={token}
mode="integration-picker"
onSuccess={(account) => console.log("connected", account)}
/>
);
}
```

If you prefer to opt the hub out of SSR entirely (Pages Router, or to skip the server pre-render):

```tsx
import dynamic from "next/dynamic";

const StackOneHub = dynamic(
() => import("@stackone/hub").then((m) => m.StackOneHub),
{ ssr: false },
);
```

A working example lives in [`dev/nextjs/`](./dev/nextjs).

### ⚠️ "Invalid hook call" — duplicate React

`@stackone/hub` declares `react` and `react-dom` as **peer dependencies** and the bundle imports them at runtime — your app's copy must be the only copy that ends up loaded. In a standard `npm install` your bundler will hoist React and you won't see this. But the following setups can leave you with **two copies of React** and trip the "Invalid hook call" error:

- **Monorepos** (npm workspaces, Yarn workspaces, Turborepo) where multiple packages each have their own `node_modules/react`.
- **pnpm** with strict isolation — a transitive copy can shadow the root copy.
- **`file:` / `link:` dependencies** pointing at a directory that has its own `node_modules/react` (this is what bit our local Vite sandbox).

Fixes by bundler:

**Vite** — add `resolve.dedupe` to your config:

```ts
// vite.config.ts
export default defineConfig({
resolve: {
dedupe: ['react', 'react-dom', 'react-hook-form'],
},
});
```

**Webpack / Next.js** — usually handled automatically. If not, alias `react` and `react-dom` to a single absolute path:

```js
// next.config.mjs
import path from "node:path";
export default {
webpack: (config) => {
config.resolve.alias["react"] = path.resolve("./node_modules/react");
config.resolve.alias["react-dom"] = path.resolve("./node_modules/react-dom");
return config;
},
};
```

**pnpm** — set `public-hoist-pattern[]=react*` in `.npmrc`, or `shamefully-hoist=true`.

To diagnose, run `npm ls react` (or `pnpm why react`) at your app's root — if you see more than one entry resolved to a different path, that's the cause.

### 💻 Local Development Usage

#### Web Component (Local)
```html
<script src="dist/StackOneHub.web.js"></script>
<my-component></my-component>
<script src="dist/webcomponent.js"></script>
<stackone-hub token="..."></stackone-hub>
```

#### React Component (Local)
```tsx
import StackOneHub from "dist/StackOneHub.esm";
import { StackOneHub } from "../dist/index.esm.js";

function App() {
return <StackOneHub />;
return <StackOneHub token={token} />;
}
```

Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
},
"files": {
"ignoreUnknown": false,
"ignore": ["**/node_modules/**", "**/dist/**"]
"ignore": ["**/node_modules/**", "**/dist/**", "**/__generated__/**", "**/.next/**"]
},
"formatter": {
"enabled": true,
Expand Down
6 changes: 6 additions & 0 deletions dev/nextjs/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
STACKONE_API_KEY=
ORIGIN_OWNER_ID=dummy_customer_id
ORIGIN_OWNER_NAME=dummy_customer_name
ORIGIN_USERNAME=dummy_customer_username
NEXT_PUBLIC_API_URL=http://localhost:4000
NEXT_PUBLIC_APP_URL=http://localhost:3000
Comment thread
adefreitas marked this conversation as resolved.
5 changes: 5 additions & 0 deletions dev/nextjs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
.next
.env
.env*.local
next-env.d.ts.bak
62 changes: 62 additions & 0 deletions dev/nextjs/app/HubWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
'use client';

import { StackOneHub } from '@stackone/hub';
import { useState } from 'react';

interface HubWrapperProps {
initialToken: string;
apiUrl: string;
appUrl: string;
}

export default function HubWrapper({ initialToken, apiUrl, appUrl }: HubWrapperProps) {
const [token, setToken] = useState(initialToken);
const [manualToken, setManualToken] = useState('');
const [theme, setTheme] = useState<'light' | 'dark'>('light');

return (
<div className={theme}>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<input
style={{ flex: 1, padding: 6, border: '1px solid #ccc', borderRadius: 4 }}
type="text"
placeholder="Paste a connect session token"
value={manualToken}
onChange={(e) => setManualToken(e.target.value)}
/>
<button onClick={() => setToken(manualToken)} disabled={!manualToken}>
Use token
</button>
<button
aria-label={
theme === 'light' ? 'Switch to dark theme' : 'Switch to light theme'
}
onClick={() => setTheme((t) => (t === 'light' ? 'dark' : 'light'))}
>
{theme === 'light' ? '🌞' : '🌚'}
</button>
</div>
{token ? (
<p style={{ fontSize: 12, color: '#666', wordBreak: 'break-all' }}>
Token: {token}
</p>
) : (
<p style={{ fontSize: 12, color: '#666' }}>
No token yet — set <code>STACKONE_API_KEY</code> in <code>.env</code> for
server-side fetching, or paste one above.
</p>
)}
<StackOneHub
key={token}
mode="integration-picker"
token={token || undefined}
baseUrl={apiUrl}
appUrl={appUrl}
theme={theme}
onSuccess={(account) => {
alert(`success: ${JSON.stringify(account)}`);
}}
/>
</div>
);
}
Loading
Loading