alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
433 lines (432 loc) • 14.3 kB
JavaScript
import { $atom, $inject, $module, $state, Alepha, AlephaError, KIND, Primitive, createPrimitive, z } from "alepha";
import { $logger } from "alepha/logger";
import { HttpClient, HttpError, ServerReply, UnauthorizedError } from "alepha/server";
//#region ../../src/server/links/schemas/apiLinksResponseSchema.ts
const apiActionSchema = z.object({
path: z.text({ description: "Pathname used to access the action." }),
method: z.text({ description: "HTTP method. Omitted when GET (the default for ~75% of actions)." }).optional(),
contentType: z.text({ description: "Content type for the request body. Only present for non-JSON types (e.g. 'multipart/form-data'). When absent, defaults to application/json." }).optional(),
kind: z.text({ description: "Action kind. Used to distinguish special action types (e.g. 'sse' for Server-Sent Events streams)." }).optional(),
service: z.text({ description: "Service name associated with the action, used for service discovery and routing." }).optional()
});
const apiRegistryResponseSchema = z.object({
prefix: z.text().optional(),
actions: z.record(z.text(), apiActionSchema),
permissions: z.array(z.text()).optional()
});
/**
* @deprecated Use `apiRegistryResponseSchema` and `ApiRegistryResponse` instead.
*/
const apiLinksResponseSchema = apiRegistryResponseSchema;
//#endregion
//#region ../../src/server/links/atoms/apiLinksAtom.ts
const apiLinksAtom = $atom({
name: "alepha.server.request.apiLinks",
schema: apiRegistryResponseSchema.optional()
});
//#endregion
//#region ../../src/server/links/atoms/linkOptionsAtom.ts
const linkOptionsAtom = $atom({
name: "alepha.server.links.options",
description: "Configuration options for the links module.",
schema: z.object({ batch: z.boolean().describe("Enable batch collection for browser-side calls.").default(true) }),
default: { batch: true }
});
//#endregion
//#region ../../src/server/links/services/BatchCollector.ts
/**
* Collects browser-side action calls within a microtask and
* sends them as a single `POST /api/_batch` request.
*
* Key behaviors:
* - Single call in the window → direct HTTP call (no batch overhead)
* - Multiple calls → coalesced into one batch request
* - Same action + same params/query/body → deduplicated, result shared
* - Exceeding MAX_BATCH_SIZE → split into multiple batch calls
* - Transport failure → all pending promises reject
*/
var BatchCollector = class BatchCollector {
static MAX_BATCH_SIZE = 20;
log = $logger();
httpClient = $inject(HttpClient);
pending = [];
scheduled = false;
/**
* Add an action call to the batch. Returns the result when the batch resolves.
*/
add(entry) {
return new Promise((resolve, reject) => {
this.pending.push({
entry,
resolve,
reject
});
if (!this.scheduled) {
this.scheduled = true;
setTimeout(() => {
this.scheduled = false;
this.flush().catch((err) => this.log.error(err));
}, 10);
}
});
}
async flush() {
const batch = this.pending.splice(0);
if (batch.length === 0) return;
const binaryEntries = [];
const remaining = [];
for (const item of batch) if (this.hasBinaryBody(item.entry.body)) binaryEntries.push(item);
else remaining.push(item);
for (const item of binaryEntries) item.entry.directCall().then((r) => item.resolve(r)).catch((e) => item.reject(e));
if (remaining.length === 0) return;
if (remaining.length === 1) {
const item = remaining[0];
try {
const result = await item.entry.directCall();
item.resolve(result);
} catch (error) {
item.reject(error);
}
return;
}
batch.length = 0;
batch.push(...remaining);
const { unique, indexMap } = this.dedupe(batch);
const chunks = this.chunk(unique, BatchCollector.MAX_BATCH_SIZE);
try {
const allResults = (await Promise.all(chunks.map((chunk) => {
const actions = [...new Set(chunk.map((b) => b.entry.action))].join(",");
return this.httpClient.fetch(`/api/_batch?actions=${actions}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(chunk.map((b) => ({
action: b.entry.action,
params: b.entry.params,
query: b.entry.query,
body: b.entry.body
})))
}).then((res) => res.data);
}))).flat();
for (let i = 0; i < batch.length; i++) {
const result = allResults[indexMap[i]];
if (result.status >= 400) batch[i].reject(new HttpError({
message: result.error ?? `${result.action} failed (${result.status})`,
status: result.status
}));
else batch[i].resolve(result.data);
}
} catch (error) {
for (const item of batch) item.reject(error);
}
}
dedupe(batch) {
const seen = /* @__PURE__ */ new Map();
const unique = [];
const indexMap = [];
for (const item of batch) {
const key = `${item.entry.action}:${JSON.stringify({
params: item.entry.params,
query: item.entry.query,
body: item.entry.body
})}`;
const existing = seen.get(key);
if (existing !== void 0) indexMap.push(existing);
else {
const idx = unique.length;
seen.set(key, idx);
unique.push(item);
indexMap.push(idx);
}
}
return {
unique,
indexMap
};
}
hasBinaryBody(body) {
if (!body || typeof body !== "object") return false;
for (const v of Object.values(body)) {
if (typeof Blob !== "undefined" && v instanceof Blob) return true;
if (typeof File !== "undefined" && v instanceof File) return true;
}
return false;
}
chunk(arr, size) {
const chunks = [];
for (let i = 0; i < arr.length; i += size) chunks.push(arr.slice(i, i + size));
return chunks;
}
};
//#endregion
//#region ../../src/server/links/providers/LinkProvider.ts
/**
* Browser, SSR friendly, service to handle links.
*/
var LinkProvider = class LinkProvider {
static path = { apiLinks: "/api/_links" };
log = $logger();
alepha = $inject(Alepha);
httpClient = $inject(HttpClient);
serverLinkMap = /* @__PURE__ */ new Map();
actionMap = /* @__PURE__ */ new Map();
permissions = /* @__PURE__ */ new Set();
lastLoadedRegistry = null;
batchCollector;
options = $state(linkOptionsAtom);
/**
* Get applicative links registered on the server.
* This does not include lazy-loaded remote links.
*/
getServerLinks() {
if (this.alepha.isBrowser()) {
this.log.warn("Getting server links in the browser is not supported. Use `fetchLinks` to get links from the server.");
return [];
}
return [...this.serverLinkMap.values()];
}
/**
* Register a new link for the application.
*/
registerLink(link) {
if (this.alepha.isBrowser()) {
this.log.warn("Registering links in the browser is not supported. Use `fetchLinks` to get links from the server.");
return;
}
if (!link.handler && !link.host) throw new AlephaError("Can't create link - 'handler' or 'host' is required");
if (this.serverLinkMap.get(link.name)?.handler && link.handler) throw new AlephaError(`Duplicate action name "${link.name}". Each action must have a unique name.`);
this.serverLinkMap.set(link.name, link);
}
/**
* Load the registry response into internal stores (actionMap, permissions, definitions).
* Called when storing from atom/fetch/SSR.
*/
loadRegistry(registry) {
this.lastLoadedRegistry = registry;
this.permissions.clear();
this.actionMap.clear();
for (const [name, action] of Object.entries(registry.actions)) this.actionMap.set(name, {
name,
path: action.path,
kind: action.kind,
method: action.method,
contentType: action.contentType,
service: action.service
});
if (registry.permissions) for (const p of registry.permissions) this.permissions.add(p);
}
get links() {
const registry = this.alepha.store.get("alepha.server.request.apiLinks");
if (registry) {
if (this.alepha.isBrowser()) {
if (this.actionMap.size === 0 || registry !== this.lastLoadedRegistry) this.loadRegistry(registry);
return [...this.actionMap.values()];
}
const links = [];
for (const name of Object.keys(registry.actions)) {
const originalLink = this.serverLinkMap.get(name);
if (originalLink) links.push(originalLink);
}
return links;
}
return [...this.serverLinkMap.values()];
}
/**
* Force browser to refresh links from the server.
*/
async fetchLinks() {
const { data } = await this.httpClient.fetch(`${LinkProvider.path.apiLinks}`, {
method: "GET",
schema: { response: apiRegistryResponseSchema }
});
this.alepha.store.set("alepha.server.request.apiLinks", data);
this.loadRegistry(data);
return [...this.actionMap.values()];
}
/**
* Create a virtual client that can be used to call actions.
*
* Use js Proxy under the hood.
*/
client(scope = {}) {
return new Proxy({}, { get: (_, prop) => {
if (typeof prop !== "string") return;
return this.createVirtualAction(prop, scope);
} });
}
/**
* Check if a link with the given name exists or a permission matches.
*
* Action names never contain colons. Permission names always do.
* - `can("getUsers")` → O(1) map lookup
* - `can("admin:*")` → wildcard match against permissions set
* - `can("admin:user:read")` → O(1) set lookup
*/
can(name) {
const registry = this.alepha.store.get("alepha.server.request.apiLinks");
if (registry && registry !== this.lastLoadedRegistry) this.loadRegistry(registry);
if (this.actionMap.size > 0) {
if (this.actionMap.has(name)) return true;
} else {
if (this.serverLinkMap.has(name)) return true;
if (this.links.some((link) => link.name === name)) return true;
}
if (name.includes(":")) {
if (name.endsWith("*")) {
const prefix = name.slice(0, -1);
for (const p of this.permissions) if (p.startsWith(prefix)) return true;
return false;
}
return this.permissions.has(name);
}
return false;
}
/**
* Resolve a link by its name and call it.
* - If link is local, it will call the local handler.
* - If link is remote, it will make a fetch request to the remote server.
*/
async follow(name, config = {}, options = {}) {
this.log.trace("Following link", {
name,
config,
options
});
const link = await this.getLinkByName(name, options);
if (link.handler && !options.request) {
this.log.trace("Local link found", { name });
return link.handler({
method: link.method,
url: new URL(`http://localhost${link.path}`),
query: config.query ?? {},
body: config.body ?? {},
params: config.params ?? {},
headers: config.headers ?? {},
metadata: {},
reply: new ServerReply()
}, options);
}
this.log.trace("Remote link found", {
name,
host: link.host,
service: link.service
});
if (this.options.batch && this.alepha.isBrowser() && !link.host) {
this.batchCollector ??= this.alepha.inject(BatchCollector);
return this.batchCollector.add({
action: name,
params: config.params,
query: config.query,
body: config.body,
directCall: () => this.followRemote(link, config, options).then((r) => r.data)
});
}
return this.followRemote(link, config, options).then((response) => response.data);
}
createVirtualAction(name, scope = {}) {
const $ = async (config = {}, options = {}) => {
return this.follow(name, config, {
...scope,
...options
});
};
Object.defineProperty($, "name", {
value: name,
writable: false
});
$.run = async (config = {}, options = {}) => {
return this.follow(name, config, {
...scope,
...options
});
};
$.fetch = async (config = {}, options = {}) => {
const link = await this.getLinkByName(name, scope);
return this.followRemote(link, config, options);
};
$.can = () => {
return this.can(name);
};
return $;
}
async followRemote(link, config = {}, options = {}) {
options.request ??= {};
options.request.headers = new Headers(options.request.headers);
const als = this.alepha.store.get("alepha.http.request");
if (als?.headers.authorization) options.request.headers.set("authorization", als.headers.authorization);
const context = this.alepha.context.get("context");
if (typeof context === "string") options.request.headers.set("x-request-id", context);
const action = {
...link,
schema: {
body: z.any(),
response: z.any()
}
};
if (!link.host && link.service) action.path = `/${link.service}${action.path}`;
action.path = `${action.prefix ?? "/api"}${action.path}`;
action.prefix = void 0;
return this.httpClient.fetchAction({
host: link.host,
config,
options,
action
});
}
async getLinkByName(name, options = {}) {
if (this.alepha.isBrowser() && !this.alepha.store.get("alepha.server.request.apiLinks")) await this.fetchLinks();
const link = this.links.find((a) => a.name === name && (!options.service || options.service === a.service));
if (!link) {
const error = new UnauthorizedError(`Action ${name} not found.`);
await this.alepha.events.emit("client:onError", {
route: link,
error
});
throw error;
}
if (options.hostname) return {
...link,
host: options.hostname
};
return link;
}
};
//#endregion
//#region ../../src/server/links/primitives/$client.ts
/**
* Create a new client.
*/
const $client = (scope) => {
return $inject(LinkProvider).client(scope);
};
$client[KIND] = "$client";
//#endregion
//#region ../../src/server/links/primitives/$remote.ts
/**
* $remote is a primitive that allows you to define remote service access.
*
* Use it only when you have 2 or more services that need to communicate with each other.
*
* All remote services can be exposed as actions, ... or not.
*
* You can add a service account if you want to use a security layer.
*/
const $remote = (options) => {
return createPrimitive(RemotePrimitive, options);
};
var RemotePrimitive = class extends Primitive {
get name() {
return this.options.name ?? this.config.propertyKey;
}
};
$remote[KIND] = RemotePrimitive;
//#endregion
//#region ../../src/server/links/index.browser.ts
const AlephaServerLinks = $module({
name: "alepha.server.links",
atoms: [apiLinksAtom, linkOptionsAtom],
primitives: [$remote, $client],
services: [LinkProvider, BatchCollector]
});
//#endregion
export { $client, $remote, AlephaServerLinks, BatchCollector, LinkProvider, RemotePrimitive, apiActionSchema, apiLinksAtom, apiLinksResponseSchema, apiRegistryResponseSchema, linkOptionsAtom };
//# sourceMappingURL=index.browser.js.map