diff --git a/README.md b/README.md index 1d63858..afbb994 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ JavaScript SDK for integrating with an [Optable Data Connectivity Node (DCN)](ht - [Rules](#rules) - [Return Value](#return-value) - [Input Type](#input-type) +- [Geotargeting](#geotargeting) - [Demo Pages](#demo-pages) ## Installing @@ -1174,6 +1175,28 @@ type NodeTargetingRule = { }; ``` +## Geotargeting + +The geotargeting addon maps a visitor's geo (country code) to the Optable host and node that should serve them, so that a single SDK bundle can route traffic to region-specific DCNs. + +```typescript +import { getGeoConfig } from "@optable/web-sdk/lib/dist/addons/geotargeting"; + +const geoConfig = getGeoConfig("acme", visitorGeo); // e.g. { host: "na.edge.optable.co", node: "acme" } for "US" + +if (geoConfig) { + const sdk = new OptableSDK({ + host: geoConfig.host, + node: geoConfig.node, + site: "my-site", + }); +} +``` + +The first argument is the node name: the tenant name, optionally with an `-auth` suffix to select the auth variant of the node (e.g. `"acme-auth"`). `getGeoConfig` returns `null` when the geo is not supported, in which case region-specific initialization should be skipped. For geos served by a dedicated cloud host rather than a regional edge host, `node` is `undefined` and the host's default node is used. + +The default `GeoMap` supports the `US`, `CA`, `GB`, `UK` and `AU` geos and reflects a specific provisioning shape: regional edge nodes in US/CA and dedicated per-tenant cloud hosts (`.cloud..optable.co`) in AU and the EU. The dedicated hosts only exist for tenants provisioned that way — pass a custom `GeoMap` as the third argument when your topology differs; see `lib/addons/geotargeting.ts` for the entry format. + ## Demo Pages The demo pages are working examples of both `identify` and `targeting` APIs, as well as an integration with the [Google Ad Manager 360](https://admanager.google.com/home/) ad server, enabling the targeting of ads served by GAM360 to audiences activated in the [Optable](https://optable.co/) DCN. diff --git a/lib/addons/geotargeting.test.js b/lib/addons/geotargeting.test.js new file mode 100644 index 0000000..6eaaf95 --- /dev/null +++ b/lib/addons/geotargeting.test.js @@ -0,0 +1,94 @@ +import { getGeoConfig, DEFAULT_GEO_MAP } from "./geotargeting.ts"; + +describe("getGeoConfig", () => { + test("resolves the regional edge host and node for US", () => { + expect(getGeoConfig("acme", "US")).toEqual({ + host: "na.edge.optable.co", + node: "acme", + }); + }); + + test("resolves the auth node for US", () => { + expect(getGeoConfig("acme-auth", "US")).toEqual({ + host: "na.edge.optable.co", + node: "acme-auth", + }); + }); + + test("resolves the regional edge host and node for CA", () => { + expect(getGeoConfig("acme", "CA")).toEqual({ + host: "ca.edge.optable.co", + node: "acme-ca", + }); + }); + + test("resolves the auth node for CA", () => { + expect(getGeoConfig("acme-auth", "CA")).toEqual({ + host: "ca.edge.optable.co", + node: "acme-ca-auth", + }); + }); + + test("resolves a dedicated cloud host with undefined node for AU", () => { + expect(getGeoConfig("acme", "AU")).toStrictEqual({ + host: "acme.cloud.au.optable.co", + node: undefined, + }); + }); + + test("resolves a dedicated auth cloud host with undefined node for AU", () => { + expect(getGeoConfig("acme-auth", "AU")).toStrictEqual({ + host: "acme-auth.cloud.au.optable.co", + node: undefined, + }); + }); + + test("resolves GB and UK to the same EU cloud host", () => { + const expected = { host: "acme.cloud.eu.optable.co", node: undefined }; + expect(getGeoConfig("acme", "GB")).toStrictEqual(expected); + expect(getGeoConfig("acme", "UK")).toStrictEqual(expected); + }); + + test("returns null for an unsupported geo", () => { + expect(getGeoConfig("acme", "FR")).toBeNull(); + }); + + test("returns null for a missing geo", () => { + expect(getGeoConfig("acme", "")).toBeNull(); + expect(getGeoConfig("acme", undefined)).toBeNull(); + }); + + test("returns null for geos inherited from Object.prototype", () => { + expect(getGeoConfig("acme", "constructor")).toBeNull(); + expect(getGeoConfig("acme", "__proto__")).toBeNull(); + expect(getGeoConfig("acme", "toString")).toBeNull(); + }); + + test("only treats a trailing -auth as the auth variant", () => { + expect(getGeoConfig("author-press", "US")).toEqual({ + host: "na.edge.optable.co", + node: "author-press", + }); + }); + + test("accepts a custom geo map", () => { + const geoMap = { + BR: ["-br.cloud", "-br-auth.cloud", "sa.edge.optable.co"], + MX: [".cloud.mx", "-auth.cloud.mx"], + }; + expect(getGeoConfig("acme", "BR", geoMap)).toEqual({ + host: "sa.edge.optable.co", + node: "acme-br", + }); + expect(getGeoConfig("acme-auth", "MX", geoMap)).toStrictEqual({ + host: "acme-auth.cloud.mx.optable.co", + node: undefined, + }); + expect(getGeoConfig("acme", "US", geoMap)).toBeNull(); + }); + + test("exposes the default geo map", () => { + expect(DEFAULT_GEO_MAP.US).toEqual([".cloud", "-auth.cloud", "na.edge.optable.co"]); + expect(DEFAULT_GEO_MAP.CA).toEqual(["-ca.cloud", "-ca-auth.cloud", "ca.edge.optable.co"]); + }); +}); diff --git a/lib/addons/geotargeting.ts b/lib/addons/geotargeting.ts new file mode 100644 index 0000000..636b430 --- /dev/null +++ b/lib/addons/geotargeting.ts @@ -0,0 +1,72 @@ +/* + * The geotargeting addon maps a visitor's geo (country code) to the Optable + * host and node that should serve them, so that a single SDK bundle can route + * traffic to region-specific DCNs. + * + * A GeoMap entry is a tuple of host fragments keyed by country code: + * [0] — host suffix for the standard (non-auth) node + * [1] — host suffix for the auth node + * [2] — optional regional edge host shared by multiple nodes + * + * When a regional edge host ([2]) is present, it is used as the host and the + * node name is derived from the tenant name plus the suffix with ".cloud" + * removed (e.g. "acme" + "-ca-auth" → "acme-ca-auth"). Otherwise the tenant + * runs on a dedicated cloud host built as `${name}${suffix}.optable.co` and + * the node is undefined (the host's default node is used). + * + * DEFAULT_GEO_MAP reflects one specific provisioning shape: regional edge + * nodes in US/CA and dedicated per-tenant cloud hosts in AU and the EU. The + * dedicated hosts only exist for tenants provisioned that way — tenants with + * a different topology must pass their own GeoMap. + */ + +export type GeoMapEntry = [string, string] | [string, string, string]; +export type GeoMap = Record; + +export interface GeoConfig { + host: string; + node: string | undefined; +} + +const EU_ENTRY: GeoMapEntry = [".cloud.eu", "-auth.cloud.eu"]; + +export const DEFAULT_GEO_MAP: GeoMap = { + AU: [".cloud.au", "-auth.cloud.au"], + CA: ["-ca.cloud", "-ca-auth.cloud", "ca.edge.optable.co"], + GB: EU_ENTRY, + UK: EU_ENTRY, + US: [".cloud", "-auth.cloud", "na.edge.optable.co"], +}; + +/* + * getGeoConfig() resolves the host and node for a node name in a given geo. + * + * nodeName is the tenant name, optionally with an "-auth" suffix selecting the + * auth variant of the node (e.g. "acme" or "acme-auth"). + * + * Returns null when the geo is missing or not present in the map, in which + * case the caller should skip region-specific initialization. + */ +export function getGeoConfig( + nodeName: string, + geo: string | undefined, + geoMap: GeoMap = DEFAULT_GEO_MAP +): GeoConfig | null { + const entry = geo === undefined ? undefined : geoMap[geo]; + // Array.isArray also rejects inherited Object.prototype members picked up + // when an unexpected geo like "constructor" is looked up in the map + if (!Array.isArray(entry)) { + return null; + } + + const auth = /-auth$/i.test(nodeName); + const name = auth ? nodeName.replace(/-auth$/i, "") : nodeName; + const suffix = auth ? entry[1] : entry[0]; + const edgeHost = entry[2]; + + if (edgeHost != null) { + return { host: edgeHost, node: name + suffix.replace(/\.cloud/, "") }; + } + + return { host: `${name}${suffix}.optable.co`, node: undefined }; +}