@digo-labs/services

中文文档
0.2.19发布于 2026/11/13TypeScriptESM + CJS近 30 天下载 2191

The browser client for the Digo platform: auth, device keys, the backend API, database tables, file storage, websocket channels, AI generation and AI jobs.

原始使用文档
文档目录12 节
  1. Setup
  2. Auth: useAuth , useGoogleSignIn , AuthHelper
  3. Device keys: Devices , useDevice , DeviceHelper
  4. Database: Database<T>
  5. Instances: Instances , useInstance , InstanceHelper
  6. Storage: Storage
  7. Websockets: Websockets<TMap>
  8. AI: Ai
  9. AI jobs: ai.jobs
  10. AI budgets: ai.budgets
  11. Lower level and CLI
  12. Gotchas

@digo-labs/services

The browser client for the Digo platform: auth, device keys, the backend API, database tables, file storage, websocket channels, AI generation and AI jobs.

How to read this. This guide ships with the package, so it matches the version you have installed; the site describes the latest release. The types in node_modules/@digo-labs/services/dist/*.d.ts are exact and win if they disagree with it. Deeper pages: https://design.digo-labs.com/docs/guide/package-services.

Setup

Don't initialise this package by hand. defineApp from @digo-labs/app registers the app name, calls AuthHelper.init (with auth) and ApiHelper.init (with backend: true), then runs your services factory, where you construct the classes below (...databases, new Storage(), new Websockets<Channels>(), new Ai()).

Auth: useAuth, useGoogleSignIn, AuthHelper

import { useAuth } from '@digo-labs/services';

const { user, session, device, instance, isReadOnly, tier, globalTier, isLoading, isOffline, isAuthenticated, signIn, signOut } = useAuth();

await signIn.social({ provider: 'google', callbackURL: window.location.href });

tier is 'public' | 'user' | 'org' | 'admin' in the selected instance: an enrolled device's tier wins; otherwise org is a verified @productionclub.net email, and admin the admin role or an admin of that instance. instance is the selected instance's slug and globalTier ignores instance roles: gate instance management, budget limits, text, proxies and DMX on it. isReadOnly is true while the instance is archived (an archive revokes the instance's device keys, so devices get 401). isAuthenticated is true for a user or a device. AuthHelper.auth is the raw Better Auth client (admin plugin included).

For a Google sign-in button, useGoogleSignIn({ callbackPath }) (default '/') returns { signIn, isSigningIn, error }: signIn() redirects to Google and back to callbackPath on the current origin, and resolves to a Result<void> tuple; isSigningIn is true until the browser leaves or the request fails; error holds the failure.

import { useGoogleSignIn } from '@digo-labs/services';

const { signIn, isSigningIn } = useGoogleSignIn({ callbackPath: from });

async function handleSignIn() {
  const [, error] = await signIn();

  if (error) {
    console.error(error);
    setMessage('Sign in failed. Please try again.');
  }
}

<button onClick={handleSignIn} disabled={isSigningIn}>Continue with Google</button>

Device keys: Devices, useDevice, DeviceHelper

A device key lets a kiosk act at a tier with nobody signed in. It belongs to the instance it was created in, lives in localStorage and is sent on every API call; a loading device switches its browser to its key's instance.

const devices = new Devices();

await devices.enrol({ name: 'Booth 1', tier: 'org', expiresInDays: 30 }); // create and store the key here, then sign this browser out
await devices.create({ name: 'Booth 2' });  // { device, key }, key not stored
await devices.list();                       // DeviceRecord[]
await devices.revoke(id);                   // boolean
await devices.regenerate(id);               // { device, key }: old key revoked, new one revealable
devices.forget();                           // drop this device's key

await devices.create({ name: 'Booth 3', isRevealable: true }); // keep an encrypted copy
await devices.reveal(id);                   // the key again (admin, while revealable)
await devices.lock(id);                     // boolean: drop the copy for good, key keeps working
await devices.lockAll();                    // number of keys locked in this instance

tier is 'user' | 'org' (default 'org'), expiresInDays up to 365 (default 30) or null for never. DeviceRecord.isRevealable says whether a key still has its copy. useDevice() returns { device, isLoading, isOffline }: only a 401 drops the stored key, while any other failure (network error, timeout, another 4xx, a 5xx) sets isOffline, keeps device as the record the backend last confirmed (kept in localStorage) and retries by itself; DeviceHelper.setKey(key) / clearKey() manage the stored key.

Database: Database<T>

One instance per table. Typed instances come from defineDatabases (@digo-labs/app/database); construct one directly for shared tables: new Database<StyleRecord>('styles') (second argument: primary key, default 'id').

await services.items.getAll(50, 0);                                            // T[]  (limit?, offset?), in primary-key order
await services.items.get(id);                                                   // T | null
await services.items.getBy('name', 'Hello');                                    // T[]  (column, value, limit?, offset?)
await services.items.readAll();                                                 // T[]  every row, paged by primary key
await services.items.readAllBy('name', 'Hello');                                // T[]  every matching row
await services.items.create({ name: 'Hello' });                                 // T
await services.items.update(id, { name: 'Hi' });                                // boolean
await services.items.update(id, { status: 'printing' }, { status: 'queued' }); // only if it still matches (needs read too)
await services.items.delete(id);                                                // boolean

To read a whole table use readAll/readAllBy, not a loop over offset: they page after the last primary key read, so every row that exists from start to end is read exactly once, even while others are changed, added or removed (single-column primary key only; other tables get a 400).

Access is per table, declared in src/db.ts and synced by digo-db push; a table without a rule is denied. The client may not send id (give it a database default) or the table's owner column (the backend fills it from the session).

Instances: Instances, useInstance, InstanceHelper

Isolated installations of one app (schema, files, signed-in channels, device keys, AI budget each; open channels only get the instance in their name). main always exists; requests carry the instance in X-Digo-Instance: the page's host (jon.<app>.digo-labs.com is jon), else the browser's selection, else main. An unknown stored selection is dropped and the page reloads in main, signed in or not.

const instances = new Instances();

await instances.list();                                  // main, then all for a global admin or the ones you administer
await instances.current();                               // { instance, tier, isReadOnly }
instances.select('jon');                                 // store for this browser and reload
instances.getSelected();                                 // 'jon'
await instances.create({ slug: 'jon', name: 'Jon' });    // global admins only, like the calls below
await instances.rename('main', 'Las Vegas');
await instances.archive('jon');                          // read-only, device keys revoked
await instances.reopen('jon');
await instances.setAdmins('jon', ['[email protected]']);   // replaces the list; admins of that instance only, never main
await instances.getAdmins('jon');

useInstance({ userId }) returns { instance, access, isLoading } (access is fetched only outside main, retried on network errors, and an unknown stored instance is dropped); useAuth() already uses it.

Storage: Storage

Files in the app's S3 bucket via presigned links. new Storage() uses <app-name>-digo-labs-storage; pass a name for a shared bucket (new Storage('common-styles-digo-labs-storage')).

const { path, url } = await storage.uploadFile(`sketches/${id}.png`, file);  // File | Blob | ArrayBuffer, contentType?
await storage.uploadImage(path, { base64, mediaType: 'image/png' });
await storage.uploadFromUrl(path, remoteUrl, 'image/png');

storage.getUrl(path);                                   // sync link through the files route
await storage.getSignedUrl(path, { expiresIn: 300 });   // short-lived link for private prefixes
await storage.download(path);                           // Blob
await storage.list('sketches/', { limit: 100 });        // StorageObject[]
await storage.delete([path]);                           // deleted paths
storage.getPathFromUrl(url);                            // string | null

Store paths in tables, not links. Per-prefix rules come from defineStorage in src/db.ts. Paths are relative to the selected instance: in jon they live under _instances/jon/, which getUrl, getPathFromUrl and list add and remove for you.

Websockets: Websockets<TMap>

Typed pub/sub on the websocket hub; TMap maps channel names to payloads. It connects in the constructor and reconnects by itself.

interface Channels { cursor: { x: number; y: number; }; }

const ws = new Websockets<Channels>({ auth: true });

ws.subscribe('cursor', data => move(data.x, data.y));
ws.send('cursor', { x: 1, y: 2 });       // to current subscribers
ws.setState('cursor', { x: 1, y: 2 });   // kept on the hub, replayed to new subscribers
ws.unsubscribe('cursor');
ws.onReady(() => refresh());             // also onOpen, onClose, onError, offReady, isLive, close

Options: auth, url (default wss://ws.digo-labs.com), app, id, reconnect, reconnectInterval, maxReconnectInterval, getToken.

  • Open (default): channels go out as <app>:<channel> (<app>__<instance>:<channel> outside main) unless they contain : or start with _. A naming convention; anyone can read them.
  • Signed in (auth: true): a backend token (session or device key) is presented; names go as written and are checked against the app's defineChannels rules.

AI: Ai

const ai = new Ai();

const stream = ai.text.generate({ messages: [{ role: 'user', content: 'Hi' }], systemPrompt, maxTokens: 400 });
for await (const { text } of stream) append(text);
const { text, stopReason, isBlocked } = await stream.collect();

const image = await ai.image.generate({ prompt, aspectRatio: '16:9' }, { persist: 'generations/a.jpg' });
await ai.image.edit({ prompt, image: url, strength: 0.6 });
await ai.image.upscale({ image: url, factor: 2 });
await ai.image.removeBackground({ image: url });
await ai.video.generate({ prompt, aspectRatio: '9:16', durationSeconds: 5 });
await ai.audio.speech({ text, voice: 'af_heart' });
await ai.audio.music({ prompt, durationSeconds: 30 });
await ai.audio.transcribe({ audio: url, language: 'en' });

A text stream that fails midway throws TextStreamError with a reason (busy, model_unavailable, provider_down, invalid_request or unknown). Text runs on Bedrock; media on fal. Media options are { persist?, signal?, onProgress? } (transcribe and text.generate take { signal } only). Results are { url, contentType?, width?, height?, seed?, durationSeconds?, model? }; without persist, url is the provider's temporary link, with it the file is copied to your bucket and url is storage.getUrl(persist). Raw providers: ai.bedrock.converseStream(body, signal?), ai.fal (subscribe, run, stream, queue, …) and ai.replicate (the replicate SDK), also exported as Bedrock, Fal, Replicate.

AI jobs: ai.jobs

Image pipelines run by the backend: input checks, tasks, outputs saved to your bucket, output checks. Reloads lose nothing.

const job = await services.ai.jobs.create({
  key:      `guest-${guestId}`,                       // idempotent per app instance
  inputs:   { sketch: `sketches/${guestId}.png` },
  checks:   [{ type: 'rekognition', input: 'sketch' }],
  tasks:    [{
    key:      'traditional',
    endpoint: 'fal:openai/gpt-image-2.5/flare/edit',   // AI_JOB_ENDPOINTS in @digo-labs/common
    input:    { prompt, image_urls: [{ $input: 'sketch' }] },
    output:   { path: `designs/${guestId}/traditional.png` }, // under a write: 'server' prefix
  }],
  outputChecks: [{ type: 'rekognition' }],
});

const stop = services.ai.jobs.watch(job.id, current => setTasks(current.tasks));

Also add(id, tasks), check(id, taskKey, checks), cancel(id), get(id), and list(filters): the instance's jobs a page at a time, oldest first, in the shape get returns; filters from / to (ISO 8601 on creation time, to excluded), statuses, limit (100 by default, 200 at most), and the page's next, passed back as cursor until it is null. onReview is 'reject' (default) or 'continue'. create takes no tasks when the job only checks its inputs for now; add then works while the job is checking, and its tasks wait for the checks. A check is { type: 'rekognition', input?, reject?, review?, minConfidence?, timeoutMs? } or { type: 'bedrock', prompt, input?, guardrail?, model?, provider?, timeoutMs? }, where provider is 'bedrock' (default) or 'anthropic' (no guardrail there) and timeoutMs is 3 to 25 s (25 by default). watch waits on the backend for each change (get(id, { since, signal }) does the same by hand), calls back only on change and stops once everything settled. endpoint: 'fake:echo' costs nothing while building. A task's fallbacks (up to two { endpoint, input? }) take over in order when its provider fails, and fallbackAfter (ms) also starts the next one when the current is slow; servedBy names the endpoint whose image was saved. Any check takes up to two fallbacks (or one fallback, not both): Claude reviews tried in order, each only when the one before can't run or times out.

AI budgets: ai.budgets

Each instance's limits for jobs, kept on the backend. null means no limit.

const { budget, usage } = await services.ai.budgets.get();
await services.ai.budgets.update({ maxInFlight: null, dailyUsd: 200 }); // only the fields sent change

budget is { maxTasksPerJob, maxInFlight, hourlyUsd, dailyUsd, isPaused } (defaults 30, 8, 10, 50, false); usage is { hourlyUsd, dailyUsd, inFlight }, the estimated spend in the last hour and day and the requests at the providers now. Both calls act on the caller's own app and instance; changing a limit needs a global admin, while an instance admin may only pause and resume (isPaused). A cap a job request exceeds answers 429.

Lower level and CLI

  • ApiHelper.client is the typed Hono client for every backend route; ApiHelper.unwrap<T>(promise) returns the JSON or throws an Error with the backend's message.
  • new Dmx().send(channel, value) sets a DMX channel (admin only). Realtime is an empty placeholder; use Websockets.
  • The digo-db binary (npm run db:push in apps): push creates the schema, runs drizzle-kit push, syncs the access, storage and channels exports of src/db.ts and pushes every instance schema of the app; also drop, verify, refresh, schema-name. Needs DATABASE_URL_MONOREPO (and ADMIN_SECRET for the refresh) in the environment, which the app-template's scripts/with-monorepo-secrets.sh reads live from AWS Secrets Manager (npm run db:push runs push through it; run the other subcommands through it too), and drizzle-kit installed.

Gotchas

  • Construct services inside the services factory. Storage and Websockets need the app name defineApp registers and throw without it; new Ai() builds a Storage, so it throws too.
  • useAuth needs auth in defineApp, or AuthHelper.auth throws. One auth project per page.
  • backendURL doesn't reach fal or Replicate: their proxies always use https://api.digo-labs.com.
  • Tiers the backend enforces: text, fal/Replicate, all ai.jobs calls and ai.budgets.get need org; ai.budgets.update and device create/list/revoke need admin, instances.list a sign-in (instances.current is open), and instance management a global admin. An instance admin is admin only on that instance's own data, files, channels, devices and jobs; text, fal/Replicate and DMX check their global tier. An archived instance answers writes with 423. 401 when signed out, 403 when the tier is too low.
  • Shared table names win. The backend resolves common tables (such as styles) before the app's, so an app table with the same name is unreachable.
  • getUrl only loads under a read: 'public' prefix (else 404); use getSignedUrl. getPublicUrl builds a raw S3 address that doesn't load: buckets are private.
  • Fixed AI values throw otherwise: image aspectRatio 1:1, 16:9, 9:16, 4:3, 3:4; video 16:9, 9:16, 1:1, duration snapped to 5 or 10 s; voice must be a Kokoro voice (af_heart, am_adam, …).
  • Websocket send/setState while disconnected are dropped, not queued. Re-read state in onReady.

版本与发布记录

@digo-labs/services 共发布 32 个版本,首次发布于 2026/05/06;过去 12 个月发布了 32 个版本。

版本发布日期距今
0.2.19latest2026/11/13今天
0.2.172026/11/131 天前
0.2.162026/11/131 天前
0.2.152026/11/131 天前
0.2.142026/11/121 天前
0.2.132026/11/112 天前

关于 @digo-labs/services 的常见问题

@digo-labs/services 是什么?

The browser client for the Digo platform: auth, device keys, the backend API, database tables, file storage, websocket channels, AI generation and AI jobs.

如何安装 @digo-labs/services?

在项目目录运行 npm install @digo-labs/services,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:react(^19.0.0)、react-dom(^19.0.0)、@digo-labs/ws(^0.2.2)、@digo-labs/common(^0.2.23)、@digo-labs/backend(^0.2.15)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。它还提供命令行工具 digo-db,可以用 npx digo-db 直接运行。

@digo-labs/services 支持 TypeScript 吗?

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

@digo-labs/services 支持 ESM 还是 CommonJS?

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

@digo-labs/services 可以商用吗?

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

@digo-labs/services 的下载量有多少?

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

@digo-labs/services 还在维护吗?

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