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
16 changes: 16 additions & 0 deletions .github/workflows/test-bloblang-playground.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@ on:
- 'src/js/vendor/wasm_exec.js'
- 'src/js/16-bloblang-interactive.js'
- 'src/js/17-bloblang-yaml.js'
- 'src/js/19-property-tooltips.js'
- 'src/css/bloblang-interactive.css'
- 'src/static/bloblang-docs.json'
- 'tests/bloblang-playground/**'
- 'tests/bloblang-interactive/**'
- 'tests/negative-cache/**'
- 'package.json'
- 'package-lock.json'
- 'gulpfile.js'
- '.github/workflows/test-bloblang-playground.yml'
pull_request:
Expand All @@ -22,10 +26,14 @@ on:
- 'src/js/vendor/wasm_exec.js'
- 'src/js/16-bloblang-interactive.js'
- 'src/js/17-bloblang-yaml.js'
- 'src/js/19-property-tooltips.js'
- 'src/css/bloblang-interactive.css'
- 'src/static/bloblang-docs.json'
- 'tests/bloblang-playground/**'
- 'tests/bloblang-interactive/**'
- 'tests/negative-cache/**'
- 'package.json'
- 'package-lock.json'
- 'gulpfile.js'
- '.github/workflows/test-bloblang-playground.yml'
workflow_dispatch:
Expand Down Expand Up @@ -99,6 +107,8 @@ jobs:
run: npx gulp test:build
- name: Test Interactive Features
run: npm run test:interactive
- name: Test Negative Cache
run: npm run test:negative-cache
- name: Upload playground test results
uses: actions/upload-artifact@v4
if: always()
Expand All @@ -111,4 +121,10 @@ jobs:
with:
name: test-results-interactive
path: test-results-interactive.json
- name: Upload negative-cache test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results-negative-cache
path: test-results-negative-cache.json

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@
"test:headless": "node tests/bloblang-playground/test-runner.js",
"test:playground": "npm run build:wasm && npm run test:headless",
"test:interactive": "node tests/bloblang-interactive/test-runner.js",
"test:all": "npm run test:playground && npm run test:interactive",
"test:negative-cache": "node tests/negative-cache/test-runner.js",
"test:all": "npm run test:playground && npm run test:interactive && npm run test:negative-cache",
"build:wasm": "cd blobl-editor/wasm && GOOS=js GOARCH=wasm go build -o ../../src/static/blobl.wasm .",
"copy:wasm-exec": "cp \"$(go env GOROOT)/lib/wasm/wasm_exec.js\" src/js/vendor/",
"serve:playground": "npx serve ."
Expand Down
121 changes: 53 additions & 68 deletions src/js/16-bloblang-interactive.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,43 @@
);
}

/**
* Track Connect JSON URLs that recently returned HTTP 404/410, so a URL
* that is known not to exist (for example, a version whose JSON was never
* generated) is not re-requested on every page view. Transient failures
* (429, 5xx, network errors) are never recorded here.
*/
const FETCH_FAILURE_KEY = 'connect-json-fetch-failures';
const FETCH_FAILURE_TTL = 60 * 60 * 1000; // 1 hour

function readFetchFailures() {
try {
const parsed = JSON.parse(localStorage.getItem(FETCH_FAILURE_KEY) || '{}');
const now = Date.now();
const fresh = {};
Object.keys(parsed).forEach((url) => {
if (now - parsed[url] < FETCH_FAILURE_TTL) fresh[url] = parsed[url];
});
return fresh;
} catch (e) {
return {};
}
}

function hasRecentFetchFailure(url) {
return url in readFetchFailures();
}

function markFetchFailure(url) {
try {
const failures = readFetchFailures();
failures[url] = Date.now();
localStorage.setItem(FETCH_FAILURE_KEY, JSON.stringify(failures));
} catch (e) {
// localStorage not available
}
}

