@ema-unlimited/frontier-apps-sdk

中文文档
0.3.0发布于 2026/10/22TypeScriptESM + CJS零依赖近 30 天下载 6951

SDK for Frontier Apps — database, storage, and Ema API access via frontier-apps-gateway

原始使用文档
文档目录23 节
  1. Why
  2. Install
  3. Subpaths
  4. Root ( @ema-unlimited/frontier-apps-sdk )
  5. One-time client init
  6. MongoDB ( @ema-unlimited/frontier-apps-sdk/mongodb )
  7. Define a schema (mechanical swap from Mongoose)
  8. Query
  9. Supported operations
  10. bulkWrite
  11. Indexes
  12. Blocked at the facade level
  13. Error handling
  14. Storage ( @ema-unlimited/frontier-apps-sdk/storage )
  15. Key layout
  16. High-level upload (handles signing for you)
  17. Browser-direct upload (large files, no Node proxy hop)
  18. Read back / list / delete
  19. Constraints (server-enforced)
  20. Error handling
  21. Where things land in GCS
  22. Development
  23. Design

@ema-unlimited/frontier-apps-sdk

SDK for Frontier Apps — provides database, storage, and Ema API access via the frontier-apps-gateway. Each capability is a separate subpath import so apps only pull in what they use.

Status: v0.x — DB routes (/v1/db/*) deployed. Storage routes (/v1/storage/*) added in 0.2.0 (currently in rc channel; see the rc dist-tag). Ema API routes are planned for a future release.

Why

Every deployed Frontier App previously talked to MongoDB directly. A single runaway query could saturate the replica set for every app. This SDK forces all traffic through the gateway where a safety blocklist, per-app connection pool, and audit trail live.

App code only changes its import line — the call surface (Mongoose Schema, model(), query chaining) is preserved.

Install

pnpm add @ema-unlimited/frontier-apps-sdk mongoose

mongoose is a required peer dependency for the DB subpath. The SDK re-exports mongoose.Schema and InferSchemaType so the migration from a direct Mongoose connection is a one-line import swap. The SDK never opens a MongoDB connection itself — Schema construction is pure JS path-building.

The package is published to the public npm registry — no auth required:

npm install @ema-unlimited/frontier-apps-sdk

Subpaths

ImportStatusWhat it does
@ema-unlimited/frontier-apps-sdkAvailableGateway init (initGatewayClient) + base error class
@ema-unlimited/frontier-apps-sdk/mongodbAvailableMongoDB-style DB access (full Mongoose surface)
@ema-unlimited/frontier-apps-sdk/storageAvailableFile storage via V4 signed URLs (put / get / list / …)

@ema-unlimited/frontier-apps-sdk/ema (Ema API proxy) is planned but not yet added.


Root (@ema-unlimited/frontier-apps-sdk)

The root export is gateway infrastructure only — not a capability.

import { initGatewayClient, FrontierError } from '@ema-unlimited/frontier-apps-sdk'
import type { GatewayClientConfig } from '@ema-unlimited/frontier-apps-sdk'

One-time client init

initGatewayClient({
  gatewayUrl: process.env.FRONTIER_GATEWAY_URL!,  // injected by publisher
  appId: process.env.APP_ID!,                      // injected by publisher
})

APP_ID is the publisher-provided value: a full UUID for published scope, preview-{shortId} for preview scope. Call this once at app boot — it wires the shared transport used by all subpaths.


MongoDB (@ema-unlimited/frontier-apps-sdk/mongodb)

Define a schema (mechanical swap from Mongoose)

// Before
import mongoose, { Schema, type InferSchemaType } from 'mongoose'

// After — only the import line changes
import { Schema, model, type InferSchemaType } from '@ema-unlimited/frontier-apps-sdk/mongodb'

const noteSchema = new Schema(
  {
    userId: { type: String, required: true, index: true },
    title: { type: String, required: true },
    content: { type: String, default: '' },
  },
  { timestamps: true },
)

export type NoteDocument = InferSchemaType<typeof noteSchema>
export const Note = model<NoteDocument>('Note', noteSchema)

Query

const notes = await Note.find({ userId }).sort({ createdAt: -1 }).limit(20)

const note = await Note.findOneAndUpdate(
  { _id: id, userId },
  { $set: { title: 'New' } },
  { new: true },
)

await Note.deleteOne({ _id: id, userId })

Supported operations

MethodReturns
find(filter?, options?)QueryBuilder<T[]> (thenable)
findOne(filter)QueryBuilder<T | null>
findById(id)QueryBuilder<T | null>
findOneAndUpdate(filter, update, ?)QueryBuilder<T | null>
findByIdAndUpdate(id, update, ?)QueryBuilder<T | null>
findOneAndDelete(filter)QueryBuilder<T | null>
findByIdAndDelete(id)QueryBuilder<T | null>
create(doc | docs[])Promise<T | T[]>
insertOne(doc)Promise<T>
insertMany(docs[])Promise<T[]>
updateOne(filter, update)Promise<UpdateResult>
updateMany(filter, update)Promise<UpdateResult>
deleteOne(filter)Promise<DeleteResult>
deleteMany(filter)Promise<DeleteResult>
countDocuments(filter?)Promise<number>
distinct(field, filter?)Promise<unknown[]>
aggregate(pipeline[])Promise<unknown[]>
bulkWrite(ops[], options?)Promise<BulkWriteResult>
createIndex(keys, options?)Promise<string> (index name)
listIndexes()Promise<IndexInfo[]>

The query chain is lazy — .sort(), .limit(), .skip(), .lean() return the same QueryBuilder and the HTTP request fires only when awaited. .lean() is a no-op (the gateway always returns plain JSON).

bulkWrite

bulkWrite takes the same op array mongoose.Model.bulkWrite takes:

await LineItem.bulkWrite([
  { updateOne: { filter: { _id: id }, update: { $set: { verdict: 'pass' } } } },
  { insertOne: { document: { sku: 'new' } } },
  { deleteOne: { filter: { sku: 'old' } } },
])

Ordering differs from Mongoose. This defaults to ordered: false where Mongoose defaults to true, so a failing op does not discard the rest of the batch. Pass { ordered: true } for stop-at-first-error semantics.

A batch is capped at 1000 ops, and the whole request body at 10 MiB — chunk above either. A failed batch throws even when some ops landed; the error message carries the counters that did apply.

Indexes

// Idempotent: safe to call at every boot.
await Note.createIndex({ userId: 1, createdAt: -1 })
await Person.createIndex({ email: 1 }, { unique: true })
const live = await Note.listIndexes()

Key order is significant for a compound index and is preserved on the wire, so build the key spec as an object literal. Supported options: name, unique, sparse, hidden, expireAfterSeconds, partialFilterExpression. Any other option is rejected, rather than dropped, so an app never believes it holds a constraint the database is not enforcing.

Re-creating an index with an identical spec is a no-op. The same name with a different spec is an IndexOptionsConflict from the server. A collection is capped at 64 indexes, MongoDB's own limit.

createIndex uses a 5-minute timeout rather than the usual 30 seconds: an index build runs to completion before the command returns.

Blocked at the facade level

These throw synchronously before any network call:

  • .populate() → use aggregate with $lookup
  • .drop() / dropCollection() → schema management is not exposed
  • dropIndex() → use createIndex and listIndexes

The gateway additionally blocks $where, $function, $accumulator, $out, mapReduce, and unbounded find({}) calls. These return HTTP 400, which the SDK surfaces as a FrontierDbError.

Error handling

import { FrontierError } from '@ema-unlimited/frontier-apps-sdk'
import { FrontierDbError } from '@ema-unlimited/frontier-apps-sdk/mongodb'

try {
  await Note.find({})
} catch (err) {
  if (err instanceof FrontierDbError) {
    console.error(err.status, err.code, err.message)
  }
  // Or catch any SDK error across subpaths:
  if (err instanceof FrontierError) { ... }
}

Storage (@ema-unlimited/frontier-apps-sdk/storage)

Per-app file storage backed by GCS. The SDK never touches GCS credentials — the gateway mints short-lived V4 signed URLs (5-min PUT, 15-min GET) and the browser/Node client does the actual byte transfer direct to storage.googleapis.com. The control plane (auth, content-type validation, quota, audit) runs at the gateway; the data plane skips it.

Storage is a pure byte store — it never parses your files — so it accepts any well-formed content type. There is no upload allow-list to grow. Safety against active content is enforced at serve time: the gateway serves text/html, image/svg+xml, and */xml objects with Content-Disposition: attachment so they download instead of executing inline on the shared GCS origin. (This does not affect <img> / <video> / <audio> embeds — browsers ignore Content-Disposition for subresource loads.)

Key layout

Every key the SDK sends is sanitised and prefixed server-side:

tenants/<tenantId>/frontier-apps/<appId>/uploads/<your-key>

Tenant + app are resolved from the gateway (per-app K8s secret) — the SDK cannot influence them. User-supplied keys must match [A-Za-z0-9._/-] and cannot start with / or contain .. / NUL.

High-level upload (handles signing for you)

import { put } from '@ema-unlimited/frontier-apps-sdk/storage'

const blob = new Blob([fileBytes], { type: 'image/png' })
const meta = await put(`users/${userId}/avatar.png`, blob, {
  contentType: 'image/png',
})
// → { key, size, contentType }

Under the hood put() does sign → PUT (direct to GCS) → confirm. If the PUT fails, confirm-put is not called — no orphan records.

Browser-direct upload (large files, no Node proxy hop)

When the upload comes from a browser, mint the URL server-side and let the browser do the PUT. The same Content-Type + size are pinned into the signature, so the caller can't enlarge or retype the upload after the gateway authorizes.

import { signPut, confirmPut } from '@ema-unlimited/frontier-apps-sdk/storage'

// 1. Server side — mint the signed URL.
const signed = await signPut(`users/${userId}/big.zip`, {
  contentType: 'application/zip',
  size: file.size,
})

// 2. Browser side — PUT bytes direct to GCS.
await fetch(signed.url, {
  method: 'PUT',
  headers: signed.requiredHeaders,  // includes Content-Type + X-Goog-Content-Length-Range
  body: file,
})

// 3. Server side — record the upload.
const meta = await confirmPut(`users/${userId}/big.zip`)

Read back / list / delete

import { get, getMeta, list, del } from '@ema-unlimited/frontier-apps-sdk/storage'

// Signed GET URL — usable directly in <img src>, <video src>, <a download>.
const { url, expiresAt, size, contentType } = await get(`users/${userId}/avatar.png`)

// Metadata only (no signing, no transfer).
const meta = await getMeta(`users/${userId}/avatar.png`)

// Paginated list scoped to the app's prefix.
const { items, nextCursor } = await list(`users/${userId}/`, { limit: 50 })

// Idempotent delete.
await del(`users/${userId}/avatar.png`)

Constraints (server-enforced)

LimitDefault
Per-object size1 GiB
Signed PUT TTL5 minutes
Signed GET TTL15 minutes
Content typesAny well-formed type/subtype accepted. Active types (text/html, image/svg+xml, */xml) are served as downloads.
Key charset[A-Za-z0-9._/-] — no .., no leading /, no NUL

Error handling

import { FrontierStorageError } from '@ema-unlimited/frontier-apps-sdk/storage'

try {
  await put('uploads/x.pdf', blob, { contentType: 'application/pdf' })
} catch (err) {
  if (err instanceof FrontierStorageError) {
    console.error(err.status, err.code, err.message)
    // err.status: HTTP status (e.g. 422 for a denied/invalid content-type or oversize)
    // err.code:   the gateway error code (e.g. "VALIDATION_ERROR") or a client
    //             classifier ("network_error" | "gcs_put_failed" | "timeout" | "aborted")
  }
}

Where things land in GCS

gs://<project-id>-frontier-apps-uploads/
  tenants/
    <tenantId>/
      frontier-apps/
        <appId>/
          uploads/
            users/<userId>/avatar.png           ← written by the storage SDK
            users/<userId>/reports/2026-Q1.pdf

The bucket is dedicated to per-app user uploads — separate from the shared <env>-emu-blobs bucket where frontier-apps-builder keeps its git snapshots. Per-tenant offboarding is a single gsutil -m rm -r gs://<bucket>/tenants/<tenantId>/ against this bucket.


Development

pnpm install
pnpm typecheck
pnpm test
pnpm build

Design

See docs/design-frontier-apps-db-proxy.md.

版本与发布记录

@ema-unlimited/frontier-apps-sdk 共发布 9 个版本,首次发布于 2026/05/21;过去 12 个月发布了 9 个版本。

版本发布日期距今
0.3.0latest2026/10/2222 天前
0.2.22026/09/231 个月前
0.2.12026/06/174 个月前
0.2.02026/05/285 个月前
0.2.0-rc.4rc2026/05/225 个月前
0.2.0-rc.32026/05/215 个月前

关于 @ema-unlimited/frontier-apps-sdk 的常见问题

@ema-unlimited/frontier-apps-sdk 是什么?

SDK for Frontier Apps — database, storage, and Ema API access via frontier-apps-gateway

如何安装 @ema-unlimited/frontier-apps-sdk?

在项目目录运行 npm install @ema-unlimited/frontier-apps-sdk,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:mongoose(^8.0.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@ema-unlimited/frontier-apps-sdk 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@ema-unlimited/frontier-apps-sdk 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@ema-unlimited/frontier-apps-sdk 可以商用吗?

@ema-unlimited/frontier-apps-sdk 使用 未声明 许可。package.json 没有声明许可证。没有许可证的代码默认保留全部权利,商用前请联系作者或查看仓库中的 LICENSE 文件。以上为概括,不构成法律意见,请以许可原文为准。

@ema-unlimited/frontier-apps-sdk 的下载量有多少?

根据 npm 官方下载统计,@ema-unlimited/frontier-apps-sdk 在 2026/10/14 – 2026/11/12 这 30 天内被下载 6,951 次,日均约 232 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@ema-unlimited/frontier-apps-sdk 还在维护吗?

最新版本 0.3.0 发布于 2026/10/22(22 天前);至今共发布 9 个版本,其中过去 12 个月发布了 9 个;首次发布于 2026/05/21。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。