Hosted components (#1229)

<!--

Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md

-->


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added a new "Hosted Components" app with its own app shell, routing,
auth-aware UI, a handler route, and a welcome page showing the signed-in
user.
* **Chores**
* Added dev tooling and configuration for the new app (build, lint,
typecheck, Vite/TS, package manifest) and updated dev env API URL.
* **Tests**
  * Excluded the new app from the test workspace.
* **Bug Fixes**
* Suppressed noisy console errors for a specific internal sentinel and
clarified related error messaging.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Konstantin Wohlwend <n2d4xc@gmail.com>
This commit is contained in:
BilalG1 2026-03-10 11:29:05 -07:00 committed by GitHub
parent 85ea5d25c8
commit a64055cfca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1287 additions and 44 deletions

View File

@ -182,6 +182,14 @@
img: "https://www.svgrepo.com/show/448400/docs.svg",
importance: 2,
},
{
name: "Hosted Components",
portSuffix: "09",
description: [
"Src: ./apps/hosted-components",
],
importance: 2,
},
{
name: "Inbucket",
portSuffix: "05",

View File

@ -0,0 +1 @@
VITE_STACK_API_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02

View File

@ -0,0 +1,4 @@
module.exports = {
extends: ["../../configs/eslint/defaults.js"],
ignorePatterns: ["/*", "!/src"],
};

View File

@ -0,0 +1,37 @@
{
"name": "@stackframe/hosted-components",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite dev --port ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}09",
"build": "vite build",
"start": "node .output/server/index.mjs",
"lint": "eslint --ext .ts,.tsx .",
"typecheck": "tsc --noEmit",
"clean": "rimraf .output && rimraf node_modules"
},
"dependencies": {
"@stackframe/react": "workspace:*",
"@stackframe/stack-shared": "workspace:*",
"@tanstack/react-router": "^1.121.3",
"@tanstack/react-start": "^1.121.3",
"@tanstack/react-start-client": "^1.121.3",
"@tanstack/react-start-server": "^1.121.3",
"@tanstack/start-client-core": "^1.121.3",
"@tanstack/start-server-core": "^1.121.3",
"@tanstack/start-plugin-core": "^1.121.3",
"react": "19.2.1",
"react-dom": "19.2.1"
},
"devDependencies": {
"@types/node": "^22.13.0",
"@types/react": "19.2.1",
"@types/react-dom": "19.2.1",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "5.9.3",
"vite": "^7.0.0",
"vite-tsconfig-paths": "^4.3.2"
},
"packageManager": "pnpm@10.23.0"
}

View File

@ -0,0 +1,12 @@
import { StartClient } from '@tanstack/react-start/client';
import { StrictMode, startTransition } from 'react';
import { hydrateRoot } from 'react-dom/client';
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<StartClient />
</StrictMode>,
);
});

View File

@ -0,0 +1,86 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as HandlerSplatRouteImport } from './routes/handler/$'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const HandlerSplatRoute = HandlerSplatRouteImport.update({
id: '/handler/$',
path: '/handler/$',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/handler/$': typeof HandlerSplatRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/handler/$': typeof HandlerSplatRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/handler/$': typeof HandlerSplatRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/handler/$'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/handler/$'
id: '__root__' | '/' | '/handler/$'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
HandlerSplatRoute: typeof HandlerSplatRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/handler/$': {
id: '/handler/$'
path: '/handler/$'
fullPath: '/handler/$'
preLoaderRoute: typeof HandlerSplatRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
HandlerSplatRoute: HandlerSplatRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}

View File

@ -0,0 +1,12 @@
import { createRouter } from '@tanstack/react-router';
import { routeTree } from './routeTree.gen';
export function getRouter() {
const router = createRouter({
routeTree,
scrollRestoration: true,
});
return router;
}

View File

@ -0,0 +1,137 @@
/// <reference types="vite/client" />
import { StackClientApp, StackProvider, StackTheme } from '@stackframe/react';
import {
HeadContent,
Outlet,
Scripts,
createRootRoute,
useNavigate
} from '@tanstack/react-router';
import type { ErrorInfo, ReactNode } from 'react';
import { Component, useEffect, useMemo, useState } from 'react';
export function getProjectId(): string | null {
// Extract from subdomain: <projectId>.built-with-stack-auth.com
// Also works with <projectId>.localhost for local dev
const hostname = window.location.hostname;
const parts = hostname.split('.');
if (parts.length >= 2) {
return parts[0];
}
return null;
}
function FullPageError({ title, message }: { title: string, message: string }) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<div style={{ textAlign: 'center', maxWidth: 480, padding: 24 }}>
<h1 style={{ fontSize: 24, marginBottom: 8 }}>{title}</h1>
<p style={{ color: '#666' }}>{message}</p>
</div>
</div>
);
}
class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
constructor(props: { children: ReactNode }) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Hosted components error:', error, errorInfo);
}
render() {
if (this.state.error) {
return <FullPageError title="Something went wrong" message={this.state.error.message} />;
}
return this.props.children;
}
}
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
],
}),
shellComponent: RootDocument,
component: RootComponent,
});
function RootDocument({ children }: { children: ReactNode }) {
return (
<html>
<head>
<HeadContent />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body style={{ fontFamily: "'Inter', sans-serif", margin: 0 }}>
{children}
<Scripts />
</body>
</html>
);
}
function RootComponent() {
const [projectId, setProjectId] = useState<string | null | undefined>("internal");
useEffect(() => {
setProjectId(getProjectId());
}, []);
const isValidProjectId = projectId ? (projectId === "internal" || /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(projectId)) : false;
const stackApp = useMemo(() => {
if (!projectId || !isValidProjectId) return null;
return new StackClientApp({
projectId,
tokenStore: "cookie",
baseUrl: import.meta.env.VITE_STACK_API_URL || undefined,
urls: {
handler: "/handler",
signIn: "/handler/sign-in",
signUp: "/handler/sign-up",
afterSignIn: "/",
afterSignUp: "/",
afterSignOut: "/handler/sign-in",
},
redirectMethod: { useNavigate: useNavigate as any }
});
}, [projectId]);
if (projectId === undefined) {
return <></>;
}
if (!projectId) {
return <FullPageError title="Invalid URL" message={`Could not determine project ID from subdomain. Visit <projectId>.${window.location.host}.`} />;
}
if (!isValidProjectId) {
return <FullPageError title="Something went wrong" message={`Invalid project ID: ${projectId}. Project IDs must be UUIDs.`} />;
}
return (
<ErrorBoundary>
<StackProvider app={stackApp!}>
<StackTheme>
<Outlet />
</StackTheme>
</StackProvider>
</ErrorBoundary>
);
}

