Skip to content

Commit

Permalink
Feat: transform support #4 (#11)
Browse files Browse the repository at this point in the history
* Feat: transform support #4
* Chore : Simplify the code
* Chore: fix package-lock.json
* Refactor: FileMap/TransformFunc type
* Refactor: check dir before copy
* Update: support E-Tag for transformed content
* Fix: improve error output
* Refactor: build transform
* Chore: format test
* Chore: restore removed comment
* Chore: update transform option docs
* Chore: improve debug output
* Chore: fix README

Co-authored-by: sapphi-red <[email protected]>
  • Loading branch information
a982246809 and sapphi-red authored Apr 21, 2022
1 parent 9da27f7 commit a5becb2
Show file tree
Hide file tree
Showing 7 changed files with 189 additions and 51 deletions.
101 changes: 73 additions & 28 deletions src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,16 @@ import { parse } from '@polka/url'
import { lookup } from 'mrmime'
import { statSync, createReadStream, Stats } from 'node:fs'
import type { Connect } from 'vite'
import fs from 'fs-extra'
import type {
IncomingMessage,
OutgoingHttpHeaders,
ServerResponse
} from 'node:http'
import { resolve } from 'node:path'
import type { FileMap } from './serve'
import type { TransformFunc } from './options'
import { calculateMd5Base64 } from './utils'

const FS_PREFIX = `/@fs/`
const VALID_ID_PREFIX = `/@id/`
Expand All @@ -34,33 +38,29 @@ const InternalPrefixRE = new RegExp(`^(?:${internalPrefixes.join('|')})`)
const isImportRequest = (url: string): boolean => importQueryRE.test(url)
const isInternalRequest = (url: string): boolean => InternalPrefixRE.test(url)

