middleware.ts 6.0 KB
Newer Older
1
import { codeFrameColumns } from '@babel/code-frame'
2
import { constants as FS, promises as fs } from 'fs'
3 4
import { IncomingMessage, ServerResponse } from 'http'
import path from 'path'
5 6 7 8 9
import {
  NullableMappedPosition,
  RawSourceMap,
  SourceMapConsumer,
} from 'source-map'
10 11 12
import { StackFrame } from 'stacktrace-parser'
import url from 'url'
import webpack from 'webpack'
13
import { getRawSourceMap } from './internal/helpers/getRawSourceMap'
14
import { launchEditor } from './internal/helpers/launchEditor'
15

16
export type OverlayMiddlewareOptions = {
17 18 19 20 21 22 23 24 25
  rootDirectory: string
  stats(): webpack.Stats
}

export type OriginalStackFrameResponse = {
  originalStackFrame: StackFrame
  originalCodeFrame: string | null
}

26 27
type Source = { map: () => RawSourceMap } | null

28
function getOverlayMiddleware(options: OverlayMiddlewareOptions) {
29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
  async function getSourceById(protocol: string, id: string): Promise<Source> {
    if (protocol === 'file:') {
      const fileContent: string | null = await fs
        .readFile(id, 'utf-8')
        .catch(() => null)

      if (fileContent == null) {
        return null
      }

      const map = getRawSourceMap(fileContent)
      if (map == null) {
        return null
      }

      return {
        map() {
          return map
        },
      }
    }

51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75
    try {
      const compilation = options.stats()?.compilation
      const m = compilation?.modules?.find(m => m.id === id)
      return (
        m?.source(
          compilation.dependencyTemplates,
          compilation.runtimeTemplate
        ) ?? null
      )
    } catch (err) {
      console.error(`Failed to lookup module by ID ("${id}"):`, err)
      return null
    }
  }

  return async function(
    req: IncomingMessage,
    res: ServerResponse,
    next: Function
  ) {
    const { pathname, query } = url.parse(req.url, true)

    if (pathname === '/__nextjs_original-stack-frame') {
      const frame = (query as unknown) as StackFrame
      if (
76 77 78 79
        !(
          (frame.file?.startsWith('webpack-internal:///') ||
            frame.file?.startsWith('file://')) &&
          Boolean(parseInt(frame.lineNumber?.toString() ?? '', 10))
80
        )
81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107
      ) {
        res.statusCode = 400
        res.write('Bad Request')
        return res.end()
      }

      const moduleUrl = new URL(frame.file)
      const moduleId: string =
        moduleUrl.protocol === 'webpack-internal:'
          ? // Important to use original file to retain full path structure.
            // e.g. `webpack-internal:///./pages/index.js`
            frame.file.slice(20)
          : moduleUrl.pathname

      let source: Source
      try {
        source = await getSourceById(moduleUrl.protocol, moduleId)
      } catch (err) {
        console.log('Failed to get source map:', err)
        res.statusCode = 500
        res.write('Internal Server Error')
        return res.end()
      }

      if (source == null) {
        res.statusCode = 404
        res.write('Not Found')
108 109
        return res.end()
      }
110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176

      const frameLine = parseInt(frame.lineNumber?.toString() ?? '', 10)
      let frameColumn: number | null = parseInt(
        frame.column?.toString() ?? '',
        10
      )
      if (!frameColumn) {
        frameColumn = null
      }

      let pos: NullableMappedPosition
      try {
        const consumer = await new SourceMapConsumer(source.map())
        pos = consumer.originalPositionFor({
          line: frameLine,
          column: frameColumn,
        })
        consumer.destroy()
      } catch (err) {
        console.log('Failed to parse source map:', err)
        res.statusCode = 500
        res.write('Internal Server Error')
        return res.end()
      }

      if (pos.source == null) {
        res.statusCode = 404
        res.write('Not Found')
        return res.end()
      }

      const filePath = path.resolve(
        options.rootDirectory,
        pos.source.startsWith('webpack:///')
          ? pos.source.substring(11)
          : pos.source
      )
      const fileContent: string | null = await fs
        .readFile(filePath, 'utf-8')
        .catch(() => null)

      const originalFrame: StackFrame = {
        file: fileContent
          ? path.relative(options.rootDirectory, filePath)
          : pos.source,
        lineNumber: pos.line,
        column: pos.column,
        methodName: frame.methodName, // TODO: resolve original method name (?)
        arguments: [],
      }

      const originalCodeFrame: string | null =
        fileContent && pos.line
          ? (codeFrameColumns(
              fileContent,
              { start: { line: pos.line, column: pos.column } },
              { forceColor: true }
            ) as string)
          : null

      const o: OriginalStackFrameResponse = {
        originalStackFrame: originalFrame,
        originalCodeFrame,
      }
      res.statusCode = 200
      res.setHeader('Content-Type', 'application/json')
      res.write(Buffer.from(JSON.stringify(o)))
177
      return res.end()
178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212
    } else if (pathname === '/__nextjs_launch-editor') {
      const frame = (query as unknown) as StackFrame

      const frameFile = frame.file?.toString() || null
      if (frameFile == null) {
        res.statusCode = 400
        res.write('Bad Request')
        return res.end()
      }

      const filePath = path.resolve(options.rootDirectory, frameFile)
      const fileExists = await fs.access(filePath, FS.F_OK).then(
        () => true,
        () => false
      )
      if (!fileExists) {
        res.statusCode = 404
        res.write('Not Found')
        return res.end()
      }

      const frameLine = parseInt(frame.lineNumber?.toString() ?? '', 10) || 1
      const frameColumn = parseInt(frame.column?.toString() ?? '', 10) || 1

      try {
        await launchEditor(filePath, frameLine, frameColumn)
      } catch (err) {
        console.log('Failed to launch editor:', err)
        res.statusCode = 500
        res.write('Internal Server Error')
        return res.end()
      }

      res.statusCode = 204
      return res.end()
213 214 215 216 217 218
    }
    return next()
  }
}

export default getOverlayMiddleware