@chatu-ai/app-sdk 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,12 +3,16 @@
3
3
  Data SDK for apps generated by **ChatU Builder**. One API, driver picked from environment variables — works in the Builder preview, on your own server, or with no config at all.
4
4
 
5
5
  ```ts
6
- import { kv } from '@chatu-ai/app-sdk' // server-side only (Route Handlers / Server Components / Server Actions)
6
+ import { kv, storage } from '@chatu-ai/app-sdk' // server-side only (Route Handlers / Server Components / Server Actions)
7
7
 
8
8
  await kv.set('todos:1', { title: 'hi' }, { ex: 3600 })
9
9
  const todo = await kv.get<{ title: string }>('todos:1')
10
10
  await kv.incr('views')
11
11
  const { keys } = await kv.list('todos:')
12
+
13
+ await storage.put('avatars/u1.png', bytes, { contentType: 'image/png' }) // ≤5MB server-side
14
+ const { url } = await storage.uploadUrl('uploads/big.mp4', { contentType: 'video/mp4' }) // hand to the browser: fetch(url, { method: 'PUT', body: file })
15
+ const src = await storage.url('avatars/u1.png', { expiresIn: 3600 }) // temporary link for <img src>
12
16
  ```
13
17
 
14
18
  | Env | Driver | Notes |
package/dist/index.d.ts CHANGED
@@ -3,3 +3,5 @@ export type { KvClient, KvSetOptions, KvListResult } from './kv.js';
3
3
  export { configure, describe } from './config.js';
4
4
  export type { ConfigureOptions, DriverKind } from './config.js';
5
5
  export { AppSdkError } from './errors.js';
6
+ export { storage, getStorage } from './storage.js';
7
+ export type { StorageClient, StorageObject, StorageListResult, UploadUrlResult } from './storage.js';
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { kv, getKv } from './kv.js';
2
2
  export { configure, describe } from './config.js';
3
3
  export { AppSdkError } from './errors.js';
