Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Webhooks for when renderMediaOnLambda() is finished #1369

Merged
merged 27 commits into from
Oct 10, 2022
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
16e857a
initial draft for webhook support
paulkuhle Oct 4, 2022
ecd348e
fix test for timeout integration test
paulkuhle Oct 4, 2022
b7610c9
Merge branch 'main' into pr/1369
JonnyBurger Oct 5, 2022
b8b8bc3
Merge branch 'main' into pr/1369
JonnyBurger Oct 5, 2022
e7d6495
Update old-version.test.ts
JonnyBurger Oct 5, 2022
a90dd12
lower timeout, it's too fast to pass
JonnyBurger Oct 5, 2022
ffd7942
log errors if webhooks failed
JonnyBurger Oct 5, 2022
8f50dce
use http client to make request
JonnyBurger Oct 5, 2022
e179502
add a 5 second timeout
JonnyBurger Oct 5, 2022
4c1dfbf
integrate feedback and finish documentation
paulkuhle Oct 9, 2022
938a5d4
fix integration test with new x-remotion-mode header
paulkuhle Oct 9, 2022
1413d70
fix webhook example endpoint in docs
paulkuhle Oct 9, 2022
b222171
Merge branch 'main' into pr/1369
JonnyBurger Oct 10, 2022
a8f56dc
update webhook payload
JonnyBurger Oct 10, 2022
f1f2436
validateWebhookSignature() endpoint
JonnyBurger Oct 10, 2022
c0b8663
add more see also links
JonnyBurger Oct 10, 2022
9a87675
webhook shape
JonnyBurger Oct 10, 2022
4e3a915
update webhook shape
JonnyBurger Oct 10, 2022
c648dbd
CLI params
JonnyBurger Oct 10, 2022
840dd66
document CLI params
JonnyBurger Oct 10, 2022
c8e41db
fix payload not being sent
JonnyBurger Oct 10, 2022
eb81308
pass content-length when invoking webhook
JonnyBurger Oct 10, 2022
2287536
Update lambda.tsx
JonnyBurger Oct 10, 2022
25c6fe4
fix test
JonnyBurger Oct 10, 2022
5072485
fix next js types
JonnyBurger Oct 10, 2022
fcad0ca
finetuning
JonnyBurger Oct 10, 2022
748ef49
headers
JonnyBurger Oct 10, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
12 changes: 11 additions & 1 deletion packages/docs/docs/lambda/rendermediaonlambda.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ The S3 bucket name in which all files are being saved.

### `cloudWatchLogs`

_Available from v3.2.10_
_available from v3.2.10_

