Extracting Airtable's Schema by Reaching Into the Page's Own JavaScript
A small Chrome extension, three context boundaries, and a circular reference that broke JSON.stringify.
The Airtable Schema Extractor is a small Manifest V2 extension on the Chrome Web Store, free for anyone to install — it isn't tied to my account or my bases in any way. Open airtable.com/api for any base you have access to, click the extension icon, and it shows you that base's full table and field schema as JSON — no manual clicking through the API docs page table by table. It's a tiny extension, but building it meant crossing three separate JavaScript execution contexts, and each crossing had its own gotcha. (The source is on GitHub.)
Content Scripts Can't See the Page's Own Variables
Airtable's own front-end code builds an in-memory application object with the full schema already on it — tables, columns, field types, everything. The obvious plan: write a content script, read application.tables, done.
That doesn't work. Chrome runs content scripts in an isolated world— same DOM, separate JS heap. Content scripts can read and modify the page you injected into, but they don't share variables with the page's own scripts. application simply doesn't exist from a content script's point of view, even though it's sitting right there in the page.
{
"manifest_version": 2,
"name": "Airtable Schema Extractor",
"description": "This extension will extract schema from the airtable.com/api page",
"version": "1.0",
"browser_action": {
"default_icon": "icon.png",
"default_popup": "popup.html"
},
"content_scripts": [{
"js": ["scripts/contentscript.js"],
"matches": ["https://airtable.com/app*"]
}],
"web_accessible_resources": [
"scripts/getSchemaScript.js"
],
"permissions": [
"activeTab"
]
}The manifest declares a content script matching airtable.com/app*, plus a second file listed under web_accessible_resources — that second entry is the workaround.
Injecting a Script Into the Page Itself
To reach variables that live in the page's own world, the content script injects an actual <script> tag into the page. A script tag inserted this way runs in the page's context, not the content script's — so it can see application directly:
function injectGetSchemaScript() {
var s = document.createElement('script');
s.id="ashwinscript';"
s.src = chrome.extension.getURL('scripts/getSchemaScript.js');
(document.head || document.documentElement).appendChild(s);
}chrome.extension.getURL(...) resolves to a URL the page is allowed to load, which is exactly what the web_accessible_resources manifest entry grants. Without it, the page would refuse to load the script at all.
The Circular Reference Bug
Once the injected script can see application.tables, extracting the schema looks like a straight JSON.stringify. It isn't — it throws TypeError: Converting circular structure to JSON. Every linked-record column carries a foreignTable reference to the related table object, and that table has columns that reference back — a cycle JSON.stringify can't serialize.
The fix: walk every column, replace the full foreignTable object reference with just its ID, and drop sampleRows too (real row data has no business in a schema dump):
function getSchema() {
var tables = application.tables;
// Delete sampleRows
for (var i =0; i < tables.length; i++) {
var table = tables[i];
delete table.sampleRows;
}
// table.foreignTable causes "Converting circular structure to JSON" error
// so get rid of it
for (var i = 0; i < tables.length; i++) {
var table = tables[i];
var columns = table.columns;
for (var j = 0; j < columns.length; j++) {
var column = columns[j];
if (column.foreignTable) {
column.foreignTableId = column.foreignTable.id;
delete column.foreignTable;
}
}
}
return JSON.stringify(tables, null, 4);
}
function sendSchemaToExtension() {
var data = getSchema();
var evt = document.createEvent("CustomEvent");
evt.initCustomEvent('retreived-schema', true, true, data);
document.dispatchEvent(evt);
}foreignTableId is enough to identify the linked table without re-embedding the whole object graph.
Getting Data Back Out of the Page World
The injected script computed the schema, but it's still stuck in the page's isolated world — the content script can't just call a function on it and get a return value back, because they don't share a call stack any more than they share variables. The bridge back is a DOM event, which both worlds can see because they share the same DOM:
var schema = '';
// This listener is to get data from "getSchemaScript" to "contentScript"
function addSchemaRetrievedListener() {
document.addEventListener('retreived-schema', function (e) {
var data = e.detail;
schema = data;
});
}The injected script dispatches a CustomEvent named retreived-schema with the JSON string as its payload; the content script listens for that exact event on document and stashes the result in a module-level variable.
One More Hop: Content Script to Popup
The schema now lives in the content script's memory. But the popup — the little window that opens when you click the extension icon — runs in yet another separate context, with its own script and no shared variables with the content script either. That's a third boundary, crossed with Chrome's extension messaging API instead of a DOM event, since the popup and content script don't share a DOM:
// This listener is to send data from "contentScript" to "popup"
function addSendSchemaToPopupListener() {
chrome.runtime.onMessage.addListener(
function(message, sender, sendResponse) {
switch(message.type) {
case "getSchema":
sendResponse(schema);
break;
default:
console.error("Unrecognised message: ", message);
}
}
);
}The popup asks for the schema by sending a { type: "getSchema" } message to the active tab, and fills a textarea with whatever comes back:
function getSchemaFromContentScript() {
chrome.tabs.query({ active: true, currentWindow: true }, function(tabs) {
var url = tabs && tabs[0] && tabs[0].url;
url = url || '';
if (!url.includes('airtable.com/app')) {
var textarea = document.getElementById('schema');
textarea.value = 'Could not find schema.This extension has to be run from airtable.com/api';
return;
}
chrome.tabs.sendMessage(tabs[0].id, { type: "getSchema" }, function(schemaFromContentScript) {
schema = schemaFromContentScript;
var textarea = document.getElementById('schema');
textarea.value = schema;
});
});
}The Full Picture
One click on the extension icon actually triggers this chain:
- Content script injects
getSchemaScript.jsinto the page's own JS world - That script reads
application.tables, stripssampleRowsandforeignTableobject references, and serializes the result - It dispatches a
CustomEventondocumentto hand the JSON back to the content script - The popup sends a
chrome.runtimemessage asking the content script for the schema - The content script responds, and the popup renders it
None of these five steps is complicated in isolation. What makes it easy to get wrong is that there are three separate JavaScript worlds involved — the page, the content script, and the popup — and each pair only talks to the other through a specific, narrower channel than a normal function call.