|
| 1 | +/*! |
| 2 | + * Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. |
| 3 | + * SPDX-License-Identifier: Apache-2.0 |
| 4 | + */ |
| 5 | + |
| 6 | +'use strict' |
| 7 | + |
| 8 | +import * as path from 'path' |
| 9 | +import * as vscode from 'vscode' |
| 10 | +import * as filesystem from '../../shared/filesystem' |
| 11 | +import { fileExists, readFileAsString } from '../../shared/filesystemUtilities' |
| 12 | +import { DefaultSettingsConfiguration } from '../../shared/settingsConfiguration' |
| 13 | +import { detectLocalLambdas, LocalLambda } from './detectLocalLambdas' |
| 14 | + |
| 15 | +import * as nls from 'vscode-nls' |
| 16 | +const localize = nls.loadMessageBundle() |
| 17 | + |
| 18 | +import * as AsyncLock from 'async-lock' |
| 19 | +const lock = new AsyncLock() |
| 20 | + |
| 21 | +export interface NodeDebugConfiguration extends vscode.DebugConfiguration { |
| 22 | + readonly type: 'node' |
| 23 | + readonly request: 'attach' | 'launch' |
| 24 | + readonly name: string |
| 25 | + readonly preLaunchTask?: string |
| 26 | + readonly address: 'localhost' |
| 27 | + readonly port: number |
| 28 | + readonly localRoot: string |
| 29 | + readonly remoteRoot: '/var/task' |
| 30 | + readonly protocol: 'legacy' | 'inspector' |
| 31 | + readonly skipFiles?: string[] |
| 32 | +} |
| 33 | + |
| 34 | +interface LambdaWithPreLaunchTask { |
| 35 | + lambda: LocalLambda |
| 36 | + task: string |
| 37 | +} |
| 38 | + |
| 39 | +interface TasksConfig { |
| 40 | + version: '2.0.0' |
| 41 | + tasks?: { |
| 42 | + label?: string |
| 43 | + }[] |
| 44 | +} |
| 45 | + |
| 46 | +export class NodeDebugConfigurationProvider implements vscode.DebugConfigurationProvider { |
| 47 | + public async resolveDebugConfiguration( |
| 48 | + folder: vscode.WorkspaceFolder | undefined, |
| 49 | + debugConfiguration: vscode.DebugConfiguration, |
| 50 | + token?: vscode.CancellationToken |
| 51 | + ): Promise<NodeDebugConfiguration> { |
| 52 | + throw new Error('Not Implemented') |
| 53 | + } |
| 54 | + |
| 55 | + public async provideDebugConfigurations( |
| 56 | + folder: vscode.WorkspaceFolder | undefined, |
| 57 | + token?: vscode.CancellationToken, |
| 58 | + event: any = {} |
| 59 | + ): Promise<NodeDebugConfiguration[]> { |
| 60 | + if (!folder) { |
| 61 | + console.error('Cannot provide debug configuration if no workspace is open.') |
| 62 | + |
| 63 | + return [] |
| 64 | + } |
| 65 | + |
| 66 | + const npmProject = await this.findNpmProject(folder, token) |
| 67 | + // tslint:disable-next-line:no-invalid-template-strings |
| 68 | + const localRoot = !!npmProject ? path.join('${workspaceFolder}', npmProject) : '${workspaceFolder}' |
| 69 | + |
| 70 | + const localLambdas: LambdaWithPreLaunchTask[] = await Promise.all( |
| 71 | + (await detectLocalLambdas([ folder ])).map(async localLambda => ({ |
| 72 | + lambda: localLambda, |
| 73 | + task: await this.addPreLaunchTask(folder, localLambda.lambda, event, 5858) |
| 74 | + })) |
| 75 | + ) |
| 76 | + |
| 77 | + return localLambdas.reduce( |
| 78 | + (accumulator: NodeDebugConfiguration[], localLamdba: LambdaWithPreLaunchTask) => { |
| 79 | + accumulator.push( |
| 80 | + { |
| 81 | + type: 'node', |
| 82 | + request: 'launch', |
| 83 | + name: localize( |
| 84 | + 'AWS.lambda.debug.node.launchConfig.name', |
| 85 | + 'Lambda: Debug {0} locally', |
| 86 | + localLamdba.lambda.lambda |
| 87 | + ), |
| 88 | + preLaunchTask: localLamdba.task, |
| 89 | + address: 'localhost', |
| 90 | + port: 5858, |
| 91 | + localRoot, |
| 92 | + remoteRoot: '/var/task', |
| 93 | + protocol: localLamdba.lambda.protocol, |
| 94 | + skipFiles: [ |
| 95 | + '/var/runtime/node_modules/**/*.js', |
| 96 | + '<node_internals>/**/*.js' |
| 97 | + ] |
| 98 | + }, |
| 99 | + { |
| 100 | + type: 'node', |
| 101 | + request: 'attach', |
| 102 | + name: localize( |
| 103 | + 'AWS.lambda.debug.node.attachConfig.name', |
| 104 | + 'Lambda: Attach to {0} locally"', |
| 105 | + localLamdba.lambda.lambda |
| 106 | + ), |
| 107 | + preLaunchTask: undefined, |
| 108 | + address: 'localhost', |
| 109 | + port: 5858, |
| 110 | + localRoot, |
| 111 | + remoteRoot: '/var/task', |
| 112 | + protocol: localLamdba.lambda.protocol, |
| 113 | + skipFiles: [ |
| 114 | + '/var/runtime/node_modules/**/*.js', |
| 115 | + '<node_internals>/**/*.js' |
| 116 | + ] |
| 117 | + } |
| 118 | + ) |
| 119 | + |
| 120 | + return accumulator |
| 121 | + }, |
| 122 | + [] |
| 123 | + ) |
| 124 | + } |
| 125 | + |
| 126 | + /** |
| 127 | + * `sam init` puts the local root in a subdirectory. We attempt to detect this subdirectory by looking |
| 128 | + * for child folders that contain a package.json file. If the root workspace folder does not contain |
| 129 | + * package.json, AND exactly one of its direct children contains package.json, use that child as the |
| 130 | + * local root. |
| 131 | + * |
| 132 | + * @returns If `folder` does not contain `package.json`, and exactly one of `folder`'s children returns |
| 133 | + * package.json, returns path to `subfolder/package.json`. Otherwise, returns undefined. |
| 134 | + */ |
| 135 | + private async findNpmProject( |
| 136 | + folder: vscode.WorkspaceFolder, |
| 137 | + token?: vscode.CancellationToken |
| 138 | + ): Promise<string | undefined> { |
| 139 | + // The root directory is an npm package, so we don't need to look in subdirectories. |
| 140 | + if (await fileExists(path.join(folder.uri.fsPath, 'package.json'))) { |
| 141 | + return undefined |
| 142 | + } |
| 143 | + |
| 144 | + const entries: string[] = await filesystem.readdirAsync(folder.uri.fsPath) |
| 145 | + |
| 146 | + const candidates: string[] = (await Promise.all(entries.map(async entry => { |
| 147 | + const entryPath = path.join(folder.uri.fsPath, entry) |
| 148 | + if (await fileExists(entryPath) && (await filesystem.statAsync(entryPath)).isDirectory()) { |
| 149 | + return await fileExists(path.join(entryPath, 'package.json')) ? entry : undefined |
| 150 | + } |
| 151 | + |
| 152 | + return undefined |
| 153 | + }))).filter(c => !!c).map(c => c as string) |
| 154 | + |
| 155 | + return candidates.length === 1 ? candidates[0] : undefined |
| 156 | + } |
| 157 | + |
| 158 | + private getTaskLabel(functionName: string): string { |
| 159 | + return localize( |
| 160 | + 'AWS.lambda.debug.node.invokeTask.label"', |
| 161 | + 'Lambda: Invoke {0} locally', |
| 162 | + functionName |
| 163 | + ) |
| 164 | + } |
| 165 | + |
| 166 | + private async addPreLaunchTask( |
| 167 | + folder: vscode.WorkspaceFolder, |
| 168 | + functionName: string, |
| 169 | + event: any, |
| 170 | + debugPort: number = 5858 |
| 171 | + ): Promise<string> { |
| 172 | + const label = this.getTaskLabel(functionName) |
| 173 | + const configRoot = path.join(folder.uri.fsPath, '.vscode') |
| 174 | + const tasksPath = path.join(configRoot, 'tasks.json') |
| 175 | + |
| 176 | + let tasks: TasksConfig | undefined |
| 177 | + if (await fileExists(tasksPath)) { |
| 178 | + tasks = JSON.parse(await readFileAsString(tasksPath, 'utf8')) as TasksConfig | undefined |
| 179 | + } |
| 180 | + |
| 181 | + if (!tasks) { |
| 182 | + tasks = { |
| 183 | + version: '2.0.0' |
| 184 | + } |
| 185 | + } |
| 186 | + |
| 187 | + if (!tasks.tasks) { |
| 188 | + tasks.tasks = [] |
| 189 | + } |
| 190 | + |
| 191 | + // TODO: If there is already a matching task, should we attempt to update it? |
| 192 | + if (!tasks.tasks.some(t => t.label === label)) { |
| 193 | + tasks.tasks.push(this.createPreLaunchTask(functionName, event, debugPort)) |
| 194 | + } |
| 195 | + |
| 196 | + // If this function is called twice in succession (for instance, if multiple lambdas |
| 197 | + // were detected), multiple calls to mkdirAsync can be added to the event queue, |
| 198 | + // leading to a pseudo-race condition despite node's single-threaded nature. |
| 199 | + await lock.acquire('create .vscode', async () => { |
| 200 | + if (!await fileExists(configRoot)) { |
| 201 | + await filesystem.mkdirAsync(configRoot) |
| 202 | + } |
| 203 | + }) |
| 204 | + |
| 205 | + if (!(await filesystem.statAsync(configRoot)).isDirectory()) { |
| 206 | + throw new Error(`${configRoot} exists, but is not a directory`) |
| 207 | + } |
| 208 | + |
| 209 | + const config = new DefaultSettingsConfiguration('editor') |
| 210 | + await filesystem.writeFileAsync( |
| 211 | + tasksPath, |
| 212 | + JSON.stringify(tasks, undefined, config.readSetting<number>('tabSize', 4)) |
| 213 | + ) |
| 214 | + |
| 215 | + return label |
| 216 | + } |
| 217 | + |
| 218 | + private createPreLaunchTask( |
| 219 | + functionName: string, |
| 220 | + event: any, |
| 221 | + debugPort: number = 5858 |
| 222 | + ) { |
| 223 | + return { |
| 224 | + type: 'shell', |
| 225 | + label: this.getTaskLabel(functionName), |
| 226 | + command: 'echo', |
| 227 | + args: [ |
| 228 | + `${this.escapeForBash(JSON.stringify(event))}`, |
| 229 | + '|', |
| 230 | + 'sam', |
| 231 | + 'local', |
| 232 | + 'invoke', |
| 233 | + `${this.escapeForBash(functionName)}`, |
| 234 | + '-d', |
| 235 | + `${debugPort}` |
| 236 | + ], |
| 237 | + windows: { |
| 238 | + args: [ |
| 239 | + `${this.escapeForPowerShell(JSON.stringify(event))}`, |
| 240 | + '|', |
| 241 | + 'sam', |
| 242 | + 'local', |
| 243 | + 'invoke', |
| 244 | + `${this.escapeForPowerShell(functionName)}`, |
| 245 | + '-d', |
| 246 | + `${debugPort}` |
| 247 | + ], |
| 248 | + }, |
| 249 | + isBackground: true, |
| 250 | + presentation: { |
| 251 | + echo: true, |
| 252 | + reveal: 'always', |
| 253 | + focus: false, |
| 254 | + panel: 'dedicated', |
| 255 | + showReuseMessage: true |
| 256 | + }, |
| 257 | + problemMatcher: { |
| 258 | + owner: 'lambda-node', |
| 259 | + // tslint:disable-next-line:no-invalid-template-strings |
| 260 | + fileLocation: [ 'relative', '${workspaceFolder}' ], |
| 261 | + pattern: [ |
| 262 | + { |
| 263 | + // TODO: For now, use regex that never matches anything. |
| 264 | + // Update as we determine what issues we can recognize. |
| 265 | + regexp: '^(x)(\b)(x)$', |
| 266 | + file: 1, |
| 267 | + location: 2, |
| 268 | + message: 3 |
| 269 | + } |
| 270 | + ], |
| 271 | + background: { |
| 272 | + activeOnStart: true, |
| 273 | + // TODO: The SAM CLI is not currently (10/30/18) localized. If/when it becomes localized, |
| 274 | + // these patterns should be updated. |
| 275 | + beginsPattern: String.raw`^Fetching lambci\/lambda:nodejs\d+\.\d+ Docker container image......$`, |
| 276 | + // tslint:disable-next-line:max-line-length |
| 277 | + endsPattern: String.raw`^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} Mounting ((\w:)?([\\\/][^\\\/]+)*) as ((\w:)?([\\\/][^\\\/]+)*:ro) inside runtime container$` |
| 278 | + } |
| 279 | + } |
| 280 | + } |
| 281 | + } |
| 282 | + |
| 283 | + private escapeForBash(input: string): string { |
| 284 | + // In bash, there are no escape sequences within a single-quoted string, so we have to concatenate instead. |
| 285 | + return `'${input.replace("'", "'\"'\"'")}'` |
| 286 | + } |
| 287 | + |
| 288 | + private escapeForPowerShell(input: string): string { |
| 289 | + // In PowerShell, the only escape sequence within a single-quoted string is '' to escape '. |
| 290 | + return `'${input.replace("'", "''")}'` |
| 291 | + } |
| 292 | +} |
0 commit comments