alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
804 lines (803 loc) • 26.3 kB
JavaScript
import { $atom, $hook, $inject, $module, $pipeline, $state, Alepha, AlephaError, KIND, Primitive, createPrimitive, z } from "alepha";
import { $action, $route, $sse, AlephaServer, HttpClient, HttpError, ServerReply, ServerTimingProvider, UnauthorizedError, serverApiOptions } from "alepha/server";
import { $logger } from "alepha/logger";
import { $retry } from "alepha/retry";
import { ServerProxyProvider } from "alepha/server/proxy";
import { createHash } from "node:crypto";
//#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/providers/RemotePrimitiveProvider.ts
var RemotePrimitiveProvider = class {
serverApi = $state(serverApiOptions);
alepha = $inject(Alepha);
proxyProvider = $inject(ServerProxyProvider);
linkProvider = $inject(LinkProvider);
remotes = [];
log = $logger();
getRemotes() {
return this.remotes;
}
configure = $hook({
on: "configure",
handler: async () => {
const remotes = this.alepha.primitives($remote);
for (const remote of remotes) await this.registerRemote(remote);
}
});
start = $hook({
on: "start",
handler: async () => {
for (const remote of this.remotes) {
const token = typeof remote.serviceAccount?.token === "function" ? await remote.serviceAccount.token() : void 0;
if (!remote.internal) continue;
const registry = await remote.links({ authorization: token });
for (const [name, action] of Object.entries(registry.actions)) {
let path = action.path.replace(remote.prefix, "");
if (action.service) path = `/${action.service}${path}`;
this.linkProvider.registerLink({
name,
path,
method: action.method ?? void 0,
contentType: action.contentType,
prefix: remote.prefix,
host: remote.url,
service: remote.name
});
}
this.log.info(`Remote '${remote.name}' OK`, {
actions: Object.keys(registry.actions).length,
prefix: remote.prefix
});
}
}
});
async registerRemote(value) {
const options = value.options;
const url = typeof options.url === "string" ? options.url : options.url();
const linkPath = LinkProvider.path.apiLinks;
const name = value.name;
const proxy = typeof options.proxy === "object" ? options.proxy : {};
const remote = {
url,
name,
prefix: "/api",
serviceAccount: options.serviceAccount,
proxy: !!options.proxy,
internal: !proxy.noInternal,
schema: async (opts) => {
const { authorization, name } = opts;
return await fetch(`${url}${linkPath}/${name}/schema`, { headers: new Headers(authorization ? { authorization } : {}) }).then((it) => it.json());
},
links: async (opts) => {
const { authorization } = opts;
const remoteApi = await this.fetchLinks.run({
service: name,
url: `${url}${linkPath}`,
authorization
});
if (remoteApi.prefix != null) remote.prefix = remoteApi.prefix;
return remoteApi;
}
};
this.remotes.push(remote);
if (options.proxy) this.proxyProvider.createProxy({
path: `${this.serverApi.prefix}/${name}/*`,
target: url,
rewrite: (url) => {
url.pathname = url.pathname.replace(`${this.serverApi.prefix}/${name}`, remote.prefix);
},
...proxy
});
}
fetchLinks = $pipeline({
use: [$retry({
max: 10,
backoff: { initial: 1e3 },
onError: (_, attempt) => {
this.log.warn(`Failed to fetch links, retry (${attempt})...`);
}
})],
handler: async (opts) => {
const { url, authorization } = opts;
const response = await fetch(url, { headers: new Headers(authorization ? { authorization } : {}) });
if (!response.ok) throw new AlephaError(`Failed to fetch links from ${url}`);
return this.alepha.codec.decode(apiRegistryResponseSchema, await response.json());
}
});
};
//#endregion
//#region ../../src/server/links/providers/ServerLinksProvider.ts
var ServerLinksProvider = class ServerLinksProvider {
serverApi = $state(serverApiOptions);
alepha = $inject(Alepha);
linkProvider = $inject(LinkProvider);
remoteProvider = $inject(RemotePrimitiveProvider);
serverTimingProvider = $inject(ServerTimingProvider);
log = $logger();
/**
* Resolved once on start. Undefined when alepha/security is not loaded.
*/
securityProvider;
/**
* Cache of serialized JSON by role key.
* Key = sorted roles joined by comma (empty string for unauthenticated).
*/
registryCache = /* @__PURE__ */ new Map();
get prefix() {
return this.serverApi.prefix;
}
onRoute = $hook({
on: "configure",
handler: () => {
for (const action of this.alepha.primitives($action)) {
const bodyContentType = action.getBodyContentType();
this.linkProvider.registerLink({
name: action.name,
group: action.group,
schema: action.options.schema,
contentType: bodyContentType && bodyContentType !== "application/json" ? bodyContentType : void 0,
secured: action.middlewares.some((m) => m?.name === "$secure") ? action.middlewares.find((m) => m?.name === "$secure")?.options ?? true : void 0,
method: action.method === "GET" ? void 0 : action.method,
prefix: action.prefix,
path: action.path,
handler: (config, options = {}) => action.run(config, options)
});
}
for (const sse of this.alepha.primitives($sse)) this.linkProvider.registerLink({
name: sse.name,
group: sse.group,
kind: "sse",
schema: { body: sse.schema?.body },
method: "POST",
prefix: sse.prefix,
path: sse.path,
handler: async (config) => {
return sse.run(config);
}
});
}
});
onStart = $hook({
on: "start",
handler: () => {
try {
this.securityProvider = this.alepha.inject("SecurityProvider");
} catch {
this.log.debug("Security module is not loaded — permission checks are disabled");
}
}
});
/**
* API registry endpoint — returns all available actions for the user.
*
* Response is filtered by the user's permissions.
* Cached by role set with ETag support.
*/
links = $route({
path: LinkProvider.path.apiLinks,
handler: async ({ user, headers, reply }) => {
const roleKey = user?.roles?.slice().sort().join(",") ?? "";
const cached = this.registryCache.get(roleKey);
if (cached) {
if (headers["if-none-match"] === cached.etag) {
reply.setStatus(304);
reply.setHeader("etag", cached.etag);
return;
}
reply.setHeader("etag", cached.etag);
reply.setHeader("content-type", "application/json");
reply.body = cached.json;
return;
}
const response = await this.getUserApiLinks({
user,
authorization: headers.authorization
});
const json = JSON.stringify(response);
const etag = `"${createHash("md5").update(json).digest("hex")}"`;
this.registryCache.set(roleKey, {
json,
etag
});
reply.setHeader("etag", etag);
reply.setHeader("content-type", "application/json");
reply.body = json;
}
});
/**
* On-demand schemas endpoint — returns JSON Schemas for requested actions.
*
* Schemas are filtered by the user's permissions (same logic as the registry).
*/
schemas = $route({
method: "POST",
path: "/api/_links/schemas",
schema: {
body: z.object({ actions: z.array(z.text()) }),
response: z.record(z.text(), z.object({
body: z.string().optional(),
response: z.string().optional()
}))
},
handler: async ({ body, user }) => {
const result = {};
for (const name of body.actions) {
const link = this.linkProvider.getServerLinks().find((l) => l.name === name && !l.host);
if (!link) continue;
if (!this.isLinkAccessible(link, user)) continue;
const entry = {};
if (link.schema?.body) entry.body = JSON.stringify(link.schema.body);
if (link.schema?.response) entry.response = JSON.stringify(link.schema.response);
result[name] = entry;
}
return result;
}
});
/**
* Batch endpoint — execute multiple actions in a single HTTP request.
* Each sub-request is independent: errors in one don't affect others.
*/
batch = $route({
method: "POST",
path: "/api/_batch",
schema: {
body: z.array(z.object({
action: z.text(),
params: z.record(z.text(), z.any()).optional(),
query: z.record(z.text(), z.any()).optional(),
body: z.record(z.text(), z.any()).optional()
})),
response: z.array(z.object({
action: z.text(),
status: z.integer(),
data: z.any().optional(),
error: z.text().optional()
}))
},
handler: async ({ body }) => {
if (body.length > ServerLinksProvider.MAX_BATCH_SIZE) throw new AlephaError(`Batch size ${body.length} exceeds maximum of ${ServerLinksProvider.MAX_BATCH_SIZE}`);
return (await Promise.allSettled(body.map((entry) => this.linkProvider.follow(entry.action, {
params: entry.params,
query: entry.query,
body: entry.body
})))).map((result, i) => {
const action = body[i].action;
if (result.status === "fulfilled") return {
action,
status: 200,
data: result.value
};
const reason = result.reason;
const status = reason?.status ?? 500;
const message = reason?.message ?? "Internal error";
this.log.warn("Batch action failed", {
action,
status,
message,
error: reason
});
return {
action,
status,
error: message
};
});
}
});
static MAX_BATCH_SIZE = 20;
/**
* Retrieves API registry for the user based on their permissions.
* Will check on local links and remote links.
*/
async getUserApiLinks(options) {
const { user } = options;
const { securityProvider } = this;
const securityPermissions = securityProvider && user ? securityProvider.getPermissions(user) : void 0;
const actions = {};
const permissions = [];
for (const permission of securityPermissions ?? []) if (!permission.path && !permission.method && permission.name && permission.group) permissions.push(`${permission.group}:${permission.name}`);
for (const link of this.linkProvider.getServerLinks()) {
if (link.host) continue;
if (!this.isLinkAccessible(link, user)) continue;
actions[link.name] = {
path: link.path,
method: link.method || void 0,
kind: link.kind,
contentType: link.contentType,
service: link.service
};
}
this.serverTimingProvider.beginTiming("fetchRemoteLinks");
const remoteResults = await Promise.all(this.remoteProvider.getRemotes().filter((it) => it.proxy).map(async (remote) => {
return {
remote,
registry: await remote.links(options)
};
}));
for (const { remote, registry } of remoteResults) {
const remotePrefix = registry.prefix ?? "/api";
for (const [name, action] of Object.entries(registry.actions)) {
let path = action.path.replace(remotePrefix, "");
if (action.service) path = `/${action.service}${path}`;
actions[name] = {
path,
method: action.method,
contentType: action.contentType,
service: remote.name
};
}
if (registry.permissions) permissions.push(...registry.permissions);
}
this.serverTimingProvider.endTiming("fetchRemoteLinks");
return {
prefix: this.serverApi.prefix,
actions,
permissions: permissions.length > 0 ? permissions : void 0
};
}
/**
* Check if a link is accessible by the given user based on security rules.
*/
isLinkAccessible(link, user) {
const { securityProvider } = this;
if (securityProvider && link.secured) {
if (!user) return false;
if (typeof link.secured === "object") {
if (link.secured.issuers?.length && (!user.realm || !link.secured.issuers.includes(user.realm))) return false;
if (link.secured.roles?.length) {
if (!link.secured.roles.some((role) => user.roles?.includes(role))) return false;
}
if (link.secured.permissions?.length) {
for (const perm of link.secured.permissions) if (!securityProvider.checkPermission(perm, ...user.roles ?? []).isAuthorized) return false;
}
}
}
return true;
}
};
//#endregion
//#region ../../src/server/links/index.ts
/**
* Type-safe API client with request deduplication.
*
* **Features:**
* - Virtual HTTP client for type-safe API calls
* - Remote action definitions
* - Type inference from action schemas
* - Request deduplication
* - Automatic error handling
*
* @module alepha.server.links
*/
const AlephaServerLinks = $module({
name: "alepha.server.links",
atoms: [apiLinksAtom, linkOptionsAtom],
primitives: [$remote, $client],
services: [
AlephaServer,
ServerLinksProvider,
RemotePrimitiveProvider,
LinkProvider,
BatchCollector
]
});
//#endregion
export { $client, $remote, AlephaServerLinks, BatchCollector, LinkProvider, RemotePrimitive, RemotePrimitiveProvider, ServerLinksProvider, apiActionSchema, apiLinksAtom, apiLinksResponseSchema, apiRegistryResponseSchema, linkOptionsAtom };
//# sourceMappingURL=index.js.map