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: {}