partial working subpath

client side relative urls are not working yet
This commit is contained in:
FoxxMD 2026-09-14 15:48:38 +00:00
parent a6090cf987
commit 36b7a6c61d
11 changed files with 52 additions and 60 deletions

View file

@ -7,9 +7,9 @@
content="Dashboard for Multi Scrobbler"
/>
<meta name="theme-color" content="#000000" />
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="%BASE_URL%logo192.png" />
<link rel="icon" type="image/svg+xml" href="%BASE_URL%icon.svg" />
<link rel="manifest" href="%BASE_URL%manifest.json" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Multi Scrobbler</title>
</head>

View file

@ -53,7 +53,17 @@ export const initServer = async (args: ServerArgs, opts: ServerOptions = {}): Pr
});
try {
app.use(router.getRouter());
const root = getRoot();
// pathname the app is mounted under (e.g. '/myapp', or '/' at root)
// decided at runtime from BASE_URL, not baked in at build time. See
// vite.config.ts (relative asset base) and src/client/App.tsx (router mode).
const local = root.get('localUrl');
const localDefined = root.get('hasDefinedBaseUrl');
const basePath = localDefined && local.pathname !== '/' ? local.pathname : '/';
// mount API routes under the same prefix the frontend's relative
// `api/...` fetches resolve against
app.use(basePath, router.getRouter());
app.use(bodyParser.json());
app.use(
bodyParser.urlencoded({
@ -68,8 +78,6 @@ export const initServer = async (args: ServerArgs, opts: ServerOptions = {}): Pr
app.use(passport.initialize());
app.use(passport.session());
const root = getRoot();
if(root.get('disableWeb')) {
logger.warn('API and Dashboard have been DISABLED. Note that any ingress sources (Webscrobbler, Listenbrainz/Lastfm Endpoint Sources, etc...) will be unusable');
return;
@ -83,8 +91,6 @@ export const initServer = async (args: ServerArgs, opts: ServerOptions = {}): Pr
const isProd = root.get('isProd');
const port = root.get('port');
const local = root.get('localUrl');
const localDefined = root.get('hasDefinedBaseUrl');
const addy = getAddress();
const addresses: string[] = [];
@ -113,26 +119,33 @@ export const initServer = async (args: ServerArgs, opts: ServerOptions = {}): Pr
app.use('/docs', express.static(path.resolve(projectRootDir, `./docsite/build`)));
if(process.env.USE_HASH_ROUTER === undefined) {
process.env.USE_HASH_ROUTER = root.get('isSubPath').toString();
}
app.get(/^\/next$/, (_, res) => {
res.redirect("/next/")
});
const useHashRouter = root.get('isSubPath');
const viteExpressOptions: Parameters<typeof ViteExpress.config>[0] = {
mode: isProd ? 'production' : 'development',
inlineViteConfig: {
base: localDefined && local.pathname !== '/' ? local.toString() : '/'
}
base: basePath
},
// tell the frontend at request time whether to use HashRouter --
// it can't be a build-time constant since the same build is
// shipped for every deployment (root or subpath)
transformer: (html) => html.replace(
'<head>',
`<head><script>window.__MS_RUNTIME__=${JSON.stringify({useHashRouter})};</script>`
)
};
if(!isProd) {
const conf = await ViteExpress.getViteConfig();
viteExpressOptions.inlineViteConfig = {
...viteExpressOptions.inlineViteConfig,
...conf
...conf,
// conf.base is the frontend's relative asset base ('./'), not
// the Express mount prefix -- keep our runtime-computed one
base: basePath
};
}

View file

@ -101,7 +101,7 @@ const routesNested: RouteObject[] = [
];
const genRouter = () => {
const useHashRouter = __USE_HASH_ROUTER__ === 'true';
const useHashRouter = window.__MS_RUNTIME__?.useHashRouter ?? false;
return useHashRouter ? createHashRouter(routesNested) : createBrowserRouter(routesNested);
}
@ -109,7 +109,7 @@ const router = genRouter();
export const sseProviderOptions = {
key: ['events'],
url: '/api/events?next=true'
url: 'api/events?next=true'
}
function App() {

View file

@ -469,15 +469,15 @@ export const ActivityStateActions = (props: {activity: PlayApiCommonDetailed, co
mutationKey: ['playAction', props.activity.uid],
mutationFn: (data: {action: string, context?: QueueContext}) => {
if(data.action === 'queue') {
return ky.post(`/api/components/${props.activity.componentId}/plays/${props.activity.uid}/queue`,{
return ky.post(`api/components/${props.activity.componentId}/plays/${props.activity.uid}/queue`,{
json: data.context ?? {}
});
}
if(data.action === 'delete') {
return ky.delete(`/api/components/${props.activity.componentId}/plays/${props.activity.uid}`);
return ky.delete(`api/components/${props.activity.componentId}/plays/${props.activity.uid}`);
}
const realAction = data.action === 'cancel' ? 'queue' : data.action;
return ky.delete(`/api/components/${props.activity.componentId}/plays/${props.activity.uid}/${realAction}`);
return ky.delete(`api/components/${props.activity.componentId}/plays/${props.activity.uid}/${realAction}`);
}
});

View file

@ -71,7 +71,7 @@ export const LogsFetchable = (props: {settings?: LogOutputConfig, streamable?: b
const connection = useSSE<{stream: MinLogInfo}>({
key: ['stream'],
url: "/api/logs/stream",
url: "api/logs/stream",
enabled: streamable,
events: {
stream: (data) => {
@ -84,7 +84,7 @@ export const LogsFetchable = (props: {settings?: LogOutputConfig, streamable?: b
});
const mutateLogSettings = useMutation({
mutationFn: (settings: Partial<LogOutputConfig>) => ky.put('/api/logs', {json: settings}),
mutationFn: (settings: Partial<LogOutputConfig>) => ky.put('api/logs', {json: settings}),
onSuccess: (sentData, variables) => {
if(variables.level !== undefined) {
setLogLevel(variables.level);

View file

@ -84,7 +84,7 @@ const dialog = createOverlay<AuthDialogProps>((props) => {
});
const {mutate, isPending: mutateIsPending, isSuccess} = useMutation({
mutationKey: ['authChange', data.id],
mutationFn: () => ky.post(`/api/components/${data.id}/auth`)
mutationFn: () => ky.post(`api/components/${data.id}/auth`)
});
useEffect(() => {
@ -193,7 +193,7 @@ export const ComponentStateBadgeActionable = (props: Omit<ComponentProps<typeof
const {mutate, isPending, variables, isSuccess} = useMutation({
mutationKey: ['stateChange', componentId],
mutationFn: (action: ComponentStateBody['state']) => ky.post(`/api/components/${componentId}/state`,{
mutationFn: (action: ComponentStateBody['state']) => ky.post(`api/components/${componentId}/state`,{
json: {state: action, reason: 'User initiated from UI'}
})
});

View file

@ -398,7 +398,7 @@ export const ListContainerFilterable = (props: { componentId: number, componentT
mutationKey: ['retryBulk', props.componentId, filters],
// eslint-disable-next-line arrow-body-style
mutationFn: (data: { action: string, context?: QueueContext }) => {
return ky.post<{ filters: QueryPlaysOptsJsonRefreshable, context?: QueueContext }>(`/api/components/${props.componentId}/plays/queue`, {
return ky.post<{ filters: QueryPlaysOptsJsonRefreshable, context?: QueueContext }>(`api/components/${props.componentId}/plays/queue`, {
json: {
filters: {
...filters,

View file

@ -38,7 +38,7 @@ export const SettingsContainer = () => {
},[setTheme]);
const {mutate, isPending, variables, isSuccess} = useMutation({
mutationFn: (clearType: CacheClearType) => ky.delete(`/api/cache/${clearType}`)
mutationFn: (clearType: CacheClearType) => ky.delete(`api/cache/${clearType}`)
});
return (

View file

@ -78,4 +78,4 @@ export const isoToHuman = (iso?: string | null, opts?: DateFormatOptions) => {
return parts.join(' ');
}
export const baseUrl = '/api/';
export const baseUrl = 'api/';

View file

@ -1,3 +1,8 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-svgr/client" />
declare const __USE_HASH_ROUTER__: boolean
// Injected server-side per-request (src/backend/server/index.ts) since the
// hash-vs-browser router choice depends on the runtime BASE_URL, not the build.
interface Window {
__MS_RUNTIME__?: { useHashRouter: boolean };
}

View file

@ -1,38 +1,15 @@
import react,{ reactCompilerPreset } from '@vitejs/plugin-react';
import babel from '@rolldown/plugin-babel';
import normalizeUrl from "normalize-url";
import { defineConfig } from 'vite';
import svgr from "vite-plugin-svgr";
import { dirname, resolve } from 'node:path'
const QUOTES_UNWRAP_REGEX: RegExp = new RegExp(/^"(.*)"$/);
export const generateBaseURL = (userUrl: string | undefined): URL => {
let cleanUserUrl = userUrl.trim();
if (QUOTES_UNWRAP_REGEX.test(cleanUserUrl)) {
const results = cleanUserUrl.match(QUOTES_UNWRAP_REGEX);
cleanUserUrl = results[1];
}
const base = normalizeUrl(cleanUserUrl, { removeSingleSlash: true });
const u = new URL(base);
if (u.port === '') {
if (u.protocol === 'https:') {
u.port = '443';
} else if (userUrl.includes(`${u.hostname}:80`)) {
u.port = '80';
}
}
return u;
}
// Relative base so the built assets work from any mount path (root or a
// runtime-chosen subpath) without needing to know it at build time
//
// the server picks the actual mount path/router mode at runtime, see
// src/backend/server/index.ts and src/client/App.tsx.
export default defineConfig(() => {
let baseUrlStr = '/';
if (process.env.BASE_URL !== undefined && process.env.BASE_URL !== '') {
const baseUrl = generateBaseURL(process.env.BASE_URL);
if (baseUrl.pathname !== '/') {
baseUrlStr = baseUrl.toString();
}
}
console.debug(`[VITE] BASE_URL ENV: ${process.env.BASE_URL} | Base Url String: ${baseUrlStr}`);
return {
server: {
allowedHosts: (true as true),
@ -43,7 +20,7 @@ export default defineConfig(() => {
esbuild: {
minifyIdentifiers: false
},
base: baseUrlStr,
base: './',
plugins: [
react(),
babel({
@ -63,9 +40,6 @@ export default defineConfig(() => {
sourcemap: true
},
},
define: {
"__USE_HASH_ROUTER__": JSON.stringify((process.env.USE_HASH_ROUTER ?? false))
},
css: {
preprocessorOptions: {
scss: {