Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,49 @@ Handlers can be registered via `controller.on(event, callback)`, `once`, and `of
npm install
```

### Generate SSL Certificates
The example server requires HTTPS. Generate local SSL certificates using [mkcert](https://github.com/FiloSottile/mkcert):

1. Install mkcert (if not already installed):
```bash
# macOS
brew install mkcert

# Windows
choco install mkcert

# Linux
# See https://github.com/FiloSottile/mkcert#installation
```

2. Install local CA:
```bash
mkcert -install
```

3. Generate certificates in the `examples/ssl` folder:
```bash
mkdir -p examples/ssl
cd examples/ssl
mkcert localhost 127.0.0.1
```

This creates `localhost+1.pem` (certificate) and `localhost+1-key.pem` (private key) in the `examples/ssl/` directory.

### Environment Variables
Create a `.env` file with the following keys:

```bash
# Environment mode (development or production)
NODE_ENV=development

# Your Pixelbin API key (get this from your Pixelbin dashboard)
PIXELBIN_API_KEY=<YOUR_API_KEY>

# Widget origin URL
WIDGET_ORIGIN=https://console.pixelbinz0.de # or https://console.pixelbin.io for production
```

### Build
To build the project and generate the SRI hash:
```bash
Expand Down
13 changes: 10 additions & 3 deletions examples/server.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
const express = require('express');
const cors = require('cors');
const crypto = require('crypto'); // Built-in Node.js module
// const crypto = require('crypto'); // Built-in Node.js module
const path = require('path');
const fs = require('fs');
const https = require('https');

// Load environment variables from .env file in the examples directory
require('dotenv').config({ quiet: true });
Expand All @@ -13,13 +15,18 @@ const PORT = 3000;
app.use(cors());
app.use(express.json());

const options = {
key: fs.readFileSync(path.join(__dirname, 'ssl', 'localhost+1-key.pem')), // Your private key file
cert: fs.readFileSync(path.join(__dirname, 'ssl', 'localhost+1.pem')) // Your certificate file
};

// ------------------------------------------------------------------
// CONFIGURATION
// ------------------------------------------------------------------
// In a real app, these should be environment variables.
// NEVER expose your API Key in the frontend code!
const PIXELBIN_API_KEY = process.env.PIXELBIN_API_KEY || 'YOUR_API_KEY_HERE';
const ALLOWED_ORIGINS = ['http://localhost:3000', 'http://127.0.0.1:3000'];
// const ALLOWED_ORIGINS = ['http://localhost:3000', 'http://127.0.0.1:3000'];
const BASE_URL = process.env.NODE_ENV === 'production' ? 'https://api.pixelbin.io' : 'https://api.pixelbinz0.de';

// ------------------------------------------------------------------
Expand Down Expand Up @@ -89,7 +96,7 @@ app.get('/api/config', (req, res) => {
// ------------------------------------------------------------------
// START SERVER
// ------------------------------------------------------------------
const server = app.listen(PORT, () => {
const server = https.createServer(options, app).listen(PORT, () => {
console.log(`\n🚀 Reference Backend Server running at http://localhost:${PORT}`);
console.log(`👉 Open http://localhost:${PORT}/basic.html to see the widget example.`);
console.log(` (Make sure you have built the SDK first with 'npm run build')\n`);
Expand Down
3 changes: 2 additions & 1 deletion src/constants/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ export const EVENTS = {
ERROR: 'WIDGET_ERROR',
LOGOUT: 'WIDGET_LOGOUT',
NAVIGATED: 'WIDGET_NAVIGATED',
SESSION_EXPIRED: 'WIDGET_SESSION_EXPIRED'
SESSION_EXPIRED: 'WIDGET_SESSION_EXPIRED',
SESSION_NOT_FOUND: 'WIDGET_SESSION_NOT_FOUND'
};

export const CMDS = {
Expand Down
194 changes: 90 additions & 104 deletions src/core/WidgetController.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
this._ready = false;
this._destroyed = false;
this._queue = [];
this._reinitializing = false;
this._authenticating = false;

// Initialize handlers
this._initHandler = new InitHandler(
Expand All @@ -84,79 +84,100 @@
}

_setupFrameAndInit() {
const bs = this.config.bootstrap || {};
const needToken = (!!bs.getToken || !!bs.endpoint) && !bs.token;
// Enforce query placement for token with fixed query param name
const QUERY_PARAM_NAME = 'btToken';

const setupFrameAndInit = () => {
// Duplicate prevention
const mount = getDomNode(this.config.domNode);
const existing = mount.querySelector('iframe[data-widget-sdk="true"]');
if (existing) {
throw new SdkError(
ERROR_CODES.CONFIG_DUPLICATE_INIT,
ERROR_MESSAGES[ERROR_CODES.CONFIG_DUPLICATE_INIT],
{ domNode: this.config.domNode }
);
}
// Duplicate prevention
const mount = getDomNode(this.config.domNode);
const existing = mount.querySelector('iframe[data-widget-sdk="true"]');
if (existing) {
throw new SdkError(
ERROR_CODES.CONFIG_DUPLICATE_INIT,
ERROR_MESSAGES[ERROR_CODES.CONFIG_DUPLICATE_INIT],
{ domNode: this.config.domNode }
);
}

// Always place token in query (if available)
const cfgForIframe = (this.config.bootstrap && this.config.bootstrap.token)
? shallowMerge(this.config, {
params: shallowMerge(this.config.params || {}, {
[QUERY_PARAM_NAME]: bs.token
})
})
: this.config;

this._logger.log('cfgForIframe', cfgForIframe);

this.iframe = createIframe(cfgForIframe, (...args) => this._logger.log(...args));

// Bind message listener
this._onMessage = this._handleMessage.bind(this);
window.addEventListener('message', this._onMessage, false);

// INIT handshake (token is never sent via INIT; always via query)
const doInit = () => {
const payload = {
version: VERSION,
token: null,
parentOrigin: window.location.origin,
params: this.config.params || {}
};
if (this.config.embedId != null) payload.embedId = this.config.embedId;

this._initHandler.start(payload, () => {
this._ready = true;
this._flushQueue();
this._em.emit('ready');
if (this.config.autostart) this.open();
});
};
// Create iframe WITHOUT btToken - server will check for existing cookie session
this._logger.log('Creating iframe without btToken (cookie-first flow)');
this.iframe = createIframe(this.config, (...args) => this._logger.log(...args));

if (this.iframe.contentWindow && this.iframe.contentDocument && this.iframe.contentDocument.readyState === 'complete') {
doInit();
} else {
this.iframe.addEventListener('load', doInit, { once: true });
}
// Bind message listener
this._onMessage = this._handleMessage.bind(this);
window.addEventListener('message', this._onMessage, false);

// Set up load handler for INIT handshake
this._setupInitHandshake();
}

/**
* Set up the INIT handshake on iframe load.
* @private
*/
_setupInitHandshake() {
const doInit = () => {
const payload = {
version: VERSION,
token: null,
parentOrigin: window.location.origin,
params: this.config.params || {}
};
if (this.config.embedId != null) payload.embedId = this.config.embedId;

this._initHandler.start(payload, () => {
this._ready = true;
this._authenticating = false;
this._flushQueue();
this._em.emit('ready');
if (this.config.autostart) this.open();
});
};

if (needToken && !(this.config.bootstrap && this.config.bootstrap.token)) {
// Need token before iframe to put into URL
this._resolveToken()
.then((tk) => { this.config.bootstrap.token = tk; setupFrameAndInit(); })
.catch((e) => {
this._logger.error('Token error', e);
// Auth errors are NOT fatal by default - integrator can retry
this._emitError(e, false);
});
if (this.iframe.contentWindow && this.iframe.contentDocument && this.iframe.contentDocument.readyState === 'complete') {
doInit();
} else {
setupFrameAndInit();
this.iframe.addEventListener('load', doInit, { once: true });
}
}

/**
* Handle session authentication when iframe reports session error.
* Fetches btToken and reloads iframe with token in URL.
* @param {Object} payload - Event payload from iframe
* @private
*/
_handleSessionAuth(payload) {
if (this._authenticating || this._destroyed) return;

this._logger.warn('Session requires authentication, fetching token...', payload);
this._authenticating = true;

// Reset ready state
this._ready = false;
this._queue = [];
this._initHandler.clear();

// Fetch token and reload iframe
this._resolveToken()
.then((token) => {
if (this._destroyed) return;

this._logger.log('Token obtained, reloading iframe with btToken');

// Build new URL with btToken
const currentUrl = new URL(this.iframe.src);
currentUrl.searchParams.set('btToken', token);

// Update iframe src (triggers reload)
this.iframe.src = currentUrl.toString();

// Re-setup INIT handshake on load
this._setupInitHandshake();
})
.catch((e) => {
this._logger.error('Token fetch failed during session auth', e);
this._authenticating = false;
this._emitError(e, false);
});
}

/**
* Validate user configuration and throw descriptive errors when invalid.
* @param {WidgetConfig} u Configuration object
Expand Down Expand Up @@ -319,49 +340,14 @@
this._navigateHandler.handleAck(data.payload);
break;
case EVENTS.SESSION_EXPIRED:
this._handleSessionExpired(data.payload);
case EVENTS.SESSION_NOT_FOUND:
this._handleSessionAuth(data.payload);
break;
default:
break;
}
}

/**
* Handle session expiration - reinitialize widget with new token.
* @private
*/
_handleSessionExpired(payload) {
if (this._reinitializing || this._destroyed) return;

this._logger.warn('Session expired, reinitializing widget...', payload);
this._reinitializing = true;

// Reset state
this._ready = false;
this._queue = [];

// Cleanup existing iframe
try {
window.removeEventListener('message', this._onMessage, false);
if (this.iframe && this.iframe.parentNode) {
this.iframe.parentNode.removeChild(this.iframe);
}
} catch (e) {
this._logger.error('Error cleaning up during reinitialization', e);
}

// Clear token to force refresh
if (this.config.bootstrap) {
this.config.bootstrap.token = null;
}

// Reinitialize
setTimeout(() => {
this._reinitializing = false;
this._setupFrameAndInit();
}, 100);
}

/**
* Flush any queued actions once iframe is ready.
* @private
Expand Down Expand Up @@ -466,7 +452,7 @@
if (this._destroyed) return;
this._destroyed = true;
this._ready = false;
this._reinitializing = false;
this._authenticating = false;
this._queue = [];

// Cleanup all handlers
Expand All @@ -474,8 +460,8 @@
this._logoutHandler.clear();
this._navigateHandler.cleanup();

try { window.removeEventListener('message', this._onMessage, false); } catch (_) { /* no-op */ }

Check warning on line 463 in src/core/WidgetController.js

View workflow job for this annotation

GitHub Actions / build

'_' is defined but never used
try { if (this.iframe && this.iframe.parentNode) this.iframe.parentNode.removeChild(this.iframe); } catch (_) { /* no-op */ }

Check warning on line 464 in src/core/WidgetController.js

View workflow job for this annotation

GitHub Actions / build

'_' is defined but never used
this._em.emit('destroy');
}

Expand Down
Loading