View File

@ -0,0 +1,14 @@
import { createFileRoute } from '@tanstack/react-router';
import { StackHandler } from '@stackframe/react';
import { useState, useEffect } from 'react';
export const Route = createFileRoute('/handler/$')({
component: HandlerPage,
});
function HandlerPage() {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => setIsMounted(true), []);
if (!isMounted) return null;
return <StackHandler fullPage />;
}

View File

@ -0,0 +1,31 @@
import { UserButton, useUser } from "@stackframe/react";
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/')({
component: HandlerPage,
pendingComponent: () => (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", minHeight: "100vh" }}>
<div style={{ width: 24, height: 24, border: "2px solid #e5e5e5", borderTop: "2px solid #333", borderRadius: "50%", animation: "spin 0.6s linear infinite" }} />
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
),
});
function HandlerPage() {
const user = useUser({ or: "redirect" });
const name = user.displayName || user.primaryEmail || "User";
return (
<div style={{ position: "relative", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", minHeight: "100vh", fontFamily: "system-ui, sans-serif" }}>
<div style={{ position: "absolute", top: "1rem", right: "1rem" }}>
<UserButton />
</div>
<h1 style={{ fontSize: "1.5rem", fontWeight: 500, marginBottom: "0.5rem" }}>
Welcome, {name}
</h1>
<p style={{ color: "#666", fontSize: "0.875rem" }}>
You are signed in.
</p>
</div>
);
}

View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"target": "ES2021",
"lib": ["DOM", "DOM.Iterable", "ES2021"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"paths": {
"~/*": ["./src/*"]
}
},
"include": ["src", "vite.config.ts"]
}

View File

@ -0,0 +1,90 @@
import fs from 'node:fs'
import path from 'node:path'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
import { defineConfig, type Plugin } from 'vite'
import tsConfigPaths from 'vite-tsconfig-paths'
/**
* Makes Vite watch specific packages inside node_modules for changes.
* By default Vite/chokidar ignores all of node_modules. This plugin uses
* the `config()` hook to inject negation patterns before chokidar is
* initialized, which is the only reliable way to un-ignore specific packages.
* See: https://github.com/vitejs/vite/issues/8619
*/
function watchNodeModules(modules: string[]): Plugin {
return {
name: 'watch-node-modules',
config() {
return {
server: {
watch: {
ignored: modules.map((m) => `!**/node_modules/${m}/**`),
},
},
}
},
}
}
/**
* Waits for workspace package dist directories to exist before letting
* Vite resolve them. Fixes the race condition where `pnpm dev` starts
* hosted-components before dependency packages have finished their
* initial build (eg. after `rimraf dist` in their dev script).
*/
function waitForWorkspacePackages(packages: string[]): Plugin {
const packageDistEntries = packages.map((pkg) => ({
name: pkg,
entry: path.resolve(__dirname, 'node_modules', pkg, 'dist', 'esm', 'index.js'),
}))
async function waitForFile(filePath: string, timeoutMs = 60_000): Promise<void> {
if (fs.existsSync(filePath)) return
const start = Date.now()
return new Promise((resolve, reject) => {
const interval = setInterval(() => {
if (fs.existsSync(filePath)) {
clearInterval(interval)
resolve()
} else if (Date.now() - start > timeoutMs) {
clearInterval(interval)
reject(new Error(`Timed out waiting for ${filePath} to exist`))
}
}, 500)
})
}
return {
name: 'wait-for-workspace-packages',
enforce: 'pre',
async buildStart() {
const missing = packageDistEntries.filter((p) => !fs.existsSync(p.entry))
if (missing.length > 0) {
console.log(`Waiting for workspace packages to build: ${missing.map((p) => p.name).join(', ')}`)
await Promise.all(missing.map((p) => waitForFile(p.entry)))
console.log('All workspace packages are ready.')
}
},
}
}
export default defineConfig({
server: {
port: Number((process.env.NEXT_PUBLIC_STACK_PORT_PREFIX || "81") + "09"),
},
resolve: {
dedupe: ['react', 'react-dom'],
},
optimizeDeps: {
exclude: ['@stackframe/react', '@stackframe/stack-shared'],
},
plugins: [
waitForWorkspacePackages(['@stackframe/react', '@stackframe/stack-shared']),
watchNodeModules(['@stackframe/react', '@stackframe/stack-shared']),
tsConfigPaths(),
tanstackStart(),
// react's vite plugin must come after start's vite plugin
viteReact(),
],
})

View File

@ -0,0 +1,29 @@
import { createGlobal } from "./globals";
export const NO_SUSPENSE_BOUNDARY_ERROR_SENTINEL = "__stack-no-suspense-boundary-error__";
export function isNoSuspenseBoundaryError(value: unknown): boolean {
return (
typeof value === "object"
&& value !== null
&& (value as Record<string, unknown>).__noSuspenseBoundarySentinel === NO_SUSPENSE_BOUNDARY_ERROR_SENTINEL
);
}
export function ensureMonkeyPatch() {
createGlobal("__console-error-monkey-patch__", () => {
const originalConsoleError = console.error;
console.error = function (...args: unknown[]) {
// React's default error handlers will log all errors to the console, even those that we intentionally use to suppress SSR.
// Next.js among others override the default error handler and will not log SSR errors to the console.
// However, vanilla React and other frameworks like TanStack Start use the default error handler.
// Hence, we suppress the error here if it is a NoSuspenseBoundaryError.
// It's very cursed, but it's really our best option. Talk to @konsti if you want to know more.
if (args.length === 1 && isNoSuspenseBoundaryError(args[0])) {
return;
}
return originalConsoleError.apply(this, args);
};
return true;
});
}

View File

@ -2,6 +2,7 @@ import React, { SetStateAction } from "react";
import { isBrowserLike } from "./env";
import { neverResolve, runAsynchronously } from "./promises";
import { AsyncResult } from "./results";
import { ensureMonkeyPatch, NO_SUSPENSE_BOUNDARY_ERROR_SENTINEL } from "./monkey-patch";
import { deindent } from "./strings";
export function componentWrapper<
@ -233,14 +234,17 @@ export function shouldRethrowRenderingError(error: unknown): boolean {
export class NoSuspenseBoundaryError extends Error {
digest: string;
reason: string;
__noSuspenseBoundarySentinel = NO_SUSPENSE_BOUNDARY_ERROR_SENTINEL;
constructor(options: { caller?: string }) {
ensureMonkeyPatch();
super(deindent`
Suspense boundary not found! Read the error message below carefully on how to fix it.
Suspense boundary not found! Read the error message below carefully (or paste it into your AI agent).
${options.caller ?? "This code path"} attempted to display a loading indicator, but didn't find a Suspense boundary above it. Please read the error message below carefully.
The fix depends on which of the 4 scenarios caused it:
There are several potential causes:
1. [Next.js] You are missing a loading.tsx file in your app directory. Fix it by adding a loading.tsx file in your app directory.
@ -260,6 +264,8 @@ export class NoSuspenseBoundaryError extends Error {
4. You caught this error with try-catch or a custom error boundary. Fix this by rethrowing the error or not catching it in the first place.
5. Your version of Stack Auth is too old. Upgrade to the latest version to see if that fixes the issue.
See: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout
More information on SSR and Suspense boundaries: https://react.dev/reference/react/Suspense#providing-a-fallback-for-server-errors-and-client-only-content

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,7 @@ import { defineWorkspace } from 'vitest/config';
export default defineWorkspace([
'packages/*',
'apps/*',
'!apps/hosted-components',
'docs',
'examples/*',
]);