mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
Add support for running under a context path, ie. /bonob, replace BONOB_WEB_ADDRESS with BONOB_URL
This commit is contained in:
17
src/app.ts
17
src/app.ts
@@ -7,6 +7,7 @@ import { InMemoryLinkCodes } from "./link_codes";
|
||||
import readConfig from "./config";
|
||||
import sonos, { bonobService } from "./sonos";
|
||||
import { MusicService } from "./music_service";
|
||||
import { SystemClock } from "./clock";
|
||||
|
||||
const config = readConfig();
|
||||
|
||||
@@ -15,7 +16,7 @@ logger.info(`Starting bonob with config ${JSON.stringify(config)}`);
|
||||
const bonob = bonobService(
|
||||
config.sonos.serviceName,
|
||||
config.sonos.sid,
|
||||
config.webAddress,
|
||||
config.bonobUrl,
|
||||
"AppLink"
|
||||
);
|
||||
|
||||
@@ -40,15 +41,15 @@ const featureFlagAwareMusicService: MusicService = {
|
||||
scrobble: (id: string) => {
|
||||
if (config.scrobbleTracks) return library.scrobble(id);
|
||||
else {
|
||||
logger.info("Track Scrobbling not enabled")
|
||||
return Promise.resolve(false);
|
||||
logger.info("Track Scrobbling not enabled");
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
},
|
||||
nowPlaying: (id: string) => {
|
||||
if (config.reportNowPlaying) return library.nowPlaying(id);
|
||||
else {
|
||||
logger.info("Reporting track now playing not enabled");
|
||||
return Promise.resolve(false);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -58,10 +59,12 @@ const featureFlagAwareMusicService: MusicService = {
|
||||
const app = server(
|
||||
sonosSystem,
|
||||
bonob,
|
||||
config.webAddress,
|
||||
config.bonobUrl,
|
||||
featureFlagAwareMusicService,
|
||||
new InMemoryLinkCodes(),
|
||||
new InMemoryAccessTokens(sha256(config.secret))
|
||||
new InMemoryAccessTokens(sha256(config.secret)),
|
||||
SystemClock,
|
||||
true,
|
||||
);
|
||||
|
||||
if (config.sonos.autoRegister) {
|
||||
@@ -75,7 +78,7 @@ if (config.sonos.autoRegister) {
|
||||
}
|
||||
|
||||
app.listen(config.port, () => {
|
||||
logger.info(`Listening on ${config.port} available @ ${config.webAddress}`);
|
||||
logger.info(`Listening on ${config.port} available @ ${config.bonobUrl}`);
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
import { hostname } from "os";
|
||||
import logger from "./logger";
|
||||
import url from "./url_builder";
|
||||
|
||||
export default function () {
|
||||
const port = +(process.env["BONOB_PORT"] || 4534);
|
||||
const webAddress =
|
||||
process.env["BONOB_WEB_ADDRESS"] || `http://${hostname()}:${port}`;
|
||||
const bonobUrl =
|
||||
process.env["BONOB_URL"] ||
|
||||
process.env["BONOB_WEB_ADDRESS"] ||
|
||||
`http://${hostname()}:${port}`;
|
||||
|
||||
if (webAddress.match("localhost")) {
|
||||
if (bonobUrl.match("localhost")) {
|
||||
logger.error(
|
||||
"BONOB_WEB_ADDRESS containing localhost is almost certainly incorrect, sonos devices will not be able to communicate with bonob using localhost, please specify either public IP or DNS entry"
|
||||
"BONOB_URL containing localhost is almost certainly incorrect, sonos devices will not be able to communicate with bonob using localhost, please specify either public IP or DNS entry"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return {
|
||||
port,
|
||||
webAddress,
|
||||
bonobUrl: url(bonobUrl),
|
||||
secret: process.env["BONOB_SECRET"] || "bonob",
|
||||
sonos: {
|
||||
serviceName: process.env["BONOB_SONOS_SERVICE_NAME"] || "bonob",
|
||||
deviceDiscovery:
|
||||
(process.env["BONOB_SONOS_DEVICE_DISCOVERY"] || "true") == "true",
|
||||
seedHost: process.env["BONOB_SONOS_SEED_HOST"],
|
||||
autoRegister: (process.env["BONOB_SONOS_AUTO_REGISTER"] || "false") == "true",
|
||||
autoRegister:
|
||||
(process.env["BONOB_SONOS_AUTO_REGISTER"] || "false") == "true",
|
||||
sid: Number(process.env["BONOB_SONOS_SERVICE_ID"] || "246"),
|
||||
},
|
||||
navidrome: {
|
||||
@@ -31,6 +35,7 @@ export default function () {
|
||||
process.env["BONOB_NAVIDROME_CUSTOM_CLIENTS"] || undefined,
|
||||
},
|
||||
scrobbleTracks: (process.env["BONOB_SCROBBLE_TRACKS"] || "true") == "true",
|
||||
reportNowPlaying: (process.env["BONOB_REPORT_NOW_PLAYING"] || "true") == "true",
|
||||
reportNowPlaying:
|
||||
(process.env["BONOB_REPORT_NOW_PLAYING"] || "true") == "true",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ const config = readConfig();
|
||||
const bonob = bonobService(
|
||||
config.sonos.serviceName,
|
||||
config.sonos.sid,
|
||||
config.webAddress,
|
||||
config.bonobUrl,
|
||||
"AppLink"
|
||||
);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
PRESENTATION_MAP_ROUTE,
|
||||
SONOS_RECOMMENDED_IMAGE_SIZES,
|
||||
LOGIN_ROUTE,
|
||||
REGISTER_ROUTE,
|
||||
} from "./smapi";
|
||||
import { LinkCodes, InMemoryLinkCodes } from "./link_codes";
|
||||
import { MusicService, isSuccess } from "./music_service";
|
||||
@@ -20,6 +21,7 @@ import { AccessTokens, AccessTokenPerAuthToken } from "./access_tokens";
|
||||
import logger from "./logger";
|
||||
import { Clock, SystemClock } from "./clock";
|
||||
import { pipe } from "fp-ts/lib/function";
|
||||
import { URLBuilder } from "./url_builder";
|
||||
|
||||
export const BONOB_ACCESS_TOKEN_HEADER = "bonob-access-token";
|
||||
|
||||
@@ -63,17 +65,19 @@ export class RangeBytesFromFilter extends Transform {
|
||||
function server(
|
||||
sonos: Sonos,
|
||||
service: Service,
|
||||
webAddress: string,
|
||||
bonobUrl: URLBuilder,
|
||||
musicService: MusicService,
|
||||
linkCodes: LinkCodes = new InMemoryLinkCodes(),
|
||||
accessTokens: AccessTokens = new AccessTokenPerAuthToken(),
|
||||
clock: Clock = SystemClock
|
||||
clock: Clock = SystemClock,
|
||||
applyContextPath = true
|
||||
): Express {
|
||||
const app = express();
|
||||
|
||||
app.use(morgan("combined"));
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
|
||||
// todo: pass options in here?
|
||||
app.use(express.static("./web/public"));
|
||||
app.engine("eta", Eta.renderFile);
|
||||
|
||||
@@ -91,12 +95,13 @@ function server(
|
||||
services,
|
||||
bonobService: service,
|
||||
registeredBonobService,
|
||||
registerRoute: bonobUrl.append({ pathname: REGISTER_ROUTE }).pathname(),
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/register", (_, res) => {
|
||||
app.post(REGISTER_ROUTE, (_, res) => {
|
||||
sonos.register(service).then((success) => {
|
||||
if (success) {
|
||||
res.render("success", {
|
||||
@@ -114,7 +119,7 @@ function server(
|
||||
res.render("login", {
|
||||
bonobService: service,
|
||||
linkCode: req.query.linkCode,
|
||||
loginRoute: LOGIN_ROUTE,
|
||||
loginRoute: bonobUrl.append({ pathname: LOGIN_ROUTE }).pathname(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -317,14 +322,20 @@ function server(
|
||||
bindSmapiSoapServiceToExpress(
|
||||
app,
|
||||
SOAP_PATH,
|
||||
webAddress,
|
||||
bonobUrl,
|
||||
linkCodes,
|
||||
musicService,
|
||||
accessTokens,
|
||||
clock
|
||||
);
|
||||
|
||||
return app;
|
||||
if (applyContextPath) {
|
||||
const container = express();
|
||||
container.use(bonobUrl.path(), app);
|
||||
return container;
|
||||
} else {
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
||||
export default server;
|
||||
|
||||
100
src/smapi.ts
100
src/smapi.ts
@@ -20,8 +20,10 @@ import {
|
||||
import { AccessTokens } from "./access_tokens";
|
||||
import { BONOB_ACCESS_TOKEN_HEADER } from "./server";
|
||||
import { Clock } from "./clock";
|
||||
import { URLBuilder } from "./url_builder";
|
||||
|
||||
export const LOGIN_ROUTE = "/login";
|
||||
export const REGISTER_ROUTE = "/register";
|
||||
export const SOAP_PATH = "/ws/sonos";
|
||||
export const STRINGS_ROUTE = "/sonos/strings.xml";
|
||||
export const PRESENTATION_MAP_ROUTE = "/sonos/presentationMap.xml";
|
||||
@@ -131,10 +133,10 @@ export function searchResult(
|
||||
|
||||
class SonosSoap {
|
||||
linkCodes: LinkCodes;
|
||||
webAddress: string;
|
||||
bonobUrl: URLBuilder;
|
||||
|
||||
constructor(webAddress: string, linkCodes: LinkCodes) {
|
||||
this.webAddress = webAddress;
|
||||
constructor(bonobUrl: URLBuilder, linkCodes: LinkCodes) {
|
||||
this.bonobUrl = bonobUrl;
|
||||
this.linkCodes = linkCodes;
|
||||
}
|
||||
|
||||
@@ -145,7 +147,10 @@ class SonosSoap {
|
||||
authorizeAccount: {
|
||||
appUrlStringId: "AppLinkMessage",
|
||||
deviceLink: {
|
||||
regUrl: `${this.webAddress}${LOGIN_ROUTE}?linkCode=${linkCode}`,
|
||||
regUrl: this.bonobUrl
|
||||
.append({ pathname: LOGIN_ROUTE })
|
||||
.with({ searchParams: { linkCode } })
|
||||
.href(),
|
||||
linkCode: linkCode,
|
||||
showLinkCode: false,
|
||||
},
|
||||
@@ -216,31 +221,21 @@ const playlist = (playlist: PlaylistSummary) => ({
|
||||
},
|
||||
});
|
||||
|
||||
export const defaultAlbumArtURI = (
|
||||
webAddress: string,
|
||||
accessToken: string,
|
||||
album: AlbumSummary
|
||||
) =>
|
||||
`${webAddress}/album/${album.id}/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`;
|
||||
export const defaultAlbumArtURI = (bonobUrl: URLBuilder, album: AlbumSummary) =>
|
||||
bonobUrl.append({ pathname: `/album/${album.id}/art/size/180` });
|
||||
|
||||
export const defaultArtistArtURI = (
|
||||
webAddress: string,
|
||||
accessToken: string,
|
||||
bonobUrl: URLBuilder,
|
||||
artist: ArtistSummary
|
||||
) =>
|
||||
`${webAddress}/artist/${artist.id}/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`;
|
||||
) => bonobUrl.append({ pathname: `/artist/${artist.id}/art/size/180` });
|
||||
|
||||
export const album = (
|
||||
webAddress: string,
|
||||
accessToken: string,
|
||||
album: AlbumSummary
|
||||
) => ({
|
||||
export const album = (bonobUrl: URLBuilder, album: AlbumSummary) => ({
|
||||
itemType: "album",
|
||||
id: `album:${album.id}`,
|
||||
artist: album.artistName,
|
||||
artistId: album.artistId,
|
||||
title: album.name,
|
||||
albumArtURI: defaultAlbumArtURI(webAddress, accessToken, album),
|
||||
albumArtURI: defaultAlbumArtURI(bonobUrl, album).href(),
|
||||
canPlay: true,
|
||||
// defaults
|
||||
// canScroll: false,
|
||||
@@ -248,11 +243,7 @@ export const album = (
|
||||
// canAddToFavorites: true
|
||||
});
|
||||
|
||||
export const track = (
|
||||
webAddress: string,
|
||||
accessToken: string,
|
||||
track: Track
|
||||
) => ({
|
||||
export const track = (bonobUrl: URLBuilder, track: Track) => ({
|
||||
itemType: "track",
|
||||
id: `track:${track.id}`,
|
||||
mimeType: track.mimeType,
|
||||
@@ -263,7 +254,7 @@ export const track = (
|
||||
albumId: track.album.id,
|
||||
albumArtist: track.artist.name,
|
||||
albumArtistId: track.artist.id,
|
||||
albumArtURI: defaultAlbumArtURI(webAddress, accessToken, track.album),
|
||||
albumArtURI: defaultAlbumArtURI(bonobUrl, track.album).href(),
|
||||
artist: track.artist.name,
|
||||
artistId: track.artist.id,
|
||||
duration: track.duration,
|
||||
@@ -273,16 +264,12 @@ export const track = (
|
||||
},
|
||||
});
|
||||
|
||||
export const artist = (
|
||||
webAddress: string,
|
||||
accessToken: string,
|
||||
artist: ArtistSummary
|
||||
) => ({
|
||||
export const artist = (bonobUrl: URLBuilder, artist: ArtistSummary) => ({
|
||||
itemType: "artist",
|
||||
id: `artist:${artist.id}`,
|
||||
artistId: artist.id,
|
||||
title: artist.name,
|
||||
albumArtURI: defaultArtistArtURI(webAddress, accessToken, artist),
|
||||
albumArtURI: defaultArtistArtURI(bonobUrl, artist).href(),
|
||||
});
|
||||
|
||||
const auth = async (
|
||||
@@ -334,13 +321,20 @@ type SoapyHeaders = {
|
||||
function bindSmapiSoapServiceToExpress(
|
||||
app: Express,
|
||||
soapPath: string,
|
||||
webAddress: string,
|
||||
bonobUrl: URLBuilder,
|
||||
linkCodes: LinkCodes,
|
||||
musicService: MusicService,
|
||||
accessTokens: AccessTokens,
|
||||
clock: Clock
|
||||
) {
|
||||
const sonosSoap = new SonosSoap(webAddress, linkCodes);
|
||||
const sonosSoap = new SonosSoap(bonobUrl, linkCodes);
|
||||
const urlWithToken = (accessToken: string) =>
|
||||
bonobUrl.append({
|
||||
searchParams: {
|
||||
"bonob-access-token": accessToken,
|
||||
},
|
||||
});
|
||||
|
||||
const soapyService = listen(
|
||||
app,
|
||||
soapPath,
|
||||
@@ -366,7 +360,11 @@ function bindSmapiSoapServiceToExpress(
|
||||
auth(musicService, accessTokens, headers)
|
||||
.then(splitId(id))
|
||||
.then(({ accessToken, type, typeId }) => ({
|
||||
getMediaURIResult: `${webAddress}/stream/${type}/${typeId}`,
|
||||
getMediaURIResult: bonobUrl
|
||||
.append({
|
||||
pathname: `/stream/${type}/${typeId}`,
|
||||
})
|
||||
.href(),
|
||||
httpHeaders: [
|
||||
{
|
||||
header: BONOB_ACCESS_TOKEN_HEADER,
|
||||
@@ -383,7 +381,10 @@ function bindSmapiSoapServiceToExpress(
|
||||
.then(splitId(id))
|
||||
.then(async ({ musicLibrary, accessToken, typeId }) =>
|
||||
musicLibrary.track(typeId!).then((it) => ({
|
||||
getMediaMetadataResult: track(webAddress, accessToken, it),
|
||||
getMediaMetadataResult: track(
|
||||
urlWithToken(accessToken),
|
||||
it
|
||||
),
|
||||
}))
|
||||
),
|
||||
search: async (
|
||||
@@ -400,7 +401,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
searchResult({
|
||||
count: it.length,
|
||||
mediaCollection: it.map((albumSummary) =>
|
||||
album(webAddress, accessToken, albumSummary)
|
||||
album(urlWithToken(accessToken), albumSummary)
|
||||
),
|
||||
})
|
||||
);
|
||||
@@ -409,7 +410,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
searchResult({
|
||||
count: it.length,
|
||||
mediaCollection: it.map((artistSummary) =>
|
||||
artist(webAddress, accessToken, artistSummary)
|
||||
artist(urlWithToken(accessToken), artistSummary)
|
||||
),
|
||||
})
|
||||
);
|
||||
@@ -418,7 +419,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
searchResult({
|
||||
count: it.length,
|
||||
mediaCollection: it.map((aTrack) =>
|
||||
album(webAddress, accessToken, aTrack.album)
|
||||
album(urlWithToken(accessToken), aTrack.album)
|
||||
),
|
||||
})
|
||||
);
|
||||
@@ -452,7 +453,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
index: paging._index,
|
||||
total,
|
||||
mediaCollection: page.map((it) =>
|
||||
album(webAddress, accessToken, it)
|
||||
album(urlWithToken(accessToken), it)
|
||||
),
|
||||
relatedBrowse:
|
||||
artist.similarArtists.length > 0
|
||||
@@ -483,10 +484,9 @@ function bindSmapiSoapServiceToExpress(
|
||||
genreId: it.genre?.id,
|
||||
duration: it.duration,
|
||||
albumArtURI: defaultAlbumArtURI(
|
||||
webAddress,
|
||||
accessToken,
|
||||
urlWithToken(accessToken),
|
||||
it.album
|
||||
),
|
||||
).href(),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -500,7 +500,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
userContent: false,
|
||||
renameable: false,
|
||||
},
|
||||
...album(webAddress, accessToken, it),
|
||||
...album(urlWithToken(accessToken), it),
|
||||
},
|
||||
// <mediaCollection readonly="true">
|
||||
// </mediaCollection>
|
||||
@@ -537,7 +537,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
musicLibrary.albums(q).then((result) => {
|
||||
return getMetadataResult({
|
||||
mediaCollection: result.results.map((it) =>
|
||||
album(webAddress, accessToken, it)
|
||||
album(urlWithToken(accessToken), it)
|
||||
),
|
||||
index: paging._index,
|
||||
total: result.total,
|
||||
@@ -616,7 +616,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
return musicLibrary.artists(paging).then((result) => {
|
||||
return getMetadataResult({
|
||||
mediaCollection: result.results.map((it) =>
|
||||
artist(webAddress, accessToken, it)
|
||||
artist(urlWithToken(accessToken), it)
|
||||
),
|
||||
index: paging._index,
|
||||
total: result.total,
|
||||
@@ -689,7 +689,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
.then(([page, total]) => {
|
||||
return getMetadataResult({
|
||||
mediaMetadata: page.map((it) =>
|
||||
track(webAddress, accessToken, it)
|
||||
track(urlWithToken(accessToken), it)
|
||||
),
|
||||
index: paging._index,
|
||||
total,
|
||||
@@ -703,7 +703,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
.then(([page, total]) => {
|
||||
return getMetadataResult({
|
||||
mediaCollection: page.map((it) =>
|
||||
album(webAddress, accessToken, it)
|
||||
album(urlWithToken(accessToken), it)
|
||||
),
|
||||
index: paging._index,
|
||||
total,
|
||||
@@ -717,7 +717,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
.then(([page, total]) => {
|
||||
return getMetadataResult({
|
||||
mediaCollection: page.map((it) =>
|
||||
artist(webAddress, accessToken, it)
|
||||
artist(urlWithToken(accessToken), it)
|
||||
),
|
||||
index: paging._index,
|
||||
total,
|
||||
@@ -730,7 +730,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
.then(([page, total]) => {
|
||||
return getMetadataResult({
|
||||
mediaMetadata: page.map((it) =>
|
||||
track(webAddress, accessToken, it)
|
||||
track(urlWithToken(accessToken), it)
|
||||
),
|
||||
index: paging._index,
|
||||
total,
|
||||
|
||||
15
src/sonos.ts
15
src/sonos.ts
@@ -6,6 +6,7 @@ import { head } from "underscore";
|
||||
import logger from "./logger";
|
||||
import { SOAP_PATH, STRINGS_ROUTE, PRESENTATION_MAP_ROUTE } from "./smapi";
|
||||
import qs from "querystring";
|
||||
import { URLBuilder } from "./url_builder";
|
||||
|
||||
export const PRESENTATION_AND_STRINGS_VERSION = "18";
|
||||
|
||||
@@ -49,25 +50,25 @@ export type Service = {
|
||||
authType: "Anonymous" | "AppLink" | "DeviceLink" | "UserId";
|
||||
};
|
||||
|
||||
export const stripTailingSlash = (url: string) =>
|
||||
export const stripTrailingSlash = (url: string) =>
|
||||
url.endsWith("/") ? url.substring(0, url.length - 1) : url;
|
||||
|
||||
export const bonobService = (
|
||||
name: string,
|
||||
sid: number,
|
||||
bonobRoot: string,
|
||||
bonobUrl: URLBuilder,
|
||||
authType: "Anonymous" | "AppLink" | "DeviceLink" | "UserId" = "AppLink"
|
||||
): Service => ({
|
||||
name,
|
||||
sid,
|
||||
uri: `${stripTailingSlash(bonobRoot)}${SOAP_PATH}`,
|
||||
secureUri: `${stripTailingSlash(bonobRoot)}${SOAP_PATH}`,
|
||||
uri: bonobUrl.append({pathname: SOAP_PATH }).href(),
|
||||
secureUri: bonobUrl.append({pathname: SOAP_PATH }).href(),
|
||||
strings: {
|
||||
uri: `${stripTailingSlash(bonobRoot)}${STRINGS_ROUTE}`,
|
||||
uri: bonobUrl.append({pathname: STRINGS_ROUTE }).href(),
|
||||
version: PRESENTATION_AND_STRINGS_VERSION,
|
||||
},
|
||||
presentation: {
|
||||
uri: `${stripTailingSlash(bonobRoot)}${PRESENTATION_MAP_ROUTE}`,
|
||||
uri: bonobUrl.append({pathname: PRESENTATION_MAP_ROUTE }).href(),
|
||||
version: PRESENTATION_AND_STRINGS_VERSION,
|
||||
},
|
||||
pollInterval: 1200,
|
||||
@@ -83,7 +84,7 @@ export interface Sonos {
|
||||
export const SONOS_DISABLED: Sonos = {
|
||||
devices: () => Promise.resolve([]),
|
||||
services: () => Promise.resolve([]),
|
||||
register: (_: Service) => Promise.resolve(false),
|
||||
register: (_: Service) => Promise.resolve(true),
|
||||
};
|
||||
|
||||
export const asService = (musicService: MusicService): Service => ({
|
||||
|
||||
72
src/url_builder.ts
Normal file
72
src/url_builder.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
function isURL(url: string | URL): url is URL {
|
||||
return (url as URL).href !== undefined;
|
||||
}
|
||||
|
||||
function isURLSearchParams(
|
||||
searchParams: Record<string, string> | URLSearchParams
|
||||
): searchParams is URLSearchParams {
|
||||
return (searchParams as URLSearchParams).getAll !== undefined;
|
||||
}
|
||||
|
||||
const stripTrailingSlash = (url: string) =>
|
||||
url.endsWith("/") ? url.substring(0, url.length - 1) : url;
|
||||
|
||||
export class URLBuilder {
|
||||
private url: URL;
|
||||
|
||||
constructor(url: string | URL) {
|
||||
this.url = isURL(url) ? url : new URL(url);
|
||||
}
|
||||
|
||||
public append = (
|
||||
bits: Partial<{
|
||||
pathname: string | undefined;
|
||||
searchParams: Record<string, string> | URLSearchParams;
|
||||
}> = { pathname: undefined, searchParams: undefined }
|
||||
) => {
|
||||
let result = new URLBuilder(this.url);
|
||||
if (bits.pathname)
|
||||
result = result.with({
|
||||
pathname: stripTrailingSlash(this.url.pathname) + bits.pathname,
|
||||
});
|
||||
if (bits.searchParams) {
|
||||
const newSearchParams = new URLSearchParams(this.url.searchParams);
|
||||
(isURLSearchParams(bits.searchParams)
|
||||
? bits.searchParams
|
||||
: new URLSearchParams(bits.searchParams)
|
||||
).forEach((v, k) => newSearchParams.append(k, v));
|
||||
result = result.with({ searchParams: newSearchParams });
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
public with = (
|
||||
bits: Partial<{
|
||||
pathname: string | undefined;
|
||||
searchParams: Record<string, string> | URLSearchParams;
|
||||
}> = { pathname: undefined, searchParams: undefined }
|
||||
) => {
|
||||
const result = new URL(this.url.href);
|
||||
if (bits.pathname) result.pathname = bits.pathname;
|
||||
if (bits.searchParams) {
|
||||
const keysToDelete: string[] = [];
|
||||
result.searchParams.forEach((_, k) => keysToDelete.push(k));
|
||||
keysToDelete.forEach((k) => result.searchParams.delete(k));
|
||||
(isURLSearchParams(bits.searchParams)
|
||||
? bits.searchParams
|
||||
: new URLSearchParams(bits.searchParams)
|
||||
).forEach((v, k) => result.searchParams.append(k, v));
|
||||
}
|
||||
return new URLBuilder(result);
|
||||
};
|
||||
|
||||
public href = () => this.url.href;
|
||||
public pathname = () => this.url.pathname;
|
||||
public searchParams = () => this.url.searchParams;
|
||||
public path = () => this.url.pathname + this.url.search;
|
||||
public toString = () => this.url.href;
|
||||
}
|
||||
|
||||
export default function url(url: string | URL): URLBuilder {
|
||||
return new URLBuilder(url);
|
||||
}
|
||||
Reference in New Issue
Block a user