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
50 changes: 33 additions & 17 deletions src/apis/html-page-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,50 @@ import axios from 'axios';

class HTMLPageAPI {
async initWithAccountToken({ server, accountToken, appUuid }) {
this.initServer(server);
this.appUuid = appUuid;
if (this.server && this.appUuid) {
try {
const res = await axios.get(`${this.server}api/v2.1/universal-apps/${this.appUuid}/access-token/`, {
headers: { Authorization: 'Token ' + accountToken }
});
this.accessToken = res.data?.access_token || '';
this.createReq();
} catch (error) {
// eslint-disable-next-line
console.log('Authorization failed');
}
if (!server || !accountToken || !appUuid) {
throw new Error('Failed to get access token: missing server, accountToken, or appUuid');
}

let res;
try {
res = await axios.get(`${server}api/v2.1/universal-apps/${appUuid}/access-token/`, {
headers: { Authorization: 'Token ' + accountToken }
});
} catch (error) {
throw new Error(`Failed to get access token: ${error.message}`);
}

const accessToken = res.data?.access_token;
if (!accessToken) {
throw new Error('Failed to get access token: access_token missing');
}
this.accessToken = accessToken;
}

init({ server, accessToken, appUuid }) {
this.initServer(server);
this.server = server;
this.accessToken = accessToken || '';
this.appUuid = appUuid;
if (this.accessToken && this.server && this.appUuid) {
this.createReq();
}
}

initServer(server) {
if (!server) return;
this.server = server.endsWith('/') ? server : `${server}/`;
async getParentOrigin({ server, accessToken, appUuid }) {
if (!server || !accessToken || !appUuid) {
throw new Error('Failed to get parentOrigin: missing server, accessToken, or appUuid');
}

try {
const response = await axios.post(
`${server}api/v2.1/universal-apps/bootstrap/`,
{ app_uuid: appUuid },
{ headers: { Authorization: 'Token ' + accessToken } },
);
return response.data?.parentOrigin || '';
} catch (error) {
throw new Error(`Failed to get parentOrigin: ${error.message}`);
}
}

createReq() {
Expand Down
106 changes: 87 additions & 19 deletions src/iframe-adapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ export const POST_MESSAGE_REQUEST_TYPE = {
GET_PREVIEW_TABLE_CONFIGS: 'get_preview_table_configs',
};

const BOOTSTRAP_REQUEST_TYPES = new Set([
POST_MESSAGE_REQUEST_TYPE.GET_SERVER,
POST_MESSAGE_REQUEST_TYPE.GET_ACCESS_TOKEN,
POST_MESSAGE_REQUEST_TYPE.GET_APP_UUID,
]);

const WINDOW_EVENT_SOURCE_TYPE = {
APP: 'app',
IFRAME: 'iframe',
Expand All @@ -27,6 +33,20 @@ const hasOwnProperty = (obj, key) => {
return Object.prototype.hasOwnProperty.call(obj, key);
};

const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);

const normalizeOrigin = (origin) => {
if (typeof origin !== 'string' || !origin) return null;

try {
const url = new URL(origin);
if (!['http:', 'https:'].includes(url.protocol) || url.origin === 'null') return null;
return url.origin;
} catch (error) {
return null;
}
};

const generatorBase64Code = (keyLength = 4) => {
let possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz0123456789';
let key = '';
Expand Down Expand Up @@ -77,10 +97,11 @@ export class IframeAdapter {
constructor(options) {
this.options = options || {};
this.selfWindow = window.parent === window.self;
this.targetOrigin = this.options.targetOrigin || '*';
this.targetOrigin = null;
this.pendingRequests = {};
this.eventHandlers = {};
this.timeout = this.options.timeout || 10000;
this._handleMessage = this.handleMessage.bind(this);
this.setupMessageListener();
}

Expand All @@ -94,12 +115,20 @@ export class IframeAdapter {

setupMessageListener() {
if (this.selfWindow) return;
window.addEventListener('message', this.handleMessage.bind(this));
window.addEventListener('message', this._handleMessage);
this.setEventsListener();
}

setTargetOrigin(origin) {
const targetOrigin = normalizeOrigin(origin);
if (!targetOrigin) {
throw new Error('Invalid trusted target origin');
}
this.targetOrigin = targetOrigin;
}

postWindowEvent(eventData) {
if (!eventData) return;
if (!eventData || !this.targetOrigin) return;
window.parent.postMessage({
type: POST_MESSAGE_TYPE.WINDOW_EVENT,
params: {
Expand Down Expand Up @@ -153,51 +182,90 @@ export class IframeAdapter {
}

async request(method, params) {
if (this.selfWindow) {
return new Promise((resolve) => {
resolve(null);
});
if (this.selfWindow) return null;
if (!this.targetOrigin) {
throw new Error('Trusted target origin has not been configured');
}
return this._request(method, params, this.targetOrigin);
}

async bootstrapRequest(method, params) {
if (this.selfWindow) return null;

// Initial server, access-token, and app-UUID requests run before the trusted
// parent origin is known. Responses still have to originate from window.parent
// and match the generated request ID. All later traffic requires the configured origin.
if (this.targetOrigin) {
return this._request(method, params, this.targetOrigin);
}
if (!BOOTSTRAP_REQUEST_TYPES.has(method)) {
throw new Error(`Unsupported bootstrap request: ${method}`);
}
return this._request(method, params, '*');
}

_request(method, params, targetOrigin) {
const requestId = this.generatorRequestId();
return new Promise((resolve, reject) => {
this.pendingRequests[requestId] = { resolve, reject };
this.pendingRequests[requestId] = { resolve, reject, targetOrigin };
window.parent.postMessage({
type: POST_MESSAGE_TYPE.HTML_PAGE_REQUEST,
requestId,
method,
params
}, this.targetOrigin);
}, targetOrigin);

// request timeout
// reject and clear the pending request
const timeoutId = setTimeout(() => {
if (hasOwnProperty(this.pendingRequests, requestId)) {
delete this.pendingRequests[requestId];
reject(new Error(`Request timeout: ${method}`));
}
}, this.timeout);

// save timeoutId for the pending request
const pending = this.pendingRequests[requestId];
if (pending) {
pending.timeoutId = timeoutId;
}
});
}

isMessageFromParent(event) {
return event && event.source === window.parent;
}

isTrustedMessage(event) {
return Boolean(this.targetOrigin && this.isMessageFromParent(event) && event.origin === this.targetOrigin);
}

isExpectedResponse(event, pending) {
// Bootstrap responses may come from any origin because the trusted origin is
// not known yet, but every response must come from window.parent. After
// bootstrap, the response origin must match the origin used for the request.
if (!this.isMessageFromParent(event)) return false;
return pending.targetOrigin === '*' || event.origin === pending.targetOrigin;
}

handleMessage(event) {
if (!isObject(event?.data)) return;

const { type, requestId, data, error, eventType, payload } = event.data;
if (type === POST_MESSAGE_TYPE.HTML_PAGE_RESPONSE) {
const pending = this.pendingRequests[requestId];
if (pending) {
clearTimeout(pending.timeoutId);
delete this.pendingRequests[requestId];
if (error) {
pending.reject(new Error(error));
} else {
if (!pending || !this.isExpectedResponse(event, pending)) return;

clearTimeout(pending.timeoutId);
delete this.pendingRequests[requestId];
if (error) {
pending.reject(new Error(error));
} else {
try {
pending.resolve(data ? JSON.parse(data) : null);
} catch (parseError) {
pending.reject(new Error('Invalid response payload'));
}
}
} else if (!this.isTrustedMessage(event)) {
return;
} else if (type === POST_MESSAGE_TYPE.HTML_PAGE_EVENT) {
this.emitEvent(eventType, payload);
} else if (type === POST_MESSAGE_TYPE.WINDOW_EVENT) {
Expand Down Expand Up @@ -289,7 +357,7 @@ export class IframeAdapter {
}

destroy() {
this.pendingRequests.forEach(pending => {
Object.values(this.pendingRequests).forEach(pending => {
clearTimeout(pending.timeoutId);
pending.reject(new Error('Adapter destroyed'));
});
Expand Down
77 changes: 53 additions & 24 deletions src/sdk.js
Original file line number Diff line number Diff line change
@@ -1,41 +1,70 @@
import HTMLPageAPI from './apis/html-page-api';
import { IframeAdapter, POST_MESSAGE_REQUEST_TYPE } from './iframe-adapter';

const AI_AGENT_PAGE_ID = 'ai_agent';

export class HTMLPageSDK {
constructor(options) {
this.options = options || {};
this.iframeAdapter = new IframeAdapter(options);
const sdkOptions = { ...(options || {}) };
delete sdkOptions.accessToken;
this.options = sdkOptions;
this.iframeAdapter = new IframeAdapter(sdkOptions);
}

async init() {
if (!this.options) {
this.options = {};
}
this.htmlPageAPI = new HTMLPageAPI();
if (!this.options.server) {
this.options.server = await this.iframeAdapter.request(POST_MESSAGE_REQUEST_TYPE.GET_SERVER);
if (Object.prototype.hasOwnProperty.call(this.options, 'accountToken')) {
await this._initDevelopment();
return;
}
if (!this.options.appUuid) {
this.options.appUuid = await this.iframeAdapter.request(POST_MESSAGE_REQUEST_TYPE.GET_APP_UUID);
await this._initProduction();
}

async _initDevelopment() {
const server = this._normalizeServer(this.options.server);
if (!server) {
throw new Error('Missing server configuration');
}
if (!this.options.pageId) {
this.options.pageId = await this.iframeAdapter.request(POST_MESSAGE_REQUEST_TYPE.GET_PAGE_ID);

this.options.server = server;
const { accountToken, appUuid } = this.options;
await this.htmlPageAPI.initWithAccountToken({ server, accountToken, appUuid });

const accessToken = this.htmlPageAPI.accessToken;
this.htmlPageAPI.init({ server, accessToken, appUuid });
}

async _initProduction() {
const server = this._normalizeServer(
await this.iframeAdapter.bootstrapRequest(POST_MESSAGE_REQUEST_TYPE.GET_SERVER)
);
if (!server) {
throw new Error('Missing server configuration');
}
if (this.options.pageId === 'ai_agent' && !Array.isArray(this.options.previewTableConfigs)) {
this.options.server = server;

const accessToken = await this.iframeAdapter.bootstrapRequest(POST_MESSAGE_REQUEST_TYPE.GET_ACCESS_TOKEN);
const appUuid = await this.iframeAdapter.bootstrapRequest(POST_MESSAGE_REQUEST_TYPE.GET_APP_UUID);
this.options.appUuid = appUuid;
await this._configureTrustedOrigin({ server, accessToken, appUuid });

this.options.pageId = await this.iframeAdapter.request(POST_MESSAGE_REQUEST_TYPE.GET_PAGE_ID);
if (this.options.pageId === AI_AGENT_PAGE_ID) {
const previewTableConfigs = await this.iframeAdapter.request(POST_MESSAGE_REQUEST_TYPE.GET_PREVIEW_TABLE_CONFIGS);
this.options.previewTableConfigs = Array.isArray(previewTableConfigs) ? previewTableConfigs : [];
}
if (this.options.accountToken) {
// dev: try to get access-token via accountToken
const { server, accountToken, appUuid } = this.options;
await this.htmlPageAPI.initWithAccountToken({ server, accountToken, appUuid });
} else {
if (!this.options.accessToken) {
this.options.accessToken = await this.iframeAdapter.request(POST_MESSAGE_REQUEST_TYPE.GET_ACCESS_TOKEN);
}
const { server, accessToken, appUuid } = this.options;
this.htmlPageAPI.init({ server, accessToken, appUuid });
}

this.htmlPageAPI.init({ server, accessToken, appUuid: this.options.appUuid });
}

async _configureTrustedOrigin({ server, accessToken, appUuid }) {
const parentOrigin = await this.htmlPageAPI.getParentOrigin({ server, accessToken, appUuid });
this.iframeAdapter.setTargetOrigin(parentOrigin);
}

_normalizeServer(server) {
if (!server) return '';
return server.endsWith('/') ? server : `${server}/`;
}

listRows({ tableName, start, limit }) {
Expand All @@ -49,7 +78,7 @@ export class HTMLPageSDK {
}

_getPreviewTableConfig({ tableName }) {
if (this.options.pageId !== 'ai_agent' || !Array.isArray(this.options.previewTableConfigs)) return undefined;
if (this.options.pageId !== AI_AGENT_PAGE_ID || !Array.isArray(this.options.previewTableConfigs)) return undefined;
const tableConfig = this.options.previewTableConfigs.find(config => tableName && config?.table_name === tableName);
if (!tableConfig) return undefined;
return {
Expand Down
Loading
Loading