/**
* Parse a Bloblang snippet into mapping, input, and metadata sections.
* Looks for # In: and # Meta: comment directives.
Expand Down Expand Up @@ -178,9 +215,14 @@
}

/**
* Try to fetch Connect JSON - uses meta tag URL or falls back to version-based path
* Try to fetch Connect JSON. The connect-json-url meta tag - resolved at
* build time against the attachments that actually exist in the catalog
* (set-available-attachment-versions extension in
* docs-extensions-and-macros) - is the only production source: only the
* newest connect-<version>.json is hosted, so guessing other versions can
* only produce 404s.
*/
async function tryFetchConnectJSON(version) {
async function tryFetchConnectJSON() {
try {
let url;

Expand All @@ -189,18 +231,22 @@
const rootPath = typeof uiRootPath !== 'undefined' ? uiRootPath : '/_';
url = `${rootPath}/connect.json`;
} else {
// Production: try meta tag URL first (resolved by Antora)
url = getConnectJsonUrl();

// Fallback if meta tag not set
if (!url) {
url = `/redpanda-connect/components/_attachments/connect-${version}.json`;
if (!url || hasRecentFetchFailure(url)) {
return null;
}
}

const response = await fetch(url);

if (!response.ok) {
// Only mark deterministic missing-resource responses (404/410).
// Transient failures (429, 5xx) are not cached, so the next page
// view retries them.
if (!isPreviewMode() && (response.status === 404 || response.status === 410)) {
markFetchFailure(url);
}
return null;
}

Expand Down Expand Up @@ -288,54 +334,6 @@
return docs;
}

/**
* Get Connect version from cache or fetch from antora.yml
* Caches in localStorage for 1 hour to avoid repeated fetches
*/
async function getConnectVersion() {
var CACHE_KEY = 'bloblang-connect-version';
var CACHE_TTL = 60 * 60 * 1000; // 1 hour

// Check cache first
try {
var cached = localStorage.getItem(CACHE_KEY);
if (cached) {
var parsed = JSON.parse(cached);
if (Date.now() - parsed.timestamp < CACHE_TTL) {
return parsed.version;
}
}
} catch (e) {
// localStorage not available or parse error
}

// Fetch from antora.yml (no rate limits, CDN-served)
try {
var resp = await fetch('https://raw.githubusercontent.com/redpanda-data/rp-connect-docs/main/antora.yml');
if (resp.ok) {
var yaml = await resp.text();
var match = yaml.match(/latest-connect-version:\s*['"]?(\d+\.\d+\.\d+)/);
if (match) {
var version = match[1];
// Cache the result
try {
localStorage.setItem(CACHE_KEY, JSON.stringify({
version: version,
timestamp: Date.now()
}));
} catch (e) {
// localStorage not available
}
return version;
}
}
} catch (e) {
// Silent fail - will use fallback versions
}

return null;
}

/**
* Load Bloblang documentation from Connect JSON
*/
Expand All @@ -358,20 +356,7 @@

// Skip remote fetches on docs-ui preview site - JSON files don't exist there
if (!isDocsUiPreview) {
// Try to get latest version from cached antora.yml
var latestVersion = await getConnectVersion();
if (latestVersion) {
data = await tryFetchConnectJSON(latestVersion);
}

// Fallback: try known recent versions
if (!data) {
var fallbackVersions = ['4.79.0', '4.78.0', '4.77.0', '4.76.0', '4.75.0'];
for (var i = 0; i < fallbackVersions.length; i++) {
data = await tryFetchConnectJSON(fallbackVersions[i]);
if (data) break;
}
}
data = await tryFetchConnectJSON();
}

// Transform data to our format
Expand Down
96 changes: 88 additions & 8 deletions src/js/19-property-tooltips.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,65 @@
)
}

var CACHE_KEY = 'redpanda-properties-cache'
var CACHE_TTL = 24 * 60 * 60 * 1000 // 24 hours
// Missing-resource markers live under their own key so they can never
// overwrite a valid cached dataset, and are only written for HTTP 404/410
// (the resource does not exist). Transient failures (5xx, offline, parse
// errors) are not cached at all: the next page view simply retries.
// The value is a per-URL map ({url: {version, timestamp}}) because the
// properties JSON URL and tag vary per doc version: a user browsing
// several versions with missing JSON must not thrash a single marker.
var MISSING_CACHE_KEY = 'redpanda-properties-missing'
var MISSING_CACHE_TTL = 60 * 60 * 1000 // 1 hour: re-check missing resources so a fix deploy is picked up

/**
* Read the missing-resource map, dropping expired or malformed entries
*/
function readMissingMarkers () {
try {
var parsed = JSON.parse(localStorage.getItem(MISSING_CACHE_KEY) || '{}')
if (!parsed || typeof parsed !== 'object') return {}
var now = Date.now()
var fresh = {}
Object.keys(parsed).forEach(function (markedUrl) {
var entry = parsed[markedUrl]
if (entry && typeof entry.timestamp === 'number' && now - entry.timestamp < MISSING_CACHE_TTL) {
fresh[markedUrl] = entry
}
})
return fresh
} catch (e) {
return {}
}
}

function hasMissingMarker (url, version) {
var entry = readMissingMarkers()[url]
return !!(entry && entry.version === version)
}

function markMissing (url, version) {
try {
var markers = readMissingMarkers()
markers[url] = { version: version, timestamp: Date.now() }
localStorage.setItem(MISSING_CACHE_KEY, JSON.stringify(markers))
} catch (e) {
// localStorage full or unavailable
}
}

function clearMissingMarker (url) {
try {
var markers = readMissingMarkers()
delete markers[url]
// Always rewrite: this also prunes expired entries from storage
localStorage.setItem(MISSING_CACHE_KEY, JSON.stringify(markers))
} catch (e) {
// localStorage full or unavailable
}
}

/**
* Fetch properties JSON with caching
*/
Expand Down Expand Up @@ -90,12 +149,12 @@
}
}

