├── .gitignore ├── README.md ├── server ├── package.json ├── src │ ├── app.ts │ ├── parseError.ts │ └── router.ts └── tsconfig.json └── static ├── .browserslistrc ├── .gitignore ├── README.md ├── babel.config.js ├── package.json ├── public └── index.html ├── src ├── App.vue └── main.js ├── uploadSourceMapPlugin.js └── vue.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | node_modules 3 | pnpm-lock.yaml 4 | package-lock.json 5 | .DS_Store 6 | server/uploads -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 生产环境js错误收集及定位源码位置Demo 2 | 3 | #### 使用 4 | 1. 启动server: 5 | ```shell 6 | > cd server 7 | > npm i 8 | > npm run dev 9 | ``` 10 | 11 | 2. 构建前端静态文件: 12 | > 构建前确保已启动server(即步骤1) 13 | ```shell 14 | > cd static 15 | > npm i 16 | > npm run build 17 | ``` 18 | 19 | 3. 打开 http://127.0.0.1:3001 ,点击按钮,查看控制台 20 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "ts-node src/app.ts" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "devDependencies": { 13 | "@types/fs-extra": "^9.0.11", 14 | "@types/hapi__hapi": "^20.0.8", 15 | "@types/hapi__inert": "^5.2.2", 16 | "@types/node": "^15.0.2", 17 | "ts-node": "^9.1.1", 18 | "typescript": "^4.2.4" 19 | }, 20 | "dependencies": { 21 | "@hapi/hapi": "^20.1.2", 22 | "@hapi/inert": "^6.0.3", 23 | "fs-extra": "^10.0.0", 24 | "source-map": "^0.7.3" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /server/src/app.ts: -------------------------------------------------------------------------------- 1 | import * as Hapi from '@hapi/hapi' 2 | import inert from '@hapi/inert' 3 | import * as routes from './router' 4 | 5 | async function start() { 6 | const server = Hapi.server({ host: '127.0.0.1', port: 3001 }) 7 | await server.register(inert) 8 | server.route(Object.values(routes)) 9 | await server.start() 10 | console.log(`Server running at: ${server.info.uri}`) 11 | } 12 | 13 | start() 14 | -------------------------------------------------------------------------------- /server/src/parseError.ts: -------------------------------------------------------------------------------- 1 | import * as Path from 'path' 2 | import * as Fs from 'fs-extra' 3 | import { SourceMapConsumer, BasicSourceMapConsumer, IndexedSourceMapConsumer } from 'source-map' 4 | 5 | const uploadDir = Path.join(process.cwd(), 'uploads') 6 | 7 | type Cache = { 8 | [key: string]: BasicSourceMapConsumer | IndexedSourceMapConsumer | undefined 9 | } 10 | 11 | export default class ParseError { 12 | /** 缓存consumer */ 13 | private cache: Cache = {} 14 | 15 | /** 读取sourcemap文件内容 */ 16 | private async rawSourceMap(filepath: string) { 17 | filepath = Path.join(uploadDir, filepath) 18 | if (await Fs.pathExists(filepath)) { 19 | return Fs.readJSON(filepath, { throws: false }) 20 | } 21 | return null 22 | } 23 | 24 | public async stack(stack: string) { 25 | const lines = stack.split('\n') 26 | const newLines: string[] = [lines[0]] 27 | // 逐行处理 28 | for (const item of lines) { 29 | if (/ +at.+.js:\d+:\d+\)$/) { 30 | const arr = item.match(/\((https?:\/\/.+):(\d+):(\d+)\)$/i) || [] 31 | if (arr.length === 4) { 32 | const url = arr[1] 33 | const line = Number(arr[2]) 34 | const column = Number(arr[3]) 35 | const filename = (url.match(/[^/]+$/) || [''])[0] 36 | 37 | const res = await this.parse(filename + '.map', line, column) 38 | if (res && res.source) { 39 | const content = ` at ${res.name} (${[res.source, res.line, res.column].join(':')})` 40 | newLines.push(content) 41 | } else { 42 | newLines.push(item) 43 | } 44 | } 45 | } 46 | } 47 | return newLines.join('\n') 48 | } 49 | 50 | /** 根据行和列,从sourcemap中定位源码的位置 */ 51 | private async parse(filename: string, line: number, column: number) { 52 | let consumer 53 | if (this.cache[filename]) { 54 | consumer = this.cache[filename] 55 | } else { 56 | const raw = await this.rawSourceMap(filename) 57 | if (raw) { 58 | consumer = await SourceMapConsumer.with(raw, null, consumer => consumer) 59 | this.cache[filename] = consumer 60 | } 61 | } 62 | return consumer ? consumer.originalPositionFor({ line, column }) : null 63 | } 64 | 65 | public destroy() { 66 | Object.keys(this.cache).forEach((key: keyof Cache) => { 67 | const item = this.cache[key] 68 | item && item.destroy() 69 | this.cache[key] = undefined 70 | }) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /server/src/router.ts: -------------------------------------------------------------------------------- 1 | import * as Hapi from '@hapi/hapi' 2 | import * as Path from 'path' 3 | import * as Fs from 'fs-extra' 4 | import { Readable } from 'stream' 5 | import ParseError from './parseError' 6 | 7 | interface MyFile extends Readable { 8 | hapi: { 9 | filename: string; 10 | } 11 | } 12 | 13 | /** 静态文件服务器 */ 14 | export const staticServer = { 15 | method: 'GET', 16 | path: '/{param*}', 17 | handler: { 18 | directory: { 19 | path: Path.join(process.cwd(), '../static/dist'), // 指向静态页面打包输出的目录 20 | index: ['index.html'] 21 | } 22 | } 23 | } 24 | 25 | /** 上传文件接口 */ 26 | export const upload = { 27 | method: 'put', 28 | path: '/upload', 29 | options: { 30 | payload: { 31 | multipart: { output: 'stream' }, 32 | allow: ['application/json', 'multipart/form-data'], 33 | } 34 | }, 35 | async handler(request, h) { 36 | const { file } = request.payload as { file: MyFile } 37 | const dir = Path.join(process.cwd(), 'uploads') 38 | await Fs.ensureDir(dir) 39 | return new Promise((resolve) => { 40 | const ws = Fs.createWriteStream(Path.join(dir, file.hapi.filename)) 41 | file.pipe(ws) 42 | 43 | file.on('end', () => { 44 | resolve(h.response({ status: true }).code(200)) 45 | }) 46 | 47 | file.on('error', () => { 48 | resolve(h.response({ status: false }).code(500)) 49 | }) 50 | }) 51 | } 52 | } 53 | 54 | /** 接受错误并解析返回 */ 55 | export const jsError = { 56 | method: 'post', 57 | path: '/api/js/error', 58 | async handler(req) { 59 | const data = <{ stack: string }>req.payload 60 | const parser = new ParseError() 61 | const result = await parser.stack(data.stack) 62 | parser.destroy() 63 | 64 | // 这里拿到result后可以做一些你想要的操作,比如推送、存数据等等 65 | // 这里直接返回解析后的结果 66 | 67 | return result 68 | } 69 | } -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "ES2020", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 8 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | // "outDir": "./", /* Redirect output structure to the directory. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true, /* Enable all strict type-checking options. */ 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 43 | // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ 44 | 45 | /* Module Resolution Options */ 46 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 47 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 48 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 49 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 50 | // "typeRoots": [], /* List of folders to include type definitions from. */ 51 | // "types": [], /* Type declaration files to be included in compilation. */ 52 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 53 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 54 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 55 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 56 | 57 | /* Source Map Options */ 58 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 61 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 62 | 63 | /* Experimental Options */ 64 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 65 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 66 | 67 | /* Advanced Options */ 68 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 69 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /static/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | -------------------------------------------------------------------------------- /static/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /static/README.md: -------------------------------------------------------------------------------- 1 | # static 2 | 3 | ## Project setup 4 | ``` 5 | npm install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | npm run serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | npm run build 16 | ``` 17 | 18 | ### Customize configuration 19 | See [Configuration Reference](https://cli.vuejs.org/config/). 20 | -------------------------------------------------------------------------------- /static/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /static/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "static", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build" 8 | }, 9 | "dependencies": { 10 | "axios": "^0.21.1", 11 | "core-js": "^3.6.5", 12 | "vue": "^2.6.11" 13 | }, 14 | "devDependencies": { 15 | "@vue/cli-plugin-babel": "~4.5.0", 16 | "@vue/cli-service": "~4.5.0", 17 | "form-data": "^4.0.0", 18 | "vue-template-compiler": "^2.6.11" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /static/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /static/src/App.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 18 | -------------------------------------------------------------------------------- /static/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import axios from 'axios' 3 | import App from './App.vue' 4 | 5 | Vue.config.productionTip = false 6 | 7 | Vue.config.errorHandler = (err, vm, info) => { 8 | axios({ 9 | url: '/api/js/error', 10 | method: 'post', 11 | data: { 12 | stack: err.stack 13 | } 14 | }).then(res => console.log('以下为解析后的报错:\n', res.data)) 15 | throw err 16 | } 17 | 18 | new Vue({ 19 | render: h => h(App), 20 | }).$mount('#app') 21 | -------------------------------------------------------------------------------- /static/uploadSourceMapPlugin.js: -------------------------------------------------------------------------------- 1 | const Path = require('path') 2 | const Fs = require('fs') 3 | const Axios = require('axios') 4 | const FormData = require('form-data') 5 | const PLUGIN_NAME = 'UploadSourceMapPlugin' 6 | 7 | class UploadSourceMapPlugin { 8 | // 读取目录下所有的 .js.map 文件 9 | async getAssets(distDir) { 10 | const files = await Fs.promises.readdir(distDir) 11 | return files.filter(el => /\.js\.map$/i.test(el)).map(el => Path.join(distDir, el)) 12 | } 13 | 14 | // 上传文件到服务端 15 | async upload(filepath) { 16 | const stream = Fs.createReadStream(filepath) 17 | const formData = new FormData() 18 | formData.append('file', stream) 19 | return Axios.default({ 20 | url: 'http://localhost:3001/upload', 21 | method: 'put', 22 | headers: formData.getHeaders(), 23 | timeout: 10000, 24 | data: formData 25 | }).then().catch((err) => { 26 | console.error(Path.basename(filepath), err.message) 27 | }) 28 | } 29 | 30 | apply(compiler) { 31 | // 路径需要与 SourceMapDevToolPlugin 插件存放 sourcemap 文件的地址一致 32 | const sourcemapDir = Path.join(compiler.options.output.path, 'sourcemap') 33 | compiler.hooks.afterEmit.tapPromise(PLUGIN_NAME, async() => { 34 | console.log('Uploading sourcemap files...') 35 | const files = await this.getAssets(Path.join(sourcemapDir, 'js')) // 只上传 js 的 sourcemap 文件 36 | for (const file of files) { 37 | await this.upload(file) 38 | } 39 | // 注意:node < 14.14.0 可以使用 Fs.promises.rmdir 替代 40 | await Fs.promises.rm(sourcemapDir, { recursive: true }) 41 | }) 42 | } 43 | } 44 | 45 | module.exports = UploadSourceMapPlugin 46 | -------------------------------------------------------------------------------- /static/vue.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const UploadSourceMapPlugin = require('./uploadSourceMapPlugin') 3 | 4 | module.exports = { 5 | publicPath: '/', 6 | configureWebpack(config) { 7 | if (process.env.NODE_ENV === 'production') { 8 | config.plugins.push( 9 | new webpack.SourceMapDevToolPlugin({ 10 | filename: 'sourcemap/[file].map', // 修改生成 sourcemap 文件的路径(对应 dist/sourcemap) 11 | append: false // 不在文件末尾添加 sourcemapUrl 12 | }), 13 | new UploadSourceMapPlugin() 14 | ) 15 | } 16 | } 17 | } --------------------------------------------------------------------------------