FY Recommendations IFX — Handlers Guide
FY Recommendations IFX — Handlers Guide
This document walks through the three basic handlers shipped in this bundle, how they are wired into the Arc XP IFX runtime, what variables they read, and how to take the bundle from a fresh clone to an installed integration.
1. What the bundle does
The bundle subscribes to a small set of Arc XP CMS events and forwards each story mutation to a downstream content collector. The flow is:
Arc XP CMS ──story:* events──▶ IFX runtime ──HTTPS──▶ Collector API (this bundle)For every story event the IFX runtime hands us, we:
- Validate the ANS document on the event.
- Resolve the website(s) the story belongs to.
- Map the ANS payload into the collector’s content schema.
- POST the mapped payload to the collector, once per website.
The handlers are idempotent: the same event delivered twice produces the same payload, so the IFX runtime’s at-least-once retry semantics are safe.
2. Files at a glance
| Path | Role |
|---|---|
src/eventsRouter.json | Maps Arc XP event names to handler names. |
src/eventsHandlers.js | Aggregates the handler modules for the IFX SDK. |
src/eventsHandlers/storyPublishHandler.js | Upsert path: publish / republish / update / create. |
src/eventsHandlers/storyUnpublishHandler.js | Delete path: story unpublished. |
src/eventsHandlers/storyDeleteHandler.js | Delete path: story removed. |
src/lib/ansToFy.js | ANS → collector schema mapping. |
src/lib/collectorClient.js | Thin HTTPS client around the collector content endpoint. |
src/utils/namedLogger.js | Structured logger used by every handler. |
test/replayIdempotency.test.js | Asserts replay safety and the basic payload shape. |
3. Variables used by the bundle
The handlers themselves take no static configuration. The collector client and the logger read everything from the runtime environment.
| Variable | Read by | Required | Purpose |
|---|---|---|---|
FY_COLLECTOR_BASE_URL | src/lib/collectorClient.js | yes | Base URL of the collector. The client appends the content path to it. |
FY_COLLECTOR_PAT | src/lib/collectorClient.js | yes | Bearer credential sent as X-Api-Key on every outbound request. |
LOG_LEVEL | src/utils/namedLogger.js | no | Pino log level. Accepts trace/debug/info/warn/error/fatal, or disabled. |
GITHUB_TOKEN | npm install (build time only) | yes | Used by .npmrc to pull the Arc XP IFX SDK from GitHub Packages. Not read at runtime. |
The collector client refuses to start a request if FY_COLLECTOR_BASE_URL or
FY_COLLECTOR_PAT is missing — it throws synchronously before any HTTP call.
That deliberately surfaces misconfiguration on the very first invocation rather
than silently dropping events.
Per-environment values live in gitignored files:
.env— used by the local testing server..env.sandbox— read bycreateSandboxBundle..env.production— read bycreateProdBundle.
None of these files should be committed.
4. The router
src/eventsRouter.json is the contract between the IFX runtime and our handlers.
The SDK reads this file at bundle-generation time and dispatches each incoming
event to the named handler.
{ "storyPublishHandler": ["story:create", "story:first-publish", "story:republish", "story:update"], "storyUnpublishHandler": ["story:unpublish"], "storyDeleteHandler": ["story:delete"]}Two design points worth calling out:
story:createis routed to the publish handler. Some tenants emit acreatebefore the first publish; treating it as an upsert keeps the catalogue in sync regardless of which event arrives first.unpublishanddeleteshare a code path but not a handler. They produce the same collector payload (action: 'delete'), but live in separate handlers so logging, metrics, and future divergence remain straightforward.
5. The handlers, step by step
All three handlers follow the same five-step shape. Reading one is enough to understand the others.
5.1 storyPublishHandler
const storyPublishHandler = async (event) => { const ans = event && event.body; if (!ans || !ans._id) { throw new Error('missing ANS document or _id'); }
const websites = getWebsiteIds(ans); if (websites.length === 0) { throw new Error('no websites in event'); }
const logger = namedLogger('storyPublishHandler').child({ arcStoryId: ans._id }); logger.info({ message: 'Received story publish event', websites });
for (const website of websites) { await postContent(ansToUpsert(ans, website)); }};Step by step:
- Pull the ANS body. The IFX runtime hands the handler a wrapper object;
the actual ANS document is on
event.body. - Validate. If the body is missing or has no
_idwe throw. The runtime will see the failure and retry — this is intentional, because a story with no_idcannot be upserted idempotently anyway. - Resolve websites.
getWebsiteIds(ans)prefersans._website_idsand falls back toObject.keys(ans.websites). Throwing on an empty result keeps us from silently dropping events that look valid but target nothing. - Log with context. A child logger pins
arcStoryIdonto every log line so the entire lifecycle of one story is greppable in aggregated logs. - Fan out per website. One
postContentcall per site. The loop is sequential on purpose: the 60-second IFX handler budget is plenty for the small number of sites a story usually targets, and serializing keeps error semantics simple — if one site fails, the runtime retries the whole event, and idempotency makes that safe.
5.2 storyUnpublishHandler and storyDeleteHandler
These are structurally identical to the publish handler. The only differences:
- They call
ansToDelete(ans, website)instead ofansToUpsert(ans, website). - They log under their own logger name so the two paths are distinguishable.
The collector receives { action: 'delete', item_id, site_id } and removes the
item from the catalogue for that site.
6. The collector client
src/lib/collectorClient.js is a single-purpose HTTPS client.
- It reads
FY_COLLECTOR_BASE_URLandFY_COLLECTOR_PATon every call. Reading per-call (rather than at module load) lets the bundle pick up env changes between invocations in long-lived runtime processes. - It POSTs to a fixed content path (
CONTENT_PATHconstant) under the base URL. - It sets three headers:
Content-Type: application/jsonArc-Priority: ingestion— the documented Arc XP header that prevents IFX feedback loops onstory:update.X-Api-Key: <FY_COLLECTOR_PAT>
- It uses a 30-second axios timeout, leaving headroom under the 60-second IFX handler ceiling.
There is no retry logic in the client. Retries are the IFX runtime’s job, and duplicating them here would interact badly with the runtime’s retry budget.
7. The ANS → collector mapping
src/lib/ansToFy.js is the seam between Arc XP’s ANS schema and the collector
schema. Two payload shapes are produced:
Upsert (ansToUpsert):
{ action: 'publish', item_id: ans._id, site_id: <one of the websites resolved above>, type: mapAnsTypeToFyType(ans.type), timestamp: extractTimestamp(ans), title: extractTitle(ans), categories: extractCategories(ans), tags: extractTags(ans), author: extractAuthor(ans), is_premium: extractIsPremium(ans), metadata: { canonical_url: ans.canonical_url || null },}Delete (ansToDelete):
{ action: 'delete', item_id: ans._id, site_id: <one of the websites resolved above>,}Notes on the helpers:
mapAnsTypeToFyTypecollapsesstoryandgallerytoarticle, and passesvideothrough. Unknown types default toarticlerather than failing.extractTimestamppreferspublish_date, thenlast_updated_date, thendisplay_date, and finally falls back to “now”. The fallback exists so a malformed ANS document still produces a deterministic payload.extractCategoriesandextractTagsdefensivelyfilter(Boolean)so a partially-populated taxonomy never producesnullentries.extractIsPremiumtreats the presence of any subscription as premium. Tenants with a richer subscription model should override this.
This file is the intended customization point. Adding fields here is safe; the handlers do not need to change.
8. Idempotency contract
The bundle’s correctness depends on one invariant:
Two deliveries of the same event must produce byte-identical collector payloads.
The mapping is deterministic on the ANS document — there is no clock read, no
random value, no per-invocation state. As long as the ANS document is the same,
ansToUpsert(ans, site) returns the same object. The collector treats item_id
as an upsert key per site_id, so replays converge instead of duplicating.
test/replayIdempotency.test.js pins this behaviour for all three handlers.
That test is run as part of the bundle build, so a regression in the mapping
will block a bundle from being produced.
9. Set-up guide
The steps below take you from a clean clone to a working local invocation.
9.1 Prerequisites
- Node.js 22 or newer.
- A GitHub PAT with
read:packagesscope — the Arc XP IFX SDK is published to GitHub Packages and is required to install. - A restricted-access Developer PAT issued from your Arc XP Developer Center, to authenticate collector requests.
- The FY Recommendations service enabled for your organization, with the collector reachable through Arc XP ASI.
9.2 Install
# Tell npm where to find the IFX SDK.cp .npmrc.example .npmrcexport GITHUB_TOKEN=<your GitHub PAT>
# Install runtime + dev dependencies.npm installThe postinstall script runs the SDK’s eventsHandlersModuleGenerator, which
reads src/eventsRouter.json and produces the glue the IFX SDK expects.
9.3 Configure
Create a local env file from the example and fill in the two collector variables:
cp .env.example .env$EDITOR .envSet:
FY_COLLECTOR_BASE_URL— the base URL of the collector for your org.FY_COLLECTOR_PAT— your restricted-access Developer PAT.LOG_LEVEL— optional; defaults toinfo.
.env is gitignored. Do not commit it.
9.4 Run the local testing server
The Arc XP IFX SDK ships a local Express server that lets you invoke handlers without deploying:
npm run localTestingServerThe server boots on a local loopback port and exposes an invoke endpoint that
mirrors the runtime’s behaviour. POST a sample ANS event to it and watch the
handler dispatch to the collector base URL configured in .env.
9.5 Run the tests
npm testThe suite covers idempotency, payload shape, and input validation. The bundle build invokes the same suite — if it fails, you cannot produce a bundle.
10. Step-by-step: introducing the bundle’s mechanism
If you are walking a new contributor through how an event becomes a collector write, this is the sequence to demonstrate:
- Pick an event. Open
src/eventsRouter.jsonand choose one of the wired event names (for example,story:republish). - Find the handler. The router maps that event to
storyPublishHandler. Opensrc/eventsHandlers/storyPublishHandler.js. - Trace validation. Show how
event.body._idis enforced and how missing websites throw — both produce IFX retries instead of silent drops. - Trace the mapping. Step into
ansToUpsertinsrc/lib/ansToFy.js. Point out that every helper is pure: same input, same output. - Trace the HTTP call. Step into
postContentinsrc/lib/collectorClient.js. HighlightArc-Priority: ingestion(loop safety) and the env-driven configuration (no hard-coded URL or credential). - Replay it. Re-send the same event through the local testing server. The collector should see an identical payload — that is the idempotency guarantee in action.
- Break it on purpose. Send an event with no
_idor no websites. Confirm the handler throws and the runtime would retry. This is the failure mode the bundle is designed around.
After these seven steps a new contributor has touched every file in the bundle and seen the runtime contract end-to-end.
11. Build and ship
When the local flow is working:
# Sandbox bundle, validated against the test suite.npm run createSandboxBundle
# Production bundle.npm run createProdBundleThe resulting zip lands in bundles/. Upload it through the Arc XP Developer
Center to install the integration on a tenant. The Developer Center is where
per-environment values (the collector base URL and the restricted PAT) are
injected into the runtime — they are never baked into the bundle itself.
Bundles need to subscribe to the event stream you need to listen; see Subscribing To Events
For more information about bundle management see Managing Your Bundle
12. Extending the bundle
Two common extensions, both safe within the runtime envelope:
- New content types. Add a handler under
src/eventsHandlers/, export it fromsrc/eventsHandlers.js, and add an entry tosrc/eventsRouter.json. If the new handler also writes to the collector, reusepostContentso loop safety and authentication stay consistent. - Richer field mapping. Edit
src/lib/ansToFy.js. As long as the helpers remain pure functions of the ANS document, the idempotency contract holds and the existing tests continue to protect you.
When adding either, re-check that handlers stay within the 60-second budget and the bundle stays under the 50 MB code-size ceiling.