function viaLocal(root: string, fileMap: Map<string, string>, uri: string) {
function viaLocal(root: string, fileMap: FileMap, uri: string) {
if (uri.endsWith('/')) {
uri = uri.slice(-1)
}

const file = fileMap.get(uri)
if (file) {
const filepath = resolve(root, file)
const stats = statSync(filepath)
const headers = toHeaders(filepath, stats)
return { filepath, stats, headers }
const filepath = resolve(root, file.src)
return { filepath, transform: file.transform }
}

for (const [key, val] of fileMap) {
const dir = key.endsWith('/') ? key : `${key}/`
if (!uri.startsWith(dir)) continue

const filepath = resolve(root, val, uri.slice(dir.length))
const stats = statSync(filepath)
const headers = toHeaders(filepath, stats)
return { filepath, stats, headers }
const filepath = resolve(root, val.src, uri.slice(dir.length))
return { filepath }
}

return undefined
}

function toHeaders(name: string, stats: Stats) {
function getStaticHeaders(name: string, stats: Stats) {
let ctype = lookup(name) || ''
if (ctype === 'text/html') ctype += ';charset=utf-8'

Expand All @@ -75,15 +75,21 @@ function toHeaders(name: string, stats: Stats) {
return headers
}

function send(
req: IncomingMessage,
res: ServerResponse,
file: string,
stats: Stats,
headers: OutgoingHttpHeaders
) {
let code = 200
const opts: { start?: number; end?: number } = {}
function getTransformHeaders(name: string, content: string) {
let ctype = lookup(name) || ''
if (ctype === 'text/html') ctype += ';charset=utf-8'

const headers: OutgoingHttpHeaders = {
'Content-Length': Buffer.byteLength(content, 'utf8'),
'Content-Type': ctype,
ETag: `W/"${calculateMd5Base64(content)}"`,
'Cache-Control': 'no-cache'
}

return headers
}

function getMergeHeaders(headers: OutgoingHttpHeaders, res: ServerResponse) {
headers = { ...headers }

for (const key in headers) {
Expand All @@ -95,6 +101,22 @@ function send(
if (contentTypeHeader) {
headers['Content-Type'] = contentTypeHeader
}
return headers
}

function sendStatic(req: IncomingMessage, res: ServerResponse, file: string) {
const stats = statSync(file)
const staticHeaders = getStaticHeaders(file, stats)

if (req.headers['if-none-match'] === staticHeaders['ETag']) {
res.writeHead(304)
res.end()
return
}

let code = 200
const headers = getMergeHeaders(staticHeaders, res)
const opts: { start?: number; end?: number } = {}

if (req.headers.range) {
code = 206
Expand All @@ -120,12 +142,35 @@ function send(
createReadStream(file, opts).pipe(res)
}

async function sendTransform(
req: IncomingMessage,
res: ServerResponse,
file: string,
transform: TransformFunc
) {
const content = await fs.readFile(file, 'utf8')
const transformedContent = transform(content, file)
const transformHeaders = getTransformHeaders(file, transformedContent)

if (req.headers['if-none-match'] === transformHeaders['ETag']) {
res.writeHead(304)
res.end()
return
}

const code = 200
const headers = getMergeHeaders(transformHeaders, res)

res.writeHead(code, headers)
res.end(transformedContent)
}

export function serveStaticCopyMiddleware(
root: string,
fileMap: Map<string, string>
fileMap: FileMap
): Connect.NextHandleFunction {
// Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...`
return function viteServeStaticCopyMiddleware(req, res, next) {
return async function viteServeStaticCopyMiddleware(req, res, next) {
// skip import request and internal requests `/@fs/ /@vite-client` etc...
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (isImportRequest(req.url!) || isInternalRequest(req.url!)) {
Expand All @@ -142,6 +187,7 @@ export function serveStaticCopyMiddleware(
}

const data = viaLocal(root, fileMap, pathname)

if (!data) {
if (next) {
next()
Expand All @@ -152,12 +198,6 @@ export function serveStaticCopyMiddleware(
return
}

if (req.headers['if-none-match'] === data.headers['ETag']) {
res.writeHead(304)
res.end()
return
}

// Matches js, jsx, ts, tsx.
// The reason this is done, is that the .ts file extension is reserved
// for the MIME type video/mp2t. In almost all cases, we can expect
Expand All @@ -167,6 +207,11 @@ export function serveStaticCopyMiddleware(
res.setHeader('Content-Type', 'application/javascript')
}

send(req, res, data.filepath, data.stats, data.headers)
if (data.transform) {
await sendTransform(req, res, data.filepath, data.transform)
return
}

sendStatic(req, res, data.filepath)
}
}
12 changes: 12 additions & 0 deletions src/options.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import type { WatchOptions } from 'chokidar'

/**
* @param content content of file
* @param filename absolute path to the file
*/
export type TransformFunc = (content: string, filename: string) => string

export type Target = {
/**
* path or glob
Expand All @@ -13,6 +19,12 @@ export type Target = {
* rename
*/
rename?: string
/**
* transform
*
* `src` should only include files when this option is used
*/
transform?: TransformFunc
}

export type ViteStaticCopyOptions = {
Expand Down
22 changes: 18 additions & 4 deletions src/serve.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Plugin, ResolvedConfig } from 'vite'
import type { ResolvedViteStaticCopyOptions } from './options'
import type { ResolvedViteStaticCopyOptions, TransformFunc } from './options'
import { serveStaticCopyMiddleware } from './middleware'
import {
collectCopyTargets,
Expand All @@ -11,18 +11,32 @@ import { debounce } from 'throttle-debounce'
import chokidar from 'chokidar'
import pc from 'picocolors'

type FileMapValue = {
src: string
transform?: TransformFunc
}
export type FileMap = Map<string, FileMapValue>

export const servePlugin = ({
targets,
flatten,
watch
}: ResolvedViteStaticCopyOptions): Plugin => {
let config: ResolvedConfig
let watcher: chokidar.FSWatcher
const fileMap = new Map<string, string>()
const fileMap: FileMap = new Map()

const collectFileMap = async () => {
const copyTargets = await collectCopyTargets(config.root, targets, flatten)
updateFileMapFromTargets(copyTargets, fileMap)
try {
const copyTargets = await collectCopyTargets(
config.root,
targets,
flatten
)
updateFileMapFromTargets(copyTargets, fileMap)
} catch (e) {
config.logger.error(formatConsole(pc.red((e as Error).toString())))
}
}
const collectFileMapDebounce = debounce(100, async () => {
await collectFileMap()
Expand Down
72 changes: 57 additions & 15 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,16 @@ import fastglob from 'fast-glob'
import path from 'node:path'
import fs from 'fs-extra'
import pc from 'picocolors'
import type { Target } from './options'
import type { Target, TransformFunc } from './options'
import type { Logger } from 'vite'
import type { FileMap } from './serve'
import { createHash } from 'node:crypto'

type SimpleTarget = { src: string; dest: string }
export type SimpleTarget = {
src: string
dest: string
transform?: TransformFunc
}

export const collectCopyTargets = async (
root: string,
Expand All @@ -14,14 +20,23 @@ export const collectCopyTargets = async (
) => {
const copyTargets: Array<SimpleTarget> = []

for (const { src, dest, rename } of targets) {
for (const { src, dest, rename, transform } of targets) {
const matchedPaths = await fastglob(src, {
onlyFiles: false,
dot: true,
cwd: root
})

for (const matchedPath of matchedPaths) {
if (transform) {
const srcStat = await fs.stat(path.resolve(root, matchedPath))
if (!srcStat.isFile()) {
throw new Error(
`"transform" option only supports a file: '${matchedPath}' is not a file`
)
}
}

// https://github.com/vladshcherbin/rollup-plugin-copy/blob/507bf5e99aa2c6d0d858821e627cb7617a1d9a6d/src/index.js#L32-L35
const { base, dir } = path.parse(matchedPath)
const destDir =
Expand All @@ -32,13 +47,24 @@ export const collectCopyTargets = async (

copyTargets.push({
src: matchedPath,
dest: path.join(destDir, rename ?? base)
dest: path.join(destDir, rename ?? base),
transform
})
}
}
return copyTargets
}

async function transformCopy(
transform: (content: string, filepath: string) => string,
src: string,
dest: string
) {
const content = await fs.readFile(src, 'utf8')
const transformedContent = transform(content, src)
await fs.outputFile(dest, transformedContent)
}

export const copyAll = async (
rootSrc: string,
rootDest: string,
Expand All @@ -47,42 +73,58 @@ export const copyAll = async (
) => {
const copyTargets = await collectCopyTargets(rootSrc, targets, flatten)
await Promise.all(
copyTargets.map(({ src, dest }) =>
// use `path.resolve` because rootDest maybe absolute path
fs.copy(path.resolve(rootSrc, src), path.resolve(rootSrc, rootDest, dest))
)
copyTargets.map(({ src, dest, transform }) => {
// use `path.resolve` because rootSrc/rootDest maybe absolute path
const resolvedSrc = path.resolve(rootSrc, src)
const resolvedDest = path.resolve(rootSrc, rootDest, dest)
if (transform) {
return transformCopy(transform, resolvedSrc, resolvedDest)
} else {
return fs.copy(resolvedSrc, resolvedDest)
}
})
)

return copyTargets.length
}

export const updateFileMapFromTargets = (
targets: SimpleTarget[],
fileMap: Map<string, string>
fileMap: FileMap
) => {
fileMap.clear()
for (const target of [...targets].reverse()) {
let dest = target.dest.replace(/\\/g, '/')
if (!dest.startsWith('/')) {
dest = `/${dest}`
}
fileMap.set(dest, target.src)
fileMap.set(dest, {
src: target.src,
transform: target.transform
})
}
}

export const calculateMd5Base64 = (content: string) =>
createHash('md5').update(content).digest('base64')

export const formatConsole = (msg: string) =>
`${pc.cyan('[vite-plugin-static-copy]')} ${msg}`

export const outputCollectedLog = (
logger: Logger,
collectedMap: Map<string, string>
) => {
export const outputCollectedLog = (logger: Logger, collectedMap: FileMap) => {
if (collectedMap.size > 0) {
logger.info(
formatConsole(pc.green(`Collected ${collectedMap.size} items.`))
)
if (process.env.DEBUG === 'vite:plugin-static-copy') {
for (const [key, val] of collectedMap) {
logger.info(formatConsole(` - '${key}' -> '${val}'`))
logger.info(
formatConsole(
` - '${key}' -> '${val.src}'${
val.transform ? ' (with content transform)' : ''
}`
)
)
}
}
} else {
Expand Down
Loading

0 comments on commit a5becb2

Please sign in to comment.