var CACHE_KEY = 'redpanda-properties-cache'
var CACHE_TTL = 24 * 60 * 60 * 1000 // 24 hours
// Use latest-redpanda-tag meta tag for cache versioning
var cacheVersion = getLatestRedpandaTag() || 'unknown'

// Check localStorage cache (skip in preview mode for easier testing)
// Check localStorage cache (skip in preview mode for easier testing).
// The dataset cache and the missing-marker each get their own try/catch
// so a corrupt entry in one cannot disable the other check.
if (!isPreviewMode()) {
try {
var cached = localStorage.getItem(CACHE_KEY)
Expand All @@ -114,12 +173,25 @@
} catch (e) {
// Ignore cache errors
}
if (hasMissingMarker(url, cacheVersion)) {
// The resource is known not to exist for this version: resolve to
// an empty lookup instead of re-requesting a 404 on every page view
propertiesData = {}
propertiesLoading = false
propertiesLoadQueue.forEach(function (resolve) {
resolve(propertiesData)
})
propertiesLoadQueue = []
return Promise.resolve(propertiesData)
}
}

return fetch(url)
.then(function (response) {
if (!response.ok) {
throw new Error('HTTP ' + response.status)
var httpError = new Error('HTTP ' + response.status)
httpError.status = response.status
throw httpError
}
return response.json()
})
Expand All @@ -128,6 +200,9 @@

// Cache the result (skip in preview mode)
if (!isPreviewMode()) {
// Clear the missing-marker before the cache write: if setItem
// throws on quota, a stale marker must not outlive a successful fetch
clearMissingMarker(url)
try {
localStorage.setItem(
CACHE_KEY,
Expand Down Expand Up @@ -182,6 +257,9 @@
}

console.warn('Property tooltips: Failed to load properties data:', error)
if (!isPreviewMode() && (error.status === 404 || error.status === 410)) {
markMissing(url, cacheVersion)
}
propertiesLoading = false
propertiesData = {}
propertiesLoadQueue.forEach(function (resolve) {
Expand Down Expand Up @@ -458,10 +536,12 @@

function tryInit (retriesLeft) {
if (window.tippy) {
// Use requestIdleCallback for non-blocking processing
var schedule = window.requestIdleCallback || function (cb) {
setTimeout(cb, 100)
}
// Use requestIdleCallback for non-blocking processing, with a
// timeout so tooltips still attach promptly when the main thread
// never goes idle (busy pages, loaded CI runners)
var schedule = window.requestIdleCallback
? function (cb) { window.requestIdleCallback(cb, { timeout: 500 }) }
: function (cb) { setTimeout(cb, 100) }
schedule(function () {
processCodeElements()
})
Expand Down
Loading
Loading