Skip to content

Commit a6a4ed4

Browse files
authored
feat(sam): improved TypeScript support
## Problem TypeScript debugger has a race condition that breaks debugging much of the time. Hard-coded tsconfig also is used which breaks anything more than a very basic typescript lambda. ## Solution Updated toolkit to generate tsconfig that attempts to match project tsconfig as close as possible, code is compiled into a ts build directory, sam deploys from there on an updated handler path (pointing to build dir). This eliminates the debugger race condition, and much more closely resembles a standard project build eliminating all of our errors. It also matches jetbrains approach here: https://github.com/aws/aws-toolkit-jetbrains/blob/c35ffdbd545c104c2cc5e23bb4dbf02bdc08953a/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsLambdaBuilder.kt Code updated: src/shared/sam/activation.ts: allow typescript code to be recognized src/shared/sam/debugger/awsSamDebugger.ts: code template generation after individual runtime configs are made so ts can update handler path src/shared/sam/debugger/typescriptSamDebug.ts: updated build directory process matching jetbrains (most of the updates) src/shared/sam/localLambdaRunner.ts: split getting template path and creating the template to facilitate awsSamDebugger.ts changes src/test/shared/sam/debugger/samDebugConfigProvider.test.ts: update ts tests Tested with unit tests, and with several typescript projects including large and small lambdas. New code lines are covered with code coverage.
1 parent b4695d4 commit a6a4ed4

File tree

6 files changed

+97
-38
lines changed

6 files changed

+97
-38
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type": "Feature",
3+
"description": "SAM: run/debug now works for a larger variety of TypeScript projects"
4+
}

src/shared/sam/activation.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ async function activateCodeLensProviders(
187187
supportedLanguages[javaLensProvider.javaLanguage] = javaCodeLensProvider
188188
supportedLanguages[csLensProvider.csharpLanguage] = csCodeLensProvider
189189
supportedLanguages[goLensProvider.goLanguage] = goCodeLensProvider
190+
supportedLanguages[jsLensProvider.typescriptLanguage] = tsCodeLensProvider
190191
}
191192

192193
disposables.push(

src/shared/sam/debugger/awsSamDebugger.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ import {
4949
AwsSamDebugConfigurationValidator,
5050
DefaultAwsSamDebugConfigurationValidator,
5151
} from './awsSamDebugConfigurationValidator'
52-
import { makeInputTemplate, makeJsonFiles } from '../localLambdaRunner'
52+
import { getInputTemplatePath, makeInputTemplate, makeJsonFiles } from '../localLambdaRunner'
5353
import { SamLocalInvokeCommand } from '../cli/samCliLocalInvoke'
5454
import { getCredentialsFromStore } from '../../../credentials/credentialsStore'
5555
import { fromString } from '../../../credentials/providers/credentials'
@@ -464,7 +464,7 @@ export class SamDebugConfigProvider implements vscode.DebugConfigurationProvider
464464
codeConfig.invokeTarget.projectRoot = pathutil.normalize(
465465
resolve(folder.uri.fsPath, config.invokeTarget.projectRoot)
466466
)
467-
templateInvoke.templatePath = await makeInputTemplate(codeConfig)
467+
templateInvoke.templatePath = getInputTemplatePath(codeConfig)
468468
}
469469

470470
const isZip = CloudFormation.isZipLambdaResource(templateResource?.Properties)
@@ -598,6 +598,9 @@ export class SamDebugConfigProvider implements vscode.DebugConfigurationProvider
598598
envFile: '', // Populated by makeConfig().
599599
apiPort: apiPort,
600600
debugPort: debugPort,
601+
invokeTarget: {
602+
...config.invokeTarget,
603+
},
601604
lambda: {
602605
...config.lambda,
603606
memoryMb: lambdaMemory,
@@ -652,6 +655,13 @@ export class SamDebugConfigProvider implements vscode.DebugConfigurationProvider
652655
throw new ToolkitError(message, { code: 'UnsupportedRuntime' })
653656
}
654657
}
658+
659+
// generate template for target=code
660+
if (launchConfig.invokeTarget.target === 'code') {
661+
const codeConfig = launchConfig as SamLaunchRequestArgs & { invokeTarget: { target: 'code' } }
662+
await makeInputTemplate(codeConfig)
663+
}
664+
655665
await makeJsonFiles(launchConfig)
656666

657667
// Set the type, then vscode will pass the config to SamDebugSession.attachRequest().

src/shared/sam/debugger/typescriptSamDebug.ts

Lines changed: 68 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6+
import { existsSync, PathLike, readFileSync } from 'fs'
67
import { writeFileSync } from 'fs-extra'
78
import * as path from 'path'
89
import * as vscode from 'vscode'
@@ -18,6 +19,11 @@ import { DefaultSamLocalInvokeCommand, waitForDebuggerMessages } from '../cli/sa
1819
import { runLambdaFunction, waitForPort } from '../localLambdaRunner'
1920
import { SamLaunchRequestArgs } from './awsSamDebugger'
2021

