iii-sdk 0.0.2-alpha

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/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "iii-sdk",
3
+ "version": "0.0.2-alpha",
4
+ "private": false,
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "main": "index.js",
9
+ "author": "III",
10
+ "license": "Apache-2.0",
11
+ "type": "module",
12
+ "description": "III SDK for Node.js",
13
+ "keywords": [
14
+ "iii",
15
+ "sdk"
16
+ ],
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.mjs",
21
+ "require": "./dist/index.cjs"
22
+ },
23
+ "./stream": {
24
+ "types": "./dist/stream.d.ts",
25
+ "import": "./dist/stream.mjs",
26
+ "require": "./dist/stream.cjs"
27
+ },
28
+ "./telemetry": {
29
+ "types": "./dist/telemetry.d.ts",
30
+ "import": "./dist/telemetry.mjs",
31
+ "require": "./dist/telemetry.cjs"
32
+ }
33
+ },
34
+ "dependencies": {
35
+ "@opentelemetry/api": "^1.9.0",
36
+ "@opentelemetry/api-logs": "^0.57.0",
37
+ "@opentelemetry/core": "^1.30.0",
38
+ "@opentelemetry/instrumentation": "^0.57.0",
39
+ "@opentelemetry/otlp-transformer": "^0.57.0",
40
+ "@opentelemetry/resources": "^1.30.0",
41
+ "@opentelemetry/sdk-logs": "^0.57.0",
42
+ "@opentelemetry/sdk-metrics": "^1.30.0",
43
+ "@opentelemetry/sdk-trace-base": "^1.30.0",
44
+ "@opentelemetry/sdk-trace-node": "^1.30.0",
45
+ "@opentelemetry/semantic-conventions": "^1.28.0",
46
+ "ws": "^8.18.3"
47
+ },
48
+ "devDependencies": {
49
+ "@types/ws": "^8.18.1",
50
+ "tsdown": "^0.17.0",
51
+ "typescript": "^5.9.3",
52
+ "vitest": "^2.1.0"
53
+ },
54
+ "scripts": {
55
+ "build": "tsdown",
56
+ "test": "vitest run",
57
+ "test:watch": "vitest"
58
+ }
59
+ }
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { ApiRequest, ApiResponse } from '../src'
3
+ import { skipIfServerUnavailable } from './setup'
4
+ import { execute, httpRequest, iii } from './utils'
5
+
6
+ describe.skipIf(skipIfServerUnavailable())('Healthcheck Endpoint', () => {
7
+ it('should register a healthcheck function and trigger', async () => {
8
+ const functionId = 'test.healthcheck'
9
+
10
+ iii.registerFunction({ id: functionId }, async (_req: ApiRequest): Promise<ApiResponse> => {
11
+ return {
12
+ status_code: 200,
13
+ body: {
14
+ status: 'healthy',
15
+ timestamp: new Date().toISOString(),
16
+ service: 'iii-sdk-test',
17
+ },
18
+ }
19
+ })
20
+
21
+ await execute(async () => {
22
+ const response = await httpRequest('GET', '/health')
23
+ expect(response.status).toBe(404)
24
+ })
25
+
26
+ const trigger = iii.registerTrigger({
27
+ trigger_type: 'api',
28
+ function_id: functionId,
29
+ config: {
30
+ api_path: 'health',
31
+ http_method: 'GET',
32
+ description: 'Healthcheck endpoint',
33
+ },
34
+ })
35
+
36
+ await execute(async () => {
37
+ const response = await httpRequest('GET', '/health')
38
+
39
+ expect(response.status).toBe(200)
40
+ expect(response.data).toHaveProperty('status', 'healthy')
41
+ expect(response.data).toHaveProperty('service', 'iii-sdk-test')
42
+ expect(response.data).toHaveProperty('timestamp')
43
+ })
44
+
45
+ trigger.unregister()
46
+
47
+ // there's an issue with unregistering
48
+ // await execute(async () => {
49
+ // const response = await httpRequest('GET', '/health')
50
+ // expect(response.status).toBe(404)
51
+ // })
52
+ })
53
+ })
package/tests/setup.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { afterAll, beforeAll } from 'vitest'
2
+ import { checkServerAvailability, iii } from './utils'
3
+
4
+ const isCI = Boolean(process.env.CI)
5
+ const hasExplicitServerUrl = Boolean(process.env.III_BRIDGE_URL || process.env.III_HTTP_URL)
6
+
7
+ let serverAvailable = false
8
+
9
+ beforeAll(async () => {
10
+ if (isCI && !hasExplicitServerUrl) {
11
+ console.warn('Running in CI without explicit server URL. Skipping integration tests.')
12
+ console.warn('To run tests in CI, set III_BRIDGE_URL and III_HTTP_URL environment variables,')
13
+ console.warn('or ensure the III Engine server is started before running tests.')
14
+ serverAvailable = false
15
+ return
16
+ }
17
+
18
+ console.log(
19
+ `Checking server availability at: ${process.env.III_HTTP_URL ?? 'http://localhost:3111'}`,
20
+ )
21
+ serverAvailable = await checkServerAvailability()
22
+
23
+ if (!serverAvailable) {
24
+ console.warn('III Engine server is not available. Skipping integration tests.')
25
+ console.warn(`Expected server at: ${process.env.III_HTTP_URL ?? 'http://localhost:3111'}`)
26
+ console.warn('To run tests locally, start the III Engine server first.')
27
+ } else {
28
+ console.log('III Engine server is available. Running integration tests.')
29
+ }
30
+ })
31
+
32
+ afterAll(async () => {
33
+ try {
34
+ const sdk = iii as { shutdown?: () => Promise<void> }
35
+ if (sdk.shutdown) {
36
+ await sdk.shutdown()
37
+ }
38
+ } catch (error) {
39
+ console.error('Error shutting down SDK:', error)
40
+ }
41
+ })
42
+
43
+ export function skipIfServerUnavailable(): boolean {
44
+ if (isCI && !hasExplicitServerUrl) {
45
+ return true
46
+ }
47
+ return false
48
+ }
@@ -0,0 +1,149 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest'
2
+ import { skipIfServerUnavailable } from './setup'
3
+ import { iii } from './utils'
4
+ import type { StateSetResult } from './types'
5
+
6
+ type TestData = {
7
+ name?: string
8
+ value: number
9
+ updated?: boolean
10
+ }
11
+
12
+ describe.skipIf(skipIfServerUnavailable())('State Operations', () => {
13
+ const testGroupId = 'test-group'
14
+ const testItemId = 'test-item'
15
+
16
+ beforeEach(async () => {
17
+ await iii
18
+ .call('state.delete', { group_id: testGroupId, item_id: testItemId })
19
+ .catch(() => void 0)
20
+ })
21
+
22
+ describe('state.set', () => {
23
+ it('should set a new state item', async () => {
24
+ const testData = {
25
+ name: 'Test Item',
26
+ value: 42,
27
+ metadata: { created: new Date().toISOString() },
28
+ }
29
+
30
+ const result = await iii.call('state.set', {
31
+ group_id: testGroupId,
32
+ item_id: testItemId,
33
+ data: testData,
34
+ })
35
+
36
+ expect(result).toBeDefined()
37
+ expect(result).toEqual({ old_value: null, new_value: testData })
38
+ })
39
+
40
+ it('should overwrite an existing state item', async () => {
41
+ const initialData: TestData = { value: 1 }
42
+ const updatedData: TestData = { value: 2, updated: true }
43
+
44
+ await iii.call('state.set', {
45
+ group_id: testGroupId,
46
+ item_id: testItemId,
47
+ data: initialData,
48
+ })
49
+
50
+ const result: StateSetResult = await iii.call('state.set', {
51
+ group_id: testGroupId,
52
+ item_id: testItemId,
53
+ data: updatedData,
54
+ })
55
+
56
+ expect(result.old_value).toEqual(initialData)
57
+ expect(result.new_value).toEqual(updatedData)
58
+ })
59
+ })
60
+
61
+ describe('state.get', () => {
62
+ it('should get an existing state item', async () => {
63
+ const testData: TestData = { name: 'Test', value: 100 }
64
+
65
+ await iii.call('state.set', {
66
+ group_id: testGroupId,
67
+ item_id: testItemId,
68
+ data: testData,
69
+ })
70
+
71
+ const result: TestData = await iii.call('state.get', {
72
+ group_id: testGroupId,
73
+ item_id: testItemId,
74
+ })
75
+
76
+ expect(result).toBeDefined()
77
+ expect(result).toEqual(testData)
78
+ })
79
+
80
+ it('should return null for non-existent item', async () => {
81
+ const result = await iii.call('state.get', {
82
+ group_id: testGroupId,
83
+ item_id: 'non-existent-item',
84
+ })
85
+
86
+ expect(result).toBeUndefined()
87
+ })
88
+ })
89
+
90
+ describe('state.delete', () => {
91
+ it('should delete an existing state item', async () => {
92
+ await iii.call('state.set', {
93
+ group_id: testGroupId,
94
+ item_id: testItemId,
95
+ data: { test: true },
96
+ })
97
+
98
+ await iii.call('state.delete', {
99
+ group_id: testGroupId,
100
+ item_id: testItemId,
101
+ })
102
+
103
+ const result = await iii.call('state.get', {
104
+ group_id: testGroupId,
105
+ item_id: testItemId,
106
+ })
107
+
108
+ expect(result).toBeUndefined()
109
+ })
110
+
111
+ it('should handle deleting non-existent item gracefully', async () => {
112
+ await expect(
113
+ iii.call('state.delete', {
114
+ group_id: testGroupId,
115
+ item_id: 'non-existent',
116
+ }),
117
+ ).resolves.not.toThrow()
118
+ })
119
+ })
120
+
121
+ describe('state.list', () => {
122
+ it('should get all items in a group', async () => {
123
+ type TestDataWithId = TestData & { id: string }
124
+
125
+ const groupId = `state-${Date.now()}`
126
+ const items: TestDataWithId[] = [
127
+ { id: 'state-item1', value: 1 },
128
+ { id: 'state-item2', value: 2 },
129
+ { id: 'state-item3', value: 3 },
130
+ ]
131
+
132
+ // Set multiple items
133
+ for (const item of items) {
134
+ await iii.call('state.set', {
135
+ group_id: groupId,
136
+ item_id: item.id,
137
+ data: item,
138
+ })
139
+ }
140
+
141
+ const result: TestDataWithId[] = await iii.call('state.list', { group_id: groupId })
142
+ const sort = (a: TestDataWithId, b: TestDataWithId) => a.id.localeCompare(b.id)
143
+
144
+ expect(Array.isArray(result)).toBe(true)
145
+ expect(result.length).toBeGreaterThanOrEqual(items.length)
146
+ expect(result.sort(sort)).toEqual(items.sort(sort))
147
+ })
148
+ })
149
+ })
@@ -0,0 +1,217 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest'
2
+ import { skipIfServerUnavailable } from './setup'
3
+ import { iii, sleep } from './utils'
4
+ import type { StreamSetInput, StreamSetResult } from '../src/stream'
5
+
6
+ type TestData = {
7
+ name?: string
8
+ value: number
9
+ updated?: boolean
10
+ }
11
+
12
+ describe.skipIf(skipIfServerUnavailable())('Stream Operations', () => {
13
+ const testStreamName = 'test-stream'
14
+ const testGroupId = 'test-group'
15
+ const testItemId = 'test-item'
16
+
17
+ beforeEach(async () => {
18
+ await iii
19
+ .call('stream.delete', {
20
+ stream_name: testStreamName,
21
+ group_id: testGroupId,
22
+ item_id: testItemId,
23
+ })
24
+ .catch(() => void 0)
25
+ })
26
+
27
+ describe('stream.set', () => {
28
+ it('should set a new stream item', async () => {
29
+ const testData = {
30
+ name: 'Test Item',
31
+ value: 42,
32
+ metadata: { created: new Date().toISOString() },
33
+ }
34
+
35
+ const result = await iii.call<StreamSetInput, StreamSetResult<TestData>>('stream.set', {
36
+ stream_name: testStreamName,
37
+ group_id: testGroupId,
38
+ item_id: testItemId,
39
+ data: testData,
40
+ })
41
+
42
+ expect(result).toBeDefined()
43
+ expect(result).toEqual({ old_value: null, new_value: testData })
44
+ })
45
+
46
+ it('should overwrite an existing stream item', async () => {
47
+ const initialData: TestData = { value: 1 }
48
+ const updatedData: TestData = { value: 2, updated: true }
49
+
50
+ await iii.call('stream.set', {
51
+ stream_name: testStreamName,
52
+ group_id: testGroupId,
53
+ item_id: testItemId,
54
+ data: initialData,
55
+ })
56
+
57
+ const result: StreamSetResult<any> = await iii.call('stream.set', {
58
+ stream_name: testStreamName,
59
+ group_id: testGroupId,
60
+ item_id: testItemId,
61
+ data: updatedData,
62
+ })
63
+
64
+ expect(result.old_value).toEqual(initialData)
65
+ expect(result.new_value).toEqual(updatedData)
66
+ })
67
+ })
68
+
69
+ describe('stream.get', () => {
70
+ it('should get an existing stream item', async () => {
71
+ const testData: TestData = { name: 'Test', value: 100 }
72
+
73
+ await iii.call('stream.set', {
74
+ stream_name: testStreamName,
75
+ group_id: testGroupId,
76
+ item_id: testItemId,
77
+ data: testData,
78
+ })
79
+
80
+ const result: TestData = await iii.call('stream.get', {
81
+ stream_name: testStreamName,
82
+ group_id: testGroupId,
83
+ item_id: testItemId,
84
+ })
85
+
86
+ expect(result).toBeDefined()
87
+ expect(result).toEqual(testData)
88
+ })
89
+
90
+ it('should return null for non-existent item', async () => {
91
+ const result = await iii.call('stream.get', {
92
+ stream_name: testStreamName,
93
+ group_id: testGroupId,
94
+ item_id: 'non-existent-item',
95
+ })
96
+
97
+ expect(result).toBeUndefined()
98
+ })
99
+ })
100
+
101
+ describe('stream.delete', () => {
102
+ it('should delete an existing stream item', async () => {
103
+ await iii.call('stream.set', {
104
+ stream_name: testStreamName,
105
+ group_id: testGroupId,
106
+ item_id: testItemId,
107
+ data: { test: true },
108
+ })
109
+
110
+ await iii.call('stream.delete', {
111
+ stream_name: testStreamName,
112
+ group_id: testGroupId,
113
+ item_id: testItemId,
114
+ })
115
+
116
+ const result = await iii.call('stream.get', {
117
+ stream_name: testStreamName,
118
+ group_id: testGroupId,
119
+ item_id: testItemId,
120
+ })
121
+
122
+ expect(result).toBeUndefined()
123
+ })
124
+
125
+ it('should handle deleting non-existent item gracefully', async () => {
126
+ await expect(
127
+ iii.call('stream.delete', {
128
+ stream_name: testStreamName,
129
+ group_id: testGroupId,
130
+ item_id: 'non-existent',
131
+ }),
132
+ ).resolves.not.toThrow()
133
+ })
134
+ })
135
+
136
+ describe('stream.list', () => {
137
+ it('should get all items in a group', async () => {
138
+ type TestDataWithId = TestData & { id: string }
139
+
140
+ const groupId = `stream-${Date.now()}`
141
+ const items: TestDataWithId[] = [
142
+ { id: 'stream-item1', value: 1 },
143
+ { id: 'stream-item2', value: 2 },
144
+ { id: 'stream-item3', value: 3 },
145
+ ]
146
+
147
+ // Set multiple items
148
+ for (const item of items) {
149
+ await iii.call('stream.set', {
150
+ stream_name: testStreamName,
151
+ group_id: groupId,
152
+ item_id: item.id,
153
+ data: item,
154
+ })
155
+ }
156
+
157
+ const result: TestDataWithId[] = await iii.call('stream.list', {
158
+ stream_name: testStreamName,
159
+ group_id: groupId,
160
+ })
161
+ const sort = (a: TestDataWithId, b: TestDataWithId) => a.id.localeCompare(b.id)
162
+
163
+ expect(Array.isArray(result)).toBe(true)
164
+ expect(result.length).toBeGreaterThanOrEqual(items.length)
165
+ expect(result.sort(sort)).toEqual(items.sort(sort))
166
+ })
167
+ })
168
+
169
+ describe('stream custom operations', () => {
170
+ it('should perform a custom operation on a stream item', async () => {
171
+ const testStreamName = `test-stream-${Date.now()}`
172
+ const state: Map<string, TestData> = new Map()
173
+
174
+ iii.createStream(testStreamName, {
175
+ get: async input => state.get(`${input.group_id}::${input.item_id}`),
176
+ set: async input => {
177
+ const key = `${input.group_id}::${input.item_id}`
178
+ const oldValue = state.get(key)
179
+ state.set(key, input.data)
180
+
181
+ return { old_value: oldValue, new_value: input.data }
182
+ },
183
+ delete: async input => {
184
+ const oldValue = state.get(`${input.group_id}::${input.item_id}`)
185
+ state.delete(`${input.group_id}::${input.item_id}`)
186
+ return { old_value: oldValue }
187
+ },
188
+ list: async input => {
189
+ return Array.from(state.keys())
190
+ .filter(key => key.startsWith(`${input.group_id}::`))
191
+ .map(key => state.get(key))
192
+ },
193
+ listGroups: async () => Array.from(state.keys()),
194
+ update: async () => {
195
+ throw new Error('Not implemented')
196
+ },
197
+ })
198
+
199
+ await sleep(1_000)
200
+
201
+ const testData: TestData = { name: 'Test', value: 100 }
202
+ const getArgs = {
203
+ stream_name: testStreamName,
204
+ group_id: testGroupId,
205
+ item_id: testItemId,
206
+ }
207
+
208
+ await iii.call('stream.set', { ...getArgs, data: testData })
209
+
210
+ expect(state.get(`${testGroupId}::${testItemId}`)).toEqual(testData)
211
+
212
+ await expect(iii.call('stream.get', getArgs)).resolves.toEqual(testData)
213
+ await iii.call('stream.delete', getArgs)
214
+ await expect(iii.call('stream.get', getArgs)).resolves.toEqual(undefined)
215
+ })
216
+ })
217
+ })
package/tests/types.ts ADDED
@@ -0,0 +1,54 @@
1
+ import type { UpdateOp, StreamSetResult } from '../src/stream'
2
+
3
+ export interface StateSetResult {
4
+ // biome-ignore lint/suspicious/noExplicitAny: any is fine here
5
+ old_value?: any
6
+ // biome-ignore lint/suspicious/noExplicitAny: any is fine here
7
+ new_value?: any
8
+ }
9
+
10
+ export type { StreamSetResult }
11
+
12
+ export interface StateSetInput {
13
+ group_id: string
14
+ item_id: string
15
+ // biome-ignore lint/suspicious/noExplicitAny: any is fine here
16
+ data: any
17
+ }
18
+
19
+ export interface StateGetInput {
20
+ group_id: string
21
+ item_id: string
22
+ }
23
+
24
+ export interface StateDeleteInput {
25
+ group_id: string
26
+ item_id: string
27
+ }
28
+
29
+ export interface StateUpdateInput {
30
+ group_id: string
31
+ item_id: string
32
+ ops: UpdateOp[]
33
+ }
34
+
35
+ export interface StateGetGroupInput {
36
+ group_id: string
37
+ }
38
+
39
+ export enum StateEventType {
40
+ Created = 'state:created',
41
+ Updated = 'state:updated',
42
+ Deleted = 'state:deleted',
43
+ }
44
+
45
+ export interface StateEventData {
46
+ type: string
47
+ event_type: StateEventType
48
+ group_id: string
49
+ item_id: string
50
+ // biome-ignore lint/suspicious/noExplicitAny: any is fine here
51
+ old_value?: any
52
+ // biome-ignore lint/suspicious/noExplicitAny: any is fine here
53
+ new_value?: any
54
+ }
package/tests/utils.ts ADDED
@@ -0,0 +1,83 @@
1
+ // import { iii } from 'iii-sdk'
2
+ import { init } from '../src/index'
3
+
4
+ const ENGINE_WS_URL = process.env.III_BRIDGE_URL ?? 'ws://localhost:49134'
5
+ const ENGINE_HTTP_URL = process.env.III_HTTP_URL ?? 'http://localhost:3111'
6
+ const RETRY_LIMIT = 100
7
+ const DELAY_MS = 100
8
+
9
+ export const engineWsUrl = ENGINE_WS_URL
10
+ export const engineHttpUrl = ENGINE_HTTP_URL
11
+
12
+ export const iii = init(engineWsUrl, {
13
+ reconnectionConfig: {
14
+ maxRetries: 3,
15
+ initialDelayMs: 100,
16
+ maxDelayMs: 1000,
17
+ },
18
+ })
19
+
20
+ export async function checkServerAvailability(): Promise<boolean> {
21
+ try {
22
+ const controller = new AbortController()
23
+ const timeoutId = setTimeout(() => controller.abort(), 2000)
24
+
25
+ try {
26
+ const response = await fetch(ENGINE_HTTP_URL, {
27
+ method: 'GET',
28
+ signal: controller.signal,
29
+ })
30
+ clearTimeout(timeoutId)
31
+ return response.status < 500
32
+ } catch {
33
+ clearTimeout(timeoutId)
34
+ return false
35
+ }
36
+ } catch {
37
+ return false
38
+ }
39
+ }
40
+
41
+ export async function httpRequest(
42
+ method: string,
43
+ path: string,
44
+ // biome-ignore lint/suspicious/noExplicitAny: any is fine here
45
+ body?: any,
46
+ // biome-ignore lint/suspicious/noExplicitAny: any is fine here
47
+ ): Promise<{ status: number; data: any }> {
48
+ const url = `${engineHttpUrl}${path}`
49
+ const options: RequestInit = { method, headers: { 'Content-Type': 'application/json' } }
50
+
51
+ if (body) {
52
+ options.body = JSON.stringify(body)
53
+ }
54
+
55
+ const response = await fetch(url, options)
56
+ const data = await response.json().catch(() => ({}))
57
+
58
+ return { status: response.status, data }
59
+ }
60
+
61
+ export function sleep(duration: number): Promise<void> {
62
+ return new Promise(resolve => {
63
+ setTimeout(() => resolve(), duration)
64
+ })
65
+ }
66
+
67
+ export async function execute<T>(operation: () => Promise<T>): Promise<T> {
68
+ let currentAttempt = 0
69
+
70
+ while (true) {
71
+ try {
72
+ return await operation()
73
+ } catch (err) {
74
+ currentAttempt++
75
+
76
+ if (currentAttempt >= RETRY_LIMIT) {
77
+ throw err
78
+ }
79
+
80
+ await sleep(DELAY_MS)
81
+ }
82
+ }
83
+ }
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ testTimeout: 30000,
7
+ hookTimeout: 30000,
8
+ setupFiles: ['./tests/setup.ts'],
9
+ },
10
+ })