diff --git a/implementations/node-web/.env.example b/implementations/node-ssr/.env.example
similarity index 100%
rename from implementations/node-web/.env.example
rename to implementations/node-ssr/.env.example
diff --git a/implementations/node-ssr/README.md b/implementations/node-ssr/README.md
new file mode 100644
index 00000000..504e789e
--- /dev/null
+++ b/implementations/node-ssr/README.md
@@ -0,0 +1,3 @@
+# Contentful Optimization Node.JS Node SSR SDK Reference Implementation
+
+This is a reference implementation for the [Optimization Node.JS Node SSR SDK](../../platforms/javascript/node/README.md) and is part of the [Contentful Optimization SDK Suite](../../README.md).
diff --git a/implementations/node-ssr/e2e/example.spec.ts b/implementations/node-ssr/e2e/example.spec.ts
new file mode 100644
index 00000000..690bd0e5
--- /dev/null
+++ b/implementations/node-ssr/e2e/example.spec.ts
@@ -0,0 +1,55 @@
+import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-core'
+import { expect, test } from '@playwright/test'
+
+const CLIENT_ID = process.env.VITE_NINETAILED_CLIENT_ID ?? 'error'
+const ANONYMOUS_ID = '__ctfl_opt_anonymous_id__'
+
+const UID_LENGTH = 36
+
+function genAnonymousIdCookie(id: string): {
+ name: string
+ value: string
+ path: string
+ domain: string
+} {
+ return { name: ANONYMOUS_ID_COOKIE, value: id, path: '/', domain: 'localhost' }
+}
+
+test('BACKEND: check client ID rendered from Optimization API on server-side render', async ({
+ request,
+}) => {
+ const response = await request.get('/')
+
+ expect(await response.text()).toContain(`"clientId":"${CLIENT_ID}"`)
+})
+
+test('BACKEND: generates new Profile id', async ({ context, page }) => {
+ await page.goto('/')
+
+ const state = await context.storageState()
+
+ const storage = state.origins[0]?.localStorage ?? []
+ const storedId = storage.find((item) => item.name === ANONYMOUS_ID)?.value
+
+ expect(storedId).toBeDefined()
+ expect(storedId).toHaveLength(UID_LENGTH)
+})
+
+test('BACKEND: identifies profile id and associates it with user id', async ({ context, page }) => {
+ const customIdentifiedId = 'custom-profile-id'
+ await context.addCookies([genAnonymousIdCookie(customIdentifiedId)])
+ await page.goto(`/user/maximus`)
+ const {
+ origins: [origin],
+ } = await context.storageState()
+
+ expect(origin?.localStorage).toEqual([{ name: ANONYMOUS_ID, value: customIdentifiedId }])
+})
+
+test('FRONTEND: check client ID rendered from Optimization API on client-side render', async ({
+ page,
+}) => {
+ await page.goto('/')
+
+ await expect(page.getByTestId('clientId')).toHaveText(CLIENT_ID)
+})
diff --git a/implementations/node-web/package.json b/implementations/node-ssr/package.json
similarity index 71%
rename from implementations/node-web/package.json
rename to implementations/node-ssr/package.json
index 7da27eae..79d6989c 100644
--- a/implementations/node-web/package.json
+++ b/implementations/node-ssr/package.json
@@ -1,6 +1,6 @@
{
"private": true,
- "name": "@implementation/node-web",
+ "name": "@implementation/node-ssr",
"description": "Reference implementation for NodeJS Web projects",
"license": "MIT",
"main": "dist/app.js",
@@ -9,21 +9,21 @@
"build": "pnpm clean; pnpm build:sdk",
"build:sdk": "pnpm --filter '../../platforms/javascript/(api-schemas|api-client|core|web)' build && cp -r ../../platforms/javascript/web/dist ./public/dist",
"clean": "rimraf ./dist ./public/dist ./coverage ./playwright-report ./test-results tsconfig.tsbuildinfo",
- "serve": "pnpm serve:mocks && pnpm serve:app",
- "serve:app": "pnpm build && docker compose up -d && pm2 start --name node-app \"tsx --env-file=.env ./src/app.ts\"",
- "serve:mocks": "pm2 start --name node-mocks \"pnpm --filter mocks serve\" && pm2 start --name web-mocks \"pnpm --filter mocks serve\"",
- "serve:stop": "docker compose down && pm2 stop web-mocks node-app node-mocks && pm2 delete web-mocks node-app node-mocks",
+ "serve": "pnpm build && pm2 start --name ssr-mocks \"pnpm --filter mocks serve\" && pm2 start --name node-app \"tsx --env-file=.env ./src/app.ts\"",
+ "serve:stop": "pm2 stop ssr-mocks node-app && pm2 delete ssr-mocks node-app",
"test:e2e": "pnpm serve && playwright test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT",
"test:e2e:codegen": "playwright codegen",
"test:e2e:report": "playwright show-report",
- "test:e2e:ui": "pnpm build && playwright test --ui",
+ "test:e2e:ui": "pnpm build && pnpm serve && playwright test --ui",
"test:unit": "vitest run --coverage",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@contentful/optimization-node": "workspace:*",
+ "@contentful/optimization-web": "workspace:*",
"express": "catalog:",
"express-rate-limit": "catalog:",
+ "cookie-parser": "1.4.7",
"tslib": "catalog:"
},
"devDependencies": {
@@ -32,6 +32,7 @@
"@types/node": "catalog:",
"@types/supertest": "catalog:",
"@vitest/coverage-v8": "catalog:",
+ "@types/cookie-parser": "1.4.7",
"dotenv": "catalog:",
"pm2": "catalog:",
"rimraf": "catalog:",
diff --git a/implementations/node-web/playwright.config.mjs b/implementations/node-ssr/playwright.config.mjs
similarity index 100%
rename from implementations/node-web/playwright.config.mjs
rename to implementations/node-ssr/playwright.config.mjs
diff --git a/implementations/node-web/src/app.test.ts b/implementations/node-ssr/src/app.test.ts
similarity index 62%
rename from implementations/node-web/src/app.test.ts
rename to implementations/node-ssr/src/app.test.ts
index 811af1ac..d02b91c6 100644
--- a/implementations/node-web/src/app.test.ts
+++ b/implementations/node-ssr/src/app.test.ts
@@ -5,8 +5,8 @@ const CLIENT_ID = process.env.VITE_NINETAILED_CLIENT_ID ?? 'error'
describe('GET /', () => {
it('returns the client ID', async () => {
- const response: Response = await request(app).get('/')
+ const response: Response = await request(app).get('/smoke-test')
- expect(response.text).toEqual(CLIENT_ID)
+ expect(response.text).toContain(`"clientId":"${CLIENT_ID}"`)
})
})
diff --git a/implementations/node-ssr/src/app.ts b/implementations/node-ssr/src/app.ts
new file mode 100644
index 00000000..e29780de
--- /dev/null
+++ b/implementations/node-ssr/src/app.ts
@@ -0,0 +1,124 @@
+import Optimization, { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-node'
+import cookieParser from 'cookie-parser'
+import express, { type Express, type Response } from 'express'
+import rateLimit from 'express-rate-limit'
+
+const limiter = rateLimit({
+ windowMs: 900_000,
+ max: 100,
+})
+
+const app: Express = express()
+
+app.use(cookieParser())
+
+app.use(limiter)
+
+const CLIENT_ID = process.env.VITE_NINETAILED_CLIENT_ID ?? ''
+const ENVIRONMENT = process.env.VITE_NINETAILED_ENVIRONMENT ?? ''
+const VITE_INSIGHTS_API_BASE_URL = process.env.VITE_INSIGHTS_API_BASE_URL ?? ''
+const VITE_EXPERIENCE_API_BASE_URL = process.env.VITE_EXPERIENCE_API_BASE_URL ?? ''
+
+const render = (sdk: Optimization): string => `
+
+
+ Test SDK page
+
+
+
+
+
+
+
+`
+
+function initSDK(anonymousId: string | undefined): Optimization {
+ const url = new URL('http://localhost:3000/')
+
+ return new Optimization({
+ clientId: CLIENT_ID,
+ environment: ENVIRONMENT,
+ logLevel: 'debug',
+ eventBuilder: {
+ getAnonymousId: () => anonymousId,
+ getLocale: () => 'en-US',
+ getUserAgent: () => 'node-js-server',
+ getPageProperties: () => ({
+ path: url.pathname,
+ query: {},
+ referrer: 'http://localhost:3000/',
+ search: url.search,
+ url: url.toString(),
+ }),
+ },
+ api: {
+ analytics: { baseUrl: VITE_INSIGHTS_API_BASE_URL },
+ personalization: { baseUrl: VITE_EXPERIENCE_API_BASE_URL },
+ },
+ })
+}
+
+function setAnonymousId(res: Response, id: string): void {
+ res.cookie(ANONYMOUS_ID_COOKIE, id, {
+ path: '/',
+ domain: 'localhost',
+ })
+}
+
+function getAnonymousIdFromCookies(cookies: unknown): string | undefined {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- req.cookies is of type any
+ return (cookies as Record)[ANONYMOUS_ID_COOKIE] ?? undefined
+}
+
+app.get('/', limiter, async (req, res) => {
+ const sdk = initSDK(getAnonymousIdFromCookies(req.cookies))
+ const { profile } = await sdk.personalization.page({})
+
+ setAnonymousId(res, profile.id)
+ res.send(render(sdk))
+})
+
+app.get('/user/:userId', limiter, async (req, res) => {
+ const { userId } = req.params as Record
+ const sdk = initSDK(getAnonymousIdFromCookies(req.cookies))
+ if (userId) await sdk.personalization.identify({ userId })
+ const { profile } = await sdk.personalization.page({})
+
+ setAnonymousId(res, profile.id)
+ res.send(render(sdk))
+})
+
+app.get('/smoke-test', limiter, (_, res) => {
+ const sdk = initSDK(undefined)
+ res.send(render(sdk))
+})
+
+app.use('/dist', express.static('./public/dist'))
+
+const port = 3000
+
+app.listen(port, () => {
+ // eslint-disable-next-line no-console -- debug
+ console.log(`Express is listening at http://localhost:${port}`)
+})
+
+export default app
diff --git a/implementations/node-web/tsconfig.json b/implementations/node-ssr/tsconfig.json
similarity index 100%
rename from implementations/node-web/tsconfig.json
rename to implementations/node-ssr/tsconfig.json
diff --git a/implementations/node-web/vitest.config.ts b/implementations/node-ssr/vitest.config.ts
similarity index 100%
rename from implementations/node-web/vitest.config.ts
rename to implementations/node-ssr/vitest.config.ts
diff --git a/implementations/node-web/README.md b/implementations/node-web/README.md
deleted file mode 100644
index 70cbdb19..00000000
--- a/implementations/node-web/README.md
+++ /dev/null
@@ -1,3 +0,0 @@
-# Contentful Optimization Node.JS Web SDK Reference Implementation
-
-This is a reference implementation for the [Optimization Node.JS Web SDK](../../platforms/javascript/node/README.md) and is part of the [Contentful Optimization SDK Suite](../../README.md).
diff --git a/implementations/node-web/docker-compose.yaml b/implementations/node-web/docker-compose.yaml
deleted file mode 100644
index 9b07319d..00000000
--- a/implementations/node-web/docker-compose.yaml
+++ /dev/null
@@ -1,11 +0,0 @@
-services:
- web:
- image: nginx:alpine
- volumes:
- - ./nginx/templates:/etc/nginx/templates:ro
- - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- - ./public:/usr/share/nginx/html:ro
- ports:
- - '4000:80'
- env_file: ./.env
- restart: unless-stopped
diff --git a/implementations/node-web/e2e/example.spec.ts b/implementations/node-web/e2e/example.spec.ts
deleted file mode 100644
index 1b4a2d3d..00000000
--- a/implementations/node-web/e2e/example.spec.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { expect, test } from '@playwright/test'
-
-const CLIENT_ID = process.env.VITE_NINETAILED_CLIENT_ID ?? 'error'
-
-test('test SSR', async ({ request }) => {
- const response = await request.get('http://localhost:3000/')
-
- expect(await response.text()).toEqual(CLIENT_ID)
-})
-
-test('test CSR', async ({ page }) => {
- await page.goto('http://localhost:4000/')
-
- await expect(page.getByTestId('clientId')).toHaveText(CLIENT_ID)
-})
diff --git a/implementations/node-web/nginx/nginx.conf b/implementations/node-web/nginx/nginx.conf
deleted file mode 100644
index 7fd6bbdb..00000000
--- a/implementations/node-web/nginx/nginx.conf
+++ /dev/null
@@ -1,36 +0,0 @@
-events {}
-
-http {
- include /etc/nginx/mime.types;
- include /etc/nginx/conf.d/*.conf;
-
- gzip on;
- gzip_min_length 1000;
- gzip_proxied no-cache no-store private expired auth;
- gzip_types
- application/atom+xml
- application/geo+json
- application/javascript
- application/x-javascript
- application/json
- application/ld+json
- application/manifest+json
- application/rdf+xml
- application/rss+xml
- application/xhtml+xml
- application/xml
- font/eot
- font/otf
- font/ttf
- image/svg+xml
- text/css
- text/javascript
- text/plain
- text/xml;
-
- absolute_redirect off;
-
- types {
- application/javascript mjs;
- }
-}
diff --git a/implementations/node-web/nginx/templates/default.conf.template b/implementations/node-web/nginx/templates/default.conf.template
deleted file mode 100644
index b6efb85c..00000000
--- a/implementations/node-web/nginx/templates/default.conf.template
+++ /dev/null
@@ -1,30 +0,0 @@
-server {
- # TODO: consider '.local' to support multiple hosts
- server_name localhost;
-
- ssi on;
- ssi_types text/html;
-
- set $NGINX_NINETAILED_CLIENT_ID "${VITE_NINETAILED_CLIENT_ID}";
- set $NGINX_NINETAILED_ENVIRONMENT "${VITE_NINETAILED_ENVIRONMENT}";
-
- set $NGINX_CONTENTFUL_TOKEN "${VITE_CONTENTFUL_TOKEN}";
- set $NGINX_CONTENTFUL_PREVIEW_TOKEN "${VITE_CONTENTFUL_PREVIEW_TOKEN}";
- set $NGINX_CONTENTFUL_ENVIRONMENT "${VITE_CONTENTFUL_ENVIRONMENT}";
- set $NGINX_CONTENTFUL_SPACE_ID "${VITE_CONTENTFUL_SPACE_ID}";
-
- set $NGINX_EXPERIENCE_API_BASE_URL "${VITE_EXPERIENCE_API_BASE_URL}";
- set $NGINX_INSIGHTS_API_BASE_URL "${VITE_INSIGHTS_API_BASE_URL}";
-
- listen 80;
-
- root /usr/share/nginx/html;
-
- index index.html;
-
- location / {
- try_files $uri $uri/ =404;
- }
-
- # TODO: add CSP & CORS directives for more thorough testing
-}
diff --git a/implementations/node-web/public/index.html b/implementations/node-web/public/index.html
deleted file mode 100644
index f2dc7c93..00000000
--- a/implementations/node-web/public/index.html
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
- Test SDK page
-
-
-
-
-
-
diff --git a/implementations/node-web/src/app.ts b/implementations/node-web/src/app.ts
deleted file mode 100644
index c4d407d4..00000000
--- a/implementations/node-web/src/app.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import Optimization from '@contentful/optimization-node'
-import express, { type Express } from 'express'
-import rateLimit from 'express-rate-limit'
-
-const limiter = rateLimit({
- windowMs: 900_000,
- max: 100,
-})
-
-const app: Express = express()
-app.use(limiter)
-
-const sdk = new Optimization({
- clientId: process.env.VITE_NINETAILED_CLIENT_ID ?? '',
- environment: process.env.VITE_NINETAILED_ENVIRONMENT ?? '',
- logLevel: 'debug',
- api: {
- analytics: { baseUrl: process.env.VITE_INSIGHTS_API_BASE_URL },
- personalization: { baseUrl: process.env.VITE_EXPERIENCE_API_BASE_URL },
- },
-})
-
-app.get('/', limiter, (_req, res) => {
- res.send(sdk.config.clientId)
-})
-
-const port = 3000
-
-app.listen(port, () => {
- // eslint-disable-next-line no-console -- debug
- console.log(`Express is listening at http://localhost:${port}`)
-})
-
-export default app
diff --git a/platforms/javascript/node/src/Optimization.ts b/platforms/javascript/node/src/Optimization.ts
index 56d4dd42..8ddae000 100644
--- a/platforms/javascript/node/src/Optimization.ts
+++ b/platforms/javascript/node/src/Optimization.ts
@@ -1,8 +1,9 @@
import { type App, type CoreConfig, CoreStateless } from '@contentful/optimization-core'
import { merge } from 'es-toolkit'
-export interface OptimizationNodeConfig extends CoreConfig {
+export interface OptimizationNodeConfig extends Omit {
app?: App
+ eventBuilder?: Partial
}
function mergeConfig(config: OptimizationNodeConfig): CoreConfig {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 892e7ce2..7c35b963 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -169,11 +169,17 @@ importers:
specifier: ^3.2.4
version: 3.2.4(@types/node@24.2.0)(happy-dom@20.0.2)(jiti@2.5.1)(msw@2.10.5(@types/node@24.2.0)(typescript@5.9.2))(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.1)
- implementations/node-web:
+ implementations/node-ssr:
dependencies:
'@contentful/optimization-node':
specifier: workspace:*
version: link:../../platforms/javascript/node/dist
+ '@contentful/optimization-web':
+ specifier: workspace:*
+ version: link:../../platforms/javascript/web/dist
+ cookie-parser:
+ specifier: 1.4.7
+ version: 1.4.7
express:
specifier: 'catalog:'
version: 5.1.0
@@ -187,6 +193,9 @@ importers:
'@playwright/test':
specifier: ^1.55.1
version: 1.56.0
+ '@types/cookie-parser':
+ specifier: 1.4.7
+ version: 1.4.7
'@types/express':
specifier: 'catalog:'
version: 5.0.3
@@ -2310,6 +2319,9 @@ packages:
'@types/conventional-commits-parser@5.0.1':
resolution: {integrity: sha512-7uz5EHdzz2TqoMfV7ee61Egf5y6NkcO4FB/1iCCQnbeiI1F3xzv3vK5dBCXUCLQgGYS+mUeigK1iKQzvED+QnQ==}
+ '@types/cookie-parser@1.4.7':
+ resolution: {integrity: sha512-Fvuyi354Z+uayxzIGCwYTayFKocfV7TuDYZClCdIP9ckhvAu/ixDtCB6qx2TT0FKjPLf1f3P/J1rgf6lPs64mw==}
+
'@types/cookie@0.6.0':
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
@@ -3476,6 +3488,10 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+ cookie-parser@1.4.7:
+ resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==}
+ engines: {node: '>= 0.8.0'}
+
cookie-signature@1.0.6:
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
@@ -9932,6 +9948,10 @@ snapshots:
'@types/node': 24.2.0
optional: true
+ '@types/cookie-parser@1.4.7':
+ dependencies:
+ '@types/express': 5.0.3
+
'@types/cookie@0.6.0': {}
'@types/cookiejar@2.1.5': {}
@@ -11373,6 +11393,11 @@ snapshots:
convert-source-map@2.0.0: {}
+ cookie-parser@1.4.7:
+ dependencies:
+ cookie: 0.7.2
+ cookie-signature: 1.0.6
+
cookie-signature@1.0.6: {}
cookie-signature@1.2.2: {}