Decrease page load latency

This commit is contained in:
Stan Wohlwend 2024-04-12 17:04:01 +02:00
parent 3613f30af3
commit 244ac7c9e3
14 changed files with 237 additions and 153 deletions

View File

@ -1,95 +1,14 @@
"use client";
import { Box, Drawer, Stack, useTheme } from "@mui/joy";
import { useState } from "react";
import { Sidebar } from "./sidebar";
import { AdminAppProvider } from "./use-admin-app";
import { Header } from "./header";
import { Icon } from '@/components/icon';
import { OnboardingDialog } from "./onboarding-dialog";
const navigationItems = [
{
name: "Users",
href: "/auth/users",
icon: <Icon icon="people_outline" />,
},
{
name: "Providers",
href: "/auth/providers",
icon: <Icon icon="device_hub" />,
},
{
name: "Domains & Handlers",
href: "/auth/urls-and-callbacks",
icon: <Icon icon="link" />,
},
{
name: "Environment",
href: "/settings/environment",
icon: <Icon icon="list_alt" />,
},
{
name: "API Keys",
href: "/settings/api-keys",
icon: <Icon icon="key" />,
},
];
import SidebarLayout from "./sidebar-layout";
export default function Layout(props: { children: React.ReactNode, params: { projectId: string } }) {
const theme = useTheme();
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const isCompactMediaQuery = theme.breakpoints.down("md");
const headerHeight = 50;
return (
<AdminAppProvider projectId={props.params.projectId}>
<OnboardingDialog />
<Stack
flexGrow={1}
direction="row"
alignItems="flex-start"
>
<Sidebar
isCompactMediaQuery={isCompactMediaQuery}
headerHeight={headerHeight}
navigationItems={navigationItems}
mode='full'
/>
<Stack flexGrow={1} direction="column" sx={{ overflow: 'hidden', height: '100vh' }}>
<Header
headerHeight={headerHeight}
navigationItems={navigationItems}
isCompactMediaQuery={isCompactMediaQuery}
onShowSidebar={() => setIsSidebarOpen(true)}
/>
<Stack
paddingX={{ md: 4, xs: 2 }}
flexGrow={1}
paddingY={2}
minWidth={0}
overflow='auto'
>
<Stack spacing={2} component="main">
{props.children}
</Stack>
</Stack>
</Stack>
</Stack>
<Drawer
open={isSidebarOpen}
onClose={() => setIsSidebarOpen(false)}
>
<Sidebar
isCompactMediaQuery={isCompactMediaQuery}
headerHeight={headerHeight}
navigationItems={navigationItems}
mode='compact'
/>
</Drawer>
<SidebarLayout params={props.params}>
{props.children}
</SidebarLayout>
</AdminAppProvider>
);
}

View File

