Skip to content

Commit

Permalink
api: better handle static file streams
Browse files Browse the repository at this point in the history
  • Loading branch information
KevinBLT committed Mar 22, 2024
1 parent 0f53cb5 commit 1693bda
Show file tree
Hide file tree
Showing 8 changed files with 98 additions and 37 deletions.
12 changes: 12 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion packages/@hec.js/api/lib/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ export class API {
apiRequest = request;
}

if (!apiRequest.path.startsWith('/.well-known') && apiRequest.path.match(/\/[_.]/)) {
return new Response(null, { status: 403 });
}

const context = {
status: 404,
url: null,
Expand Down Expand Up @@ -125,7 +129,7 @@ export class API {
}
}
}
}
}

findRoutes(apiRequest, this.#routes);

Expand Down
69 changes: 49 additions & 20 deletions packages/@hec.js/api/lib/src/files/local.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { stat, readFile } from 'fs/promises';
import { lookup } from 'mime-types';
import path from 'path';
import { stat, open } from 'fs/promises';
import { lookup } from 'mime-types';
import path from 'path';
import md5 from 'md5-file';
import { ReadStream } from 'fs';

/** @typedef {{ etag?: string, 'last-modified'?: string } & { [key: string]: string }} FileInfo */

Expand Down Expand Up @@ -51,37 +53,60 @@ export function files(options = {}) {
return new Response(null, { status: 404 });
}

const data = await readFile(filePath),
etag = `"${Buffer.from(await crypto.subtle.digest('SHA-1', data)).toString('hex')}"`,
const etag = `W/"${ await md5(filePath) }"`,
size = fileStats.size,
lastModified = new Date(fileStats.mtime).toUTCString(),
range = parseRangeHeader(request, size);

if (headers.has('if-range') && ifRange != lastModified && !ifRange?.includes(etag)) {
if (headers.has('if-range') && ifRange !== lastModified && ifRange !== etag) {
headers.delete('range');
}

const responseHeaders = {
'content-type' : lookup(filePath) || 'application/octect-stream',
'content-size' : (range.end - range.start).toString(),
'cache-control' : options.cacheControl,
'last-modified' : lastModified,
'etag' : etag,
'accept-ranges' : 'bytes',
'content-range' : `bytes ${range.start}-${range.end - 1}/${size}`
'content-type' : lookup(filePath) || 'application/octect-stream',
'content-length' : (range.end - range.start + 1).toString(),
'cache-control' : options.cacheControl,
'last-modified' : lastModified,
'etag' : etag,
'accept-ranges' : 'bytes',
};

if (request.headers.has('range')) {
responseHeaders['content-range'] = `bytes ${range.start}-${range.end}/${size}`;
}

if (ifNoneMatch === etag || ifModifiedSince === lastModified) {
return new Response(null, { status: 304, headers: responseHeaders });
}

cache.set(filePath, responseHeaders);
if (options.cacheDuration) {
cache.set(filePath, responseHeaders);
setTimeout(() => cache.delete(filePath), options.cacheDuration);
}

const file = await open(filePath),
fileStream = file.createReadStream(range);

setTimeout(() => cache.delete(filePath), options.cacheDuration);
return new Response(new ReadableStream({
start(stream) {
let isClosed = false;

return new Response(
headers.has('range') ? data.subarray(range.start, range.end) : data,
{
const end = async () => {
if (!isClosed) {
isClosed = true;
stream.close();
fileStream.close();
await file.close();
}
}

request.signal.addEventListener('abort', () => end());

fileStream.on('data', (data) => stream.enqueue(data));
fileStream.on('close', () => end());
fileStream.on('error', () => end());
}
}), {
status : headers.has('range') ? 206 : 200,
headers : responseHeaders
}
Expand All @@ -100,7 +125,11 @@ export function files(options = {}) {
*/
function parseRangeHeader(request, size) {
const r = request.headers.get('range'),
s = r ? r.substring(6).split('-').map(parseInt) : [0, size];
m = size - 1,
s = r ? r.substring(6).split('-').map(e => parseInt(e)) : [0, m];

return { start : s[0] || 0, end: Math.min(s[1] || size, size) };
return {
start: s[0] || 0,
end: Math.min(s[1] || m, m)
};
}
26 changes: 17 additions & 9 deletions packages/@hec.js/api/lib/src/pages/local.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@ import path from 'path';
* fileProvider: (request: Request) => Promise<Response>,
* indexes?: string[],
* index?: string,
* errorPages?: { [key: number]: string }
* errorPages?: { [key: number]: string },
* cacheControl?: string
* }} options
*
* @returns { (request: Request) => Promise<Response> }
*
* @description
* Option `fileProvder` is called to retrieve a file
* Option `index` if this is set, it will serve all requests that don't match a file
* Option `indexes` is used to append strings the and of a url if it's not found.
* Option `fileProvder` is called to retrieve a file
* Option `index` if this is set, it will serve all requests that don't match a file
* Option `indexes` is used to append strings the and of a url if it's not found.
* Option `cacheControl` is used to set a `cache-control` header to the `html` responses.
* Example using ['.html', 'index.html']:
* - request: `/foobar` => /foobar.html
* - request: `/foobar/` => /foobar/index.html
Expand All @@ -23,8 +25,9 @@ import path from 'path';
* Example: { 404: '/404.html' }
*/
export function pages(options) {
options.directory ??= '.';
options.indexes ??= ['.html', 'index.html'];
options.directory ??= '.';
options.indexes ??= ['.html', 'index.html'];
options.cacheControl ??= 'no-cache';

const fileProvider = options.fileProvider;

Expand All @@ -41,16 +44,21 @@ export function pages(options) {
response = await options.fileProvider(new Request(request.url + index, request));

if (response.ok) {
response.headers.set('cache-control', 'no-cache');
response.headers.set('cache-control', options.cacheControl);

return response;
}
}

if (options.index) {
return options.fileProvider(new Request(origin + options.index));
}
response = await options.fileProvider(new Request(origin + options.index));

if (response.ok) {
response.headers.set('cache-control', options.cacheControl);
}

return response;
}
}

if (!response.ok) {
Expand Down
5 changes: 5 additions & 0 deletions packages/@hec.js/api/lib/src/routing/serve.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,15 @@ export function serveBy(fetch) {
req.headers['cf-connecting-ip'] ??= req.socket.remoteAddress;
req.headers['x-real-ip'] ??= req.socket.remoteAddress;

const abort = new AbortController();

req.on('close', () => abort.abort());

fetch(
new Request(`${ scheme }://${ req.headers.host }${ req.url }`, {
method: req.method,
duplex: 'half',
signal: abort.signal,
// @ts-ignore
headers: req.headers,
body: ['HEAD', 'GET', 'OPTIONS'].includes(req.method) ? null :
Expand Down
9 changes: 5 additions & 4 deletions packages/@hec.js/api/lib/types/src/pages/local.d.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
/**
* @template T
*
* @param {{
* directory?: string,
* fileProvider: (request: Request) => Promise<Response>,
* indexes?: string[],
* index?: string,
* errorPages?: { [key: number]: string }
* errorPages?: { [key: number]: string },
* cacheControl?: string
* }} options
*
* @returns { (request: Request) => Promise<Response> }
Expand All @@ -15,19 +14,21 @@
* Option `fileProvder` is called to retrieve a file
* Option `index` if this is set, it will serve all requests that don't match a file
* Option `indexes` is used to append strings the and of a url if it's not found.
* Option `cacheControl` is used to set a `cache-control` header to the `html` responses.
* Example using ['.html', 'index.html']:
* - request: `/foobar` => /foobar.html
* - request: `/foobar/` => /foobar/index.html
*
* Option `errorPages` is used to determine a HTML page for a given error status code.
* Example: { 404: '/404.html' }
*/
export function pages<T>(options: {
export function pages(options: {
directory?: string;
fileProvider: (request: Request) => Promise<Response>;
indexes?: string[];
index?: string;
errorPages?: {
[key: number]: string;
};
cacheControl?: string;
}): (request: Request) => Promise<Response>;
5 changes: 3 additions & 2 deletions packages/@hec.js/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
},
"dependencies": {
"@hec.js/api": "file:./",
"urlpattern-polyfill": "^9.0.0",
"mime-types": "^2.1.35"
"md5-file": "^5.0.0",
"mime-types": "^2.1.35",
"urlpattern-polyfill": "^9.0.0"
}
}
3 changes: 2 additions & 1 deletion packages/@hec.js/api/test/files.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ api.route({
path: '/*',
fetch: files({
directory: './packages/@hec.js/api/test/assets',
cacheControl: 'private, max-age=0'
cacheControl: 'private, max-age=0',
cacheDuration: 0
})
});

Expand Down

0 comments on commit 1693bda

Please sign in to comment.