@traffic-lights/events
v0.3.2
Published
Traffic Lights Node.js SDK — emit business events to Traffic Lights over HTTPS. Server-side only.
Readme
Traffic Lights Node.js SDK
@traffic-lights/events — emit business events to Traffic Lights
over HTTPS.
🛑 SERVER-SIDE ONLY. Never ship this to a browser. The API key travels in a request header on every call, so anything that reaches an end user's device reaches their developer tools.
RestTransportrefuses a non-HTTPSbaseUrlfor the same reason, and the package importsnode:crypto, so it is Node-only by construction rather than by convention.
Requires Node 18 or later.
One package. REST is the default and the single contract. EventBridge is an internal adapter
behind the /eventbridge subpath export — importing it is a deliberate act visible in the import
line, which is what keeps SAI's "REST is the contract" ruling from being decorative.
Three ways to integrate
1 · Node server-side — the normal path
npm install @traffic-lights/eventsimport { emitEvent } from '@traffic-lights/events';
// TL_API_BASE_URL and TL_API_KEY from the environment, or pass them explicitly.
const eventId = await emitEvent('billing.close', 'FLOW_CALLBACK', {
runID: 'MEC-20260925T000000Z',
taskID: '20006510',
status: 'SUCCESS',
});2 · Inside the Traffic Lights AWS account — the EventBridge adapter
Not a peer transport: REST is the contract, and this exists for in-account emitters only. You inject your own AWS client, because this package does not depend on the AWS SDK and must not start.
import { EventBridgeTransport } from '@traffic-lights/events/eventbridge';
import { EventBridgeClient, PutEventsCommand } from '@aws-sdk/client-eventbridge';
const tr = new EventBridgeTransport({
client: new EventBridgeClient({}), eventBusName: 'tl-bus', PutEventsCommand,
});
await tr.emit({ source: 'sai.tl.acme.orders', 'detail-type': 'FLOW_CALLBACK', detail });3 · Google Apps Script — no npm, no bundler
Apps Script cannot install packages, so there is nothing to import: the contract is one HTTPS
POST and this is the whole client. Runnable as-is — paste it in, set the two script
properties, run tlEmit.
/**
* Traffic Lights — emit one event from Google Apps Script.
* Script properties required: TL_API_BASE_URL, TL_API_KEY.
*/
function tlEmit(detailType, detail, idempotencyKey) {
var props = PropertiesService.getScriptProperties();
var baseUrl = props.getProperty('TL_API_BASE_URL');
var apiKey = props.getProperty('TL_API_KEY');
if (!baseUrl || !apiKey) {
throw new Error('Set TL_API_BASE_URL and TL_API_KEY in Script Properties.');
}
if (!detail || !detail.runID) {
// The correlation key is never generated for you: a synthesised id correlates nothing
// while looking like it does.
throw new Error('detail.runID is required.');
}
// 🛑 ONE KEY PER EVENT, NOT PER ATTEMPT. Apps Script retries on its own; without a stable key
// a retried timeout becomes a SECOND event. Pass your own for anything re-runnable.
var key = idempotencyKey || Utilities.getUuid();
var res = UrlFetchApp.fetch(baseUrl.replace(/\/+$/, '') + '/api/v1/events', {
method: 'post',
contentType: 'application/json',
headers: { 'X-Api-Key': apiKey, 'Idempotency-Key': key },
payload: JSON.stringify({
source: 'sai.tl.appsscript',
'detail-type': detailType,
detail: detail,
}),
muteHttpExceptions: true, // so a refusal is readable instead of an opaque throw
});
var code = res.getResponseCode();
var body = {};
try { body = JSON.parse(res.getContentText() || '{}'); } catch (e) { /* keep {} */ }
if (code !== 202) {
// ⚠️ THE SERVER'S REASON IS THE POINT. A 422 saying only "rejected" sends you to your JSON
// when the real cause may be that the run is not awaiting a callback.
throw new Error('Traffic Lights refused (' + code + '): ' +
(body.reason || '') + ' ' + (body.detail || res.getContentText()));
}
return body.eventId;
}
function demo() {
Logger.log(tlEmit('FLOW_CALLBACK', {
runID: 'MEC-20260925T000000Z', taskID: '20006510', status: 'SUCCESS',
}));
}⚠️ A non-202 is a refusal, including a 200. The contract says 202; a 200 means something other than this endpoint answered — a proxy, a redirect target, a router that matched a different route — and treating it as success is how a caller believes an event landed somewhere it did not.
Types
./types/*.d.ts are generated from the JSDoc in src/. The events-intake contract is a
separate, generated artefact — @traffic-lights/events/api-types — derived from the api-tlcore
OpenAPI spec, so the request body and the 202 response cannot drift from the server silently:
import type { EventEnvelopeIn, EventAccepted, RefusalReason }
from '@traffic-lights/events/api-types';⚠️ RefusalReason is derived from the server's own refusal vocabulary rather than from the spec,
because the spec does not carry it — its only documented error response is a generic validation
error. Regenerate with make gen-types; make check-types-drift refuses a publish when the
committed artefact no longer matches the contract.
import { RestTransport, validateEnvelope } from '@traffic-lights/events';
import { EventBridgeTransport } from '@traffic-lights/events/eventbridge'; // deliberateThe envelope is defined once
../schema/envelope.schema.json, beside the Python SDK. Neither language restates it, and
../fixtures/ holds golden envelopes that both suites load and must agree on, envelope for
envelope. That diff is the contract test; a fixture only one language reads proves nothing.
🛑 take, takeOne and complete REFUSE today
They throw NotWiredError naming what they wait on — emit waits on TL-701, the executor-queue
operations on [TL]'s auth/contract report. ⚠️ They do not return an empty array or
{ok:true}. An empty work list is byte-identical to "the queue is empty", so a stub would let
a caller build against a transport that has never spoken to anything, with green tests. A refusal
cannot be mistaken for a working call. emit still validates first, so the envelope contract
is exercised today.
The runID spelling is one constant
RUN_ID_KEY in src/envelope.js, and it is runID.
🛑 CORRECTED 2026-09-28 ([TL]). This paragraph said the ruling was PENDING and that the shipped
Python wheel "accepts runId and explicitly refuses runID" — the OPPOSITE of what ships.
It was written 2026-09-25 against wheel 2.0.0; SAI ruled on 2026-09-26, one day later, and
the ruling went the other way.
MEASURED 2026-09-28 against the artefacts, not the source:
wheel traffic-lights-events 2.4.1 (installed) RUN_ID_KEY = "runID" RUN_ID_ALIAS = "runId"
deployed api-tlcore-QA, app/events_intake_core RUN_ID_KEY, TASK_ID_KEY = "runID", "taskID"
RUN_ID_ALIAS, TASK_ID_ALIAS = "runId", "taskId"⇒ runID is canonical. runId is a DEPRECATED ALIAS — accepted for one minor version,
normalised away before the event reaches the bus, and logged so the lapse is visible. Both
spellings work on the wire today; only one is correct, and the alias is removed next minor.
⚠️ WHY THIS IS RECORDED RATHER THAN QUIETLY REWRITTEN. A stale line that merely goes out of date costs a reader some confusion. This one was an instruction in the imperative, pointing at the spelling the estate had just ruled against — and it was believed: it was quoted back as evidence during the 2026-09-28 marketing-site verification. A caution that names a deployed artefact's behaviour is a measurable claim; measure it on the way past.
No credentials, ever
RestTransport takes getAuthToken: () => ..., a callback, and refuses at construction if
handed token/apiKey/secret/password as a value. The SDK holds no credential.
EventBridgeTransport takes a client the host constructs, so this package declares no
@aws-sdk dependency and stays installable where that would not resolve.
Why the source is typed JavaScript rather than .ts
tsc runs in checkJs + emitDeclarationOnly mode: the source is strict-typechecked and .d.ts
files are generated from it, so TS consumers get full types from one artefact instead of a .ts
that compiles into a .js nobody reads. ⚠️ The load-bearing consequence: npm test runs under
node --test with ZERO dependencies, so the cross-language contract test executes in CI today
rather than waiting on an npm ci. A contract test that cannot run yet is a promise, not a gate.
npm test # node --test test/ — no install required
npm run typecheck
npm run build # emits types/From the repo root: make sdk-js-test, make sdk-js-typecheck, make sdk-js-build,
make sdk-envelope-parity.
