UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

804 lines (803 loc) 26.3 kB
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