Skip to content

Commit

Permalink
Merge pull request #1863 from simoncozens/server-api
Browse files Browse the repository at this point in the history
Clarify server API
  • Loading branch information
justvanrossum authored Dec 19, 2024
2 parents 02974ab + ef4f446 commit e230eeb
Show file tree
Hide file tree
Showing 19 changed files with 563 additions and 217 deletions.
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ repos:
hooks:
- id: prettier
exclude: "src/fontra/client/third-party"
types_or: [css, javascript, json, xml, yaml, markdown, html]
types_or: [css, javascript, json, xml, yaml, markdown, html, ts]

- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
Expand Down
146 changes: 146 additions & 0 deletions src/fontra/client/core/backend-api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { getRemoteProxy } from "../core/remote.js";
import { fetchJSON } from "./utils.js";
import { StaticGlyph } from "./var-glyph.js";
import { VarPackedPath } from "./var-path.js";
/** @import { RemoteFont } from "remotefont" */

/**
* @module fontra/client/core/backend-api
* @description
* This module provides a class that can be used to interact with the backend API.
* The default Fontra backend is the Python-based web server. This class provides
* an abstraction over the functionality of the web server, so that alternative
* backends can be used.
*
*/
class AbstractBackend {
/**
* Get a list of projects from the backend.
* @returns {Promise<string[]>} An array of project names.
*/
static async getProjects() {}

/**
* Get a suggested glyph name for a given code point.
* @param {number} codePoint - The code point.
* @returns {Promise<string>} The suggested glyph name.
*/
static async getSuggestedGlyphName(codePoint) {}

/**
* Get the code point for a given glyph name.
* @param {string} glyphName - The glyph name.
* @returns {Promise<number>} The code point.
*/
static async getCodePointFromGlyphName(glyphName) {}

/**
* Parse clipboard data.
*
* Returns a glyph object parsed from either a SVG string or an UFO .glif.
* @param {string} data - The clipboard data.
* @returns {Promise<StaticGlyph>} - The glyph object, if parsable.
*/
static async parseClipboard(data) {}

/**
* Remove overlaps in a path
*
* In this and all following functions, the paths are represented as
* JSON VarPackedPath objects; i.e. they have `coordinates`, `pointTypes`,
* `contourInfo`, and `pointAttrbutes` fields.
*
* @param {VarPackedPath} path - The first path.
* @returns {Promise<VarPackedPath>} The union of the two paths.
*/
static async unionPath(path) {}

/**
* Subtract one path from another.
* @param {VarPackedPath} pathA - The first path.
* @param {VarPackedPath} pathB - The second path.
* @returns {Promise<VarPackedPath>} The difference of the two paths.
*/
static async subtractPath(pathA, pathB) {}

/**
* Intersect two paths.
* @param {VarPackedPath} pathA - The first path.
* @param {VarPackedPath} pathB - The second path.
* @returns {Promise<VarPackedPath>} The intersection of the two paths.
*/
static async intersectPath(pathA, pathB) {}

/**
* Exclude one path from another.
* @param {VarPackedPath} pathA - The first path.
* @param {VarPackedPath} pathB - The second path.
* @returns {Promise<VarPackedPath>} The exclusion of the two paths.
*/
static async excludePath(pathA, pathB) {}
}

class PythonBackend extends AbstractBackend {
static async getProjects() {
return fetchJSON("/projectlist");
}

static async _callServerAPI(functionName, kwargs) {
const response = await fetch(`/api/${functionName}`, {
method: "POST",
body: JSON.stringify(kwargs),
});

const result = await response.json();
if (result.error) {
throw new Error(result.error);
}
return result.returnValue;
}

static async getSuggestedGlyphName(codePoint) {
return await this._callServerAPI("getSuggestedGlyphName", { codePoint });
}

static async getCodePointFromGlyphName(glyphName) {
return await this._callServerAPI("getCodePointFromGlyphName", { glyphName });
}

static async parseClipboard(data) {
let result = await this._callServerAPI("parseClipboard", { data });
return result ? StaticGlyph.fromObject(result) : undefined;
}

static async unionPath(path) {
const newPath = await this._callServerAPI("unionPath", { path });
return VarPackedPath.fromObject(newPath);
}

static async subtractPath(pathA, pathB) {
const newPath = await this._callServerAPI("subtractPath", { pathA, pathB });
return VarPackedPath.fromObject(newPath);
}

static async intersectPath(pathA, pathB) {
const newPath = await this._callServerAPI("intersectPath", { pathA, pathB });
return VarPackedPath.fromObject(newPath);
}

static async excludePath(pathA, pathB) {
const newPath = await this._callServerAPI("excludePath", { pathA, pathB });
return VarPackedPath.fromObject(newPath);
}

/**
*
* @param {string} projectPath
* @returns {Promise<RemoteFont>} Proxy object representing a font on the server.
*/
static async remoteFont(projectPath) {
const protocol = window.location.protocol === "http:" ? "ws" : "wss";
const wsURL = `${protocol}://${window.location.host}/websocket/${projectPath}`;
return getRemoteProxy(wsURL);
}
}

