From 727954a77d4ba75792d4864515150d382186d780 Mon Sep 17 00:00:00 2001 From: Maxime David Date: Wed, 30 Sep 2026 15:46:21 +0000 Subject: [PATCH 1/8] feat(context): add w3c() helper for W3C trace fields Exposes W3C trace-context fields (traceparent, tracestate, baggage) carried on clientContext.w3c via a new context.w3c() helper. Fields are allowlisted; the source clientContext.w3c is removed after construction so callers only see the helper. --- src/context/constants.ts | 8 + src/context/context-builder.test.ts | 269 ++++++++++++++++++++++++++++ src/context/context-builder.ts | 54 +++++- src/context/types.ts | 6 + 4 files changed, 333 insertions(+), 4 deletions(-) diff --git a/src/context/constants.ts b/src/context/constants.ts index ff315b4..a79833f 100644 --- a/src/context/constants.ts +++ b/src/context/constants.ts @@ -24,6 +24,14 @@ export const REQUIRED_ENV_VARS = [ "AWS_LAMBDA_LOG_STREAM_NAME", ]; +export const W3C_ALLOWED_FIELDS = [ + "traceparent", + "tracestate", + "baggage", +] as const; + +export type W3CFieldName = (typeof W3C_ALLOWED_FIELDS)[number]; + // This RIC is used by Nodejs24 and above, it's used by NOdejs22 only for LMI and not OD export const CALLBACK_ERROR_NODEJS22 = "ERROR: AWS Lambda does not support callback-based function handlers when using Node.js 22 with Managed Instances. To use Managed Instances, modify this function to use a supported handler signature. For more information see https://docs.aws.amazon.com/lambda/latest/dg/nodejs-handler.html."; diff --git a/src/context/context-builder.test.ts b/src/context/context-builder.test.ts index c6d8833..71840f3 100644 --- a/src/context/context-builder.test.ts +++ b/src/context/context-builder.test.ts @@ -60,6 +60,7 @@ describe("ContextBuilder", () => { // Methods getRemainingTimeInMillis: expect.any(Function), + w3c: expect.any(Function), }); }); @@ -183,6 +184,274 @@ describe("ContextBuilder", () => { }); }); + describe("w3c", () => { + it("should return {} when no clientContext header is provided", () => { + // GIVEN + const headersWithoutClientContext: Record = { + ...mockValidHeaders, + }; + delete headersWithoutClientContext[HEADERS.CLIENT_CONTEXT]; + + // WHEN + const context = ContextBuilder.build(headersWithoutClientContext); + + // THEN + expect(context.w3c()).toEqual({}); + }); + + it("should return {} when clientContext has no w3c key", () => { + // GIVEN + const headers = { + ...mockValidHeaders, + [HEADERS.CLIENT_CONTEXT]: JSON.stringify({ custom: { value: "test" } }), + }; + + // WHEN + const context = ContextBuilder.build(headers); + + // THEN + expect(context.w3c()).toEqual({}); + // clientContext is untouched when there was nothing to strip + expect(context.clientContext).toEqual({ custom: { value: "test" } }); + }); + + it("should return {baggage:'abc'} when only baggage is set", () => { + // GIVEN + const headers = { + ...mockValidHeaders, + [HEADERS.CLIENT_CONTEXT]: JSON.stringify({ + w3c: { baggage: "abc" }, + }), + }; + + // WHEN + const context = ContextBuilder.build(headers); + + // THEN + expect(context.w3c()).toEqual({ baggage: "abc" }); + }); + + it("should return every w3c field carried on clientContext", () => { + // GIVEN + const headers = { + ...mockValidHeaders, + [HEADERS.CLIENT_CONTEXT]: JSON.stringify({ + custom: { value: "test" }, + w3c: { + traceparent: + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + tracestate: "rojo=00f067aa0ba902b7", + baggage: "userId=alice", + }, + }), + }; + + // WHEN + const context = ContextBuilder.build(headers); + + // THEN + expect(context.w3c()).toEqual({ + traceparent: + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + tracestate: "rojo=00f067aa0ba902b7", + baggage: "userId=alice", + }); + }); + + it("should remove the source clientContext.w3c (and nested fields) after construction", () => { + // GIVEN + const headers = { + ...mockValidHeaders, + [HEADERS.CLIENT_CONTEXT]: JSON.stringify({ + custom: { value: "test" }, + w3c: { + traceparent: + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + baggage: "userId=alice", + }, + }), + }; + + // WHEN + const context = ContextBuilder.build(headers); + + // THEN + expect(context.clientContext).toBeDefined(); + expect(context.clientContext).not.toHaveProperty("w3c"); + expect( + (context.clientContext as Record)["w3c"], + ).toBeUndefined(); + // Sibling clientContext fields are preserved + expect(context.clientContext).toEqual({ custom: { value: "test" } }); + }); + + it("should ignore non-string w3c field values while still stripping the source", () => { + // GIVEN + const headers = { + ...mockValidHeaders, + [HEADERS.CLIENT_CONTEXT]: JSON.stringify({ + w3c: { + baggage: "abc", + traceparent: 42, // wrong type — must be dropped + tracestate: null, // wrong type — must be dropped + }, + }), + }; + + // WHEN + const context = ContextBuilder.build(headers); + + // THEN + expect(context.w3c()).toEqual({ baggage: "abc" }); + expect(context.clientContext).not.toHaveProperty("w3c"); + }); + + it("should treat a non-object w3c value as empty and still strip the source", () => { + // GIVEN + const headers = { + ...mockValidHeaders, + [HEADERS.CLIENT_CONTEXT]: JSON.stringify({ + w3c: "not-an-object", + }), + }; + + // WHEN + const context = ContextBuilder.build(headers); + + // THEN + expect(context.w3c()).toEqual({}); + expect(context.clientContext).not.toHaveProperty("w3c"); + }); + + it("should treat an array w3c value as empty and still strip the source", () => { + // GIVEN + const headers = { + ...mockValidHeaders, + [HEADERS.CLIENT_CONTEXT]: JSON.stringify({ + w3c: ["baggage=abc"], + }), + }; + + // WHEN + const context = ContextBuilder.build(headers); + + // THEN + expect(context.w3c()).toEqual({}); + expect(context.clientContext).not.toHaveProperty("w3c"); + }); + + it("should return a fresh copy so callers cannot mutate the underlying map", () => { + // GIVEN + const headers = { + ...mockValidHeaders, + [HEADERS.CLIENT_CONTEXT]: JSON.stringify({ + w3c: { baggage: "abc" }, + }), + }; + + // WHEN + const context = ContextBuilder.build(headers); + const first = context.w3c(); + first["baggage"] = "tampered"; + first["injected"] = "nope"; + + // THEN + expect(context.w3c()).toEqual({ baggage: "abc" }); + }); + + it("should only surface the allowlisted fields (traceparent, tracestate, baggage)", () => { + // GIVEN — every allowlisted field set, plus a non-allowlisted one + const headers = { + ...mockValidHeaders, + [HEADERS.CLIENT_CONTEXT]: JSON.stringify({ + w3c: { + traceparent: + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + tracestate: "rojo=00f067aa0ba902b7", + baggage: "userId=alice", + }, + }), + }; + + // WHEN + const context = ContextBuilder.build(headers); + + // THEN + expect(context.w3c()).toEqual({ + traceparent: + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + tracestate: "rojo=00f067aa0ba902b7", + baggage: "userId=alice", + }); + }); + + it("should drop non-allowlisted w3c keys even when the value is a valid string", () => { + // GIVEN + const headers = { + ...mockValidHeaders, + [HEADERS.CLIENT_CONTEXT]: JSON.stringify({ + w3c: { + baggage: "keep=me", + // Non-allowlisted keys — must NOT be surfaced by w3c() + unknownField: "should-not-appear", + "x-custom-trace": "should-not-appear", + __proto__: "should-not-appear", + constructor: "should-not-appear", + toString: "should-not-appear", + }, + }), + }; + + // WHEN + const context = ContextBuilder.build(headers); + + // THEN + expect(context.w3c()).toEqual({ baggage: "keep=me" }); + // Source is still stripped regardless + expect(context.clientContext).not.toHaveProperty("w3c"); + }); + + it("should omit allowlisted keys when they are absent (no undefined leaks)", () => { + // GIVEN — only baggage present + const headers = { + ...mockValidHeaders, + [HEADERS.CLIENT_CONTEXT]: JSON.stringify({ + w3c: { baggage: "abc" }, + }), + }; + + // WHEN + const context = ContextBuilder.build(headers); + + // THEN + const result = context.w3c(); + expect(result).toEqual({ baggage: "abc" }); + expect("traceparent" in result).toBe(false); + expect("tracestate" in result).toBe(false); + }); + + it("should drop allowlisted keys whose value is not a string", () => { + // GIVEN — every allowlisted key present, but with wrong types + const headers = { + ...mockValidHeaders, + [HEADERS.CLIENT_CONTEXT]: JSON.stringify({ + w3c: { + traceparent: 42, + tracestate: null, + baggage: { nested: "no" }, + }, + }), + }; + + // WHEN + const context = ContextBuilder.build(headers); + + // THEN + expect(context.w3c()).toEqual({}); + expect(context.clientContext).not.toHaveProperty("w3c"); + }); + }); + describe("getRemainingTimeInMillis", () => { it("should calculate remaining time correctly", () => { // GIVEN diff --git a/src/context/context-builder.ts b/src/context/context-builder.ts index 1a6a40e..18aa497 100644 --- a/src/context/context-builder.ts +++ b/src/context/context-builder.ts @@ -3,6 +3,7 @@ import { OPTIONAL_INVOKE_HEADERS, REQUIRED_ENV_VARS, REQUIRED_INVOKE_HEADERS, + W3C_ALLOWED_FIELDS, } from "./constants.js"; import { InvokeContext, InvokeHeaders } from "./types.js"; @@ -32,11 +33,15 @@ export class ContextBuilder { private static getHeaderData(invokeHeaders: InvokeHeaders) { const deadline = this.parseDeadline(invokeHeaders); + const clientContext = this.parseJsonHeader>( + invokeHeaders[OPTIONAL_INVOKE_HEADERS.CLIENT_CONTEXT], + OPTIONAL_INVOKE_HEADERS.CLIENT_CONTEXT, + ); + + const w3cFields = this.extractAndStripW3c(clientContext); + return { - clientContext: this.parseJsonHeader>( - invokeHeaders[OPTIONAL_INVOKE_HEADERS.CLIENT_CONTEXT], - OPTIONAL_INVOKE_HEADERS.CLIENT_CONTEXT, - ), + clientContext, identity: this.parseJsonHeader>( invokeHeaders[OPTIONAL_INVOKE_HEADERS.COGNITO_IDENTITY], OPTIONAL_INVOKE_HEADERS.COGNITO_IDENTITY, @@ -48,9 +53,50 @@ export class ContextBuilder { getRemainingTimeInMillis: function () { return deadline - Date.now(); }, + w3c: function (): Record { + return { ...w3cFields }; + }, }; } + /** + * Pulls `w3c` out of the parsed `clientContext` and returns a normalized + * copy of the allowlisted string fields (see `W3C_ALLOWED_FIELDS`). The + * `w3c` key is removed from `clientContext` itself so callers cannot read + * the source through `context.clientContext`. + */ + private static extractAndStripW3c( + clientContext: Record | undefined, + ): Record { + if (!clientContext || typeof clientContext !== "object") { + return {}; + } + if (!("w3c" in clientContext)) { + return {}; + } + + const rawW3c = clientContext.w3c; + delete clientContext.w3c; + + if ( + !rawW3c || + typeof rawW3c !== "object" || + Array.isArray(rawW3c) + ) { + return {}; + } + + const source = rawW3c as Record; + const fields: Record = {}; + for (const key of W3C_ALLOWED_FIELDS) { + const value = source[key]; + if (typeof value === "string") { + fields[key] = value; + } + } + return fields; + } + private static parseDeadline(invokeHeaders: InvokeHeaders) { const deadline = parseInt( invokeHeaders[REQUIRED_INVOKE_HEADERS.DEADLINE_MS], diff --git a/src/context/types.ts b/src/context/types.ts index d57986a..723ddee 100644 --- a/src/context/types.ts +++ b/src/context/types.ts @@ -35,6 +35,12 @@ export interface InvokeContext { // Methods getRemainingTimeInMillis(): number; + + /** + * Returns the W3C trace context fields (traceparent, tracestate, baggage) + * that were carried on `clientContext.w3c` at invoke time. + */ + w3c(): Record; } export interface StreamOptions { From d6a87309ca19166830abbf440d650085d37fe491 Mon Sep 17 00:00:00 2001 From: Maxime David Date: Wed, 30 Sep 2026 16:01:57 +0000 Subject: [PATCH 2/8] feat: add harness testing --- .github/workflows/dockerized-test.yml | 49 +++++++++ Dockerfile.test | 11 ++ test/dockerized/suites/ctx.json | 30 ++++++ test/dockerized/suites/w3c.json | 149 ++++++++++++++++++++++++++ test/dockerized/tasks/w3c.mjs | 26 +++++ 5 files changed, 265 insertions(+) create mode 100644 .github/workflows/dockerized-test.yml create mode 100644 Dockerfile.test create mode 100644 test/dockerized/suites/ctx.json create mode 100644 test/dockerized/suites/w3c.json create mode 100644 test/dockerized/tasks/w3c.mjs diff --git a/.github/workflows/dockerized-test.yml b/.github/workflows/dockerized-test.yml new file mode 100644 index 0000000..22803cb --- /dev/null +++ b/.github/workflows/dockerized-test.yml @@ -0,0 +1,49 @@ +name: dockerized-test + +permissions: + contents: read + +on: + push: + branches: [nodejs24.x] + pull_request: + branches: ['*'] + workflow_dispatch: + +jobs: + dockerized-test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node_version: ['22', '24'] + steps: + - uses: actions/checkout@v5 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node_version }} + cache: npm + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Build the RIC tarball + # Produces build-artifacts/aws-lambda-ric-.tgz, which + # Dockerfile.test unpacks into /var/runtime in the test image. + run: npm run build:container + + - name: Build the test image + run: | + docker build . \ + -t local/test \ + -f Dockerfile.test \ + --build-arg BASE_IMAGE=public.ecr.aws/lambda/nodejs:${{ matrix.node_version }} + + - name: Run dockerized suites + uses: aws/containerized-test-runner-for-aws-lambda@511d270614f2c6b1613848db6dcf920a591c3c89 # main + with: + suiteFileArray: '["./test/dockerized/suites/*.json"]' + dockerImageName: 'local/test' + taskFolder: './test/dockerized/tasks' diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..5a83234 --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,11 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +ARG BASE_IMAGE=public.ecr.aws/lambda/nodejs:24 +FROM $BASE_IMAGE + +# Swap the RIC shipped in the base image for the one we just built. +ADD build-artifacts/aws-lambda-ric-*.tgz /tmp/ +RUN mv /tmp/package/* /var/runtime/ && rm -rf /tmp/package + +COPY test/dockerized/tasks /var/task/ diff --git a/test/dockerized/suites/ctx.json b/test/dockerized/suites/ctx.json new file mode 100644 index 0000000..564cb05 --- /dev/null +++ b/test/dockerized/suites/ctx.json @@ -0,0 +1,30 @@ +{ + "tests": [ + { + "name": "client_context_is_echoed_when_no_w3c_key", + "handler": "w3c.echoClientContext", + "request": {}, + "clientContext": { + "custom": { "value": "hello" }, + "environment": { "stage": "beta" } + }, + "assertions": [ + { + "response": { + "custom": { "value": "hello" }, + "environment": { "stage": "beta" } + } + } + ] + }, + + { + "name": "client_context_is_null_when_header_absent", + "handler": "w3c.echoClientContext", + "request": {}, + "assertions": [ + { "response": null } + ] + } + ] +} diff --git a/test/dockerized/suites/w3c.json b/test/dockerized/suites/w3c.json new file mode 100644 index 0000000..d0607bc --- /dev/null +++ b/test/dockerized/suites/w3c.json @@ -0,0 +1,149 @@ +{ + "tests": [ + { + "name": "w3c_is_a_function_on_context", + "handler": "w3c.w3cIsFunction", + "request": {}, + "assertions": [ + { "response": { "isFunction": true } } + ] + }, + + { + "name": "w3c_returns_empty_when_no_client_context_header", + "handler": "w3c.getW3c", + "request": {}, + "assertions": [ + { "response": {} } + ] + }, + + { + "name": "w3c_returns_empty_when_client_context_has_no_w3c_key", + "handler": "w3c.getW3c", + "request": {}, + "clientContext": { + "custom": { "value": "test" } + }, + "assertions": [ + { "response": {} } + ] + }, + + { + "name": "w3c_returns_baggage_only", + "handler": "w3c.getW3c", + "request": {}, + "clientContext": { + "w3c": { "baggage": "userId=alice" } + }, + "assertions": [ + { "response": { "baggage": "userId=alice" } } + ] + }, + + { + "name": "w3c_returns_all_three_allowlisted_fields", + "handler": "w3c.getW3c", + "request": {}, + "clientContext": { + "w3c": { + "traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + "tracestate": "rojo=00f067aa0ba902b7", + "baggage": "userId=alice" + } + }, + "assertions": [ + { + "response": { + "traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + "tracestate": "rojo=00f067aa0ba902b7", + "baggage": "userId=alice" + } + } + ] + }, + + { + "name": "w3c_allowlist_drops_non_allowlisted_keys", + "handler": "w3c.getW3c", + "request": {}, + "clientContext": { + "w3c": { + "baggage": "keep=me", + "unknownField": "should-not-appear", + "x-custom-trace": "should-not-appear" + } + }, + "assertions": [ + { "response": { "baggage": "keep=me" } } + ] + }, + + { + "name": "w3c_drops_allowlisted_fields_with_non_string_values", + "handler": "w3c.getW3c", + "request": {}, + "clientContext": { + "w3c": { + "traceparent": 42, + "tracestate": null, + "baggage": { "nested": "no" } + } + }, + "assertions": [ + { "response": {} } + ] + }, + + { + "name": "w3c_treats_non_object_as_empty", + "handler": "w3c.getW3c", + "request": {}, + "clientContext": { + "w3c": "not-an-object" + }, + "assertions": [ + { "response": {} } + ] + }, + + { + "name": "w3c_treats_array_as_empty", + "handler": "w3c.getW3c", + "request": {}, + "clientContext": { + "w3c": ["baggage=abc"] + }, + "assertions": [ + { "response": {} } + ] + }, + + { + "name": "w3c_strips_source_clientContext_w3c_after_construction", + "handler": "w3c.getW3cAndSource", + "request": {}, + "clientContext": { + "custom": { "value": "test" }, + "w3c": { + "traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + "baggage": "userId=alice" + } + }, + "assertions": [ + { + "response": { + "w3c": { + "traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + "baggage": "userId=alice" + }, + "clientContextIsDefined": true, + "clientContextHasW3c": false, + "clientContext": { "custom": { "value": "test" } } + } + } + ] + } + ] +} diff --git a/test/dockerized/tasks/w3c.mjs b/test/dockerized/tasks/w3c.mjs new file mode 100644 index 0000000..aab3150 --- /dev/null +++ b/test/dockerized/tasks/w3c.mjs @@ -0,0 +1,26 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +// + +export const getW3c = async (_event, context) => { + return context.w3c(); +}; + +export const getW3cAndSource = async (_event, context) => { + const clientContext = context.clientContext; + return { + w3c: context.w3c(), + clientContextIsDefined: clientContext !== undefined, + clientContextHasW3c: + clientContext !== undefined && "w3c" in clientContext, + clientContext: clientContext ?? null, + }; +}; + +export const echoClientContext = async (_event, context) => { + return context.clientContext ?? null; +}; + +export const w3cIsFunction = async (_event, context) => { + return { isFunction: typeof context.w3c === "function" }; +}; \ No newline at end of file From 65b4831e0b28fae44e79dc4f83c997063596d14c Mon Sep 17 00:00:00 2001 From: Maxime David Date: Thu, 1 Oct 2026 09:18:58 +0000 Subject: [PATCH 3/8] fix: linter --- .github/workflows/dockerized-test.yml | 8 ++------ src/context/context-builder.test.ts | 6 ++---- src/context/context-builder.ts | 6 +----- src/context/types.ts | 2 +- src/global.d.ts | 1 - test/dockerized/tasks/w3c.mjs | 5 ++--- 6 files changed, 8 insertions(+), 20 deletions(-) diff --git a/.github/workflows/dockerized-test.yml b/.github/workflows/dockerized-test.yml index 22803cb..8fa31e9 100644 --- a/.github/workflows/dockerized-test.yml +++ b/.github/workflows/dockerized-test.yml @@ -13,17 +13,13 @@ on: jobs: dockerized-test: runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - node_version: ['22', '24'] steps: - uses: actions/checkout@v5 - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: ${{ matrix.node_version }} + node-version: '24' cache: npm - name: Install dependencies @@ -39,7 +35,7 @@ jobs: docker build . \ -t local/test \ -f Dockerfile.test \ - --build-arg BASE_IMAGE=public.ecr.aws/lambda/nodejs:${{ matrix.node_version }} + --build-arg BASE_IMAGE=public.ecr.aws/lambda/nodejs:24 - name: Run dockerized suites uses: aws/containerized-test-runner-for-aws-lambda@511d270614f2c6b1613848db6dcf920a591c3c89 # main diff --git a/src/context/context-builder.test.ts b/src/context/context-builder.test.ts index 71840f3..659484c 100644 --- a/src/context/context-builder.test.ts +++ b/src/context/context-builder.test.ts @@ -251,8 +251,7 @@ describe("ContextBuilder", () => { // THEN expect(context.w3c()).toEqual({ - traceparent: - "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", tracestate: "rojo=00f067aa0ba902b7", baggage: "userId=alice", }); @@ -378,8 +377,7 @@ describe("ContextBuilder", () => { // THEN expect(context.w3c()).toEqual({ - traceparent: - "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", tracestate: "rojo=00f067aa0ba902b7", baggage: "userId=alice", }); diff --git a/src/context/context-builder.ts b/src/context/context-builder.ts index 18aa497..c1931bc 100644 --- a/src/context/context-builder.ts +++ b/src/context/context-builder.ts @@ -78,11 +78,7 @@ export class ContextBuilder { const rawW3c = clientContext.w3c; delete clientContext.w3c; - if ( - !rawW3c || - typeof rawW3c !== "object" || - Array.isArray(rawW3c) - ) { + if (!rawW3c || typeof rawW3c !== "object" || Array.isArray(rawW3c)) { return {}; } diff --git a/src/context/types.ts b/src/context/types.ts index 723ddee..f903c5d 100644 --- a/src/context/types.ts +++ b/src/context/types.ts @@ -35,7 +35,7 @@ export interface InvokeContext { // Methods getRemainingTimeInMillis(): number; - + /** * Returns the W3C trace context fields (traceparent, tracestate, baggage) * that were carried on `clientContext.w3c` at invoke time. diff --git a/src/global.d.ts b/src/global.d.ts index a30988c..b3d1849 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -1,7 +1,6 @@ import { HttpResponseStream } from "./stream/index.ts"; declare global { - // eslint-disable-next-line no-var var awslambda: { /** * Marks a handler as streaming and (optionally) captures a highWaterMark. diff --git a/test/dockerized/tasks/w3c.mjs b/test/dockerized/tasks/w3c.mjs index aab3150..490cf85 100644 --- a/test/dockerized/tasks/w3c.mjs +++ b/test/dockerized/tasks/w3c.mjs @@ -11,8 +11,7 @@ export const getW3cAndSource = async (_event, context) => { return { w3c: context.w3c(), clientContextIsDefined: clientContext !== undefined, - clientContextHasW3c: - clientContext !== undefined && "w3c" in clientContext, + clientContextHasW3c: clientContext !== undefined && "w3c" in clientContext, clientContext: clientContext ?? null, }; }; @@ -23,4 +22,4 @@ export const echoClientContext = async (_event, context) => { export const w3cIsFunction = async (_event, context) => { return { isFunction: typeof context.w3c === "function" }; -}; \ No newline at end of file +}; From 6a75cc0a663257908cf0a7246bc81cb7041c3732 Mon Sep 17 00:00:00 2001 From: Maxime David Date: Thu, 1 Oct 2026 09:27:23 +0000 Subject: [PATCH 4/8] fix: linting --- Dockerfile.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile.js b/Dockerfile.js index dde5a77..3d920cd 100644 --- a/Dockerfile.js +++ b/Dockerfile.js @@ -23,11 +23,11 @@ RUN mkdir -p /build && \ ls -R /build/deps # Copy bare config -COPY package.json tsconfig.json eslint.config.js vitest.config.js vitest.setup.ts /app/ +COPY package.json package-lock.json tsconfig.json eslint.config.js vitest.config.js vitest.setup.ts /app/ WORKDIR /app -RUN npm install --ignore-scripts +RUN npm ci --ignore-scripts COPY src /app/src COPY scripts/build.js /app/scripts/build.js From c23f78f8928d0943d6c398fda64ca4c15ae6e8b4 Mon Sep 17 00:00:00 2001 From: Maxime David Date: Thu, 1 Oct 2026 12:08:38 +0000 Subject: [PATCH 5/8] fix: set branch --- .github/workflows/dockerized-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dockerized-test.yml b/.github/workflows/dockerized-test.yml index 8fa31e9..0b07319 100644 --- a/.github/workflows/dockerized-test.yml +++ b/.github/workflows/dockerized-test.yml @@ -38,7 +38,7 @@ jobs: --build-arg BASE_IMAGE=public.ecr.aws/lambda/nodejs:24 - name: Run dockerized suites - uses: aws/containerized-test-runner-for-aws-lambda@511d270614f2c6b1613848db6dcf920a591c3c89 # main + uses: aws/containerized-test-runner-for-aws-lambda@76eacfb110903739d9c5f7fcdaafede6324a1473 # maxday/client-context with: suiteFileArray: '["./test/dockerized/suites/*.json"]' dockerImageName: 'local/test' From 362f9be09760213ec1059bf67e00132e594fffd9 Mon Sep 17 00:00:00 2001 From: Maxime David Date: Mon, 5 Oct 2026 11:04:33 +0000 Subject: [PATCH 6/8] feat: use W3CFieldName --- src/context/context-builder.ts | 7 ++++--- src/context/types.ts | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/context/context-builder.ts b/src/context/context-builder.ts index c1931bc..45dcf98 100644 --- a/src/context/context-builder.ts +++ b/src/context/context-builder.ts @@ -4,6 +4,7 @@ import { REQUIRED_ENV_VARS, REQUIRED_INVOKE_HEADERS, W3C_ALLOWED_FIELDS, + W3CFieldName, } from "./constants.js"; import { InvokeContext, InvokeHeaders } from "./types.js"; @@ -53,7 +54,7 @@ export class ContextBuilder { getRemainingTimeInMillis: function () { return deadline - Date.now(); }, - w3c: function (): Record { + w3c: function (): Partial> { return { ...w3cFields }; }, }; @@ -67,7 +68,7 @@ export class ContextBuilder { */ private static extractAndStripW3c( clientContext: Record | undefined, - ): Record { + ): Partial> { if (!clientContext || typeof clientContext !== "object") { return {}; } @@ -83,7 +84,7 @@ export class ContextBuilder { } const source = rawW3c as Record; - const fields: Record = {}; + const fields: Partial> = {}; for (const key of W3C_ALLOWED_FIELDS) { const value = source[key]; if (typeof value === "string") { diff --git a/src/context/types.ts b/src/context/types.ts index f903c5d..8b6a871 100644 --- a/src/context/types.ts +++ b/src/context/types.ts @@ -2,6 +2,7 @@ import { WritableResponseStream } from "../stream/index.js"; import { OPTIONAL_INVOKE_HEADERS, REQUIRED_INVOKE_HEADERS, + W3CFieldName, } from "./constants.js"; export interface InvokeHeaders { @@ -40,7 +41,7 @@ export interface InvokeContext { * Returns the W3C trace context fields (traceparent, tracestate, baggage) * that were carried on `clientContext.w3c` at invoke time. */ - w3c(): Record; + w3c(): Partial>; } export interface StreamOptions { From 9af6257717f8520b7d7f199423c790de62704115 Mon Sep 17 00:00:00 2001 From: Maxime David Date: Mon, 5 Oct 2026 11:05:45 +0000 Subject: [PATCH 7/8] feat: use main for aws/containerized-test-runner-for-aws-lambda --- .github/workflows/dockerized-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dockerized-test.yml b/.github/workflows/dockerized-test.yml index 0b07319..377a8a5 100644 --- a/.github/workflows/dockerized-test.yml +++ b/.github/workflows/dockerized-test.yml @@ -38,7 +38,7 @@ jobs: --build-arg BASE_IMAGE=public.ecr.aws/lambda/nodejs:24 - name: Run dockerized suites - uses: aws/containerized-test-runner-for-aws-lambda@76eacfb110903739d9c5f7fcdaafede6324a1473 # maxday/client-context + uses: aws/containerized-test-runner-for-aws-lambda@0863dd17b5fc19585250a2405c0f939a77b4f397 # main with: suiteFileArray: '["./test/dockerized/suites/*.json"]' dockerImageName: 'local/test' From 78978b311dacd845aeb9da758c2de97ae9cd62fa Mon Sep 17 00:00:00 2001 From: Maxime David Date: Mon, 5 Oct 2026 11:18:17 +0000 Subject: [PATCH 8/8] feat: w3c, return a frozen W3CFields from w3c() instead of a per-call clone --- src/context/constants.ts | 1 + src/context/context-builder.test.ts | 21 ++++++++++++++++----- src/context/context-builder.ts | 23 ++++++++++++----------- src/context/types.ts | 4 ++-- test/dockerized/suites/w3c.json | 6 +++--- test/dockerized/tasks/w3c.mjs | 10 ++++++++-- 6 files changed, 42 insertions(+), 23 deletions(-) diff --git a/src/context/constants.ts b/src/context/constants.ts index a79833f..302f22b 100644 --- a/src/context/constants.ts +++ b/src/context/constants.ts @@ -31,6 +31,7 @@ export const W3C_ALLOWED_FIELDS = [ ] as const; export type W3CFieldName = (typeof W3C_ALLOWED_FIELDS)[number]; +export type W3CFields = Readonly>>; // This RIC is used by Nodejs24 and above, it's used by NOdejs22 only for LMI and not OD export const CALLBACK_ERROR_NODEJS22 = diff --git a/src/context/context-builder.test.ts b/src/context/context-builder.test.ts index 659484c..99a7c49 100644 --- a/src/context/context-builder.test.ts +++ b/src/context/context-builder.test.ts @@ -339,7 +339,7 @@ describe("ContextBuilder", () => { expect(context.clientContext).not.toHaveProperty("w3c"); }); - it("should return a fresh copy so callers cannot mutate the underlying map", () => { + it("should expose a frozen object so callers cannot mutate the fields", () => { // GIVEN const headers = { ...mockValidHeaders, @@ -350,11 +350,22 @@ describe("ContextBuilder", () => { // WHEN const context = ContextBuilder.build(headers); - const first = context.w3c(); - first["baggage"] = "tampered"; - first["injected"] = "nope"; - // THEN + // THEN — the object is frozen + expect(Object.isFrozen(context.w3c())).toBe(true); + + // AND — attempts to write silently no-op in sloppy mode and throw in + // strict mode. The test file is a strict ESM TypeScript module, so + // both overwriting an existing key and adding a new one throw. + const mutable = context.w3c() as Record; + expect(() => { + mutable["baggage"] = "tampered"; + }).toThrow(TypeError); + expect(() => { + mutable["injected"] = "nope"; + }).toThrow(TypeError); + + // AND — the value is unchanged. expect(context.w3c()).toEqual({ baggage: "abc" }); }); diff --git a/src/context/context-builder.ts b/src/context/context-builder.ts index 45dcf98..4be5dc5 100644 --- a/src/context/context-builder.ts +++ b/src/context/context-builder.ts @@ -4,6 +4,7 @@ import { REQUIRED_ENV_VARS, REQUIRED_INVOKE_HEADERS, W3C_ALLOWED_FIELDS, + W3CFields, W3CFieldName, } from "./constants.js"; import { InvokeContext, InvokeHeaders } from "./types.js"; @@ -54,33 +55,33 @@ export class ContextBuilder { getRemainingTimeInMillis: function () { return deadline - Date.now(); }, - w3c: function (): Partial> { - return { ...w3cFields }; + w3c: function (): W3CFields { + return w3cFields; }, }; } /** - * Pulls `w3c` out of the parsed `clientContext` and returns a normalized - * copy of the allowlisted string fields (see `W3C_ALLOWED_FIELDS`). The - * `w3c` key is removed from `clientContext` itself so callers cannot read - * the source through `context.clientContext`. + * Pulls `w3c` out of the parsed `clientContext` and returns a frozen, + * normalized copy of the allowlisted string fields (see + * `W3C_ALLOWED_FIELDS`). The `w3c` key is removed from `clientContext` + * itself so callers cannot read the source through `context.clientContext`. */ private static extractAndStripW3c( clientContext: Record | undefined, - ): Partial> { + ): W3CFields { if (!clientContext || typeof clientContext !== "object") { - return {}; + return Object.freeze({}); } if (!("w3c" in clientContext)) { - return {}; + return Object.freeze({}); } const rawW3c = clientContext.w3c; delete clientContext.w3c; if (!rawW3c || typeof rawW3c !== "object" || Array.isArray(rawW3c)) { - return {}; + return Object.freeze({}); } const source = rawW3c as Record; @@ -91,7 +92,7 @@ export class ContextBuilder { fields[key] = value; } } - return fields; + return Object.freeze(fields); } private static parseDeadline(invokeHeaders: InvokeHeaders) { diff --git a/src/context/types.ts b/src/context/types.ts index 8b6a871..7b48580 100644 --- a/src/context/types.ts +++ b/src/context/types.ts @@ -2,7 +2,7 @@ import { WritableResponseStream } from "../stream/index.js"; import { OPTIONAL_INVOKE_HEADERS, REQUIRED_INVOKE_HEADERS, - W3CFieldName, + W3CFields, } from "./constants.js"; export interface InvokeHeaders { @@ -41,7 +41,7 @@ export interface InvokeContext { * Returns the W3C trace context fields (traceparent, tracestate, baggage) * that were carried on `clientContext.w3c` at invoke time. */ - w3c(): Partial>; + w3c(): W3CFields; } export interface StreamOptions { diff --git a/test/dockerized/suites/w3c.json b/test/dockerized/suites/w3c.json index d0607bc..dd170cc 100644 --- a/test/dockerized/suites/w3c.json +++ b/test/dockerized/suites/w3c.json @@ -1,11 +1,11 @@ { "tests": [ { - "name": "w3c_is_a_function_on_context", - "handler": "w3c.w3cIsFunction", + "name": "w3c_is_a_function_returning_a_frozen_object", + "handler": "w3c.w3cShape", "request": {}, "assertions": [ - { "response": { "isFunction": true } } + { "response": { "typeofW3c": "function", "typeofResult": "object", "isFrozen": true, "isObject": true } } ] }, diff --git a/test/dockerized/tasks/w3c.mjs b/test/dockerized/tasks/w3c.mjs index 490cf85..7b37076 100644 --- a/test/dockerized/tasks/w3c.mjs +++ b/test/dockerized/tasks/w3c.mjs @@ -20,6 +20,12 @@ export const echoClientContext = async (_event, context) => { return context.clientContext ?? null; }; -export const w3cIsFunction = async (_event, context) => { - return { isFunction: typeof context.w3c === "function" }; +export const w3cShape = async (_event, context) => { + const value = context.w3c(); + return { + typeofW3c: typeof context.w3c, + typeofResult: typeof value, + isFrozen: Object.isFrozen(value), + isObject: value !== null && typeof value === "object", + }; };