UNPKG

alinea

Version:

[![npm](https://img.shields.io/npm/v/alinea.svg)](https://npmjs.org/package/alinea) [![install size](https://packagephobia.com/badge?p=alinea)](https://packagephobia.com/result?p=alinea)

324 lines (322 loc) 10.1 kB
import { unzlibSync } from "../chunks/chunk-SBZY6HII.js"; import { enums, object, string } from "../chunks/chunk-7LBNER34.js"; import { mergeUpdatesV2 } from "../chunks/chunk-OYP4EJOA.js"; import "../chunks/chunk-O6EXLFU2.js"; import { pLimit } from "../chunks/chunk-QQTYTFWR.js"; import "../chunks/chunk-U5RRZUYZ.js"; // src/backend/Handler.ts import { Auth, Connection, Entry, EntryPhase, parseYDoc } from "alinea/core"; import { MutationType } from "alinea/core/Mutation"; import { createSelection } from "alinea/core/pages/CreateSelection"; import { Realm } from "alinea/core/pages/Realm"; import { Selection } from "alinea/core/pages/Selection"; import { base64, base64url } from "alinea/core/util/Encoding"; import { Logger, Report } from "alinea/core/util/Logger"; import * as Y from "alinea/yjs"; import { ChangeSetCreator } from "./data/ChangeSet.js"; import { EntryResolver } from "./resolver/EntryResolver.js"; import { router } from "./router/Router.js"; var limit = pLimit(1); var Handler = class { constructor(options) { this.options = options; this.resolver = new EntryResolver( options.db, options.config.schema, this.parsePreview.bind(this) ); this.changes = new ChangeSetCreator(options.config); const auth = options.auth || Auth.anonymous(); this.connect = (ctx) => new HandlerConnection(this, ctx); this.router = createRouter(auth, this.connect); } connect; router; changes; lastSync = 0; resolver; resolve = async (params) => { const { resolveDefaults } = this.options; await this.periodicSync(); return this.resolver.resolve({ ...resolveDefaults, ...params }); }; previewAuth() { return { logger: new Logger("parsePreview"), token: this.options.previewAuthToken }; } async parsePreview(preview) { const { config } = this.options; await this.periodicSync(); const update = unzlibSync(base64url.parse(preview.update)); const entry = await this.resolver.resolve({ selection: createSelection( Entry({ entryId: preview.entryId }).maybeFirst() ), realm: Realm.PreferDraft }); if (!entry) return; const currentDraft = await this.options.drafts?.getDraft( preview.entryId, this.previewAuth() ); const apply = currentDraft ? mergeUpdatesV2([currentDraft.draft, update]) : update; const type = config.schema[entry.type]; if (!type) return; const doc = new Y.Doc(); Y.applyUpdateV2(doc, apply); const entryData = parseYDoc(type, doc); return { ...entry, ...entryData, path: entry.path }; } async periodicSync() { const now = Date.now(); if (now - this.lastSync < 5e3) return; this.lastSync = now; try { await this.syncPending(); } catch { } } syncPending() { return limit(async () => { const { pending, db } = this.options; const meta = await db.meta(); if (!pending) return meta; try { const toApply = await pending.pendingSince( meta.commitHash, this.previewAuth() ); if (!toApply) return meta; await db.applyMutations(toApply.mutations, toApply.toCommitHash); } catch (error) { console.error(error); console.warn("> could not sync pending mutations"); } return db.meta(); }); } }; var HandlerConnection = class { constructor(handler, ctx) { this.handler = handler; this.ctx = ctx; this.resolve = handler.resolve; } resolve; // Target async mutate(mutations, retry = 0) { const { target, db } = this.handler.options; if (!target) throw new Error("Target not available"); const changeSet = this.handler.changes.create(mutations); const { commitHash: fromCommitHash } = await this.handler.syncPending(); let toCommitHash; try { const result = await target.mutate( { commitHash: fromCommitHash, mutations: changeSet }, this.ctx ); toCommitHash = result.commitHash; } catch (error) { if ("expectedCommitHash" in error) { if (retry >= 3) throw error; return this.mutate(mutations, retry + 1); } throw error; } await db.applyMutations(mutations, toCommitHash); const tasks = []; for (const mutation of mutations) { switch (mutation.type) { case MutationType.Edit: tasks.push(this.persistEdit(mutation)); continue; } } await Promise.all(tasks); return { commitHash: toCommitHash }; } previewToken() { const { previews } = this.handler.options; const user = this.ctx.user; if (!user) return previews.sign({ anonymous: true }); return previews.sign({ sub: user.sub }); } // Media prepareUpload(file) { const { media } = this.handler.options; if (!media) throw new Error("Media not available"); return media.prepareUpload(file, this.ctx); } // History async revisions(file) { const { history } = this.handler.options; if (!history) return []; return history.revisions(file, this.ctx); } async revisionData(file, revisionId) { const { history } = this.handler.options; if (!history) throw new Error("History not available"); return history.revisionData(file, revisionId, this.ctx); } // Syncable async syncRequired(contentHash) { const { db } = this.handler.options; await this.handler.syncPending(); return db.syncRequired(contentHash); } async sync(contentHashes) { const { db } = this.handler.options; await this.handler.syncPending(); return db.sync(contentHashes); } // Drafts async persistEdit(mutation) { const { drafts } = this.handler.options; if (!drafts || !mutation.update) return; const update = base64.parse(mutation.update); const currentDraft = await this.getDraft(mutation.entryId); await this.storeDraft({ entryId: mutation.entryId, fileHash: mutation.entry.fileHash, draft: currentDraft ? mergeUpdatesV2([currentDraft.draft, update]) : update }); } getDraft(entryId) { const { drafts } = this.handler.options; if (!drafts) throw new Error("Drafts not available"); return drafts.getDraft(entryId, this.ctx); } storeDraft(draft) { const { drafts } = this.handler.options; if (!drafts) throw new Error("Drafts not available"); return drafts.storeDraft(draft, this.ctx); } }; function respond({ result, logger }) { return router.jsonResponse(result, { headers: { "server-timing": Report.toServerTiming(logger.report()) } }); } var ResolveBody = object({ selection: Selection.adt, locale: string.optional, realm: enums(Realm), preview: object({ entryId: string, phase: enums(EntryPhase), update: string }).optional }); var PrepareBody = object({ filename: string }); function createRouter(auth, createApi) { const matcher = router.startAt(Connection.routes.base); async function context(input) { const logger = new Logger(`${input.request.method} ${input.url.pathname}`); return { ...input, ctx: { ...await auth.contextFor(input.request), logger }, logger }; } return router( auth.router, matcher.get(Connection.routes.previewToken()).map(context).map(({ ctx }) => { const api = createApi(ctx); return ctx.logger.result(api.previewToken()); }).map(respond), // History matcher.get(Connection.routes.revisions()).map(context).map(({ ctx, url }) => { const api = createApi(ctx); const file = url.searchParams.get("file"); const revisionId = url.searchParams.get("revisionId"); return ctx.logger.result( revisionId ? api.revisionData(file, revisionId) : api.revisions(file) ); }).map(respond), matcher.post(Connection.routes.resolve()).map(context).map(router.parseJson).map(({ ctx, body }) => { const api = createApi(ctx); return ctx.logger.result(api.resolve(ResolveBody(body))); }).map(respond), // Target matcher.post(Connection.routes.mutate()).map(context).map(router.parseJson).map(({ ctx, body }) => { const api = createApi(ctx); if (!Array.isArray(body)) throw new Error("Expected array"); return ctx.logger.result(api.mutate(body)); }).map(respond), // Syncable matcher.get(Connection.routes.sync()).map(context).map(({ ctx, url }) => { const api = createApi(ctx); const contentHash = url.searchParams.get("contentHash"); return ctx.logger.result(api.syncRequired(contentHash)); }).map(respond), matcher.post(Connection.routes.sync()).map(context).map(router.parseJson).map(({ ctx, body }) => { const api = createApi(ctx); if (!Array.isArray(body)) throw new Error(`Array expected`); const contentHashes = body; return ctx.logger.result(api.sync(contentHashes)); }).map(respond), // Media matcher.post(Connection.routes.prepareUpload()).map(context).map(router.parseJson).map(({ ctx, body }) => { const api = createApi(ctx); const { filename } = PrepareBody(body); return ctx.logger.result(api.prepareUpload(filename)); }).map(respond), // Drafts matcher.get(Connection.routes.draft()).map(context).map(({ ctx, url }) => { const api = createApi(ctx); const entryId = url.searchParams.get("entryId"); return ctx.logger.result( api.getDraft(entryId).then((draft) => { if (!draft) return null; return { ...draft, draft: base64.stringify(draft.draft) }; }) ); }).map(respond), matcher.post(Connection.routes.draft()).map(context).map(router.parseJson).map(({ ctx, body }) => { const api = createApi(ctx); const data = body; const draft = { ...data, draft: new Uint8Array(base64.parse(data.draft)) }; return ctx.logger.result(api.storeDraft(draft)); }).map(respond) ).recover(router.reportError); } export { Handler };