diff --git a/ext/files.ts b/ext/files.ts index e23b357..40d18ae 100644 --- a/ext/files.ts +++ b/ext/files.ts @@ -2,119 +2,156 @@ import { R2Bucket } from 'https://cdn.jsdelivr.net/npm/@cloudflare/workers-types@4.20230821.0/index.ts' import { join } from 'https://deno.land/std@0.199.0/path/mod.ts' import { createExtension } from '../extensions.ts' +import { AppContext } from '../mod.ts' // An extension to serve static files from Cloudflare R2, an S3 bucket, or the local file system. +type GeneralOptions = { + cacheControl?: string + etag?: boolean +} + +type FsOptions = { + type?: 'fs' + directory: string +} + +type R2Options = { + type: 'r2' + name: string +} + +type S3Options = { + type: 's3' + endpoint: string + bucketName: string + accessKeyId: string + secretAccessKey: string +} + /** * An extension to serve static files from Cloudflare R2 or the local file system. * * @since v1.2 */ export const files = createExtension<{ - serve: - & { - cacheControl?: string - etag?: boolean - } - & ( - | { - directory: string - type?: 'fs' - } - | { - name: string - type: 'r2' - } - ) - // | { - // endpoint: string - // bucketName: string - // accessKeyId: string - // secretAccessKey: string - // type: 's3' - // } + serve: GeneralOptions & (FsOptions | R2Options | S3Options) }>({ - async onRequest({ + onRequest({ app, prefix, _: { serve, }, }) { - if (serve.type === 'r2') { - if (!app.env) { - throw new Error( - 'You need to use the Cloudflare Workers runtime to serve static files from an R2 bucket!', - ) - } - - const bucket = app.env[serve.name] as R2Bucket + switch (serve.type) { + case 'r2': + return handleR2Files(app, serve, prefix) + case 's3': + throw new Error('S3 is not yet supported!') + case 'fs': + default: + return handleFsFiles(app, serve, prefix) + } + }, +}) - const object = await bucket.get( - prefix !== '*' - ? app.request.pathname.substring(prefix.length + 1) - : app.request.pathname, - ) +async function handleR2Files( + app: AppContext, + serve: GeneralOptions & R2Options, + prefix: string, +) { + if (app.runtime !== 'cloudflare' || !app.env) { + throw new Error( + 'You need to use the Cloudflare Workers runtime to serve static files from an R2 bucket!', + ) + } - if (object === null) { - return - } + const bucket = app.env[serve.name] as R2Bucket - return new Response(object.body as ReadableStream, { + const object = await bucket.get( + prefix !== '*' + ? app.request.pathname.substring(prefix.length + 1) + : app.request.pathname, + ) + if (object) { + return new Response(object.body as ReadableStream, { + headers: { + ...(serve.etag !== false && { etag: object.httpEtag }), + 'cache-control': serve.cacheControl ?? 's-maxage=300', // 5m + }, + }) + } else { + const indexPath = join(app.request.pathname, 'index.html') + const indexObject = await bucket.get(indexPath) + if (indexObject) { + return new Response(indexObject.body as ReadableStream, { headers: { - ...(serve.etag !== false && { etag: object.httpEtag }), + ...(serve.etag !== false && { etag: indexObject.httpEtag }), 'cache-control': serve.cacheControl ?? 's-maxage=300', // 5m }, }) } else { - const path = join( - serve.directory, - prefix !== '*' - ? app.request.pathname.substring(prefix.length + 1) - : app.request.pathname, - ) - - let exists, - stat: Deno.FileInfo | undefined - - try { - stat = await Deno.lstat( - path, - ) - - exists = stat.isFile - } catch (_) { - exists = false + const errorPath = join(prefix, '404.html') + const errorObject = await bucket.get(errorPath) + if (errorObject) { + return new Response(errorObject.body as ReadableStream, { + headers: { + 'cache-control': serve.cacheControl ?? 's-maxage=300', // 5m + }, + }) } + } + } +} - if (!exists || !stat) { - return - } +async function handleFsFiles( + app: AppContext, + serve: GeneralOptions & FsOptions, + prefix: string, +) { + const path = join( + serve.directory, + prefix !== '*' + ? app.request.pathname.substring(prefix.length + 1) + : app.request.pathname, + ) - const file = await Deno.open( - path, - { read: true }, - ) + let stat: Deno.FileInfo + let file: Deno.FsFile - return new Response(file.readable, { - headers: { - ...(serve.etag !== false && - { - etag: Array.prototype.map.call( - new Uint8Array( - await crypto.subtle.digest( - { name: 'SHA-1' }, - new TextEncoder().encode( - `${stat.birthtime?.getTime()}:${stat.mtime?.getTime()}:${stat.size}`, - ), - ), - ), - (x) => ('00' + x.toString(16)).slice(-2), - ).join(''), - }), - 'cache-control': serve.cacheControl ?? 's-maxage=300', // 5m - }, - }) + try { + stat = await Deno.lstat(path) + if (stat.isDirectory) { + stat = await Deno.lstat(join(path, 'index.html')) + file = await Deno.open(join(path, 'index.html'), { read: true }) + } else { + file = await Deno.open(path, { read: true }) } - }, -}) + } catch { + try { + stat = await Deno.lstat(join(serve.directory, '404.html')) + file = await Deno.open(join(serve.directory, '404.html'), { read: true }) + } catch { + return + } + } + + return new Response(file.readable, { + headers: { + ...(serve.etag !== false && { etag: await etag(stat) }), + 'cache-control': serve.cacheControl ?? 's-maxage=300', // 5m + }, + }) +} + +async function etag(stat: Deno.FileInfo) { + const encoder = new TextEncoder() + const data = encoder.encode( + `${stat.birthtime?.getTime()}:${stat.mtime?.getTime()}:${stat.size}`, + ) + const hash = await crypto.subtle.digest({ name: 'SHA-1' }, data) + const hashArray = Array.from(new Uint8Array(hash)) + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') + return hashHex +}