Skip to content

Commit b868b75

Browse files
authored
Merge pull request #1405 from bbc/upstream/support-serving-sub-directory
feat: support hosting sofie under subdirectory
2 parents 4c2f99b + 2dbf81f commit b868b75

27 files changed

+236
-82
lines changed

meteor/__mocks__/_setupMocks.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,6 @@ afterEach(() => {
3939
// Expect all log messages that have been explicitly supressed, to have been handled:
4040
SupressLogMessages.expectAllMessagesToHaveBeenHandled()
4141
})
42+
43+
// @ts-expect-error mock meteor runtime config
44+
global.__meteor_runtime_config__ = {}

meteor/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"indexof": "0.0.1",
5858
"koa": "^2.15.3",
5959
"koa-bodyparser": "^4.4.1",
60+
"koa-mount": "^4.0.0",
6061
"koa-static": "^5.0.0",
6162
"meteor-node-stubs": "^1.2.12",
6263
"moment": "^2.30.1",
@@ -85,6 +86,7 @@
8586
"@types/jest": "^29.5.14",
8687
"@types/koa": "^2.15.0",
8788
"@types/koa-bodyparser": "^4.3.12",
89+
"@types/koa-mount": "^4",
8890
"@types/koa-static": "^4.0.4",
8991
"@types/koa__cors": "^5.0.0",
9092
"@types/koa__router": "^12.0.4",

meteor/server/api/peripheralDevice.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ import KoaRouter from '@koa/router'
6969
import bodyParser from 'koa-bodyparser'
7070
import { assertConnectionHasOneOfPermissions } from '../security/auth'
7171
import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio'
72+
import { getRootSubpath } from '../lib'
7273