A link to CloudWatch (if you haven't disabled it) that you can visit to see the logs for the render.

Expand All @@ -235,6 +235,16 @@ If a custom out name is specified and a file already exists at this key in the S

An existing file at the output S3 key will conflict with the render and must be deleted beforehand. If this setting is `false` and a conflict occurs, an error will be thrown.

### `webhook`

_optional, available from v3.2.XX_

If specified, Remotion will send a POST request to the provided endpoint to notify your application when the Lambda rendering process finishes, errors out or times out.

The request headers include a cryptographic signature that you can use to [validate incoming webhooks](/docs/lambda/validate-webhooks).

DOCUMENT RESPONSE

## See also

- [Source code for this function](https://github.com/remotion-dev/remotion/blob/main/packages/lambda/src/api/render-media-on-lambda.ts)
Expand Down
25 changes: 25 additions & 0 deletions packages/docs/docs/lambda/validate-webhooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
sidebar_label: Validate Webhooks
title: Validate Webhooks
slug: /lambda/validate-webhooks
---

```javascript
import * as Crypto from "crypto";

// Express API endpoint
router.post("/my-remotion-webhook-endpoint", (req, res) => {
paulkuhle marked this conversation as resolved.
Show resolved Hide resolved
const hmac = Crypto.createHmac("sha1", REMOTION_AWS_SECRET_ACCESS_KEY);
paulkuhle marked this conversation as resolved.
Show resolved Hide resolved
const signature = `sha1=${hmac
.update(JSON.stringify(req.body))
.digest("hex")}`;

if (signature !== req.header("X-Remotion-Signature")) {
// Request wasn't sent by Remotion
// ...
} else {
// Request was sent by Remotion
// ...
}
});
```
1 change: 1 addition & 0 deletions packages/docs/sidebars.js
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ module.exports = {
"lambda/custom-layers",
"lambda/custom-destination",
"lambda/checklist",
"lambda/validate-webhooks",
{
type: "category",
label: "Troubleshooting",
Expand Down
4 changes: 4 additions & 0 deletions packages/lambda/src/api/render-media-on-lambda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export type RenderMediaOnLambdaInput = {
downloadBehavior?: DownloadBehavior | null;
muted?: boolean;
overwrite?: boolean;
webhook?: string
};

export type RenderMediaOnLambdaOutput = {
Expand All @@ -72,6 +73,7 @@ export type RenderMediaOnLambdaOutput = {
* @param params.region The AWS region in which the media should be rendered.
* @param params.maxRetries How often rendering a chunk may fail before the media render gets aborted. Default "1"
* @param params.logLevel Level of logging that Lambda function should perform. Default "info".
* @param params.webhook Webhook URL to be called upon completion or timeout of the rendering process.
* @returns {Promise<RenderMediaOnLambdaOutput>} See documentation for detailed structure
*/

Expand Down Expand Up @@ -103,6 +105,7 @@ export const renderMediaOnLambda = async ({
downloadBehavior,
muted,
overwrite,
webhook,
}: RenderMediaOnLambdaInput): Promise<RenderMediaOnLambdaOutput> => {
const actualCodec = validateLambdaCodec(codec);
validateServeUrl(serveUrl);
Expand Down Expand Up @@ -143,6 +146,7 @@ export const renderMediaOnLambda = async ({
muted: muted ?? false,
version: VERSION,
overwrite: overwrite ?? false,
webhook,
},
region,
});
Expand Down
1 change: 1 addition & 0 deletions packages/lambda/src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type LambdaCommandLineOptions = {
['architecture']: LambdaArchitecture;
['custom-role-arn']: string | undefined;
privacy: Privacy;
webhook: string | undefined;
};

export const parsedLambdaCli = minimist<LambdaCommandLineOptions>(
Expand Down
1 change: 1 addition & 0 deletions packages/lambda/src/cli/commands/render/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export const renderCommand = async (args: string[]) => {
concurrencyPerLambda: parsedLambdaCli['concurrency-per-lambda'],
muted,
overwrite,
webhook: parsedLambdaCli.webhook ?? undefined
});

const totalSteps = outName ? 5 : 4;
Expand Down
57 changes: 57 additions & 0 deletions packages/lambda/src/functions/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
rendersPrefix,
} from '../shared/constants';
import {DOCS_URL} from '../shared/docs-url';
import {invokeWebhook} from '../shared/invoke-webhook';
import {getServeUrlHash} from '../shared/make-s3-url';
import {validateFramesPerLambda} from '../shared/validate-frames-per-lambda';
import {validateOutname} from '../shared/validate-outname';
Expand Down Expand Up @@ -93,6 +94,27 @@ const innerLaunchHandler = async (params: LambdaPayload, options: Options) => {

const startedDate = Date.now();

let webhookInvoked = false;
const webhookDueToTimeout = setTimeout(async () => {
if (params.webhook && !webhookInvoked) {
try {
await invokeWebhook({
url: params.webhook,
type: 'timeout',
renderId: params.renderId,
});
webhookInvoked = true;
} catch (err) {
if (process.env.NODE_ENV === 'test') {
throw err;
}

console.log('Failed to invoke webhook:');
console.log(err);
}
}
}, Math.max(params.timeoutInMilliseconds - 1000, 1000));

const [browserInstance, optimization] = await Promise.all([
getBrowserInstance(
RenderInternals.isEqualOrBelowLogLevel(params.logLevel, 'verbose'),
Expand Down Expand Up @@ -515,6 +537,25 @@ const innerLaunchHandler = async (params: LambdaPayload, options: Options) => {
});

await Promise.all([cleanupChunksProm, fs.promises.rm(outfile)]);

clearTimeout(webhookDueToTimeout);
if (params.webhook && !webhookInvoked) {
try {
await invokeWebhook({
url: params.webhook,
type: 'success',
renderId: params.renderId,
});
webhookInvoked = true;
} catch (err) {
if (process.env.NODE_ENV === 'test') {
throw err;
}

console.log('Failed to invoke webhook:');
console.log(err);
}
}
};

export const launchHandler = async (
Expand Down Expand Up @@ -551,5 +592,21 @@ export const launchHandler = async (
expectedBucketOwner: options.expectedBucketOwner,
renderId: params.renderId,
});
if (params.webhook) {
try {
await invokeWebhook({
url: params.webhook,
type: 'error',
renderId: params.renderId,
});
} catch (error) {
if (process.env.NODE_ENV === 'test') {
throw error;
}

console.log('Failed to invoke webhook:');
console.log(error);
}
}
}
};
1 change: 1 addition & 0 deletions packages/lambda/src/functions/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export const startHandler = async (params: LambdaPayload, options: Options) => {
downloadBehavior: params.downloadBehavior,
muted: params.muted,
overwrite: params.overwrite,
webhook: params.webhook,
};
await getLambdaClient(getCurrentRegionInFunction()).send(
new InvokeCommand({
Expand Down
2 changes: 2 additions & 0 deletions packages/lambda/src/shared/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ export type LambdaPayloads = {
muted: boolean;
version: string;
overwrite: boolean;
webhook: string | undefined;
};
launch: {
type: LambdaRoutines.launch;
Expand Down Expand Up @@ -256,6 +257,7 @@ export type LambdaPayloads = {
downloadBehavior: DownloadBehavior;
muted: boolean;
overwrite: boolean;
webhook: string | undefined;
};
status: {
type: LambdaRoutines.status;
Expand Down
82 changes: 82 additions & 0 deletions packages/lambda/src/shared/invoke-webhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import * as Crypto from 'crypto';
import http from 'http';
import https from 'https';

/**
* @description Calculates cryptographically secure signature for webhooks using Hmac.
* @link https://remotion.dev/docs/lambda/validate-webhooks
* @param payload Stringified request body to encode in the signature.
*/
export function calculateSignature(payload: string) {
const secret = 'INSECURE_DEFAULT_SECRET';
const hmac = Crypto.createHmac('sha1', secret);
const signature = 'sha1=' + hmac.update(payload).digest('hex');
return signature;
}

export type InvokeWebhookInput = {
url: string;
type: 'success' | 'error' | 'timeout';
renderId: string;
};

const getWebhookClient = (url: string) => {
if (url.startsWith('https://')) {
return mockableHttpClients.https;
}

if (url.startsWith('http://')) {
return mockableHttpClients.http;
}

throw new Error('Can only request URLs starting with http:// or https://');
};

export const mockableHttpClients = {
http: http.request,
https: https.request,
};

/**
* @description Calls a webhook.
* @link https://remotion.dev/docs/lambda/rendermediaonlambda#webhook
* @param params.url URL of webhook to call.
*/
export function invokeWebhook({url, type, renderId}: InvokeWebhookInput) {
const payload = JSON.stringify({result: type, renderId});

return new Promise<void>((resolve, reject) => {
const req = getWebhookClient(url)(
url,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Remotion-Signature': calculateSignature(payload),
'X-Remotion-Status': type,
},
timeout: 5000,
JonnyBurger marked this conversation as resolved.
Show resolved Hide resolved
},
(res) => {
if (res.statusCode && res.statusCode > 299) {
reject(
new Error(
`Sent a webhook but got a status code of ${res.statusCode}`
)
);
return;
}

resolve();
}
);

req.write(payload);

req.on('error', (err) => {
reject(err);
});

req.end();
});
}
1 change: 1 addition & 0 deletions packages/lambda/src/test/integration/renders/gif.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ test('Should make a distributed GIF', async () => {
muted: false,
version: VERSION,
overwrite: true,
webhook: undefined,
},
extraContext
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ test('Should fail when using an incompatible version', async () => {
muted: false,
version: VERSION,
overwrite: true,
webhook: undefined,
},
extraContext
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ test('Should be able to render to another bucket', async () => {
muted: false,
version: VERSION,
overwrite: true,
webhook: undefined,
},
extraContext
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ test('Should add silent audio if there is no audio', async () => {
muted: false,
version: VERSION,
overwrite: true,
webhook: undefined,
},
extraContext
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ test('Should make a transparent video', async () => {
muted: false,
version: VERSION,
overwrite: true,
webhook: undefined,
},
extraContext
);
Expand Down