4
+ export { storage, getStorage } from './storage.js';
@@ -0,0 +1,44 @@
1
+ export interface StorageObject {
2
+ key: string;
3
+ size: number;
4
+ lastModified?: string | null;
5
+ }
6
+ export interface StorageListResult {
7
+ items: StorageObject[];
8
+ nextCursor: string | null;
9
+ }
10
+ export interface UploadUrlResult {
11
+ url: string;
12
+ method: 'PUT';
13
+ expiresIn: number;
14
+ headers?: Record<string, string>;
15
+ }
16
+ export interface StorageClient {
17
+ /** 服务端小文件直传(≤5MB) */
18
+ put(key: string, data: Uint8Array | ArrayBuffer | string | Blob, opts?: {
19
+ contentType?: string;
20
+ }): Promise<{
21
+ key: string;
22
+ size: number;
23
+ }>;
24
+ /** 浏览器直传:返回预签名 PUT 地址(把它交给前端 fetch(url, { method:'PUT', body:file })) */
25
+ uploadUrl(key: string, opts?: {
26
+ contentType?: string;
27
+ size?: number;
28
+ }): Promise<UploadUrlResult>;
29
+ /** 读取对象内容(服务端) */
30
+ get(key: string): Promise<Uint8Array | null>;
31
+ /** 临时访问地址(默认 10 分钟;可指定秒数、下载文件名)—— 用于 <img src> / 下载链接 */
32
+ url(key: string, opts?: {
33
+ expiresIn?: number;
34
+ downloadName?: string;
35
+ }): Promise<string>;
36
+ head(key: string): Promise<StorageObject | null>;
37
+ delete(key: string): Promise<void>;
38
+ list(prefix?: string, opts?: {
39
+ cursor?: string | null;
40
+ limit?: number;
41
+ }): Promise<StorageListResult>;
42
+ }
43
+ export declare function getStorage(): StorageClient;
44
+ export declare const storage: StorageClient;
@@ -0,0 +1,112 @@
1
+ import { resolveConfig } from './config.js';
2
+ import { AppSdkError } from './errors.js';
3
+ function toBytes(data) {
4
+ if (typeof data === 'string')
5
+ return Promise.resolve(new TextEncoder().encode(data));
6
+ if (data instanceof Uint8Array)
7
+ return Promise.resolve(data);
8
+ if (data instanceof ArrayBuffer)
9
+ return Promise.resolve(new Uint8Array(data));
10
+ return data.arrayBuffer().then(b => new Uint8Array(b));
11
+ }
12
+ // ---------- platform driver ----------
13
+ function platformStorage(cfg) {
14
+ const base = { 'x-api-key': cfg.apiKey, 'x-chatu-env': cfg.env };
15
+ const enc = (key) => key.split('/').map(encodeURIComponent).join('/');
16
+ async function json(method, path, body) {
17
+ const res = await cfg.fetchImpl(`${cfg.baseUrl}/storage${path}`, { method, headers: { ...base, 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) });
18
+ let j = null;
19
+ try {
20
+ j = await res.json();
21
+ }
22
+ catch { /* ignore */ }
23
+ if (!res.ok || j?.ok === false)
24
+ throw new AppSdkError(j?.error ?? `HTTP_${res.status}`, j?.message ?? `storage ${method} ${path} failed (${res.status})`, res.status);
25
+ return j;
26
+ }
27
+ return {
28
+ async put(key, data, opts) {
29
+ const bytes = await toBytes(data);
30
+ const res = await cfg.fetchImpl(`${cfg.baseUrl}/storage/${enc(key)}`, { method: 'PUT', headers: { ...base, 'content-type': opts?.contentType ?? 'application/octet-stream' }, body: bytes });
31
+ let j = null;
32
+ try {
33
+ j = await res.json();
34
+ }
35
+ catch { /* ignore */ }
36
+ if (!res.ok || j?.ok === false)
37
+ throw new AppSdkError(j?.error ?? `HTTP_${res.status}`, j?.message ?? `storage put failed (${res.status})`, res.status);
38
+ return { key, size: j.size ?? bytes.byteLength };
39
+ },
40
+ async uploadUrl(key, opts) {
41
+ const r = await json('POST', '/upload-url', { key, contentType: opts?.contentType, size: opts?.size });
42
+ return { url: r.url, method: 'PUT', expiresIn: r.expiresIn, headers: r.headers?.contentType ? { 'content-type': r.headers.contentType } : undefined };
43
+ },
44
+ async get(key) {
45
+ const r = await json('POST', '/sign', { key });
46
+ const res = await cfg.fetchImpl(r.url);
47
+ if (res.status === 404)
48
+ return null;
49
+ if (!res.ok)
50
+ throw new AppSdkError(`HTTP_${res.status}`, `storage get failed (${res.status})`, res.status);
51
+ return new Uint8Array(await res.arrayBuffer());
52
+ },
53
+ async url(key, opts) {
54
+ const r = await json('POST', '/sign', { key, expiresIn: opts?.expiresIn, downloadName: opts?.downloadName });
55
+ return r.url;
56
+ },
57
+ async head(key) {
58
+ const res = await cfg.fetchImpl(`${cfg.baseUrl}/storage/${enc(key)}?meta=1`, { headers: base });
59
+ if (res.status === 404)
60
+ return null;
61
+ const j = await res.json().catch(() => null);
62
+ if (!res.ok || j?.ok === false)
63
+ throw new AppSdkError(j?.error ?? `HTTP_${res.status}`, j?.message ?? 'storage head failed', res.status);
64
+ return { key, size: j.size, lastModified: j.lastModified ?? null };
65
+ },
66
+ async delete(key) { await json('DELETE', `/${enc(key)}`); },
67
+ async list(prefix = '', opts) {
68
+ const q = new URLSearchParams({ prefix, limit: String(opts?.limit ?? 100) });
69
+ if (opts?.cursor)
70
+ q.set('cursor', opts.cursor);
71
+ const r = await json('GET', `?${q.toString()}`);
72
+ return { items: r.items, nextCursor: r.nextCursor ?? null };
73
+ },
74
+ };
75
+ }
76
+ // ---------- memory driver ----------
77
+ function memoryStorage() {
78
+ const store = new Map();
79
+ return {
80
+ async put(key, data, opts) { const bytes = await toBytes(data); store.set(key, { bytes, contentType: opts?.contentType, at: new Date().toISOString() }); return { key, size: bytes.byteLength }; },
81
+ async uploadUrl(key) { return { url: `memory://${key}`, method: 'PUT', expiresIn: 0 }; },
82
+ async get(key) { return store.get(key)?.bytes ?? null; },
83
+ async url(key) { const e = store.get(key); if (!e)
84
+ return `memory://${key}`; const b64 = btoa(String.fromCharCode(...e.bytes)); return `data:${e.contentType ?? 'application/octet-stream'};base64,${b64}`; },
85
+ async head(key) { const e = store.get(key); return e ? { key, size: e.bytes.byteLength, lastModified: e.at } : null; },
86
+ async delete(key) { store.delete(key); },
87
+ async list(prefix = '', opts) {
88
+ const all = [...store.entries()].filter(([k]) => k.startsWith(prefix)).sort(([a], [b]) => a.localeCompare(b));
89
+ const start = opts?.cursor ? Number(opts.cursor) : 0;
90
+ const limit = opts?.limit ?? 100;
91
+ const page = all.slice(start, start + limit).map(([key, e]) => ({ key, size: e.bytes.byteLength, lastModified: e.at }));
92
+ return { items: page, nextCursor: start + limit < all.length ? String(start + limit) : null };
93
+ },
94
+ };
95
+ }
96
+ let cached = null;
97
+ export function getStorage() {
98
+ const cfg = resolveConfig();
99
+ const key = cfg.kind === 'platform' ? `platform|${cfg.baseUrl}|${cfg.env}|${cfg.apiKey.slice(-4)}` : 'memory';
100
+ if (!cached || cached.key !== key)
101
+ cached = { key, client: cfg.kind === 'platform' ? platformStorage(cfg) : memoryStorage() };
102
+ return cached.client;
103
+ }
104
+ export const storage = {
105
+ put: (k, d, o) => getStorage().put(k, d, o),
106
+ uploadUrl: (k, o) => getStorage().uploadUrl(k, o),
107
+ get: (k) => getStorage().get(k),
108
+ url: (k, o) => getStorage().url(k, o),
109
+ head: (k) => getStorage().head(k),
110
+ delete: (k) => getStorage().delete(k),
111
+ list: (p, o) => getStorage().list(p, o),
112
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { configure, getStorage, storage } from './index';
3
+ describe('storage memory driver', () => {
4
+ it('put/get/head/list/delete', async () => {
5
+ configure({ driver: 'memory' });
6
+ await storage.put('img/a.txt', 'hello', { contentType: 'text/plain' });
7
+ expect(new TextDecoder().decode((await storage.get('img/a.txt')))).toBe('hello');
8
+ expect((await storage.head('img/a.txt'))?.size).toBe(5);
9
+ expect((await storage.url('img/a.txt')).startsWith('data:text/plain;base64,')).toBe(true);
10
+ expect((await storage.list('img/')).items.map(i => i.key)).toEqual(['img/a.txt']);
11
+ await storage.delete('img/a.txt');
12
+ expect(await storage.get('img/a.txt')).toBeNull();
13
+ });
14
+ });
15
+ describe('storage platform driver', () => {
16
+ it('put sends bytes with api key; url signs; list parses', async () => {
17
+ const calls = [];
18
+ const fetchImpl = (async (url, init) => {
19
+ calls.push({ url, init });
20
+ if (init?.method === 'PUT')
21
+ return new Response(JSON.stringify({ ok: true, key: 'a/b.png', size: 3 }), { status: 200 });
22
+ if (url.endsWith('/storage/sign'))
23
+ return new Response(JSON.stringify({ ok: true, url: 'https://cos/signed' }), { status: 200 });
24
+ if (url.includes('/storage?'))
25
+ return new Response(JSON.stringify({ ok: true, items: [{ key: 'a/b.png', size: 3 }], nextCursor: null }), { status: 200 });
26
+ if (url.endsWith('/storage/upload-url'))
27
+ return new Response(JSON.stringify({ ok: true, url: 'https://cos/put', expiresIn: 600, headers: { contentType: 'image/png' } }), { status: 200 });
28
+ return new Response(JSON.stringify({ ok: false, error: 'PAYMENT_REQUIRED' }), { status: 402 });
29
+ });
30
+ configure({ driver: 'platform', baseUrl: 'https://api.test/data/v1', apiKey: 'sk-conv-x', env: 'dev', fetchImpl });
31
+ const s = getStorage();
32
+ expect(await s.put('a/b.png', new Uint8Array([1, 2, 3]), { contentType: 'image/png' })).toEqual({ key: 'a/b.png', size: 3 });
33
+ expect(calls[0].url).toBe('https://api.test/data/v1/storage/a/b.png');
34
+ expect(calls[0].init.headers['x-api-key']).toBe('sk-conv-x');
35
+ expect(await s.url('a/b.png', { expiresIn: 60 })).toBe('https://cos/signed');
36
+ expect((await s.list('a/')).items[0].key).toBe('a/b.png');
37
+ expect((await s.uploadUrl('a/c.png', { contentType: 'image/png' })).headers).toEqual({ 'content-type': 'image/png' });
38
+ await expect(s.delete('zzz')).rejects.toMatchObject({ code: 'PAYMENT_REQUIRED', status: 402 });
39
+ });
40
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatu-ai/app-sdk",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "Runtime data SDK for apps generated by ChatU Builder: kv (and more) with platform / memory drivers selected by environment variables",
5
5
  "license": "MIT",
6
6
  "type": "module",