mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-17 12:56:05 +00:00
confirmed SSR support with Remix
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { AuthApi } from "auth/api/AuthApi";
|
import { AuthApi } from "auth/api/AuthApi";
|
||||||
import { DataApi } from "data/api/DataApi";
|
import { DataApi } from "data/api/DataApi";
|
||||||
import { decode } from "hono/jwt";
|
import { decode } from "hono/jwt";
|
||||||
|
import { omit } from "lodash-es";
|
||||||
import { MediaApi } from "media/api/MediaApi";
|
import { MediaApi } from "media/api/MediaApi";
|
||||||
import { SystemApi } from "modules/SystemApi";
|
import { SystemApi } from "modules/SystemApi";
|
||||||
|
|
||||||
@@ -14,15 +15,18 @@ declare global {
|
|||||||
|
|
||||||
export type ApiOptions = {
|
export type ApiOptions = {
|
||||||
host: string;
|
host: string;
|
||||||
|
user?: object;
|
||||||
token?: string;
|
token?: string;
|
||||||
storage?: "localStorage" | "manual";
|
headers?: Headers;
|
||||||
key?: string;
|
key?: string;
|
||||||
|
localStorage?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class Api {
|
export class Api {
|
||||||
private token?: string;
|
private token?: string;
|
||||||
private user?: object;
|
private user?: object;
|
||||||
private verified = false;
|
private verified = false;
|
||||||
|
private token_transport: "header" | "cookie" | "none" = "header";
|
||||||
|
|
||||||
public system!: SystemApi;
|
public system!: SystemApi;
|
||||||
public data!: DataApi;
|
public data!: DataApi;
|
||||||
@@ -30,7 +34,12 @@ export class Api {
|
|||||||
public media!: MediaApi;
|
public media!: MediaApi;
|
||||||
|
|
||||||
constructor(private readonly options: ApiOptions) {
|
constructor(private readonly options: ApiOptions) {
|
||||||
if (options.token) {
|
if (options.user) {
|
||||||
|
this.user = options.user;
|
||||||
|
this.token_transport = "none";
|
||||||
|
this.verified = true;
|
||||||
|
} else if (options.token) {
|
||||||
|
this.token_transport = "header";
|
||||||
this.updateToken(options.token);
|
this.updateToken(options.token);
|
||||||
} else {
|
} else {
|
||||||
this.extractToken();
|
this.extractToken();
|
||||||
@@ -39,33 +48,44 @@ export class Api {
|
|||||||
this.buildApis();
|
this.buildApis();
|
||||||
}
|
}
|
||||||
|
|
||||||
get tokenStorage() {
|
|
||||||
return this.options.storage ?? "manual";
|
|
||||||
}
|
|
||||||
get tokenKey() {
|
get tokenKey() {
|
||||||
return this.options.key ?? "auth";
|
return this.options.key ?? "auth";
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractToken() {
|
private extractToken() {
|
||||||
if (this.tokenStorage === "localStorage") {
|
if (this.options.headers) {
|
||||||
|
// try cookies
|
||||||
|
const cookieToken = getCookieValue(this.options.headers.get("cookie"), "auth");
|
||||||
|
if (cookieToken) {
|
||||||
|
this.updateToken(cookieToken);
|
||||||
|
this.token_transport = "cookie";
|
||||||
|
this.verified = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// try authorization header
|
||||||
|
const headerToken = this.options.headers.get("authorization")?.replace("Bearer ", "");
|
||||||
|
if (headerToken) {
|
||||||
|
this.token_transport = "header";
|
||||||
|
this.updateToken(headerToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (this.options.localStorage) {
|
||||||
const token = localStorage.getItem(this.tokenKey);
|
const token = localStorage.getItem(this.tokenKey);
|
||||||
if (token) {
|
if (token) {
|
||||||
this.token = token;
|
this.token_transport = "header";
|
||||||
this.user = decode(token).payload as any;
|
this.updateToken(token);
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (typeof window !== "undefined" && "__BKND__" in window) {
|
|
||||||
this.user = window.__BKND__.user;
|
|
||||||
this.verified = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//console.warn("Couldn't extract token");
|
||||||
}
|
}
|
||||||
|
|
||||||
updateToken(token?: string, rebuild?: boolean) {
|
updateToken(token?: string, rebuild?: boolean) {
|
||||||
this.token = token;
|
this.token = token;
|
||||||
this.user = token ? (decode(token).payload as any) : undefined;
|
this.user = token ? omit(decode(token).payload as any, ["iat", "iss", "exp"]) : undefined;
|
||||||
|
|
||||||
if (this.tokenStorage === "localStorage") {
|
if (this.options.localStorage) {
|
||||||
const key = this.tokenKey;
|
const key = this.tokenKey;
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -94,7 +114,9 @@ export class Api {
|
|||||||
private buildApis() {
|
private buildApis() {
|
||||||
const baseParams = {
|
const baseParams = {
|
||||||
host: this.options.host,
|
host: this.options.host,
|
||||||
token: this.token
|
token: this.token,
|
||||||
|
headers: this.options.headers,
|
||||||
|
token_transport: this.token_transport
|
||||||
};
|
};
|
||||||
|
|
||||||
this.system = new SystemApi(baseParams);
|
this.system = new SystemApi(baseParams);
|
||||||
@@ -106,3 +128,15 @@ export class Api {
|
|||||||
this.media = new MediaApi(baseParams);
|
this.media = new MediaApi(baseParams);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCookieValue(cookies: string | null, name: string) {
|
||||||
|
if (!cookies) return null;
|
||||||
|
|
||||||
|
for (const cookie of cookies.split("; ")) {
|
||||||
|
const [key, value] = cookie.split("=");
|
||||||
|
if (key === name && value) {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,7 +35,16 @@ export class AuthController implements ClassController {
|
|||||||
|
|
||||||
hono.get("/logout", async (c) => {
|
hono.get("/logout", async (c) => {
|
||||||
await this.auth.authenticator.logout(c);
|
await this.auth.authenticator.logout(c);
|
||||||
return c.json({ ok: true });
|
if (this.auth.authenticator.isJsonRequest(c)) {
|
||||||
|
return c.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const referer = c.req.header("referer");
|
||||||
|
if (referer) {
|
||||||
|
return c.redirect(referer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.redirect("/");
|
||||||
});
|
});
|
||||||
|
|
||||||
hono.get("/strategies", async (c) => {
|
hono.get("/strategies", async (c) => {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export type BaseModuleApiOptions = {
|
|||||||
host: string;
|
host: string;
|
||||||
basepath?: string;
|
basepath?: string;
|
||||||
token?: string;
|
token?: string;
|
||||||
|
headers?: Headers;
|
||||||
|
token_transport?: "header" | "cookie" | "none";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ApiResponse<Data = any> = {
|
export type ApiResponse<Data = any> = {
|
||||||
@@ -53,14 +55,18 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = new Headers(_init?.headers ?? {});
|
const headers = new Headers(this.options.headers ?? {});
|
||||||
|
// add init headers
|
||||||
|
for (const [key, value] of Object.entries(_init?.headers ?? {})) {
|
||||||
|
headers.set(key, value as string);
|
||||||
|
}
|
||||||
|
|
||||||
headers.set("Accept", "application/json");
|
headers.set("Accept", "application/json");
|
||||||
|
|
||||||
if (this.options.token) {
|
// only add token if initial headers not provided
|
||||||
|
if (this.options.token && this.options.token_transport === "header") {
|
||||||
//console.log("setting token", this.options.token);
|
//console.log("setting token", this.options.token);
|
||||||
headers.set("Authorization", `Bearer ${this.options.token}`);
|
headers.set("Authorization", `Bearer ${this.options.token}`);
|
||||||
} else {
|
|
||||||
//console.log("no token");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: any = _init?.body;
|
let body: any = _init?.body;
|
||||||
|
|||||||
@@ -38,20 +38,26 @@ export class AdminController implements ClassController {
|
|||||||
return this.app.modules.ctx();
|
return this.app.modules.ctx();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private withBasePath(route: string = "") {
|
||||||
|
return (this.app.modules.configs().server.admin.basepath + route).replace(/\/+$/, "/");
|
||||||
|
}
|
||||||
|
|
||||||
getController(): Hono<any> {
|
getController(): Hono<any> {
|
||||||
const auth = this.app.module.auth;
|
const auth = this.app.module.auth;
|
||||||
const configs = this.app.modules.configs();
|
const configs = this.app.modules.configs();
|
||||||
// if auth is not enabled, authenticator is undefined
|
// if auth is not enabled, authenticator is undefined
|
||||||
const auth_enabled = configs.auth.enabled;
|
const auth_enabled = configs.auth.enabled;
|
||||||
const basepath = (String(configs.server.admin.basepath) + "/").replace(/\/+$/, "/");
|
|
||||||
const hono = new Hono<{
|
const hono = new Hono<{
|
||||||
Variables: {
|
Variables: {
|
||||||
html: string;
|
html: string;
|
||||||
};
|
};
|
||||||
}>().basePath(basepath);
|
}>().basePath(this.withBasePath());
|
||||||
|
|
||||||
hono.use("*", async (c, next) => {
|
hono.use("*", async (c, next) => {
|
||||||
const obj = { user: auth.authenticator?.getUser() };
|
const obj = {
|
||||||
|
user: auth.authenticator?.getUser(),
|
||||||
|
logout_route: this.withBasePath(authRoutes.logout)
|
||||||
|
};
|
||||||
const html = await this.getHtml(obj);
|
const html = await this.getHtml(obj);
|
||||||
if (!html) {
|
if (!html) {
|
||||||
console.warn("Couldn't generate HTML for admin UI");
|
console.warn("Couldn't generate HTML for admin UI");
|
||||||
@@ -85,7 +91,6 @@ export class AdminController implements ClassController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hono.get("*", async (c) => {
|
hono.get("*", async (c) => {
|
||||||
console.log("admin", c.req.url);
|
|
||||||
if (!this.ctx.guard.granted(SystemPermissions.accessAdmin)) {
|
if (!this.ctx.guard.granted(SystemPermissions.accessAdmin)) {
|
||||||
await addFlashMessage(c, "You are not authorized to access the Admin UI", "error");
|
await addFlashMessage(c, "You are not authorized to access the Admin UI", "error");
|
||||||
return c.redirect(authRoutes.login);
|
return c.redirect(authRoutes.login);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { notifications } from "@mantine/notifications";
|
//import { notifications } from "@mantine/notifications";
|
||||||
import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager";
|
import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager";
|
||||||
import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react";
|
import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react";
|
||||||
import type { ModuleConfigs, ModuleSchemas } from "../../modules";
|
import type { ModuleConfigs, ModuleSchemas } from "../../modules";
|
||||||
@@ -83,7 +83,6 @@ export function BkndProvider({
|
|||||||
|
|
||||||
if (!fetched || !schema) return null;
|
if (!fetched || !schema) return null;
|
||||||
const app = new AppReduced(schema?.config as any);
|
const app = new AppReduced(schema?.config as any);
|
||||||
|
|
||||||
const actions = getSchemaActions({ client, setSchema, reloadSchema });
|
const actions = getSchemaActions({ client, setSchema, reloadSchema });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -93,6 +92,20 @@ export function BkndProvider({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BkndWindowContext = {
|
||||||
|
user?: object;
|
||||||
|
logout_route: string;
|
||||||
|
};
|
||||||
|
export function useBkndWindowContext(): BkndWindowContext {
|
||||||
|
if (typeof window !== "undefined" && window.__BKND__) {
|
||||||
|
return window.__BKND__ as any;
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
logout_route: "/api/auth/logout"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useBknd({ withSecrets }: { withSecrets?: boolean } = {}): BkndContext {
|
export function useBknd({ withSecrets }: { withSecrets?: boolean } = {}): BkndContext {
|
||||||
const ctx = useContext(BkndContext);
|
const ctx = useContext(BkndContext);
|
||||||
if (withSecrets) ctx.requireSecrets();
|
if (withSecrets) ctx.requireSecrets();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { createContext, useContext, useEffect, useState } from "react";
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
import { useBkndWindowContext } from "ui/client/BkndProvider";
|
||||||
import { AppQueryClient } from "./utils/AppQueryClient";
|
import { AppQueryClient } from "./utils/AppQueryClient";
|
||||||
|
|
||||||
const ClientContext = createContext<{ baseUrl: string; client: AppQueryClient }>({
|
const ClientContext = createContext<{ baseUrl: string; client: AppQueryClient }>({
|
||||||
@@ -15,8 +16,13 @@ export const queryClient = new QueryClient({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ClientProvider = ({ children, baseUrl }: { children?: any; baseUrl?: string }) => {
|
export const ClientProvider = ({
|
||||||
|
children,
|
||||||
|
baseUrl,
|
||||||
|
user
|
||||||
|
}: { children?: any; baseUrl?: string; user?: object }) => {
|
||||||
const [actualBaseUrl, setActualBaseUrl] = useState<string | null>(null);
|
const [actualBaseUrl, setActualBaseUrl] = useState<string | null>(null);
|
||||||
|
const winCtx = useBkndWindowContext();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const _ctx_baseUrl = useBaseUrl();
|
const _ctx_baseUrl = useBaseUrl();
|
||||||
@@ -40,8 +46,8 @@ export const ClientProvider = ({ children, baseUrl }: { children?: any; baseUrl?
|
|||||||
return null; // or a loader/spinner if desired
|
return null; // or a loader/spinner if desired
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("client provider11 with", { baseUrl, fallback: actualBaseUrl });
|
//console.log("client provider11 with", { baseUrl, fallback: actualBaseUrl, user });
|
||||||
const client = createClient(actualBaseUrl);
|
const client = createClient(actualBaseUrl, user ?? winCtx.user);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
@@ -52,11 +58,11 @@ export const ClientProvider = ({ children, baseUrl }: { children?: any; baseUrl?
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createClient(baseUrl: string = window.location.origin) {
|
export function createClient(baseUrl: string, user?: object) {
|
||||||
return new AppQueryClient(baseUrl);
|
return new AppQueryClient(baseUrl, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createOrUseClient(baseUrl: string = window.location.origin) {
|
export function createOrUseClient(baseUrl: string) {
|
||||||
const context = useContext(ClientContext);
|
const context = useContext(ClientContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
console.warn("createOrUseClient returned a new client");
|
console.warn("createOrUseClient returned a new client");
|
||||||
|
|||||||
@@ -13,9 +13,13 @@ import { queryClient } from "../ClientProvider";
|
|||||||
|
|
||||||
export class AppQueryClient {
|
export class AppQueryClient {
|
||||||
api: Api;
|
api: Api;
|
||||||
constructor(public baseUrl: string) {
|
constructor(
|
||||||
|
public baseUrl: string,
|
||||||
|
user?: object
|
||||||
|
) {
|
||||||
this.api = new Api({
|
this.api = new Api({
|
||||||
host: baseUrl
|
host: baseUrl,
|
||||||
|
user
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from "react-icons/tb";
|
} from "react-icons/tb";
|
||||||
import { Button } from "ui";
|
import { Button } from "ui";
|
||||||
import { useAuth, useBknd } from "ui/client";
|
import { useAuth, useBknd } from "ui/client";
|
||||||
|
import { useBkndWindowContext } from "ui/client/BkndProvider";
|
||||||
import { useBkndSystemTheme } from "ui/client/schema/system/use-bknd-system";
|
import { useBkndSystemTheme } from "ui/client/schema/system/use-bknd-system";
|
||||||
import { IconButton } from "ui/components/buttons/IconButton";
|
import { IconButton } from "ui/components/buttons/IconButton";
|
||||||
import { Logo } from "ui/components/display/Logo";
|
import { Logo } from "ui/components/display/Logo";
|
||||||
@@ -147,11 +148,12 @@ export function Header({ hasSidebar = true }) {
|
|||||||
function UserMenu() {
|
function UserMenu() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const [navigate] = useNavigate();
|
const [navigate] = useNavigate();
|
||||||
|
const { logout_route } = useBkndWindowContext();
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await auth.logout();
|
await auth.logout();
|
||||||
// @todo: grab from somewhere constant
|
// @todo: grab from somewhere constant
|
||||||
window.location.href = "/auth/logout";
|
navigate(logout_route, { reload: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
|
|||||||
@@ -63,8 +63,15 @@ export function useNavigate() {
|
|||||||
return [
|
return [
|
||||||
(
|
(
|
||||||
url: string,
|
url: string,
|
||||||
options?: { query?: object; absolute?: boolean; replace?: boolean; state?: any }
|
options?:
|
||||||
|
| { query?: object; absolute?: boolean; replace?: boolean; state?: any }
|
||||||
|
| { reload: true }
|
||||||
) => {
|
) => {
|
||||||
|
if (options && "reload" in options) {
|
||||||
|
window.location.href = url;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const _url = options?.absolute ? `~/${basepath}${url}`.replace(/\/+/g, "/") : url;
|
const _url = options?.absolute ? `~/${basepath}${url}`.replace(/\/+/g, "/") : url;
|
||||||
navigate(options?.query ? withQuery(_url, options?.query) : _url, {
|
navigate(options?.query ? withQuery(_url, options?.query) : _url, {
|
||||||
replace: options?.replace,
|
replace: options?.replace,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||||
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";
|
import { Links, Meta, Outlet, Scripts, ScrollRestoration, useLoaderData } from "@remix-run/react";
|
||||||
import { Api } from "bknd";
|
import { Api } from "bknd";
|
||||||
import { ClientProvider } from "bknd/ui";
|
import { ClientProvider } from "bknd/ui";
|
||||||
|
|
||||||
@@ -22,15 +22,26 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const loader = async (args: LoaderFunctionArgs) => {
|
export const loader = async (args: LoaderFunctionArgs) => {
|
||||||
args.context.api = new Api({
|
const api = new Api({
|
||||||
host: new URL(args.request.url).origin
|
host: new URL(args.request.url).origin,
|
||||||
|
headers: args.request.headers
|
||||||
});
|
});
|
||||||
return null;
|
|
||||||
|
// add api to the context
|
||||||
|
args.context.api = api;
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: api.getAuthState().user
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const data = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
// add user to the client provider to indicate
|
||||||
|
// that you're authed using cookie
|
||||||
return (
|
return (
|
||||||
<ClientProvider>
|
<ClientProvider user={data.user}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</ClientProvider>
|
</ClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ export const meta: MetaFunction = () => {
|
|||||||
|
|
||||||
export const loader = async (args: LoaderFunctionArgs) => {
|
export const loader = async (args: LoaderFunctionArgs) => {
|
||||||
const api = args.context.api as Api;
|
const api = args.context.api as Api;
|
||||||
|
const user = api.getAuthState().user;
|
||||||
const { data } = await api.data.readMany("todos");
|
const { data } = await api.data.readMany("todos");
|
||||||
return { data };
|
return { data, user };
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import "bknd/dist/styles.css";
|
|||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
}, []);
|
}, []);
|
||||||
if (!loaded) return null;
|
if (!loaded) return null;
|
||||||
|
|||||||
@@ -18,7 +18,8 @@
|
|||||||
"bknd": "workspace:*",
|
"bknd": "workspace:*",
|
||||||
"isbot": "^4.1.0",
|
"isbot": "^4.1.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0"
|
"react-dom": "^18.2.0",
|
||||||
|
"remix-utils": "^7.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@remix-run/dev": "^2.14.0",
|
"@remix-run/dev": "^2.14.0",
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user