export const Backend = PythonBackend;
18 changes: 14 additions & 4 deletions src/fontra/client/core/font-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,18 @@ import {
mapBackward,
mapForward,
} from "./var-model.js";
/**
* @import { RemoteFont, FontSource } from 'remotefont';
* */

const GLYPH_CACHE_SIZE = 2000;
const BACKGROUND_IMAGE_CACHE_SIZE = 100;
const NUM_TASKS = 12;

export class FontController {
/**
* @param {RemoteFont} font
*/
constructor(font) {
this.font = font;
this._glyphsPromiseCache = new LRUCache(GLYPH_CACHE_SIZE); // glyph name -> var-glyph promise
Expand Down Expand Up @@ -78,12 +84,12 @@ export class FontController {
this._resolveInitialized();
}

subscribeChanges(change, wantLiveChanges) {
this.font.subscribeChanges(change, wantLiveChanges);
subscribeChanges(pathOrPattern, wantLiveChanges) {
this.font.subscribeChanges(pathOrPattern, wantLiveChanges);
}

unsubscribeChanges(change, wantLiveChanges) {
this.font.unsubscribeChanges(change, wantLiveChanges);
unsubscribeChanges(pathOrPattern, wantLiveChanges) {
this.font.unsubscribeChanges(pathOrPattern, wantLiveChanges);
}

getRootKeys() {
Expand Down Expand Up @@ -1081,6 +1087,10 @@ function ensureDenseAxes(axes) {
return { ...axes, axes: axes.axes || [], mappings: axes.mappings || [] };
}

/**
* @param {Record<string, FontSource>} sources
* @returns {Record<string, FontSource>}
*/
function ensureDenseSources(sources) {
return mapObjectValues(sources, (source) => {
return {
Expand Down
8 changes: 4 additions & 4 deletions src/fontra/client/core/glyph-lines.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getCodePointFromGlyphName, getSuggestedGlyphName } from "./server-utils.js";
import { Backend } from "./backend-api.js";
import { splitGlyphNameExtension } from "./utils.js";

export async function glyphLinesFromText(text, characterMap, glyphMap) {
Expand Down Expand Up @@ -56,7 +56,7 @@ async function glyphNamesFromText(text, characterMap, glyphMap) {
// a glyph name associated with that character
let properBaseGlyphName = characterMap[baseCharCode];
if (!properBaseGlyphName) {
properBaseGlyphName = await getSuggestedGlyphName(baseCharCode);
properBaseGlyphName = await Backend.getSuggestedGlyphName(baseCharCode);
}
if (properBaseGlyphName) {
glyphName = properBaseGlyphName + extension;
Expand All @@ -67,7 +67,7 @@ async function glyphNamesFromText(text, characterMap, glyphMap) {
} else {
// This is a regular glyph name, but it doesn't exist in the font.
// Try to see if there's a code point associated with it.
const codePoint = await getCodePointFromGlyphName(glyphName);
const codePoint = await Backend.getCodePointFromGlyphName(glyphName);
if (codePoint) {
char = String.fromCodePoint(codePoint);
}
Expand All @@ -85,7 +85,7 @@ async function glyphNamesFromText(text, characterMap, glyphMap) {
if (glyphName !== "") {
let isUndefined = false;
if (!glyphName && char) {
glyphName = await getSuggestedGlyphName(char.codePointAt(0));
glyphName = await Backend.getSuggestedGlyphName(char.codePointAt(0));
isUndefined = true;
} else if (glyphName) {
isUndefined = !(glyphName in glyphMap);
Expand Down
90 changes: 50 additions & 40 deletions src/fontra/client/core/remote.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { RemoteError } from "./errors.js";

export async function getRemoteProxy(wsURL) {
const remote = new RemoteObject(wsURL);
await remote.connect();
await remote._connect();
const remoteProxy = new Proxy(remote, {
get: (remote, propertyName) => {
if (propertyName === "then" || propertyName === "toJSON") {
Expand All @@ -14,7 +14,7 @@ export async function getRemoteProxy(wsURL) {
return remote[propertyName];
}
return (...args) => {
return remote.doCall(propertyName, args);
return remote._doCall(propertyName, args);
};
},
set: (remote, propertyName, value) => {
Expand All @@ -35,6 +35,13 @@ export class RemoteObject {

this.wsURL = wsURL;
this._callReturnCallbacks = {};
this._handlers = {
close: this._default_onclose,
error: this._default_onerror,
messageFromServer: undefined,
externalChange: undefined,
reloadData: undefined,
};

const g = _genNextClientCallID();
this._getNextClientCallID = () => {
Expand All @@ -46,14 +53,30 @@ export class RemoteObject {
(event) => {
if (document.visibilityState === "visible" && this.websocket.readyState > 1) {
// console.log("wake reconnect");
this.connect();
this._connect();
}
},
false
);
}

connect() {
on(event, callback) {
if (this._handlers.hasOwnProperty(event)) {
this._handlers[event] = callback;
} else {
console.error(`Ignoring attempt to register handler for unknown event: ${event}`);
}
}

async _trigger(event, ...args) {
if (this._handlers.hasOwnProperty(event)) {
return await this._handlers[event](...args);
} else {
throw new Error(`Recieved unknown event from server: ${event}`);
}
}

_connect() {
if (this._connectPromise !== undefined) {
// websocket is still connecting/opening, return the same promise
return this._connectPromise;
Expand All @@ -67,8 +90,8 @@ export class RemoteObject {
this.websocket.onopen = (event) => {
resolve(event);
delete this._connectPromise;
this.websocket.onclose = (event) => this._onclose(event);
this.websocket.onerror = (event) => this._onerror(event);
this.websocket.onclose = (event) => this._trigger("close", event);
this.websocket.onerror = (event) => this._trigger("error", event);
const message = {
"client-uuid": this.clientUUID,
};
Expand All @@ -79,20 +102,12 @@ export class RemoteObject {
return this._connectPromise;
}

_onclose(event) {
if (this.onclose) {
this.onclose(event);
} else {
console.log(`websocket closed`, event);
}
_default_onclose(event) {
console.log(`websocket closed`, event);
}

_onerror(event) {
if (this.onerror) {
this.onerror(event);
} else {
console.log(`websocket error`, event);
}
_default_onerror(event) {
console.log(`websocket error`, event);
}

async _handleIncomingMessage(event) {
Expand All @@ -113,32 +128,27 @@ export class RemoteObject {
delete this._callReturnCallbacks[clientCallID];
} else if (serverCallID !== undefined) {
// this is an incoming server -> client call
if (this.receiver) {
let returnMessage;
try {
let method = this.receiver[message["method-name"]];
if (method === undefined) {
throw new Error(`undefined receiver method: ${message["method-name"]}`);
}
method = method.bind(this.receiver);
const returnValue = await method(...message["arguments"]);
returnMessage = {
"server-call-id": serverCallID,
"return-value": returnValue,
};
} catch (error) {
console.log("exception in receiver call", error.toString());
console.error(error, error.stack);
returnMessage = { "server-call-id": serverCallID, "error": error.toString() };
let returnMessage;
try {
let method = message["method-name"];
if (!this._handlers.hasOwnProperty(method)) {
throw new Error(`undefined method: ${method}`);
}
this.websocket.send(JSON.stringify(returnMessage));
} else {
console.log("no receiver in place to receive server messages", message);
const returnValue = await this._trigger(method, ...message["arguments"]);
returnMessage = {
"server-call-id": serverCallID,
"return-value": returnValue,
};
} catch (error) {
console.log("exception in method call", error.toString());
console.error(error, error.stack);
returnMessage = { "server-call-id": serverCallID, "error": error.toString() };
}
this.websocket.send(JSON.stringify(returnMessage));
}
}

async doCall(methodName, args) {
async _doCall(methodName, args) {
// console.log("--- doCall", methodName);
const clientCallID = this._getNextClientCallID();
const message = {
Expand All @@ -148,7 +158,7 @@ export class RemoteObject {
};
if (this.websocket.readyState !== 1) {
// console.log("waiting for reconnect");
await this.connect();
await this._connect();
}
this.websocket.send(JSON.stringify(message));

Expand Down
Loading

0 comments on commit e230eeb

Please sign in to comment.