Skip to content
This repository has been archived by the owner on Jun 27, 2024. It is now read-only.

refactor(ext/files): respect index.html and 404.html files #197

Merged
merged 3 commits into from
Aug 24, 2023
Merged
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
211 changes: 124 additions & 87 deletions ext/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}