22+
const tsConfigFile = 'aws-toolkit-tsconfig.json'
23+
24+
// use project tsconfig.json as initial base - if unable to parse existing config
25+
const tsConfigInitialBaseFile = 'tsconfig.json'
26+
2127
/**
2228
* Launches and attaches debugger to a SAM Node project.
2329
*/
@@ -29,8 +35,7 @@ export async function invokeTypescriptLambda(
2935
// eslint-disable-next-line @typescript-eslint/unbound-method
3036
config.onWillAttachDebugger = waitForPort
3137

32-
const onAfterBuild = () => compileTypeScript(config)
33-
const c = (await runLambdaFunction(ctx, config, onAfterBuild)) as NodejsDebugConfiguration
38+
const c = (await runLambdaFunction(ctx, config, async () => {})) as NodejsDebugConfiguration
3439
return c
3540
}
3641

@@ -68,6 +73,9 @@ export async function makeTypescriptConfig(config: SamLaunchRequestArgs): Promis
6873
let remoteRoot: string | undefined
6974
config.codeRoot = pathutil.normalize(config.codeRoot)
7075

76+
// compile typescript code and convert lambda handler if necessary
77+
await compileTypeScript(config)
78+
7179
const isImageLambda = isImageLambdaConfig(config)
7280

7381
if (isImageLambda && !config.noDebug) {
@@ -113,9 +121,9 @@ export async function makeTypescriptConfig(config: SamLaunchRequestArgs): Promis
113121
* Compiles non-template (target=code) debug configs, using a temporary default
114122
* tsconfig.json file.
115123
*
116-
* Assumes that `sam build` was already performed.
124+
* Assumes that `sam build` was not already performed.
117125
*/
118-
async function compileTypeScript(config: NodejsDebugConfiguration): Promise<void> {
126+
async function compileTypeScript(config: SamLaunchRequestArgs): Promise<void> {
119127
if (!config.baseBuildDir) {
120128
throw Error('invalid state: config.baseBuildDir was not set')
121129
}
@@ -134,46 +142,76 @@ async function compileTypeScript(config: NodejsDebugConfiguration): Promise<void
134142

135143
// Require tsconfig.json or *.ts in the top-level of the source app, to
136144
// indicate a typescript Lambda. #2086
137-
// Note: we don't use this tsconfig.json for compiling the target=code
138-
// Lambda app below, instead we generate a minimal one.
145+
// Note: we use this tsconfig.json as a base for compiling the target=code
146+
// Lambda app below. If it does not exist, we generate a minimal one.
139147
const isTsApp = (await findTsOrTsConfig(config.codeRoot, false)) !== undefined
140148
if (!isTsApp) {
141149
return
142150
}
143151

144-
const buildOutputDir = path.join(config.baseBuildDir, 'output')
145-
const buildDirTsFile = await findTsOrTsConfig(buildOutputDir, true)
146-
if (!buildDirTsFile) {
147-
// Should never happen: `sam build` should have copied the tsconfig.json from the source app dir.
148-
throw new Error(`tsconfig.json or *.ts not found in: "${buildOutputDir}/*"`)
149-
}
150-
// XXX: `sam` may rename the CodeUri (and thus "output/<app>/" dir) if the
151-
// original "<app>/" dir contains special chars, so get it this way. #2086
152-
const buildDirApp = path.dirname(buildDirTsFile)
153-
const buildDirTsConfig = path.join(buildDirApp, 'tsconfig.json')
152+
const loadBaseConfig = (tsConfigPath: PathLike) => {
153+
if (!existsSync(tsConfigPath)) {
154+
return undefined
155+
}
154156

155-
const tsc = await systemutil.SystemUtilities.findTypescriptCompiler()
156-
if (!tsc) {
157-
throw new Error('TypeScript compiler "tsc" not found in node_modules/ or the system.')
157+
try {
158+
const tsConfig = JSON.parse(readFileSync(tsConfigPath).toString())
159+
getLogger('channel').info(`Using base TypeScript config: ${tsConfigPath}`)
160+
return tsConfig
161+
} catch (err) {
162+
getLogger('channel').error(`Unable to use TypeScript base: ${tsConfigPath}`)
163+
}
164+
165+
return undefined
158166
}
159167

160-
// Default config.
161-
// Adapted from: https://github.com/aws/aws-toolkit-jetbrains/blob/911c54252d6a4271ee6cacf0ea1023506c4b504a/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsLambdaBuilder.kt#L60
162-
const defaultTsconfig = {
163-
compilerOptions: {
168+
const tsConfigPath = path.join(config.codeRoot, tsConfigFile)
169+
170+
const tsConfig =
171+
loadBaseConfig(tsConfigPath) ?? loadBaseConfig(path.join(config.codeRoot, tsConfigInitialBaseFile)) ?? {}
172+
173+
if (tsConfig.compilerOptions === undefined) {
174+
getLogger('channel').info('Creating TypeScript config')
175+
tsConfig.compilerOptions = {
164176
target: 'es6',
165177
module: 'commonjs',
166-
typeRoots: ['node_modules/@types'],
167-
types: ['node'],
168-
rootDir: '.',
169178
inlineSourceMap: true,
170-
},
179+
}
171180
}
181+
182+
const compilerOptions = tsConfig.compilerOptions
183+
184+
// determine build directory
185+
const tsBuildDir = path.resolve(config.codeRoot, config.sam?.buildDir ?? compilerOptions.outDir ?? '.')
186+
compilerOptions.outDir = tsBuildDir
187+
188+
// overwrite rootDir, sourceRoot
189+
compilerOptions.rootDir = '.'
190+
compilerOptions.sourceRoot = config.codeRoot
191+
192+
const typeRoots: string[] = Array.isArray(compilerOptions.typeRoots) ? compilerOptions.typeRoots : []
193+
typeRoots.push('node_modules/@types')
194+
compilerOptions.typeRoots = [...new Set(typeRoots)]
195+
196+
const types: string[] = Array.isArray(compilerOptions.types) ? compilerOptions.types : []
197+
types.push('node')
198+
compilerOptions.types = [...new Set(types)]
199+
200+
writeFileSync(tsConfigPath, JSON.stringify(tsConfig, undefined, 4))
201+
202+
// resolve ts lambda handler to point into build directory relative to codeRoot
203+
const tsLambdaHandler = path.relative(config.codeRoot, path.join(tsBuildDir, config.invokeTarget.lambdaHandler))
204+
config.invokeTarget.lambdaHandler = tsLambdaHandler
205+
getLogger('channel').info(`Resolved compiled lambda handler to ${tsLambdaHandler}`)
206+
207+
const tsc = await systemutil.SystemUtilities.findTypescriptCompiler()
208+
if (!tsc) {
209+
throw new Error('TypeScript compiler "tsc" not found in node_modules/ or the system.')
210+
}
211+
172212
try {
173-
// Overwrite the tsconfig.json copied by `sam build`.
174-
writeFileSync(buildDirTsConfig, JSON.stringify(defaultTsconfig, undefined, 4))
175213
getLogger('channel').info(`Compiling TypeScript app with: "${tsc}"`)
176-
await new ChildProcess(tsc, ['--project', buildDirApp]).run()
214+
await new ChildProcess(tsc, ['--project', tsConfigPath]).run()
177215
} catch (error) {
178216
getLogger('channel').error(`TypeScript compile error: ${error}`)
179217
throw Error('Failed to compile TypeScript app')

src/shared/sam/localLambdaRunner.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,17 @@ function makeResourceName(config: SamLaunchRequestArgs): string {
7676
const samLocalPortCheckRetryIntervalMillis: number = 125
7777
const attachDebuggerRetryDelayMillis: number = 1000
7878

79+
export function getInputTemplatePath(config: SamLaunchRequestArgs & { invokeTarget: { target: 'code' } }): string {
80+
const inputTemplatePath: string = path.join(config.baseBuildDir!, 'app___vsctk___template.yaml')
81+
82+
return pathutil.normalize(inputTemplatePath)
83+
}
84+
7985
export async function makeInputTemplate(
8086
config: SamLaunchRequestArgs & { invokeTarget: { target: 'code' } }
81-
): Promise<string> {
87+
): Promise<void> {
8288
let newTemplate: SamTemplateGenerator
83-
const inputTemplatePath: string = path.join(config.baseBuildDir!, 'app___vsctk___template.yaml')
89+
const inputTemplatePath: string = config.templatePath
8490
const resourceName = makeResourceName(config)
8591

8692
// code type - generate ephemeral SAM template
@@ -90,7 +96,7 @@ export async function makeInputTemplate(
9096
.withRuntime(config.lambda!.runtime!)
9197
.withCodeUri(pathutil.normalize(config.invokeTarget.projectRoot))
9298

93-
if (config.lambda?.environmentVariables) {
99+
if (config.lambda?.environmentVariables && Object.entries(config.lambda?.environmentVariables).length) {
94100
newTemplate = newTemplate.withEnvironment({
95101
Variables: config.lambda?.environmentVariables,
96102
})
@@ -108,8 +114,6 @@ export async function makeInputTemplate(
108114
}
109115

110116
await newTemplate.generate(inputTemplatePath)
111-
112-
return pathutil.normalize(inputTemplatePath)
113117
}
114118

115119
async function buildLambdaHandler(

src/test/shared/sam/debugger/samDebugConfigProvider.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -718,7 +718,9 @@ describe('SamDebugConfigurationProvider', async function () {
718718
debugPort: actual.debugPort,
719719
documentUri: vscode.Uri.file(''), // TODO: remove or test.
720720
handlerName: 'app.handler',
721-
invokeTarget: { ...input.invokeTarget },
721+
invokeTarget: {
722+
...input.invokeTarget,
723+
},
722724
lambda: {
723725
...input.lambda,
724726
},

0 commit comments

Comments
 (0)