UNPKG

alepha

Version:

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

433 lines (432 loc) 14.3 kB
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