Skip to content

Commit 81b13e1

Browse files
authored
Add DebugConfigurationProvider for debugging lambdas locally. (#144)
1 parent ce746c6 commit 81b13e1

17 files changed

+1236
-145
lines changed

package.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
"onCommand:aws.invokeLambda",
2727
"onCommand:aws.deployLambda",
2828
"onCommand:aws.getLambdaConfig",
29-
"onCommand:aws.getLambdaPolicy"
29+
"onCommand:aws.getLambdaPolicy",
30+
"onDebugInitialConfigurations"
3031
],
3132
"main": "./out/src/extension",
3233
"contributes": {
@@ -56,6 +57,12 @@
5657
}
5758
}
5859
},
60+
"debuggers": [
61+
{
62+
"type": "lambda-node",
63+
"label": "%AWS.lambda.debug.node.label%"
64+
}
65+
],
5966
"viewsContainers": {
6067
"activitybar": [
6168
{

package.nls.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@
2626
"AWS.explorerNode.signIn": "Connect to AWS...",
2727
"AWS.explorerNode.signIn.tooltip": "Connect to AWS using a credential profile",
2828
"AWS.lambda.explorerTitle": "Lambda",
29+
"AWS.lambda.debug.node.label": "Lambda (NodeJS)",
30+
"AWS.lambda.debug.node.launchConfig.name": "Lambda: Debug {0} locally",
31+
"AWS.lambda.debug.node.attachConfig.name": "Lambda: Attach to {0} locally",
32+
"AWS.lambda.debug.node.invokeTask.label": "Lambda: Invoke {0} locally",
2933
"AWS.message.credentials.error": "There was an issue trying to use credentials profile {0}.\nYou will be disconnected from AWS.\n\n{1}",
3034
"AWS.message.enterProfileName": "Enter the name of the credential profile to use",
3135
"AWS.message.selectRegion": "Select an AWS region",

src/extension.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import * as nls from 'vscode-nls'
1010

1111
import { RegionNode } from './lambda/explorer/regionNode'
1212
import { LambdaProvider } from './lambda/lambdaProvider'
13+
import { NodeDebugConfigurationProvider } from './lambda/local/debugConfigurationProvider'
1314
import { AWSClientBuilder } from './shared/awsClientBuilder'
1415
import { AwsContextTreeCollection } from './shared/awsContextTreeCollection'
1516
import { extensionSettingsPrefix } from './shared/constants'
@@ -72,6 +73,11 @@ export async function activate(context: vscode.ExtensionContext) {
7273
context.subscriptions.push(vscode.window.registerTreeDataProvider(p.viewProviderId, p))
7374
})
7475

76+
context.subscriptions.push(vscode.debug.registerDebugConfigurationProvider(
77+
'lambda-node',
78+
new NodeDebugConfigurationProvider()
79+
))
80+
7581
await ext.statusBar.updateContext(undefined)
7682
}
7783

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
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

Comments
 (0)