@ -1,3 +1,5 @@
"use client";
import { Dialog } from "@/components/dialog";
import { useEffect, useState } from "react";
import { useAdminApp } from "./use-admin-app";
@ -37,7 +39,6 @@ export function OnboardingDialog() {
<Dialog
titleIcon="library_add"
title="Onboarding"
cancelButton
okButton={{
label: "Continue",
onClick: async () => setApiKey(null),

View File

@ -0,0 +1,92 @@
"use client";
import { Drawer, Stack, useTheme } from "@mui/joy";
import { useState } from "react";
import { Sidebar } from "./sidebar";
import { Header } from "./header";
import { Icon } from '@/components/icon';
const navigationItems = [
{
name: "Users",
href: "/auth/users",
icon: <Icon icon="people_outline" />,
},
{
name: "Providers",
href: "/auth/providers",
icon: <Icon icon="device_hub" />,
},
{
name: "Domains & Handlers",
href: "/auth/urls-and-callbacks",
icon: <Icon icon="link" />,
},
{
name: "Environment",
href: "/settings/environment",
icon: <Icon icon="list_alt" />,
},
{
name: "API Keys",
href: "/settings/api-keys",
icon: <Icon icon="key" />,
},
];
export default function SidebarLayout(props: { children: React.ReactNode, params: { projectId: string } }) {
const theme = useTheme();
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const isCompactMediaQuery = theme.breakpoints.down("md");
const headerHeight = 50;
return (
<>
<Stack
flexGrow={1}
direction="row"
alignItems="flex-start"
>
<Sidebar
isCompactMediaQuery={isCompactMediaQuery}
headerHeight={headerHeight}
navigationItems={navigationItems}
mode='full'
/>
<Stack flexGrow={1} direction="column" sx={{ overflow: 'hidden', height: '100vh' }}>
<Header
headerHeight={headerHeight}
navigationItems={navigationItems}
isCompactMediaQuery={isCompactMediaQuery}
onShowSidebar={() => setIsSidebarOpen(true)}
/>
<Stack
paddingX={{ md: 4, xs: 2 }}
flexGrow={1}
paddingY={2}
minWidth={0}
overflow='auto'
>
<Stack spacing={2} component="main">
{props.children}
</Stack>
</Stack>
</Stack>
</Stack>
<Drawer
open={isSidebarOpen}
onClose={() => setIsSidebarOpen(false)}
>
<Sidebar
isCompactMediaQuery={isCompactMediaQuery}
headerHeight={headerHeight}
navigationItems={navigationItems}
mode='compact'
/>
</Drawer>
</>
);
}

View File

@ -6,16 +6,15 @@ import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { cacheFunction } from "@stackframe/stack-shared/dist/utils/caches";
import { CurrentUser, StackAdminApp } from "@stackframe/stack/dist/lib/stack-app";
const StackAdminAppContext = React.createContext<StackAdminApp<true> | null>(null);
const StackAdminAppContext = React.createContext<StackAdminApp<false> | null>(null);
const usersMap = new Map<string, CurrentUser>();
const createAdminApp = cacheFunction((baseUrl: string, projectId: string, userId: string) => {
console.log("new app", baseUrl, projectId, userId, usersMap);
return new StackAdminApp({
return new StackAdminApp<false, string>({
baseUrl,
projectId,
tokenStore: "nextjs-cookie",
tokenStore: null,
projectOwnerTokens: usersMap.get(userId)!.tokenStore,
});
});

View File

@ -1,4 +1,4 @@
"use client";;
"use client";
import { Logo } from "@/components/logo";
import { Sheet, SheetProps, Stack } from "@mui/joy";

View File

@ -23,7 +23,7 @@ export const GET = deprecatedSmartRouteHandler(async (req: NextRequest) => {
} = await deprecatedParseRequest(req, getSchema);
if (!await checkApiKeySet(projectId, { superSecretAdminKey }) && !await isProjectAdmin(projectId, adminAccessToken)) {
throw new StatusError(StatusError.Forbidden);
throw new StatusError(StatusError.Forbidden, "Invalid API key or insufficient permissions");
}
const apiKeys = await listApiKeySets(

View File

@ -7,6 +7,7 @@ import { Icon } from "./icon";
export const SmartLink = React.forwardRef((props: LinkProps & { hideExternalIndicator?: boolean }, ref) => {
const [isExternal, setIsExternal] = useState(!!props.href?.match(/^[a-z]+:/));
const [isClicked, setIsClicked] = useState(false);
const { hideExternalIndicator, ...linkProps } = props;

View File

@ -2,7 +2,6 @@ import * as yup from 'yup';
import { ApiKeySetFirstViewJson, ApiKeySetJson } from '@stackframe/stack-shared';
import { ApiKeySet } from '@prisma/client';
import { generateSecureRandomString } from '@stackframe/stack-shared/dist/utils/crypto';
import * as crypto from 'node:crypto';
import { prismaClient } from '@/prisma-client';
import { generateUuid } from '@stackframe/stack-shared/dist/utils/uuids';

View File

@ -93,7 +93,7 @@ async function parseBody(req: NextRequest): Promise<SmartRequest["body"]> {
}
}
export async function parseRequest<T extends DeepPartial<SmartRequest>>(req: NextRequest, schema: yup.Schema<T>, options?: { params: Record<string, string> }): Promise<T> {
async function parseRequest<T extends DeepPartial<SmartRequest>>(req: NextRequest, schema: yup.Schema<T>, options?: { params: Record<string, string> }): Promise<T> {
const urlObject = new URL(req.url);
const toValidate: SmartRequest = {
url: req.url,
@ -132,7 +132,7 @@ function isBinaryBody(body: unknown): body is BodyInit {
|| ArrayBuffer.isView(body);
}
export async function createResponse<T extends SmartResponse>(req: NextRequest, requestId: string, obj: T, schema: yup.Schema<T>): Promise<Response> {
async function createResponse<T extends SmartResponse>(req: NextRequest, requestId: string, obj: T, schema: yup.Schema<T>): Promise<Response> {
const validated = await validate(obj, schema);
let status = validated.statusCode;
@ -241,7 +241,7 @@ export function deprecatedSmartRouteHandler(handler: (req: NextRequest, options:
}
console.log(`[ ERR] [${requestId}] ${req.method} ${req.url}: ${statusError.message}`);
console.debug(`For the error above with request ID ${requestId}, the full error is:`, statusError);
console.log(`For the error above with request ID ${requestId}, the full error is:`, statusError);
const res = await createResponse(req, requestId, {
statusCode: statusError.statusCode,

View File

@ -2,8 +2,7 @@ import * as oauth from 'oauth4webapi';
import crypto from "crypto";
import { AsyncResult, Result } from "../utils/results";
import { ReadonlyJson, parseJson } from '../utils/json';
import { typedAssign } from '../utils/objects';
import { ReadonlyJson } from '../utils/json';
import { AsyncStore, ReadonlyAsyncStore } from '../utils/stores';
import { KnownError, KnownErrors } from '../known-errors';
@ -280,7 +279,15 @@ export class StackClientInterface {
}
const url = this.getApiUrl() + path;
const params = {
const params: RequestInit = {
/**
* This fetch may be cross-origin, in which case we don't want to send cookies of the
* original origin (this is the default behaviour of `credentials`).
*
* To help debugging, also omit cookies on same-origin, so we don't accidentally
* implement reliance on cookies anywhere.
*/
credentials: "omit",
...options,
headers: {
"X-Stack-Override-Error-Status": "true",
@ -292,7 +299,7 @@ export class StackClientInterface {
"X-Stack-Publishable-Client-Key": this.options.publishableClientKey,
} : {},
...'projectOwnerTokens' in this.options ? {
"X-Stack-Admin-Access-Token": AsyncResult.or(this.options.projectOwnerTokens?.get(), null)?.accessToken ?? "",
"X-Stack-Admin-Access-Token": (await this.options.projectOwnerTokens?.getOrWait())?.accessToken ?? "",
} : {},
...options.headers,
},

View File

@ -63,6 +63,7 @@ export class AsyncCache<D extends any[], T> {
readonly isCacheAvailable = this._createKeyed("isCacheAvailable");
readonly getIfCached = this._createKeyed("getIfCached");
readonly getOrWait = this._createKeyed("getOrWait");
readonly forceSetCachedValue = this._createKeyed("forceSetCachedValue");
readonly refresh = this._createKeyed("refresh");
readonly invalidate = this._createKeyed("invalidate");
readonly onChange = this._createKeyed("onChange");
@ -70,6 +71,7 @@ export class AsyncCache<D extends any[], T> {
class AsyncValueCache<T> {
private _store: AsyncStore<T>;
private _pendingPromise: ReactPromise<T> | undefined;
private _fetcher: () => Promise<T>;
private readonly _rateLimitOptions: Omit<RateLimitOptions, "batchCalls">;
private _subscriptionsCount = 0;
@ -85,7 +87,7 @@ class AsyncValueCache<T> {
this._store = new AsyncStore();
this._rateLimitOptions = {
concurrency: 1,
debounceMs: 300,
throttleMs: 300,
...filterUndefined(_options.rateLimiter ?? {}),
};
@ -110,28 +112,31 @@ class AsyncValueCache<T> {
return resolved(cached.data);
}
return pending(this._refetch(cacheStrategy === "read-write" ? "write-only" : cacheStrategy));
return this._refetch(cacheStrategy);
}
private _set(value: T): void {
this._store.set(value);
}
private async _setAsync(value: Promise<T>): Promise<boolean> {
return await this._store.setAsync(value);
private _setAsync(value: Promise<T>): ReactPromise<boolean> {
return pending(this._store.setAsync(value));
}
private async _refetch(cacheStrategy: "write-only" | "never"): Promise<T> {
try {
const res = this._fetcher();
if (cacheStrategy === "write-only") {
await this._setAsync(res);
}
return await res;
} catch (e) {
this._store.setRejected(e);
throw e;
private _refetch(cacheStrategy: CacheStrategy): ReactPromise<T> {
if (cacheStrategy === "read-write" && this._pendingPromise) {
return this._pendingPromise;
}
const promise = pending(this._fetcher());
if (cacheStrategy === "never") {
return promise;
}
this._pendingPromise = promise;
return pending(this._setAsync(promise).then(() => promise));
}
forceSetCachedValue(value: T): void {
this._set(value);
}
async refresh(): Promise<T> {
@ -140,6 +145,7 @@ class AsyncValueCache<T> {
async invalidate(): Promise<T> {
this._store.setUnavailable();
this._pendingPromise = undefined;
return await this.refresh();
}

View File

@ -63,9 +63,20 @@ export function neverResolve(): ReactPromise<never> {
}
export function pending<T>(promise: Promise<T>): ReactPromise<T> {
return Object.assign(promise, {
const res = Object.assign(promise, {
status: "pending",
} as const);
} as Pick<ReactPromise<T>, "status"> & { value: T, reason: unknown });
res.then(
value => {
res.status = "fulfilled";
res.value = value;
},
reason => {
res.status = "rejected";
res.reason = reason;
},
);
return res;
}
export async function wait(ms: number) {
@ -78,12 +89,17 @@ export async function waitUntil(date: Date) {
class ErrorDuringRunAsynchronously extends Error {
constructor() {
super("The error above originated in a runAsynchronously() call. Below is the stacktrace associated with it.");
super("The error above originated in a runAsynchronously() call. Here is the stacktrace associated with it.");
this.name = "ErrorDuringRunAsynchronously";
}
}
export function runAsynchronously(promiseOrFunc: Promise<unknown> | (() => Promise<unknown>) | undefined): void {
export function runAsynchronously(
promiseOrFunc: Promise<unknown> | (() => Promise<unknown>) | undefined,
options: {
ignoreErrors?: boolean,
} = {},
): void {
if (typeof promiseOrFunc === "function") {
promiseOrFunc = promiseOrFunc();
}
@ -95,8 +111,10 @@ export function runAsynchronously(promiseOrFunc: Promise<unknown> | (() => Promi
cause: error,
}
);
console.error(newError);
console.error(duringError);
if (!options.ignoreErrors) {
console.error(newError);
console.error(duringError);
}
});
}

View File

@ -1,5 +1,5 @@
import * as crypto from "crypto";
import { AsyncResult } from "./results";
import { AsyncResult, Result } from "./results";
import { generateUuid } from "./uuids";
import { ReactPromise, pending, rejected, resolved } from "./promises";
@ -70,24 +70,38 @@ export class AsyncStore<T> implements ReadonlyAsyncStore<T> {
return pending(withFinally);
}
_setIfLatest(value: T, curCounter: number) {
if (!this._isAvailable || this._isRejected || this._value !== value) {
const oldValue = this._value;
if (curCounter > this._lastSuccessfulUpdate) {
this._lastSuccessfulUpdate = curCounter;
this._isAvailable = true;
this._isRejected = false;
this._value = value;
this._callbacks.forEach((callback) => callback(value, oldValue));
return true;
_setIfLatest(result: Result<T>, curCounter: number) {
if (curCounter > this._lastSuccessfulUpdate) {
switch (result.status) {
case "ok": {
if (!this._isAvailable || this._isRejected || this._value !== result.data) {
const oldValue = this._value;
this._lastSuccessfulUpdate = curCounter;
this._isAvailable = true;
this._isRejected = false;
this._value = result.data;
this._rejectionError = undefined;
this._callbacks.forEach((callback) => callback(result.data, oldValue));
return true;
}
return false;
}
case "error": {
this._lastSuccessfulUpdate = curCounter;
this._isAvailable = false;
this._isRejected = true;
this._value = undefined;
this._rejectionError = result.error;
this._waitingRejectFunctions.forEach((reject) => reject(result.error));
return true;
}
}
return false;
}
return false;
}
set(value: T): void {
this._setIfLatest(value, ++this._updateCounter);
this._setIfLatest(Result.ok(value), ++this._updateCounter);
}
update(updater: (value: T | undefined) => T): T {
@ -98,21 +112,20 @@ export class AsyncStore<T> implements ReadonlyAsyncStore<T> {
async setAsync(promise: Promise<T>): Promise<boolean> {
const curCounter = ++this._updateCounter;
const value = await promise;
return this._setIfLatest(value, curCounter);
const result = await Result.fromPromise(promise);
return this._setIfLatest(result, curCounter);
}
setUnavailable(): void {
this._lastSuccessfulUpdate = ++this._updateCounter;
this._isAvailable = false;
this._isRejected = false;
this._value = undefined;
this._rejectionError = undefined;
}
setRejected(error: unknown): void {
this._isRejected = true;
this._value = undefined;
this._rejectionError = error;
this._waitingRejectFunctions.forEach((reject) => reject(error));
this._setIfLatest(Result.error(error), ++this._updateCounter);
}
map<U>(mapper: (value: T) => U): AsyncStore<U> {

View File

@ -13,7 +13,7 @@ import { RedirectType, redirect, useRouter } from "next/navigation";
import { ReadonlyJson } from "@stackframe/stack-shared/dist/utils/json";
import { constructRedirectUrl } from "../utils/url";
import { filterUndefined } from "@stackframe/stack-shared/dist/utils/objects";
import { neverResolve, resolved } from "@stackframe/stack-shared/dist/utils/promises";
import { neverResolve, resolved, runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
import { AsyncCache } from "@stackframe/stack-shared/dist/utils/caches";
import { ApiKeySetBaseJson, ApiKeySetCreateOptions, ApiKeySetFirstViewJson, ApiKeySetJson, ProjectUpdateOptions } from "@stackframe/stack-shared/dist/interface/adminInterface";
import { suspend } from "@stackframe/stack-shared/dist/utils/react";
@ -109,6 +109,8 @@ export type StackAdminAppConstructorOptions<HasTokenStore extends boolean, Proje
export type StackClientAppJson<HasTokenStore extends boolean, ProjectId extends string> = StackClientAppConstructorOptions<HasTokenStore, ProjectId> & {
uniqueIdentifier: string,
currentClientUserJson: UserJson | null,
currentProjectJson: ClientProjectJson,
};
const defaultBaseUrl = "https://app.stackframe.co";
@ -267,13 +269,13 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
& {
uniqueIdentifier?: string,
checkString?: string,
currentClientUserJson?: UserJson | null,
currentProjectJson?: ClientProjectJson,
}
& (
| StackClientAppConstructorOptions<HasTokenStore, ProjectId>
| Pick<StackClientAppConstructorOptions<HasTokenStore, ProjectId>, "tokenStore" | "urls"> & {
interface: StackClientInterface,
tokenStore: TokenStoreOptions<HasTokenStore>,
urls: Partial<HandlerUrls> | undefined,
}
)
) {
@ -295,16 +297,36 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
throw new Error("A Stack client app with the same unique identifier already exists");
}
allClientApps.set(this._uniqueIdentifier, [options.checkString ?? "default check string", this]);
// For some important calls, either use the provided cached values or start fetching them now
if (options.currentClientUserJson !== undefined) {
this._currentUserCache.forceSetCachedValue([getTokenStore(this._tokenStoreOptions)], options.currentClientUserJson);
} else if (this.hasPersistentTokenStore()) {
runAsynchronously(() => this.getUser());
}
if (options.currentProjectJson !== undefined) {
this._currentProjectCache.forceSetCachedValue([], options.currentProjectJson);
} else {
runAsynchronously(this.getProject());
}
}
protected hasPersistentTokenStore(): this is StackClientApp<true, ProjectId> {
return this._tokenStoreOptions !== null;
}
protected _ensurePersistentTokenStore(): asserts this is StackClientApp<true, ProjectId> {
if (!this._tokenStoreOptions) {
if (!this.hasPersistentTokenStore()) {
throw new Error("Cannot call this function on a Stack app without a persistent token store. Make sure the tokenStore option is set to a non-null value when initializing Stack.");
}
}
protected isInternalProject(): this is { projectId: "internal" } {
return this.projectId === "internal";
}
protected _ensureInternalProject(): asserts this is { projectId: "internal" } {
if (this.projectId !== "internal") {
if (!this.isInternalProject()) {
throw new Error("Cannot call this function on a Stack app with a project ID other than 'internal'.");
}
}
@ -453,7 +475,7 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
async getUser(options?: GetUserOptions): Promise<CurrentUser | null> {
this._ensurePersistentTokenStore();
const tokenStore = getTokenStore(this._tokenStoreOptions);
const userJson = await this._currentUserCache.getOrWait([tokenStore], "never");
const userJson = await this._currentUserCache.getOrWait([tokenStore], "write-only");
if (userJson === null) {
switch (options?.or) {
@ -580,7 +602,7 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
}
async getProject(): Promise<ClientProjectJson> {
return await this._currentProjectCache.getOrWait([], "never");
return await this._currentProjectCache.getOrWait([], "write-only");
}
useProject(): ClientProjectJson {
@ -594,7 +616,7 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
async listOwnedProjects(): Promise<Project[]> {
this._ensureInternalProject();
const tokenStore = getTokenStore(this._tokenStoreOptions);
const json = await this._ownedProjectsCache.getOrWait([tokenStore], "never");
const json = await this._ownedProjectsCache.getOrWait([tokenStore], "write-only");
return json.map((j) => this._projectAdminFromJson(
j,
this._createAdminInterface(j.id, tokenStore),
@ -683,6 +705,12 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
// TODO find a way to do this
throw Error("Cannot serialize to JSON from an application without a publishable client key");
}
const [user, project] = await Promise.all([
this.getUser(),
this.getProject(),
]);
return {
baseUrl: this._interface.options.baseUrl,
projectId: this.projectId,
@ -690,6 +718,8 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
tokenStore: this._tokenStoreOptions,
urls: this._urlOptions,
uniqueIdentifier: this._uniqueIdentifier,
currentClientUserJson: user?.toJson() ?? null,
currentProjectJson: project,
};
}
};
@ -811,7 +841,7 @@ class _StackServerAppImpl<HasTokenStore extends boolean, ProjectId extends strin
async getServerUser(): Promise<CurrentServerUser | null> {
this._ensurePersistentTokenStore();
const tokenStore = getTokenStore(this._tokenStoreOptions);
const userJson = await this._currentServerUserCache.getOrWait([tokenStore], "never");
const userJson = await this._currentServerUserCache.getOrWait([tokenStore], "write-only");
return this._currentServerUserFromJson(userJson, tokenStore);
}
@ -839,7 +869,7 @@ class _StackServerAppImpl<HasTokenStore extends boolean, ProjectId extends strin
}
async listServerUsers(): Promise<ServerUser[]> {
const json = await this._serverUsersCache.getOrWait([], "never");
const json = await this._serverUsersCache.getOrWait([], "write-only");
return json.map((j) => this._serverUserFromJson(j));
}
@ -945,7 +975,7 @@ class _StackAdminAppImpl<HasTokenStore extends boolean, ProjectId extends string
async getProjectAdmin(): Promise<Project> {
return this._projectAdminFromJson(
await this._adminProjectCache.getOrWait([], "never"),
await this._adminProjectCache.getOrWait([], "write-only"),
this._interface,
() => this._refreshProject()
);
@ -971,7 +1001,7 @@ class _StackAdminAppImpl<HasTokenStore extends boolean, ProjectId extends string
}
async listApiKeySets(): Promise<ApiKeySet[]> {
const json = await this._apiKeySetsCache.getOrWait([], "never");
const json = await this._apiKeySetsCache.getOrWait([], "write-only");
return json.map((j) => this._createApiKeySetFromJson(j));
}
@ -1212,8 +1242,7 @@ export type StackAdminApp<HasTokenStore extends boolean = boolean, ProjectId ext
);
type StackAdminAppConstructor = {
new <
TokenStoreType extends string,
HasTokenStore extends (TokenStoreType extends {} ? true : boolean),
HasTokenStore extends boolean,
ProjectId extends string
>(options: StackAdminAppConstructorOptions<HasTokenStore, ProjectId>): StackAdminApp<HasTokenStore, ProjectId>,
new (options: StackAdminAppConstructorOptions<boolean, string>): StackAdminApp<boolean, string>,