Skip to content
Open
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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@
},
"prettier": "@harperdb/code-guidelines/prettier",
"scripts": {
"dev": "harperdb dev .",
"start": "harperdb run .",
"dev": "harper dev .",
"start": "harper run .",
"lint": "eslint . --config eslint.config.mjs",
"test": "npx vitest run --config vitest.config.js"
},
Expand Down
27 changes: 15 additions & 12 deletions resources/BulkUpload.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,13 @@ export default class BulkUpload extends Resource {
* { urlList: Array<string>, refreshInterval: number }
* { sitemap: string, isIndex: boolean, pageRefreshInterval: number, sitemapRefreshInterval: number }
*
* @param {object} data - Bulk upload request payload.
* @param {object} target - Request target.
* @param {Promise<object>} data - Bulk upload request payload (must be awaited).
* @param {object} context - Request context.
* @returns {Promise<object>} - Status code and message.
*/
async post(data) {
static async post(target, data, context) {
data = await data;
if (!data) {
return {
status: 400,
Expand All @@ -68,13 +71,13 @@ export default class BulkUpload extends Resource {
}

// Add single URL to job queue
if (data.url) return await this.handleSingleUrl(data);
if (data.url) return await BulkUpload.handleSingleUrl(data);

// Add list of URLs to job queue
if (data.urlList) return await this.handleUrlList(data);
if (data.urlList) return await BulkUpload.handleUrlList(data);

// Parse sitemap and add URLs to job queue
if (data.sitemap) return await this.handleSitemap(data);
if (data.sitemap) return await BulkUpload.handleSitemap(data);

return {
status: 400,
Expand All @@ -87,7 +90,7 @@ export default class BulkUpload extends Resource {
* Handle PATCH requests.
* @returns {object} - 405 error for PATCH method
*/
async patch() {
static async patch(target, data, context) {
return {
status: 405,
headers: { 'Content-Type': 'application/json' },
Expand All @@ -101,7 +104,7 @@ export default class BulkUpload extends Resource {
/** Handle PUT requests.
* @returns {object} - 405 error for PUT method
*/
async put() {
static async put(target, data, context) {
return {
status: 405,
headers: { 'Content-Type': 'application/json' },
Expand All @@ -115,7 +118,7 @@ export default class BulkUpload extends Resource {
/** Handle DELETE requests.
* @returns {object} - 405 error for DELETE method
*/
async delete() {
static async delete(target, context) {
return {
status: 405,
headers: { 'Content-Type': 'application/json' },
Expand All @@ -129,7 +132,7 @@ export default class BulkUpload extends Resource {
/** Handle GET requests.
* @returns {object} - 405 error for GET method
*/
async get() {
static async get(target, context) {
return {
status: 405,
headers: { 'Content-Type': 'application/json' },
Expand All @@ -147,7 +150,7 @@ export default class BulkUpload extends Resource {
* @param {number} data.refreshInterval - Refresh interval in milliseconds.
* @returns {Promise<object>} - Status code and message.
*/
async handleSingleUrl({ url, refreshInterval }) {
static async handleSingleUrl({ url, refreshInterval }) {
try {
if (!url || typeof url !== 'string') {
return {
Expand Down Expand Up @@ -181,7 +184,7 @@ export default class BulkUpload extends Resource {
* @returns {Promise<object>} - Status code and message.
* @throws {object} - If the entire batch fails, throws an object with the URLs and error message.
*/
async handleUrlList({ urlList, refreshInterval }) {
static async handleUrlList({ urlList, refreshInterval }) {
try {
if (!urlList || !Array.isArray(urlList) || urlList.length === 0) {
return {
Expand Down Expand Up @@ -237,7 +240,7 @@ export default class BulkUpload extends Resource {
* @param {number} data.sitemapRefreshInterval - Sitemap refresh interval in milliseconds.
* @returns {Promise<object>} - Status code and message.
*/
async handleSitemap({ sitemap, isIndex, pageRefreshInterval, sitemapRefreshInterval }) {
static async handleSitemap({ sitemap, isIndex, pageRefreshInterval, sitemapRefreshInterval }) {
try {
if (!sitemap || typeof sitemap !== 'string') {
return {
Expand Down
4 changes: 3 additions & 1 deletion resources/CacheMetrics.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ const { hdb_analytics } = databases.system;
export default class CacheMetrics extends Resource {
/**
* Retrieves page content cache metrics from the last 60 seconds.
* @param {object} target - Request target.
* @param {object} context - Request context.
* @returns {Promise<Array<Object>>} - An array of metric objects matching the query.
*/
async get() {
static async get(target, context) {
logger.info('Retrieving page content cache metrics from the last 60 seconds');

// Compute rolling time window: [now - 60s, now]
Expand Down
158 changes: 79 additions & 79 deletions resources/PageContent.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
* - All write methods (POST, PUT, PATCH, DELETE) are intentionally disabled to preserve read-only semantics.
*/

import { server } from 'harperdb';
import { server } from 'harper';
import Job from './JobQueue.js';
import { allowedReadRoles, allowStaleContent } from '../utils/constants.js';
import { parseQuery } from '../utils/parse.js';
Expand All @@ -56,6 +56,70 @@ let usageCache = new Map();
* - Normalizes headers, injects error handling, and generates error pages on failures.
* - Responds with 405 "Method Not Allowed" for unsupported HTTP methods.
*/
/**
* Merge and normalize headers from page + context.
* Applies overrides for content errors and caching rules.
*
* @param {object} pageHeaders - Headers returned by source/page.
* @param {Headers} responseHeaders - Existing response headers.
* @param {boolean} contentError - Whether content retrieval failed.
* @param {number} contentLength - Final length of the content.
* @returns {Headers} - Normalized headers object.
*/
function getHeaders(pageHeaders, responseHeaders, contentError, contentLength) {
const pgHeaders = JSON.parse(pageHeaders || {});
const headers = responseHeaders || new Headers();
for (const [key, value] of Object.entries(pgHeaders)) {
switch (key) {
case 'server-timing': {
headers.append(key, value);
break;
}

case 'content-type': {
headers.set(key, contentError ? 'text/markdown; charset=utf-8' : value);
break;
}

case 'content-length': {
headers.set(key, contentLength);
break;
}

default: {
headers.set(key, value);
break;
}
}
}

if (contentError) {
headers.set('retry-after', 300); // 5 minute retry for errors
headers.delete('cache-control');
headers.delete('content-encoding'); // Blob error page is not gzipped to save time
}

return headers;
}

/**
* Returns 405 Method Not Allowed response
* for unsupported HTTP methods (POST, PUT, PATCH, DELETE)
* with a markdown message indicating the allowed method (GET)
* @returns {object} - object with status, headers, and markdown content
*/
function methodNotAllowed() {
const content = '# Method Not Allowed \n\nPlease use GET to retrieve the page content.';
const contentType = 'text/markdown; charset=utf-8';
const contentLength = Buffer.byteLength(content, 'utf8');

return {
status: 405,
data: { data: content, contentType },
headers: new Headers({ 'content-type': contentType, 'content-length': contentLength }),
};
}

export default class Content extends Resource {
static directURLMapping = true;

Expand All @@ -75,15 +139,15 @@ export default class Content extends Resource {
* and scheduling a background refresh job.
* - Merges response headers with overrides based on error conditions.
*
* @param {object} query - Request query.
* @param {object} target - Request target (extends URLSearchParams).
* @param {object} context - Request context.
* @returns {Promise<object>} - Response object with status, headers, and content.
*/
async get(query) {
const queryPath = this.getId();
const context = this.getContext();
static async get(target, context) {
const queryPath = target.id;

// Parse the request into host, path, and query string
const urlCacheKey = parseQuery(queryPath, query, context);
const urlCacheKey = parseQuery(queryPath, target, context);
logger.info(`Fetching content for: ${urlCacheKey}`);

// Check cache first, update lastAccessed if more than 1 minute old
Expand Down Expand Up @@ -134,7 +198,7 @@ export default class Content extends Resource {
}

const contentLength = contentError ? Buffer.byteLength(blob, 'utf8') : page.contentLength;
const respHeaders = this.getHeaders(page.headers, context.responseHeaders, contentError, contentLength);
const respHeaders = getHeaders(page.headers, context.responseHeaders, contentError, contentLength);

return {
headers: respHeaders,
Expand All @@ -144,88 +208,24 @@ export default class Content extends Resource {
};
}

/**
* Merge and normalize headers from page + context.
* Applies overrides for content errors and caching rules.
*
* @param {object} pageHeaders - Headers returned by source/page.
* @param {Headers} responseHeaders - Existing response headers.
* @param {boolean} contentError - Whether content retrieval failed.
* @param {number} contentLength - Final length of the content.
* @returns {Headers} - Normalized headers object.
*/
getHeaders(pageHeaders, responseHeaders, contentError, contentLength) {
const pgHeaders = JSON.parse(pageHeaders || {});
const headers = responseHeaders || new Headers();
for (const [key, value] of Object.entries(pgHeaders)) {
switch (key) {
case 'server-timing': {
headers.append(key, value);
break;
}

case 'content-type': {
headers.set(key, contentError ? 'text/markdown; charset=utf-8' : value);
break;
}

case 'content-length': {
headers.set(key, contentLength);
break;
}

default: {
headers.set(key, value);
break;
}
}
}

if (contentError) {
headers.set('retry-after', 300); // 5 minute retry for errors
headers.delete('cache-control');
headers.delete('content-encoding'); // Blob error page is not gzipped to save time
}

return headers;
}

/**
* Returns 405 Method Not Allowed response
* for unsupported HTTP methods (POST, PUT, PATCH, DELETE)
* with a markdown message indicating the allowed method (GET)
* @returns {object} - object with status, headers, and markdown content
*/
methodNotAllowed() {
const content = '# Method Not Allowed \n\nPlease use GET to retrieve the page content.';
const contentType = 'text/markdown; charset=utf-8';
const contentLength = Buffer.byteLength(content, 'utf8');

return {
status: 405,
data: { data: content, contentType },
headers: new Headers({ 'content-type': contentType, 'content-length': contentLength }),
};
}

/** @returns {object} - 405 error for POST method */
post() {
return this.methodNotAllowed();
static post() {
return methodNotAllowed();
}

/** @returns {object} - 405 error for PUT method */
put() {
return this.methodNotAllowed();
static put() {
return methodNotAllowed();
}

/** @returns {object} - 405 error for PATCH method */
patch() {
return this.methodNotAllowed();
static patch() {
return methodNotAllowed();
}

/** @returns {object} - 405 error for DELETE method */
delete() {
return this.methodNotAllowed();
static delete() {
return methodNotAllowed();
}
}

Expand Down
25 changes: 15 additions & 10 deletions resources/PageFilter.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,12 @@ export default class Filter extends Resource {
/**
* Get filters for a given path.
*
* @param {string} pathname - Path identifier for the page.
* @param {object} target - Request target (extends URLSearchParams); target.id is the path.
* @param {object} context - Request context.
* @returns {Promise<object>} Result of the get operation.
*/
async get() {
const pathname = this.getId();
static async get(target, context) {
const pathname = target.id;
if (!pathname || typeof pathname !== 'string') {
return {
status: 400,
Expand All @@ -68,13 +69,16 @@ export default class Filter extends Resource {
/**
* Create or update a filter record for a path.
*
* @param {object} data - The filter data.
* @param {object} target - Request target.
* @param {Promise<object>} data - The filter data (must be awaited).
* @param {string} data.path - Path identifier for the page (gets normalized internally).
* @param {string} data.filters - Comma-separated CSS selectors string.
* Example: `"header, #id1, .class2"`.
* @param {object} context - Request context.
* @returns {Promise<object>} Result of the post operation, or an error response if validation fails.
*/
async post(data) {
static async post(target, data, context) {
data = await data;
if (!data.path || typeof data.path !== 'string') {
return {
status: 400,
Expand Down Expand Up @@ -113,11 +117,12 @@ export default class Filter extends Resource {
/**
* Delete filters for a given path.
*
* @param {string} pathname - Path identifier for the page.
* @param {object} target - Request target; target.id is the path identifier.
* @param {object} context - Request context.
* @returns {Promise<object>} Result of the delete operation.
*/
async delete() {
const pathname = this.getId();
static async delete(target, context) {
const pathname = target.id;
if (!pathname || typeof pathname !== 'string') {
return {
status: 400,
Expand Down Expand Up @@ -145,7 +150,7 @@ export default class Filter extends Resource {
* Method not allowed, must use PUT to create/update filters.
* @returns {object} - 405 error for PATCH method
*/
async patch() {
static async patch(target, data, context) {
return {
status: 405,
headers: { 'Content-Type': 'application/json' },
Expand All @@ -160,7 +165,7 @@ export default class Filter extends Resource {
* Method not allowed, must use POST to create/update filters.
* @returns {object} - 405 error for PUT method
*/
async put() {
static async put(target, data, context) {
return {
status: 405,
headers: { 'Content-Type': 'application/json' },
Expand Down
Loading