@chatu-ai/app-sdk 0.4.1 → 0.5.0
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 +1 -0
- package/dist/byo.d.ts +11 -0
- package/dist/byo.js +117 -0
- package/dist/config.d.ts +21 -2
- package/dist/config.js +36 -2
- package/dist/kv.js +3 -2
- package/dist/kv.test.js +13 -0
- package/dist/storage.js +3 -2
- package/package.json +17 -1
package/README.md
CHANGED
|
@@ -18,6 +18,7 @@ const src = await storage.url('avatars/u1.png', { expiresIn: 3600 }) //
|
|
|
18
18
|
| Env | Driver | Notes |
|
|
19
19
|
| --- | --- | --- |
|
|
20
20
|
| `CHATU_DATA_URL` + `CHATU_APP_KEY` (+ `CHATU_DATA_ENV=dev\|prod`) | **platform** — ChatU hosted Data API, metered | set automatically in the Builder preview; copy from the publish panel for your own server |
|
|
21
|
+
| `REDIS_URL` and/or `S3_BUCKET` (+ `S3_ENDPOINT` `S3_REGION` `S3_ACCESS_KEY` `S3_SECRET_KEY` `S3_PREFIX`) | **byo** — your own Redis / S3-compatible bucket (Tencent COS, MinIO, AWS) | install optional deps: `npm i ioredis @aws-sdk/client-s3 @aws-sdk/s3-request-presigner` |
|
|
21
22
|
| none | **memory** — in-process, lost on restart | local dev / fallback |
|
|
22
23
|
|
|
23
24
|
Never expose `CHATU_APP_KEY` to the browser. MIT.
|
package/dist/byo.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ByoConfig } from './config.js';
|
|
2
|
+
import type { KvClient } from './kv.js';
|
|
3
|
+
import type { StorageClient } from './storage.js';
|
|
4
|
+
/**
|
|
5
|
+
* 模式 A(自带云资源)驱动:
|
|
6
|
+
* - KV:ioredis(`npm i ioredis`),REDIS_URL;键前缀 CHATU_KV_PREFIX(默认 app:)
|
|
7
|
+
* - 对象存储:@aws-sdk/client-s3 + @aws-sdk/s3-request-presigner(`npm i @aws-sdk/client-s3 @aws-sdk/s3-request-presigner`),
|
|
8
|
+
* S3_ENDPOINT / S3_REGION / S3_BUCKET / S3_ACCESS_KEY / S3_SECRET_KEY / S3_PREFIX / S3_FORCE_PATH_STYLE(腾讯云 COS:endpoint https://cos.<region>.myqcloud.com)
|
|
9
|
+
*/
|
|
10
|
+
export declare function byoKv(cfg: ByoConfig, fallback: KvClient): KvClient;
|
|
11
|
+
export declare function byoStorage(cfg: ByoConfig, fallback: StorageClient): StorageClient;
|
package/dist/byo.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { optionalImport } from './config.js';
|
|
2
|
+
import { AppSdkError } from './errors.js';
|
|
3
|
+
/**
|
|
4
|
+
* 模式 A(自带云资源)驱动:
|
|
5
|
+
* - KV:ioredis(`npm i ioredis`),REDIS_URL;键前缀 CHATU_KV_PREFIX(默认 app:)
|
|
6
|
+
* - 对象存储:@aws-sdk/client-s3 + @aws-sdk/s3-request-presigner(`npm i @aws-sdk/client-s3 @aws-sdk/s3-request-presigner`),
|
|
7
|
+
* S3_ENDPOINT / S3_REGION / S3_BUCKET / S3_ACCESS_KEY / S3_SECRET_KEY / S3_PREFIX / S3_FORCE_PATH_STYLE(腾讯云 COS:endpoint https://cos.<region>.myqcloud.com)
|
|
8
|
+
*/
|
|
9
|
+
export function byoKv(cfg, fallback) {
|
|
10
|
+
if (!cfg.redisUrl)
|
|
11
|
+
return fallback;
|
|
12
|
+
let clientPromise = null;
|
|
13
|
+
const client = () => (clientPromise ??= optionalImport('ioredis', 'run `npm i ioredis`').then(m => new (m.default ?? m.Redis ?? m)(cfg.redisUrl, { lazyConnect: false, maxRetriesPerRequest: 2 })));
|
|
14
|
+
const k = (key) => cfg.kvPrefix + key;
|
|
15
|
+
const parse = (v) => { if (v === null)
|
|
16
|
+
return null; try {
|
|
17
|
+
return JSON.parse(v);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return v;
|
|
21
|
+
} };
|
|
22
|
+
return {
|
|
23
|
+
async get(key) { return parse(await (await client()).get(k(key))); },
|
|
24
|
+
async set(key, value, opts) { const c = await client(); const v = JSON.stringify(value); if (opts?.ex)
|
|
25
|
+
await c.set(k(key), v, 'EX', opts.ex);
|
|
26
|
+
else
|
|
27
|
+
await c.set(k(key), v); },
|
|
28
|
+
async del(key) { return (await (await client()).del(k(key))) > 0; },
|
|
29
|
+
async incr(key, by = 1) { try {
|
|
30
|
+
return Number(await (await client()).incrby(k(key), by));
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
throw new AppSdkError('NOT_AN_INTEGER', e?.message ?? 'incr failed');
|
|
34
|
+
} },
|
|
35
|
+
async expire(key, seconds) { return (await (await client()).expire(k(key), seconds)) === 1; },
|
|
36
|
+
async mget(keys) { const vals = await (await client()).mget(keys.map(k)); return vals.map(parse); },
|
|
37
|
+
async list(prefix = '', opts) {
|
|
38
|
+
const c = await client();
|
|
39
|
+
const [next, keys] = await c.scan(opts?.cursor ?? '0', 'MATCH', escapeGlob(k(prefix)) + '*', 'COUNT', opts?.limit ?? 100);
|
|
40
|
+
return { keys: keys.map((x) => x.slice(cfg.kvPrefix.length)), nextCursor: next === '0' ? null : next };
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export function byoStorage(cfg, fallback) {
|
|
45
|
+
const s3cfg = cfg.s3;
|
|
46
|
+
if (!s3cfg)
|
|
47
|
+
return fallback;
|
|
48
|
+
let mods = null;
|
|
49
|
+
const load = () => (mods ??= Promise.all([
|
|
50
|
+
optionalImport('@aws-sdk/client-s3', 'run `npm i @aws-sdk/client-s3 @aws-sdk/s3-request-presigner`'),
|
|
51
|
+
optionalImport('@aws-sdk/s3-request-presigner', 'run `npm i @aws-sdk/client-s3 @aws-sdk/s3-request-presigner`'),
|
|
52
|
+
]).then(([s3, presign]) => ({ s3, presign })));
|
|
53
|
+
let clientInst = null;
|
|
54
|
+
const client = async () => {
|
|
55
|
+
const { s3 } = await load();
|
|
56
|
+
return (clientInst ??= new s3.S3Client({
|
|
57
|
+
region: s3cfg.region,
|
|
58
|
+
endpoint: s3cfg.endpoint,
|
|
59
|
+
forcePathStyle: s3cfg.forcePathStyle,
|
|
60
|
+
credentials: { accessKeyId: s3cfg.accessKey, secretAccessKey: s3cfg.secretKey },
|
|
61
|
+
}));
|
|
62
|
+
};
|
|
63
|
+
const K = (key) => s3cfg.prefix + key;
|
|
64
|
+
return {
|
|
65
|
+
async put(key, data, opts) {
|
|
66
|
+
const { s3 } = await load();
|
|
67
|
+
const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data instanceof Blob ? new Uint8Array(await data.arrayBuffer()) : data instanceof ArrayBuffer ? new Uint8Array(data) : data;
|
|
68
|
+
await (await client()).send(new s3.PutObjectCommand({ Bucket: s3cfg.bucket, Key: K(key), Body: bytes, ContentType: opts?.contentType }));
|
|
69
|
+
return { key, size: bytes.byteLength };
|
|
70
|
+
},
|
|
71
|
+
async uploadUrl(key, opts) {
|
|
72
|
+
const { s3, presign } = await load();
|
|
73
|
+
const url = await presign.getSignedUrl(await client(), new s3.PutObjectCommand({ Bucket: s3cfg.bucket, Key: K(key), ContentType: opts?.contentType }), { expiresIn: 600 });
|
|
74
|
+
return { url, method: 'PUT', expiresIn: 600, headers: opts?.contentType ? { 'content-type': opts.contentType } : undefined };
|
|
75
|
+
},
|
|
76
|
+
async get(key) {
|
|
77
|
+
const { s3 } = await load();
|
|
78
|
+
try {
|
|
79
|
+
const res = await (await client()).send(new s3.GetObjectCommand({ Bucket: s3cfg.bucket, Key: K(key) }));
|
|
80
|
+
return new Uint8Array(await res.Body.transformToByteArray());
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
if (e?.name === 'NoSuchKey' || e?.$metadata?.httpStatusCode === 404)
|
|
84
|
+
return null;
|
|
85
|
+
throw e;
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
async url(key, opts) {
|
|
89
|
+
const { s3, presign } = await load();
|
|
90
|
+
return presign.getSignedUrl(await client(), new s3.GetObjectCommand({ Bucket: s3cfg.bucket, Key: K(key), ResponseContentDisposition: opts?.downloadName ? `attachment; filename="${encodeURIComponent(opts.downloadName)}"` : undefined }), { expiresIn: opts?.expiresIn ?? 600 });
|
|
91
|
+
},
|
|
92
|
+
async head(key) {
|
|
93
|
+
const { s3 } = await load();
|
|
94
|
+
try {
|
|
95
|
+
const res = await (await client()).send(new s3.HeadObjectCommand({ Bucket: s3cfg.bucket, Key: K(key) }));
|
|
96
|
+
return { key, size: Number(res.ContentLength ?? 0), lastModified: res.LastModified ? new Date(res.LastModified).toISOString() : null };
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
if (e?.name === 'NotFound' || e?.$metadata?.httpStatusCode === 404)
|
|
100
|
+
return null;
|
|
101
|
+
throw e;
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
async delete(key) { const { s3 } = await load(); await (await client()).send(new s3.DeleteObjectCommand({ Bucket: s3cfg.bucket, Key: K(key) })); },
|
|
105
|
+
async list(prefix = '', opts) {
|
|
106
|
+
const { s3 } = await load();
|
|
107
|
+
const res = await (await client()).send(new s3.ListObjectsV2Command({ Bucket: s3cfg.bucket, Prefix: K(prefix), MaxKeys: opts?.limit ?? 100, ContinuationToken: opts?.cursor ?? undefined }));
|
|
108
|
+
return {
|
|
109
|
+
items: (res.Contents ?? []).map((o) => ({ key: String(o.Key).slice(s3cfg.prefix.length), size: Number(o.Size ?? 0), lastModified: o.LastModified ? new Date(o.LastModified).toISOString() : null })),
|
|
110
|
+
nextCursor: res.IsTruncated ? (res.NextContinuationToken ?? null) : null,
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function escapeGlob(s) {
|
|
116
|
+
return s.replace(/[\\*?[\]]/g, m => '\\' + m);
|
|
117
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* - 都没有 → memory(进程内存,重启即丢;本地开发/无配置降级)
|
|
5
5
|
* 只在服务端使用(Route Handler / Server Component / Server Action);密钥不得暴露给浏览器。
|
|
6
6
|
*/
|
|
7
|
-
export type DriverKind = 'platform' | 'memory';
|
|
7
|
+
export type DriverKind = 'platform' | 'byo' | 'memory';
|
|
8
8
|
export interface PlatformConfig {
|
|
9
9
|
kind: 'platform';
|
|
10
10
|
baseUrl: string;
|
|
@@ -15,7 +15,22 @@ export interface PlatformConfig {
|
|
|
15
15
|
export interface MemoryConfig {
|
|
16
16
|
kind: 'memory';
|
|
17
17
|
}
|
|
18
|
-
|
|
18
|
+
/** 自带云资源(模式 A):REDIS_URL → KV;S3_* → 对象存储(腾讯云 COS / MinIO / AWS 等 S3 兼容) */
|
|
19
|
+
export interface ByoConfig {
|
|
20
|
+
kind: 'byo';
|
|
21
|
+
redisUrl?: string;
|
|
22
|
+
kvPrefix: string;
|
|
23
|
+
s3?: {
|
|
24
|
+
endpoint?: string;
|
|
25
|
+
region: string;
|
|
26
|
+
bucket: string;
|
|
27
|
+
accessKey: string;
|
|
28
|
+
secretKey: string;
|
|
29
|
+
prefix: string;
|
|
30
|
+
forcePathStyle: boolean;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export type ResolvedConfig = PlatformConfig | ByoConfig | MemoryConfig;
|
|
19
34
|
export interface ConfigureOptions {
|
|
20
35
|
baseUrl?: string;
|
|
21
36
|
apiKey?: string;
|
|
@@ -31,4 +46,8 @@ export declare function describe(): {
|
|
|
31
46
|
driver: DriverKind;
|
|
32
47
|
env?: 'dev' | 'prod';
|
|
33
48
|
baseUrl?: string;
|
|
49
|
+
kv?: string;
|
|
50
|
+
storage?: string;
|
|
34
51
|
};
|
|
52
|
+
/** 动态加载可选依赖(ioredis / @aws-sdk/*),不参与打包静态分析;缺失时给出可操作的错误 */
|
|
53
|
+
export declare function optionalImport<T = any>(name: string, hint: string): Promise<T>;
|
package/dist/config.js
CHANGED
|
@@ -9,7 +9,27 @@ export function resolveConfig() {
|
|
|
9
9
|
const env = proc?.env ?? {};
|
|
10
10
|
const baseUrl = override.baseUrl ?? env.CHATU_DATA_URL;
|
|
11
11
|
const apiKey = override.apiKey ?? env.CHATU_APP_KEY ?? env.CHATU_CUSTOMER_API_KEY;
|
|
12
|
-
const
|
|
12
|
+
const redisUrl = env.REDIS_URL;
|
|
13
|
+
const s3Bucket = env.S3_BUCKET;
|
|
14
|
+
const driver = override.driver ?? (baseUrl && apiKey ? 'platform' : redisUrl || s3Bucket ? 'byo' : 'memory');
|
|
15
|
+
if (driver === 'byo') {
|
|
16
|
+
return {
|
|
17
|
+
kind: 'byo',
|
|
18
|
+
redisUrl,
|
|
19
|
+
kvPrefix: env.CHATU_KV_PREFIX ?? 'app:',
|
|
20
|
+
s3: s3Bucket
|
|
21
|
+
? {
|
|
22
|
+
endpoint: env.S3_ENDPOINT,
|
|
23
|
+
region: env.S3_REGION ?? 'us-east-1',
|
|
24
|
+
bucket: s3Bucket,
|
|
25
|
+
accessKey: env.S3_ACCESS_KEY ?? '',
|
|
26
|
+
secretKey: env.S3_SECRET_KEY ?? '',
|
|
27
|
+
prefix: env.S3_PREFIX ?? '',
|
|
28
|
+
forcePathStyle: (env.S3_FORCE_PATH_STYLE ?? '').toLowerCase() === 'true',
|
|
29
|
+
}
|
|
30
|
+
: undefined,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
13
33
|
if (driver === 'platform') {
|
|
14
34
|
if (!baseUrl || !apiKey)
|
|
15
35
|
throw new Error('@chatu-ai/app-sdk: platform driver requires CHATU_DATA_URL and CHATU_APP_KEY');
|
|
@@ -21,5 +41,19 @@ export function resolveConfig() {
|
|
|
21
41
|
/** 当前生效的驱动与环境(诊断用,不含密钥) */
|
|
22
42
|
export function describe() {
|
|
23
43
|
const c = resolveConfig();
|
|
24
|
-
|
|
44
|
+
if (c.kind === 'platform')
|
|
45
|
+
return { driver: 'platform', env: c.env, baseUrl: c.baseUrl };
|
|
46
|
+
if (c.kind === 'byo')
|
|
47
|
+
return { driver: 'byo', kv: c.redisUrl ? 'redis' : 'memory', storage: c.s3 ? 's3' : 'memory' };
|
|
48
|
+
return { driver: 'memory' };
|
|
49
|
+
}
|
|
50
|
+
/** 动态加载可选依赖(ioredis / @aws-sdk/*),不参与打包静态分析;缺失时给出可操作的错误 */
|
|
51
|
+
export async function optionalImport(name, hint) {
|
|
52
|
+
try {
|
|
53
|
+
const dyn = new Function('m', 'return import(m)');
|
|
54
|
+
return await dyn(name);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new Error(`@chatu-ai/app-sdk: byo driver requires "${name}" — ${hint}`);
|
|
58
|
+
}
|
|
25
59
|
}
|
package/dist/kv.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { resolveConfig } from './config.js';
|
|
2
2
|
import { AppSdkError } from './errors.js';
|
|
3
|
+
import { byoKv } from './byo.js';
|
|
3
4
|
// ---------- platform driver ----------
|
|
4
5
|
function platformKv(cfg) {
|
|
5
6
|
const headers = { 'x-api-key': cfg.apiKey, 'x-chatu-env': cfg.env, 'content-type': 'application/json' };
|
|
@@ -67,9 +68,9 @@ let cached = null;
|
|
|
67
68
|
/** 按当前配置取 KV 客户端(惰性、缓存;configure() 后自动重建) */
|
|
68
69
|
export function getKv() {
|
|
69
70
|
const cfg = resolveConfig();
|
|
70
|
-
const key = cfg.kind === 'platform' ? `platform|${cfg.baseUrl}|${cfg.env}|${cfg.apiKey.slice(-4)}` : 'memory';
|
|
71
|
+
const key = cfg.kind === 'platform' ? `platform|${cfg.baseUrl}|${cfg.env}|${cfg.apiKey.slice(-4)}` : cfg.kind === 'byo' ? `byo|${cfg.redisUrl ?? ''}|${cfg.kvPrefix}` : 'memory';
|
|
71
72
|
if (!cached || cached.key !== key)
|
|
72
|
-
cached = { key, client: cfg.kind === 'platform' ? platformKv(cfg) : memoryKv() };
|
|
73
|
+
cached = { key, client: cfg.kind === 'platform' ? platformKv(cfg) : cfg.kind === 'byo' ? byoKv(cfg, memoryKv()) : memoryKv() };
|
|
73
74
|
return cached.client;
|
|
74
75
|
}
|
|
75
76
|
/** 便捷单例:`import { kv } from '@chatu-ai/app-sdk'` */
|
package/dist/kv.test.js
CHANGED
|
@@ -39,3 +39,16 @@ d('platform driver', () => {
|
|
|
39
39
|
await expect(kv.get('bad*')).rejects.toMatchObject({ code: 'INVALID_KEY', status: 400 });
|
|
40
40
|
});
|
|
41
41
|
});
|
|
42
|
+
d('byo driver', () => {
|
|
43
|
+
it('resolves from REDIS_URL / S3_BUCKET and reports missing optional deps clearly', async () => {
|
|
44
|
+
const proc = globalThis.process;
|
|
45
|
+
proc.env.REDIS_URL = 'redis://127.0.0.1:1';
|
|
46
|
+
proc.env.S3_BUCKET = 'b';
|
|
47
|
+
configure({});
|
|
48
|
+
expect(describe()).toEqual({ driver: 'byo', kv: 'redis', storage: 's3' });
|
|
49
|
+
await expect(kv.get('x')).rejects.toThrow(/ioredis|ECONNREFUSED|connect/i);
|
|
50
|
+
delete proc.env.REDIS_URL;
|
|
51
|
+
delete proc.env.S3_BUCKET;
|
|
52
|
+
configure({ driver: 'memory' });
|
|
53
|
+
});
|
|
54
|
+
});
|
package/dist/storage.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { resolveConfig } from './config.js';
|
|
2
2
|
import { AppSdkError } from './errors.js';
|
|
3
|
+
import { byoStorage } from './byo.js';
|
|
3
4
|
function toBytes(data) {
|
|
4
5
|
if (typeof data === 'string')
|
|
5
6
|
return Promise.resolve(new TextEncoder().encode(data));
|
|
@@ -96,9 +97,9 @@ function memoryStorage() {
|
|
|
96
97
|
let cached = null;
|
|
97
98
|
export function getStorage() {
|
|
98
99
|
const cfg = resolveConfig();
|
|
99
|
-
const key = cfg.kind === 'platform' ? `platform|${cfg.baseUrl}|${cfg.env}|${cfg.apiKey.slice(-4)}` : 'memory';
|
|
100
|
+
const key = cfg.kind === 'platform' ? `platform|${cfg.baseUrl}|${cfg.env}|${cfg.apiKey.slice(-4)}` : cfg.kind === 'byo' ? `byo|${cfg.s3?.bucket ?? ''}|${cfg.s3?.prefix ?? ''}` : 'memory';
|
|
100
101
|
if (!cached || cached.key !== key)
|
|
101
|
-
cached = { key, client: cfg.kind === 'platform' ? platformStorage(cfg) : memoryStorage() };
|
|
102
|
+
cached = { key, client: cfg.kind === 'platform' ? platformStorage(cfg) : cfg.kind === 'byo' ? byoStorage(cfg, memoryStorage()) : memoryStorage() };
|
|
102
103
|
return cached.client;
|
|
103
104
|
}
|
|
104
105
|
export const storage = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatu-ai/app-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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",
|
|
@@ -43,6 +43,22 @@
|
|
|
43
43
|
"typescript": "^5.7.0",
|
|
44
44
|
"vitest": "^3.0.0"
|
|
45
45
|
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"ioredis": ">=5",
|
|
48
|
+
"@aws-sdk/client-s3": ">=3",
|
|
49
|
+
"@aws-sdk/s3-request-presigner": ">=3"
|
|
50
|
+
},
|
|
51
|
+
"peerDependenciesMeta": {
|
|
52
|
+
"ioredis": {
|
|
53
|
+
"optional": true
|
|
54
|
+
},
|
|
55
|
+
"@aws-sdk/client-s3": {
|
|
56
|
+
"optional": true
|
|
57
|
+
},
|
|
58
|
+
"@aws-sdk/s3-request-presigner": {
|
|
59
|
+
"optional": true
|
|
60
|
+
}
|
|
61
|
+
},
|
|
46
62
|
"scripts": {
|
|
47
63
|
"build": "tsc -p tsconfig.json",
|
|
48
64
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|