7374
const apmNamespace = 'peripheralDevice'
7475
export namespace ServerPeripheralDeviceAPI {
@@ -680,7 +681,7 @@ peripheralDeviceRouter.get('/:deviceId/oauthResponse', async (ctx) => {
680681
.catch(logger.error)
681682
}
682683

683-
ctx.redirect(`/settings/peripheralDevice/${deviceId}`)
684+
ctx.redirect(`${getRootSubpath()}/settings/peripheralDevice/${deviceId}`)
684685
} catch (e) {
685686
ctx.response.type = 'text/plain'
686687
ctx.response.status = 500

meteor/server/api/rest/api.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { peripheralDeviceRouter } from '../peripheralDevice'
1111
import { blueprintsRouter } from '../blueprints/http'
1212
import { createLegacyApiRouter } from './v0/index'
1313
import { heapSnapshotPrivateApiRouter } from '../heapSnapshot'
14+
import { getRootSubpath } from '../../lib'
1415

1516
const LATEST_REST_API = 'v1.0'
1617

@@ -34,7 +35,7 @@ apiRouter.use(
3435
)
3536

3637
async function redirectToLatest(ctx: koa.ParameterizedContext, _next: koa.Next): Promise<void> {
37-
ctx.redirect(`/api/${LATEST_REST_API}`)
38+
ctx.redirect(`${getRootSubpath()}/api/${LATEST_REST_API}`)
3839
ctx.status = 307
3940
}
4041

meteor/server/api/rest/koa.ts

Lines changed: 59 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
import Koa from 'koa'
22
import cors from '@koa/cors'
33
import KoaRouter from '@koa/router'
4+
import KoaMount from 'koa-mount'
45
import { WebApp } from 'meteor/webapp'
56
import { Meteor } from 'meteor/meteor'
67
import { getRandomString } from '@sofie-automation/corelib/dist/lib'
78
import _ from 'underscore'
8-
import { public_dir } from '../../lib'
9+
import { getRootSubpath, public_dir } from '../../lib'
910
import staticServe from 'koa-static'
1011
import { logger } from '../../logging'
1112
import { PackageInfo } from '../../coreSystem'
1213
import { profiler } from '../profiler'
14+
import fs from 'fs/promises'
1315

1416
declare module 'http' {
1517
interface IncomingMessage {
@@ -78,48 +80,86 @@ Meteor.startup(() => {
7880

7981
// serve the webui through koa
8082
// This is to avoid meteor injecting anything into the served html
81-
const webuiServer = staticServe(public_dir)
82-
koaApp.use(webuiServer)
83+
const webuiServer = staticServe(public_dir, {
84+
index: false, // Performed manually
85+
})
86+
koaApp.use(KoaMount(getRootSubpath() || '/', webuiServer))
8387
logger.debug(`Serving static files from ${public_dir}`)
8488

85-
// Serve the meteor runtime config
86-
rootRouter.get('/meteor-runtime-config.js', async (ctx) => {
87-
const versionExtended: string = PackageInfo.versionExtended || PackageInfo.version // package version
88-
89-
ctx.body = `window.__meteor_runtime_config__ = (${JSON.stringify({
90-
// @ts-expect-error missing types for internal meteor detail
91-
...__meteor_runtime_config__,
92-
sofieVersionExtended: versionExtended,
93-
})})`
94-
})
89+
if (Meteor.isDevelopment) {
90+
// Serve the meteor runtime config. In production, this gets baked into the html
91+
rootRouter.get(getRootSubpath() + '/meteor-runtime-config.js', async (ctx) => {
92+
ctx.body = getExtendedMeteorRuntimeConfig()
93+
})
94+
}
9595

9696
koaApp.use(rootRouter.routes()).use(rootRouter.allowedMethods())
9797

9898
koaApp.use(async (ctx, next) => {
9999
if (ctx.method !== 'GET') return next()
100100

101+
// Ensure the path is scoped to the root subpath
102+
const rootSubpath = getRootSubpath()
103+
if (!ctx.path.startsWith(rootSubpath)) return next()
104+
101105
// Don't use the fallback for certain paths
102-
if (ctx.path.startsWith('/assets/')) return next()
106+
if (ctx.path.startsWith(rootSubpath + '/assets/')) return next()
103107

104108
// Don't use the fallback for anything handled by another router
105109
// This does not feel efficient, but koa doesn't appear to have any shared state between the router handlers
106110
for (const bindPath of boundRouterPaths) {
107111
if (ctx.path.startsWith(bindPath)) return next()
108112
}
109113

110-
// fallback to the root file
111-
ctx.path = '/'
112-
return webuiServer(ctx, next)
114+
// fallback to serving html
115+
return serveIndexHtml(ctx, next)
113116
})
114117
})
115118

119+
function getExtendedMeteorRuntimeConfig() {
120+
const versionExtended: string = PackageInfo.versionExtended || PackageInfo.version // package version
121+
122+
return `window.__meteor_runtime_config__ = (${JSON.stringify({
123+
// @ts-expect-error missing types for internal meteor detail
124+
...__meteor_runtime_config__,
125+
sofieVersionExtended: versionExtended,
126+
})})`
127+
}
128+
129+
async function serveIndexHtml(ctx: Koa.ParameterizedContext, next: Koa.Next) {
130+
try {
131+
// Read the file
132+
const indexFileBuffer = await fs.readFile(public_dir + '/index.html', 'utf8')
133+
const indexFileStr = indexFileBuffer.toString()
134+
135+
const rootPath = getRootSubpath()
136+
137+
// Perform various runtime modifications, to ensure paths have the correct absolute prefix
138+
let modifiedFile = indexFileStr
139+
modifiedFile = modifiedFile.replace(
140+
// Replace the http load with injected js, to avoid risk of issues where this load fails and the app gets confused
141+
'<script type="text/javascript" src="/meteor-runtime-config.js"></script>',
142+
`<script type="text/javascript">${getExtendedMeteorRuntimeConfig()}</script>`
143+
)
144+
modifiedFile = modifiedFile.replaceAll('href="/', `href="${rootPath}/`)
145+
modifiedFile = modifiedFile.replaceAll('href="./', `href="${rootPath}/`)
146+
modifiedFile = modifiedFile.replaceAll('src="./', `src="${rootPath}/`)
147+
148+
ctx.body = modifiedFile
149+
} catch (e) {
150+
return next()
151+
}
152+
}
153+
116154
export function bindKoaRouter(koaRouter: KoaRouter, bindPath: string): void {
155+
const bindPathWithPrefix = getRootSubpath() + bindPath
156+
117157
// Track this path as having a router
118-
let bindPathFull = bindPath
158+
let bindPathFull = bindPathWithPrefix
119159
if (!bindPathFull.endsWith('/')) bindPathFull += '/'
120160
boundRouterPaths.push(bindPathFull)
121161

122-
rootRouter.use(bindPath, koaRouter.routes()).use(bindPath, koaRouter.allowedMethods())
162+
rootRouter.use(bindPathWithPrefix, koaRouter.routes()).use(bindPathWithPrefix, koaRouter.allowedMethods())
123163
}
124164

125165
const REVERSE_PROXY_COUNT = process.env.HTTP_FORWARDED_COUNT ? parseInt(process.env.HTTP_FORWARDED_COUNT) : 0

meteor/server/lib.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ export const public_dir = Meteor.isProduction
3333
: // In development, find the webui package and use its public directory
3434
path.join(process.cwd(), '../../../../../../packages/webui/public')
3535

36+
export function getRootSubpath(): string {
37+
// @ts-expect-error Untyped meteor export
38+
const settings: any = __meteor_runtime_config__
39+
40+
return settings.ROOT_URL_PATH_PREFIX || ''
41+
}
42+
3643
/**
3744
* Get the i18next locale object for a given `languageCode`. If the translations file can not be found or it can't be
3845
* parsed, it will return an empty object.

meteor/server/webmanifest.ts

Lines changed: 29 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { logger } from './logging'
88
import { MongoQuery } from '@sofie-automation/corelib/dist/mongo'
99
import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio'
1010
import { RundownPlaylists, Rundowns, Studios } from './collections'
11-
import { getLocale, Translations } from './lib'
11+
import { getLocale, getRootSubpath, Translations } from './lib'
1212
import { generateTranslation } from './lib/tempLib'
1313
import { ITranslatableMessage } from '@sofie-automation/blueprints-integration'
1414
import { interpollateTranslation } from '@sofie-automation/corelib/dist/TranslatableMessage'
@@ -21,20 +21,6 @@ import { bindKoaRouter } from './api/rest/koa'
2121
import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError'
2222

2323
const appShortName = 'Sofie'
24-
const SOFIE_DEFAULT_ICONS: ManifestImageResource[] = [
25-
{
26-
src: '/icons/mstile-144x144.png',
27-
sizes: '144x144',
28-
purpose: 'monochrome',
29-
type: 'image/png',
30-
},
31-
{
32-
src: '/icons/maskable-96x96.png',
33-
sizes: '96x96',
34-
purpose: 'maskable',
35-
type: 'image/png',
36-
},
37-
]
3824

3925
const t = generateTranslation
4026

@@ -48,6 +34,21 @@ function getShortcutsForStudio(
4834
studio: Pick<DBStudio, '_id' | 'name'>,
4935
studioCount: number
5036
): ShortcutItem[] {
37+
const SOFIE_DEFAULT_ICONS: ManifestImageResource[] = [
38+
{
39+
src: getRootSubpath() + '/icons/mstile-144x144.png',
40+
sizes: '144x144',
41+
purpose: 'monochrome',
42+
type: 'image/png',
43+
},
44+
{
45+
src: getRootSubpath() + '/icons/maskable-96x96.png',
46+
sizes: '96x96',
47+
purpose: 'maskable',
48+
type: 'image/png',
49+
},
50+
]
51+
5152
const multiStudio = studioCount > 1
5253
return [
5354
{
@@ -61,7 +62,7 @@ function getShortcutsForStudio(
6162
: t('Active Rundown')
6263
),
6364
icons: SOFIE_DEFAULT_ICONS,
64-
url: `/activeRundown/${studio._id}`,
65+
url: getRootSubpath() + `/activeRundown/${studio._id}`,
6566
},
6667
{
6768
id: `${studio._id}_prompter`,
@@ -74,7 +75,7 @@ function getShortcutsForStudio(
7475
: t('Prompter')
7576
),
7677
icons: SOFIE_DEFAULT_ICONS,
77-
url: `/prompter/${studio._id}`,
78+
url: getRootSubpath() + `/prompter/${studio._id}`,
7879
},
7980
{
8081
id: `${studio._id}_countdowns`,
@@ -83,7 +84,7 @@ function getShortcutsForStudio(
8384
multiStudio ? t('{{studioName}}: Presenter screen', { studioName: studio.name }) : t('Presenter screen')
8485
),
8586
icons: SOFIE_DEFAULT_ICONS,
86-
url: `/countdowns/${studio._id}/presenter`,
87+
url: getRootSubpath() + `/countdowns/${studio._id}/presenter`,
8788
},
8889
]
8990
}
@@ -111,31 +112,31 @@ async function getWebManifest(languageCode: string): Promise<JSONSchemaForWebApp
111112
short_name: appShortName,
112113
icons: [
113114
{
114-
src: '/icons/android-chrome-192x192.png',
115+
src: getRootSubpath() + '/icons/android-chrome-192x192.png',
115116
sizes: '192x192',
116117
purpose: 'any',
117118
type: 'image/png',
118119
},
119120
{
120-
src: '/icons/android-chrome-512x512.png',
121+
src: getRootSubpath() + '/icons/android-chrome-512x512.png',
121122
sizes: '512x512',
122123
purpose: 'any',
123124
type: 'image/png',
124125
},
125126
{
126-
src: '/icons/mstile-144x144.png',
127+
src: getRootSubpath() + '/icons/mstile-144x144.png',
127128
sizes: '144x144',
128129
purpose: 'monochrome',
129130
type: 'image/png',
130131
},
131132
{
132-
src: '/icons/maskable-96x96.png',
133+
src: getRootSubpath() + '/icons/maskable-96x96.png',
133134
sizes: '96x96',
134135
purpose: 'maskable',
135136
type: 'image/png',
136137
},
137138
{
138-
src: '/icons/maskable-512x512.png',
139+
src: getRootSubpath() + '/icons/maskable-512x512.png',
139140
sizes: '512x512',
140141
purpose: 'maskable',
141142
type: 'image/png',
@@ -144,14 +145,14 @@ async function getWebManifest(languageCode: string): Promise<JSONSchemaForWebApp
144145
theme_color: '#2d89ef',
145146
background_color: '#252627',
146147
display: 'fullscreen',
147-
start_url: '/',
148-
scope: '/',
148+
start_url: getRootSubpath() + '/',
149+
scope: getRootSubpath() + '/',
149150
orientation: 'landscape',
150151
shortcuts: shortcuts.length > 0 ? shortcuts : undefined,
151152
protocol_handlers: [
152153
{
153154
protocol: 'web+nrcs',
154-
url: '/url/nrcs?q=%s',
155+
url: getRootSubpath() + '/url/nrcs?q=%s',
155156
},
156157
],
157158
}
@@ -261,14 +262,14 @@ async function webNrcsRundownRoute(ctx: Koa.ParameterizedContext, parsedUrl: URL
261262
// we couldn't find the External ID for Rundown/Rundown Playlist
262263
logger.debug(`NRCS URL: External ID not found "${externalId}"`)
263264
ctx.body = `Could not find requested object: "${externalId}", see the full list`
264-
ctx.redirect('/')
265+
ctx.redirect(`${getRootSubpath()}/`)
265266
ctx.response.status = 303
266267
return
267268
}
268269

269270
logger.debug(`NRCS URL: External ID found "${externalId}" in "${rundownPlaylist._id}"`)
270271
ctx.body = `Requested object found in Rundown Playlist "${rundownPlaylist._id}"`
271-
ctx.redirect(`/rundown/${rundownPlaylist._id}`)
272+
ctx.redirect(`${getRootSubpath()}/rundown/${rundownPlaylist._id}`)
272273
}
273274

274275
Meteor.startup(() => {

meteor/tsconfig-base.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
/* At the time of writing we are not ready for stricter rules */
55
"strict": true,
66

7+
"target": "es2022",
8+
79
"skipLibCheck": true,
810
"sourceMap": true,
911
"allowJs": false,
10-
"lib": ["dom", "es6", "dom.iterable", "scripthost", "es2017", "es2018", "es2019", "ES2020.Promise"],
12+
"lib": ["dom", "es2022", "dom.iterable", "scripthost"],
1113

1214
"paths": {
1315
"meteor/*": [

0 commit comments

Comments
 (0)