Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Console Dev Mode onboarding via
dev
command (#11896)
- Loading branch information
Showing
19 changed files
with
1,390 additions
and
40 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
<!-- | ||
title: Serverless Framework - Console Dev Mode | ||
menuText: Serverless Console Dev Mode | ||
menuOrder: 12 | ||
description: Launch a Serverless Console dev mode session in the terminal | ||
layout: Doc | ||
--> | ||
|
||
<!-- DOCS-SITE-LINK:START automatically generated --> | ||
|
||
### [Read this on the main serverless docs site](https://www.serverless.com/framework/docs/guides/dev/) | ||
|
||
<!-- DOCS-SITE-LINK:END --> | ||
|
||
# Serverless Console Dev Mode | ||
|
||
The `serverless dev` command will launch a [Serverless Console Dev Mode](https://www.serverless.com/console/docs/application-guide/dev-mode) session in your terminal. | ||
|
||
```bash | ||
serverless dev | ||
``` | ||
|
||
## Options | ||
|
||
- `--org` The organization that your AWS account is associated with in Serverless Console. | ||
- `--region` or `-r` The region in that your function was deployed to. | ||
- `--stage` or `-s` The stage in your service was deploy to. | ||
- `--function` or `-f` The name of the function that you want to focus your dev mode activity on. If this option is excluded then all function activity will be streamed to your terminal. | ||
- `--verbose` or `-v` If this flag is included all span input/output and lambda request/response data will be streamed to the terminal. | ||
|
||
## Examples | ||
|
||
### Start dev mode interactively selecting an organization | ||
|
||
```bash | ||
serverless dev | ||
``` | ||
|
||
### Start dev mode with an org pre selected | ||
|
||
```bash | ||
serverless dev --org myorg | ||
``` | ||
|
||
### Start dev mode with an org pre selected and all input output information logged | ||
|
||
```bash | ||
serverless deploy function --function helloWorld --update-config | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,294 @@ | ||
'use strict'; | ||
|
||
const { writeText, style, log, progress } = require('@serverless/utils/log'); | ||
const { frontend } = require('@serverless/utils/lib/auth/urls'); | ||
const colorize = require('json-colorizer'); | ||
const WebSocket = require('ws'); | ||
const chalk = require('chalk'); | ||
const { devModeFeed } = require('@serverless/utils/lib/auth/urls'); | ||
const consoleUi = require('@serverless/utils/console-ui'); | ||
const streamBuffers = require('stream-buffers'); | ||
const apiRequest = require('@serverless/utils/api-request'); | ||
const promptWithHistory = require('@serverless/utils/inquirer/prompt-with-history'); | ||
|
||
const streamBuff = new streamBuffers.ReadableStreamBuffer({ | ||
frequency: 500, | ||
chunkSize: 2048 * 1000000, | ||
}); | ||
|
||
const consoleMonitoringCounter = { | ||
logBatches: 0, | ||
events: 0, | ||
responses: 0, | ||
}; | ||
|
||
const jsonError = { | ||
colors: { | ||
BRACE: '#FD5750', | ||
BRACKET: '#FD5750', | ||
COLON: '#FD5750', | ||
COMMA: '#FD5750', | ||
STRING_KEY: '#FD5750', | ||
STRING_LITERAL: '#FD5750', | ||
NUMBER_LITERAL: '#FD5750', | ||
BOOLEAN_LITERAL: '#FD5750', | ||
NULL_LITERAL: '#FD5750', | ||
}, | ||
}; | ||
|
||
const jsonColors = { | ||
colors: { | ||
BRACE: 'white', | ||
BRACKET: 'white', | ||
COLON: 'white.bold', | ||
COMMA: 'white', | ||
STRING_KEY: 'white.bold', | ||
STRING_LITERAL: 'white', | ||
NUMBER_LITERAL: 'white', | ||
BOOLEAN_LITERAL: 'white', | ||
NULL_LITERAL: 'white', | ||
}, | ||
}; | ||
const headerChalk = chalk.grey; | ||
const errorTracker = {}; | ||
|
||
const handleSocketMessage = (context) => (data) => { | ||
try { | ||
const verbose = context.options.verbose; | ||
const splitData = data.toString('utf-8').split(';;;'); | ||
const jsonArray = splitData | ||
.filter((item) => item !== '' && item.startsWith('[')) | ||
.flatMap((item) => JSON.parse(item)); | ||
const sortedItems = consoleUi.omitAndSortDevModeActivity(jsonArray); | ||
|
||
for (const activity of sortedItems) { | ||
const resourceName = ((activity.tags || {}).aws || {}).resourceName; | ||
const time = consoleUi.formatConsoleDate(new Date(activity.timestamp)); | ||
|
||
const tryPrintJSON = (str) => { | ||
try { | ||
const parsedBody = JSON.parse(str); | ||
if (typeof parsedBody === 'string') { | ||
throw new Error('Not a JSON object'); | ||
} | ||
const colors = activity.severityText === 'ERROR' ? jsonError : jsonColors; | ||
process.stdout.write(`${colorize(JSON.stringify(parsedBody, null, 2), colors)}\n`); | ||
} catch (error) { | ||
process.stdout.write(chalk.white(`${str}${str.endsWith('\n') ? '' : '\n'}`)); | ||
} | ||
}; | ||
|
||
switch (activity.type) { | ||
case 'log': | ||
consoleMonitoringCounter.logBatches += 1; | ||
process.stdout.write(headerChalk(`\n${time} • ${resourceName} • Log\n`)); | ||
tryPrintJSON(activity.body); | ||
break; | ||
case 'span': { | ||
const span = consoleUi.formatConsoleSpan(activity); | ||
process.stdout.write( | ||
headerChalk(`\n${time} • ${resourceName} • Span • ${span.niceName}\n`) | ||
); | ||
if (verbose) { | ||
if (activity.input) { | ||
process.stdout.write(headerChalk('Input\n')); | ||
tryPrintJSON(activity.input); | ||
} | ||
if (activity.output) { | ||
process.stdout.write(headerChalk('Output\n')); | ||
tryPrintJSON(activity.output); | ||
} | ||
} | ||
break; | ||
} | ||
case 'aws-lambda-request': | ||
process.stdout.write(headerChalk(`\n${time} • ${resourceName} • Invocation Started\n`)); | ||
if (verbose) { | ||
tryPrintJSON(activity.body); | ||
} | ||
break; | ||
case 'aws-lambda-response': | ||
consoleMonitoringCounter.responses += 1; | ||
process.stdout.write(headerChalk(`\n${time} • ${resourceName} • Invocation Ended\n`)); | ||
if (verbose) { | ||
tryPrintJSON(activity.body); | ||
} | ||
if (errorTracker[activity.traceId]) { | ||
const uiLink = `${frontend}/${ | ||
context.org.orgName | ||
}/explorer?explorerSubScope=invocations&explorerTraceId=${encodeURIComponent( | ||
activity.traceId | ||
)}&globalScope=awsLambda&globalTimeFrame=24h`; | ||
process.stdout.write(chalk.white(`View full trace: ${uiLink}\n`)); | ||
delete errorTracker[activity.traceId]; | ||
} | ||
break; | ||
case 'event': { | ||
consoleMonitoringCounter.events += 1; | ||
const { message, payload } = consoleUi.formatConsoleEvent(activity); | ||
const isError = /ERROR •/.test(message); | ||
const headerWriter = isError ? chalk.hex('#FD5750') : headerChalk; | ||
const options = isError ? jsonError : jsonColors; | ||
process.stdout.write(headerWriter(`\n${time} • ${resourceName} • ${message}\n`)); | ||
process.stdout.write(`${colorize(JSON.stringify(payload, null, 2), options)}\n`); | ||
if (isError) { | ||
errorTracker[activity.traceId] = true; | ||
} | ||
break; | ||
} | ||
default: | ||
} | ||
} | ||
} catch (error) { | ||
process.stdout.write(error, '\n'); | ||
} | ||
}; | ||
|
||
const connectToWebSocket = async ({ functionName, region, accountId, org, state }) => { | ||
const { token } = await apiRequest(`/api/identity/orgs/${org.orgId}/token`); | ||
const ws = new WebSocket(`${devModeFeed}?Auth=${token}`, { | ||
perMessageDeflate: false, | ||
}); | ||
|
||
ws.on('open', () => { | ||
if (state && state === 'firstConnection') { | ||
const functionNameQueryParams = functionName | ||
.map((name) => `devModeFunctionName=${encodeURIComponent(name)}`) | ||
.join('&'); | ||
const uiLink = `${frontend}/${org.orgName}/dev-mode?devModeCloudAccountId=${accountId}&${functionNameQueryParams}`; | ||
writeText( | ||
style.aside( | ||
'\n• Use the `--verbose` flag to see inputs and outputs of all requests (e.g. DynamoDB inputs/outputs).', | ||
`• Use the Console Dev Mode UI for deeper inspection: ${uiLink}\n`, | ||
'Waiting for activity... Invoke your functions now.' | ||
) | ||
); | ||
} else if (state && state === 'resume') { | ||
writeText(style.aside('\nResuming for dev mode activity...')); | ||
} | ||
ws.send( | ||
JSON.stringify({ filters: { functionName, region: [region], accountId: [accountId] } }) | ||
); | ||
}); | ||
ws.on('message', (data) => { | ||
streamBuff.put(`${data.toString('utf-8')};;;`); | ||
}); | ||
return ws; | ||
}; | ||
|
||
const startDevModeFeed = async (context, devModeFeedConnection) => | ||
new Promise((resolve) => { | ||
const createStillWorkingTimeout = () => | ||
setTimeout(async () => { | ||
clearInterval(eventPublishTimer); | ||
clearInterval(connectionRefreshTimer); | ||
devModeFeedConnection.terminate(); | ||
writeText(style.aside('Pausing for dev mode activity.\n')); | ||
const shouldContinue = await promptWithHistory({ | ||
name: 'shouldContinue', | ||
message: 'Are you still working?', | ||
stepHistory: context.stepHistory, | ||
type: 'confirm', | ||
}); | ||
|
||
if (shouldContinue) { | ||
await startDevModeFeed(context, 'resume'); | ||
} | ||
resolve(); | ||
}, 1000 * 60 * 60 * 1.5); // Check for activity every 1.5 hours | ||
|
||
let stillWorkingTimer = createStillWorkingTimeout(); | ||
|
||
const connectionRefreshTimer = setInterval(async () => { | ||
const newConnection = await connectToWebSocket({ | ||
functionName: context.consoleDevModeTargetFunctions, | ||
accountId: context.awsAccountId, | ||
region: context.serverless.service.provider.region, | ||
org: context.org, | ||
state: false, | ||
}); | ||
const oldConnection = devModeFeedConnection; | ||
oldConnection.terminate(); | ||
devModeFeedConnection = newConnection; | ||
watchStream(devModeFeedConnection); | ||
}, 1000 * 60 * 60); // Refresh every hour | ||
|
||
const eventPublishTimer = setInterval(async () => { | ||
const { userId } = await apiRequest('/api/identity/me'); | ||
const body = { | ||
source: 'web.dev_mode.activity.v1', | ||
event: { | ||
orgUid: context.org.orgId, | ||
userId, | ||
logBatches: consoleMonitoringCounter.logBatches, | ||
responses: consoleMonitoringCounter.responses, | ||
events: consoleMonitoringCounter.events, | ||
source: 'cli:serverless', | ||
}, | ||
}; | ||
await apiRequest('/api/events/publish', { | ||
method: 'POST', | ||
body, | ||
}); | ||
consoleMonitoringCounter.logBatches = 0; | ||
consoleMonitoringCounter.responses = 0; | ||
consoleMonitoringCounter.events = 0; | ||
}, 1000 * 60); // Publish every 60 seconds | ||
|
||
const watchStream = (feed) => { | ||
feed.on('message', (data) => { | ||
// Ignore connection message | ||
const parsedData = JSON.parse(data.toString('utf-8')); | ||
if (!parsedData.resetThrottle) { | ||
clearTimeout(stillWorkingTimer); | ||
stillWorkingTimer = createStillWorkingTimeout(); | ||
} | ||
}); | ||
feed.on('close', () => { | ||
// Clean up if we receive a close event | ||
clearInterval(eventPublishTimer); | ||
clearInterval(connectionRefreshTimer); | ||
clearTimeout(stillWorkingTimer); | ||
resolve(); | ||
}); | ||
}; | ||
watchStream(devModeFeedConnection); | ||
}); | ||
|
||
module.exports = { | ||
async isApplicable(context) { | ||
const { isConsoleDevMode, org, consoleDevModeTargetFunctions } = context; | ||
|
||
if (!isConsoleDevMode) { | ||
context.inapplicabilityReasonCode = 'NON_DEV_MODE_CONTEXT'; | ||
return false; | ||
} | ||
|
||
if (!org) { | ||
context.inapplicabilityReasonCode = 'UNRESOLVED_ORG'; | ||
return false; | ||
} | ||
|
||
if (!consoleDevModeTargetFunctions) { | ||
context.inapplicabilityReasonCode = 'NO_TARGET_FUNCTIONS'; | ||
return false; | ||
} | ||
|
||
return true; | ||
}, | ||
|
||
async run(context) { | ||
const devModeProgress = progress.get('dev-mode-progress'); | ||
devModeProgress.remove(); | ||
log.notice.success('Dev Mode Initialized.'); | ||
streamBuff.on('data', handleSocketMessage(context)); | ||
const devModeFeedConnection = await connectToWebSocket({ | ||
functionName: context.consoleDevModeTargetFunctions, | ||
accountId: context.awsAccountId, | ||
region: context.serverless.service.provider.region, | ||
org: context.org, | ||
state: 'firstConnection', | ||
}); | ||
await startDevModeFeed(context, devModeFeedConnection); | ||
}, | ||
}; |
Oops, something went wrong.