Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions lib/Controller/AssetController.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,29 +27,40 @@
* the current user can read the underlying file before serving any bytes.
*/
class AssetController extends Controller {
/** Mirrors `MIME_BY_EXTENSION` in `src/elpx/asset-map.ts` (parity-tested). */
private const MIME_MAP = [
'html' => 'text/html; charset=utf-8',
'htm' => 'text/html; charset=utf-8',
'xhtml' => 'application/xhtml+xml; charset=utf-8',
'css' => 'text/css; charset=utf-8',
'js' => 'text/javascript; charset=utf-8',
'mjs' => 'text/javascript; charset=utf-8',
'json' => 'application/json; charset=utf-8',
'xml' => 'application/xml; charset=utf-8',
'txt' => 'text/plain; charset=utf-8',
'svg' => 'image/svg+xml',
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
'webp' => 'image/webp',
'ico' => 'image/x-icon',
'mp3' => 'audio/mpeg',
'mp4' => 'video/mp4',
'm4a' => 'audio/mp4',
'm4v' => 'video/mp4',
'ogg' => 'audio/ogg',
'oga' => 'audio/ogg',
'ogv' => 'video/ogg',
'wav' => 'audio/wav',
'webm' => 'video/webm',
'vtt' => 'text/vtt',
'woff' => 'font/woff',
'woff2' => 'font/woff2',
'ttf' => 'font/ttf',
'otf' => 'font/otf',
'eot' => 'application/vnd.ms-fontobject',
'pdf' => 'application/pdf',
];

public function __construct(
Expand Down
8 changes: 6 additions & 2 deletions src/elpx/service-worker-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ async function registerRuntimeWorker(): Promise<RuntimeWorker> {

/**
* Resolves once the registered Service Worker reaches the `activated`
* state. Resolves immediately when the worker is already controlling the
* state, and rejects if it becomes `redundant`. Resolves immediately when the worker is already controlling the
* page; otherwise listens for `statechange` on the
* installing/waiting/active worker.
* @param registration Result of `navigator.serviceWorker.register`.
Expand All @@ -72,7 +72,7 @@ function waitForActive(registration: ServiceWorkerRegistration): Promise<void> {
if (registration.active && navigator.serviceWorker.controller) {
return Promise.resolve()
}
return new Promise((resolve) => {
return new Promise((resolve, reject) => {
const sw = registration.installing || registration.waiting || registration.active
if (!sw) {
resolve()
Expand All @@ -85,6 +85,10 @@ function waitForActive(registration: ServiceWorkerRegistration): Promise<void> {
sw.addEventListener('statechange', () => {
if (sw.state === 'activated') {
resolve()
} else if (sw.state === 'redundant') {
// Install failed or was superseded; without this the viewer
// would wait forever instead of using the server fallback.
reject(new Error('The eXeLearning Service Worker failed to activate.'))
}
})
})
Expand Down
6 changes: 6 additions & 0 deletions src/viewer/ElpxViewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,12 @@ export default defineComponent({
try {
const worker = await ensureRuntimeWorker()
await registerSession(worker, session)
if (this.$.isUnmounted) {
// Closed while loading: teardown already ran without
// this session, so release the bytes held by the SW.
void unregisterSession(worker, session.id)
return
}

this.session = session
this.worker = worker
Expand Down
10 changes: 10 additions & 0 deletions tests/Unit/Controller/AssetControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,16 @@ public function testUsesOctetStreamForUnknownExtension(): void {
self::assertSame('application/octet-stream', $response->getHeaders()['Content-Type']);
}

public function testMimeMapMatchesTheServiceWorkerMap(): void {
$source = file_get_contents(__DIR__ . '/../../../src/elpx/asset-map.ts');
self::assertIsString($source);
preg_match_all("/^\t(\w+): '([^']+)',$/m", $source, $matches);
$tsMap = array_combine($matches[1], $matches[2]);

self::assertNotEmpty($tsMap);
self::assertSame($tsMap, (new \ReflectionClassConstant(AssetController::class, 'MIME_MAP'))->getValue());
}

private function authenticate(): IUser {
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn('alice');
Expand Down
49 changes: 49 additions & 0 deletions tests/js/service-worker-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it, vi } from 'vitest'

vi.mock('@nextcloud/router', () => ({ generateUrl: (path: string) => path }))

/** Fake ServiceWorker whose state the test drives. */
function fakeWorker(state: ServiceWorkerState) {
const worker = new EventTarget() as ServiceWorker & { state: ServiceWorkerState }
worker.state = state
return worker
}

async function loadClient(installing: ServiceWorker) {
vi.resetModules()
const registration = { installing, waiting: null, active: null }
vi.stubGlobal('navigator', {
serviceWorker: { controller: null, register: vi.fn().mockResolvedValue(registration) },
})
return import('../../src/elpx/service-worker-client')
}

describe('ensureRuntimeWorker', () => {
afterEach(() => {
vi.unstubAllGlobals()
})

it('resolves once the worker activates', async () => {
const worker = fakeWorker('installing')
const { ensureRuntimeWorker } = await loadClient(worker)

const pending = ensureRuntimeWorker()
await Promise.resolve()
worker.state = 'activated'
worker.dispatchEvent(new Event('statechange'))

await expect(pending).resolves.toMatchObject({ scope: '/apps/exelearning/runtime/' })
})

it('rejects when the worker becomes redundant so the viewer can fall back', async () => {
const worker = fakeWorker('installing')
const { ensureRuntimeWorker } = await loadClient(worker)

const pending = ensureRuntimeWorker()
await Promise.resolve()
worker.state = 'redundant'
worker.dispatchEvent(new Event('statechange'))

await expect(pending).rejects.toThrow('failed to activate')
})
})
Loading