298 lines
5.3 KiB
JavaScript
298 lines
5.3 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const SEGMENT_PATTERN =
|
|
/^segment_[0-9]{5}\.ts$/;
|
|
|
|
const DEFAULT_TIMEOUT_MS =
|
|
Number(
|
|
process.env.CAST_SEGMENT_WAIT_TIMEOUT_MS ||
|
|
300000
|
|
);
|
|
|
|
const DEFAULT_POLL_MS =
|
|
Number(
|
|
process.env.CAST_SEGMENT_WAIT_POLL_MS ||
|
|
100
|
|
);
|
|
|
|
const DEFAULT_STABILITY_MS =
|
|
Number(
|
|
process.env.CAST_SEGMENT_STABILITY_MS ||
|
|
50
|
|
);
|
|
|
|
|
|
function sleep(ms) {
|
|
return new Promise(resolve => {
|
|
setTimeout(resolve, ms);
|
|
});
|
|
}
|
|
|
|
|
|
async function getStableFileSize(
|
|
filePath,
|
|
stabilityMs
|
|
) {
|
|
try {
|
|
const first =
|
|
await fs.promises.stat(filePath);
|
|
|
|
if (
|
|
!first.isFile() ||
|
|
first.size <= 0
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
await sleep(stabilityMs);
|
|
|
|
const second =
|
|
await fs.promises.stat(filePath);
|
|
|
|
if (
|
|
!second.isFile() ||
|
|
second.size <= 0 ||
|
|
second.size !== first.size
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return second.size;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
|
|
function isInsideRoot(root, candidate) {
|
|
const relative = path.relative(
|
|
root,
|
|
candidate
|
|
);
|
|
|
|
return (
|
|
relative !== '' &&
|
|
relative !== '..' &&
|
|
!relative.startsWith(`..${path.sep}`) &&
|
|
!path.isAbsolute(relative)
|
|
);
|
|
}
|
|
|
|
|
|
function requestIsClosed(req, res) {
|
|
return (
|
|
req.destroyed ||
|
|
res.destroyed ||
|
|
res.writableEnded
|
|
);
|
|
}
|
|
|
|
|
|
function createWaitForCastSegment({
|
|
castRoot,
|
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
pollMs = DEFAULT_POLL_MS,
|
|
stabilityMs = DEFAULT_STABILITY_MS
|
|
}) {
|
|
const resolvedRoot =
|
|
path.resolve(castRoot);
|
|
|
|
return async function waitForCastSegment(
|
|
req,
|
|
res,
|
|
next
|
|
) {
|
|
try {
|
|
if (
|
|
req.method !== 'GET' &&
|
|
req.method !== 'HEAD'
|
|
) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
let pathname;
|
|
|
|
try {
|
|
pathname = decodeURIComponent(
|
|
req.path ||
|
|
req.url.split('?')[0]
|
|
);
|
|
} catch {
|
|
res
|
|
.status(400)
|
|
.end('Invalid URL');
|
|
|
|
return;
|
|
}
|
|
|
|
const filename =
|
|
path.basename(pathname);
|
|
|
|
if (!SEGMENT_PATTERN.test(filename)) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
/*
|
|
* Il middleware è montato su /cast.
|
|
* req.path è quindi relativo a public/cast.
|
|
*/
|
|
const relativePath =
|
|
pathname.replace(/^\/+/, '');
|
|
|
|
const filePath =
|
|
path.resolve(
|
|
resolvedRoot,
|
|
relativePath
|
|
);
|
|
|
|
if (!isInsideRoot(
|
|
resolvedRoot,
|
|
filePath
|
|
)) {
|
|
res
|
|
.status(403)
|
|
.end('Forbidden');
|
|
|
|
return;
|
|
}
|
|
|
|
/*
|
|
* Se il segmento esiste già ed è stabile,
|
|
* prosegue immediatamente verso express.static.
|
|
*/
|
|
const initialSize =
|
|
await getStableFileSize(
|
|
filePath,
|
|
stabilityMs
|
|
);
|
|
|
|
if (initialSize !== null) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
const startedAt = Date.now();
|
|
const deadline =
|
|
startedAt + timeoutMs;
|
|
|
|
let lastProgressLogAt = 0;
|
|
|
|
console.log(
|
|
`[castSegmentWait] WAIT ` +
|
|
`${relativePath} ` +
|
|
`timeoutMs=${timeoutMs}`
|
|
);
|
|
|
|
while (Date.now() < deadline) {
|
|
if (requestIsClosed(req, res)) {
|
|
console.log(
|
|
`[castSegmentWait] CLIENT_CLOSED ` +
|
|
`${relativePath} ` +
|
|
`waitedMs=${Date.now() - startedAt}`
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
const size =
|
|
await getStableFileSize(
|
|
filePath,
|
|
stabilityMs
|
|
);
|
|
|
|
if (size !== null) {
|
|
console.log(
|
|
`[castSegmentWait] READY ` +
|
|
`${relativePath} ` +
|
|
`waitedMs=${Date.now() - startedAt} ` +
|
|
`size=${size}`
|
|
);
|
|
|
|
/*
|
|
* Il file ora esiste ed è stabile.
|
|
* express.static lo invierà al client.
|
|
*/
|
|
next();
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
|
|
if (
|
|
now - lastProgressLogAt >= 5000
|
|
) {
|
|
console.log(
|
|
`[castSegmentWait] STILL_WAITING ` +
|
|
`${relativePath} ` +
|
|
`elapsedMs=${now - startedAt}`
|
|
);
|
|
|
|
lastProgressLogAt = now;
|
|
}
|
|
|
|
await sleep(pollMs);
|
|
}
|
|
|
|
if (requestIsClosed(req, res)) {
|
|
return;
|
|
}
|
|
|
|
console.warn(
|
|
`[castSegmentWait] TIMEOUT ` +
|
|
`${relativePath} ` +
|
|
`waitedMs=${Date.now() - startedAt}`
|
|
);
|
|
|
|
res
|
|
.status(503)
|
|
.set({
|
|
'Cache-Control':
|
|
'no-store',
|
|
'Retry-After':
|
|
'1'
|
|
})
|
|
.end(
|
|
'Segment not ready'
|
|
);
|
|
} catch (err) {
|
|
/*
|
|
* Chromecast può chiudere richieste speculative.
|
|
* Non è un errore di transcodifica.
|
|
*/
|
|
if (
|
|
err &&
|
|
(
|
|
err.code === 'EPIPE' ||
|
|
err.code === 'ECONNRESET'
|
|
)
|
|
) {
|
|
console.log(
|
|
`[castSegmentWait] CLIENT_DISCONNECTED ` +
|
|
`${req.originalUrl || req.url}`
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
console.error(
|
|
`[castSegmentWait] ERROR ` +
|
|
`${req.originalUrl || req.url}: ` +
|
|
`${err.stack || err.message}`
|
|
);
|
|
|
|
if (!requestIsClosed(req, res)) {
|
|
next(err);
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
|
|
module.exports = {
|
|
createWaitForCastSegment
|
|
};
|