api-ape 1.0.2 → 2.0.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.
Files changed (42) hide show
  1. package/README.md +63 -16
  2. package/client/README.md +32 -0
  3. package/client/browser.js +7 -1
  4. package/client/connectSocket.js +323 -8
  5. package/dist/ape.js +289 -44
  6. package/example/Bun/README.md +74 -0
  7. package/example/Bun/api/message.ts +11 -0
  8. package/example/Bun/index.html +76 -0
  9. package/example/Bun/package.json +9 -0
  10. package/example/Bun/server.ts +59 -0
  11. package/example/Bun/styles.css +128 -0
  12. package/example/ExpressJs/README.md +5 -7
  13. package/example/ExpressJs/backend.js +23 -21
  14. package/example/NextJs/ape/client.js +3 -3
  15. package/example/NextJs/ape/onConnect.js +5 -5
  16. package/example/NextJs/package-lock.json +1353 -60
  17. package/example/NextJs/package.json +0 -1
  18. package/example/NextJs/pages/index.tsx +21 -10
  19. package/example/NextJs/server.js +7 -11
  20. package/example/README.md +51 -0
  21. package/example/Vite/README.md +68 -0
  22. package/example/Vite/ape/client.ts +66 -0
  23. package/example/Vite/ape/onConnect.ts +52 -0
  24. package/example/Vite/api/message.ts +57 -0
  25. package/example/Vite/index.html +16 -0
  26. package/example/Vite/package.json +19 -0
  27. package/example/Vite/server.ts +62 -0
  28. package/example/Vite/src/App.vue +170 -0
  29. package/example/Vite/src/components/Info.vue +352 -0
  30. package/example/Vite/src/main.ts +5 -0
  31. package/example/Vite/src/style.css +200 -0
  32. package/example/Vite/src/vite-env.d.ts +7 -0
  33. package/example/Vite/vite.config.ts +20 -0
  34. package/index.d.ts +31 -3
  35. package/package.json +2 -3
  36. package/server/README.md +44 -0
  37. package/server/index.js +10 -2
  38. package/server/lib/fileTransfer.js +247 -0
  39. package/server/lib/main.js +172 -9
  40. package/server/lib/wiring.js +4 -2
  41. package/server/socket/receive.js +118 -3
  42. package/server/socket/send.js +97 -2
@@ -0,0 +1,200 @@
1
+ * {
2
+ box-sizing: border-box;
3
+ margin: 0;
4
+ padding: 0;
5
+ }
6
+
7
+ .container {
8
+ min-height: 100vh;
9
+ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
10
+ color: #fff;
11
+ font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif;
12
+ }
13
+
14
+ .main {
15
+ max-width: 600px;
16
+ margin: 0 auto;
17
+ padding: 2rem;
18
+ width: 100%;
19
+ }
20
+
21
+ .title {
22
+ font-size: 2.5rem;
23
+ text-align: center;
24
+ margin-bottom: 0.5rem;
25
+ }
26
+
27
+ .gradient {
28
+ background: linear-gradient(90deg, #00d2ff, #3a7bd5);
29
+ -webkit-background-clip: text;
30
+ -webkit-text-fill-color: transparent;
31
+ background-clip: text;
32
+ }
33
+
34
+ .subtitle {
35
+ text-align: center;
36
+ color: #0f0;
37
+ margin-bottom: 2rem;
38
+ font-weight: bold;
39
+ }
40
+
41
+ .join-form {
42
+ display: flex;
43
+ gap: 1rem;
44
+ justify-content: center;
45
+ }
46
+
47
+ .input {
48
+ padding: 1rem 1.5rem;
49
+ font-size: 1rem;
50
+ border: none;
51
+ border-radius: 50px;
52
+ background: rgba(255, 255, 255, 0.1);
53
+ color: #fff;
54
+ outline: none;
55
+ width: 250px;
56
+ }
57
+
58
+ .input::placeholder {
59
+ color: rgba(255, 255, 255, 0.5);
60
+ }
61
+
62
+ .button {
63
+ padding: 1rem 2rem;
64
+ font-size: 1rem;
65
+ border: none;
66
+ border-radius: 50px;
67
+ background: linear-gradient(90deg, #00d2ff, #3a7bd5);
68
+ color: #fff;
69
+ cursor: pointer;
70
+ font-weight: bold;
71
+ transition: opacity 0.2s;
72
+ }
73
+
74
+ .button:hover {
75
+ opacity: 0.9;
76
+ }
77
+
78
+ .button:disabled {
79
+ opacity: 0.5;
80
+ cursor: not-allowed;
81
+ }
82
+
83
+ .chat-container {
84
+ background: rgba(255, 255, 255, 0.05);
85
+ border-radius: 20px;
86
+ overflow: hidden;
87
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
88
+ }
89
+
90
+ .header {
91
+ display: flex;
92
+ justify-content: space-between;
93
+ padding: 1rem 1.5rem;
94
+ background: rgba(255, 255, 255, 0.1);
95
+ font-weight: bold;
96
+ }
97
+
98
+ .user-count {
99
+ font-size: 0.85rem;
100
+ color: #0f0;
101
+ }
102
+
103
+ .messages {
104
+ height: 350px;
105
+ overflow-y: auto;
106
+ padding: 1rem;
107
+ }
108
+
109
+ .messages::-webkit-scrollbar {
110
+ width: 6px;
111
+ }
112
+
113
+ .messages::-webkit-scrollbar-track {
114
+ background: rgba(255, 255, 255, 0.05);
115
+ }
116
+
117
+ .messages::-webkit-scrollbar-thumb {
118
+ background: rgba(255, 255, 255, 0.2);
119
+ border-radius: 3px;
120
+ }
121
+
122
+ .empty-state {
123
+ text-align: center;
124
+ color: #666;
125
+ margin-top: 140px;
126
+ }
127
+
128
+ .message {
129
+ display: flex;
130
+ flex-direction: column;
131
+ gap: 0.25rem;
132
+ padding: 0.75rem 1rem;
133
+ margin-bottom: 0.5rem;
134
+ background: rgba(255, 255, 255, 0.05);
135
+ border-radius: 12px;
136
+ border-left: 3px solid #3a7bd5;
137
+ }
138
+
139
+ .my-message {
140
+ background: rgba(0, 210, 255, 0.15);
141
+ border-left-color: #00d2ff;
142
+ }
143
+
144
+ .message .username {
145
+ color: #00d2ff;
146
+ font-size: 0.85rem;
147
+ }
148
+
149
+ .message .time {
150
+ color: #666;
151
+ font-size: 0.7rem;
152
+ }
153
+
154
+ .input-form {
155
+ display: flex;
156
+ gap: 0.5rem;
157
+ padding: 1rem;
158
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
159
+ }
160
+
161
+ .message-input {
162
+ flex: 1;
163
+ padding: 0.75rem 1rem;
164
+ font-size: 1rem;
165
+ border: none;
166
+ border-radius: 50px;
167
+ background: rgba(255, 255, 255, 0.1);
168
+ color: #fff;
169
+ outline: none;
170
+ transition: background 0.2s;
171
+ }
172
+
173
+ .message-input::placeholder {
174
+ color: rgba(255, 255, 255, 0.5);
175
+ }
176
+
177
+ .message-input:focus {
178
+ background: rgba(255, 255, 255, 0.15);
179
+ }
180
+
181
+ .send-button {
182
+ padding: 0.75rem 1.5rem;
183
+ font-size: 1rem;
184
+ border: none;
185
+ border-radius: 50px;
186
+ background: #3a7bd5;
187
+ color: #fff;
188
+ cursor: pointer;
189
+ font-weight: bold;
190
+ transition: opacity 0.2s;
191
+ }
192
+
193
+ .send-button:hover {
194
+ opacity: 0.9;
195
+ }
196
+
197
+ .send-button:disabled {
198
+ opacity: 0.5;
199
+ cursor: not-allowed;
200
+ }
@@ -0,0 +1,7 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ declare module '*.vue' {
4
+ import type { DefineComponent } from 'vue'
5
+ const component: DefineComponent<{}, {}, any>
6
+ export default component
7
+ }
@@ -0,0 +1,20 @@
1
+ import { defineConfig } from 'vite'
2
+ import vue from '@vitejs/plugin-vue'
3
+
4
+ export default defineConfig({
5
+ plugins: [vue()],
6
+ server: {
7
+ port: 5173,
8
+ proxy: {
9
+ '/api': {
10
+ target: 'http://localhost:3000',
11
+ ws: true,
12
+ changeOrigin: true
13
+ }
14
+ }
15
+ },
16
+ build: {
17
+ outDir: 'dist',
18
+ emptyOutDir: true
19
+ }
20
+ })
package/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Type definitions for api-ape
2
2
  // Project: https://github.com/codemeasandwich/api-ape
3
3
 
4
- import { Application } from 'express'
4
+ import { Server as HttpServer } from 'http'
5
5
  import { WebSocket } from 'ws'
6
6
  import { IncomingMessage } from 'http'
7
7
 
@@ -94,9 +94,18 @@ export interface ApeServerOptions {
94
94
  }
95
95
 
96
96
  /**
97
- * Initialize api-ape on an Express app
97
+ * Initialize api-ape on a Node.js HTTP/HTTPS server
98
98
  */
99
- declare function ape(app: Application, options: ApeServerOptions): void
99
+ declare function ape(server: HttpServer, options: ApeServerOptions): void
100
+
101
+ declare namespace ape {
102
+ /** Broadcast to all connected clients */
103
+ export function broadcast(type: string, data: any, excludeHostId?: string): void
104
+ /** Get count of connected clients */
105
+ export function online(): number
106
+ /** Get all connected client hostIds */
107
+ export function getClients(): string[]
108
+ }
100
109
 
101
110
  export default ape
102
111
 
@@ -104,6 +113,21 @@ export default ape
104
113
  // CLIENT TYPES
105
114
  // =============================================================================
106
115
 
116
+ /**
117
+ * Connection state enum values
118
+ */
119
+ export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'closing'
120
+
121
+ /**
122
+ * Connection state enum object
123
+ */
124
+ export declare const ConnectionState: {
125
+ Disconnected: 'disconnected'
126
+ Connecting: 'connecting'
127
+ Connected: 'connected'
128
+ Closing: 'closing'
129
+ }
130
+
107
131
  /**
108
132
  * Message received from server
109
133
  */
@@ -140,6 +164,8 @@ export type SetOnReceiver = {
140
164
  export interface ApeClient {
141
165
  sender: ApeSender
142
166
  setOnReciver: SetOnReceiver
167
+ /** Subscribe to connection state changes. Returns unsubscribe function. */
168
+ onConnectionChange: (handler: (state: ConnectionState) => void) => () => void
143
169
  }
144
170
 
145
171
  /**
@@ -161,6 +187,8 @@ export interface ConnectSocket {
161
187
  configure(options: ApeClientConfig): void
162
188
  /** Enable auto-reconnection on disconnect */
163
189
  autoReconnect(): void
190
+ /** Connection state enum */
191
+ ConnectionState: typeof ConnectionState
164
192
  }
165
193
 
166
194
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "api-ape",
3
- "version": "1.0.2",
3
+ "version": "2.0.0",
4
4
  "description": "Remote procedure events",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -27,7 +27,6 @@
27
27
  },
28
28
  "homepage": "https://github.com/codemeasandwich/api-ape#readme",
29
29
  "dependencies": {
30
- "express-ws": "^5.0.2",
31
30
  "jest": "^29.3.1",
32
31
  "ua-parser-js": "^1.0.37",
33
32
  "ws": "^8.14.0"
@@ -35,4 +34,4 @@
35
34
  "devDependencies": {
36
35
  "esbuild": "^0.27.2"
37
36
  }
38
- }
37
+ }
package/server/README.md CHANGED
@@ -11,6 +11,7 @@ server/
11
11
  │ ├── main.js # Express integration & setup
12
12
  │ ├── loader.js # Auto-loads controller files from folder
13
13
  │ ├── broadcast.js # Client tracking & broadcast utilities
14
+ │ ├── fileTransfer.js # Binary file transfer manager
14
15
  │ └── wiring.js # WebSocket handler setup
15
16
  ├── socket/
16
17
  │ ├── receive.js # Incoming message handler
@@ -52,6 +53,19 @@ app.listen(3000)
52
53
  |--------|------|-------------|
53
54
  | `where` | `string` | Directory containing controller files |
54
55
  | `onConnent` | `function` | Connection lifecycle hook |
56
+ | `fileTransferOptions` | `object` | Binary transfer settings (see below) |
57
+
58
+ ### File Transfer Options
59
+
60
+ ```js
61
+ ape(app, {
62
+ where: 'api',
63
+ fileTransferOptions: {
64
+ startTimeout: 60000, // Time to wait for transfer start (ms)
65
+ completeTimeout: 60000 // Time after start before cleanup (ms)
66
+ }
67
+ })
68
+ ```
55
69
 
56
70
  ### Controller Context (`this`)
57
71
 
@@ -91,3 +105,33 @@ api/
91
105
  │ ├── list.js → ape.users.list(data)
92
106
  │ └── create.js → ape.users.create(data)
93
107
  ```
108
+
109
+ ## File Transfers
110
+
111
+ Controllers can return `Buffer` data directly. The framework handles conversion:
112
+
113
+ ```js
114
+ // api/files/download.js
115
+ const fs = require('fs')
116
+
117
+ module.exports = function(filename) {
118
+ return {
119
+ name: filename,
120
+ data: fs.readFileSync(`./uploads/${filename}`)
121
+ }
122
+ }
123
+ ```
124
+
125
+ For uploads, the controller receives `Buffer` data:
126
+
127
+ ```js
128
+ // api/files/upload.js
129
+ module.exports = function({ name, data }) {
130
+ // data is a Buffer
131
+ fs.writeFileSync(`./uploads/${name}`, data)
132
+ return { success: true }
133
+ }
134
+ ```
135
+
136
+ Binary data is transferred via `/api/ape/data/:hash` with session verification and HTTPS enforcement (localhost exempt).
137
+
package/server/index.js CHANGED
@@ -1,6 +1,14 @@
1
1
  /**
2
2
  * api-ape server entry point
3
- * Exports the main ape function
3
+ * Exports the main ape function and broadcast utilities
4
4
  */
5
5
 
6
- module.exports = require('./lib/main')
6
+ const ape = require('./lib/main')
7
+ const { broadcast, online, getClients } = require('./lib/broadcast')
8
+
9
+ // Attach broadcast utilities to the main function for clean exports
10
+ ape.broadcast = broadcast
11
+ ape.online = online
12
+ ape.getClients = getClients
13
+
14
+ module.exports = ape
@@ -0,0 +1,247 @@
1
+ /**
2
+ * FileTransferManager - Handles temporary binary data endpoints
3
+ *
4
+ * For downloads (server → client):
5
+ * - Registers binary data with a hash
6
+ * - Creates temporary endpoint at GET /api/ape/data/:hash
7
+ * - Verifies session before allowing download
8
+ * - Auto-cleanup after timeout
9
+ *
10
+ * For uploads (client → server):
11
+ * - Registers upload expectation with queryId + pathHash
12
+ * - Receives data via PUT /api/ape/data/:queryId/:pathHash
13
+ * - Waits for matching WS message before processing
14
+ */
15
+
16
+ // Default timeouts (configurable)
17
+ const DEFAULT_START_TIMEOUT = 60 * 1000 // 1 minute to start download
18
+ const DEFAULT_COMPLETE_TIMEOUT = 60 * 1000 // 1 minute after download starts
19
+
20
+ class FileTransferManager {
21
+ constructor(options = {}) {
22
+ this.startTimeout = options.startTimeout || DEFAULT_START_TIMEOUT
23
+ this.completeTimeout = options.completeTimeout || DEFAULT_COMPLETE_TIMEOUT
24
+
25
+ // Map<hash, { data, contentType, sessionHostId, createdAt, downloadStarted, timer }>
26
+ this.pendingDownloads = new Map()
27
+
28
+ // Map<`${queryId}/${pathHash}`, { sessionHostId, createdAt, resolver, rejector, timer, data }>
29
+ this.pendingUploads = new Map()
30
+
31
+ // Cleanup interval
32
+ this._cleanupInterval = setInterval(() => this._cleanup(), 30000)
33
+ }
34
+
35
+ /**
36
+ * Register a binary download
37
+ * @param {string} hash - Unique hash for this download
38
+ * @param {Buffer|ArrayBuffer} data - Binary data to serve
39
+ * @param {string} contentType - MIME type (e.g., 'application/octet-stream')
40
+ * @param {string} sessionHostId - Host ID of the client session
41
+ * @returns {string} The hash (for confirmation)
42
+ */
43
+ registerDownload(hash, data, contentType, sessionHostId) {
44
+ // Clear any existing entry with same hash
45
+ if (this.pendingDownloads.has(hash)) {
46
+ const existing = this.pendingDownloads.get(hash)
47
+ if (existing.timer) clearTimeout(existing.timer)
48
+ }
49
+
50
+ const entry = {
51
+ data,
52
+ contentType: contentType || 'application/octet-stream',
53
+ sessionHostId,
54
+ createdAt: Date.now(),
55
+ downloadStarted: false,
56
+ timer: setTimeout(() => {
57
+ // Auto-remove if download never started
58
+ if (!this.pendingDownloads.get(hash)?.downloadStarted) {
59
+ this.pendingDownloads.delete(hash)
60
+ console.log(`📦 Download expired (never started): ${hash}`)
61
+ }
62
+ }, this.startTimeout)
63
+ }
64
+
65
+ this.pendingDownloads.set(hash, entry)
66
+ console.log(`📦 Registered download: ${hash} for session ${sessionHostId}`)
67
+ return hash
68
+ }
69
+
70
+ /**
71
+ * Get download data (called by HTTP handler)
72
+ * @param {string} hash - Download hash
73
+ * @param {string} requestingHostId - Host ID of requester (from session/cookie)
74
+ * @returns {{ data: Buffer, contentType: string } | null}
75
+ */
76
+ getDownload(hash, requestingHostId) {
77
+ const entry = this.pendingDownloads.get(hash)
78
+
79
+ if (!entry) {
80
+ console.warn(`📦 Download not found: ${hash}`)
81
+ return null
82
+ }
83
+
84
+ // Session verification
85
+ if (entry.sessionHostId !== requestingHostId) {
86
+ console.warn(`📦 Session mismatch for ${hash}: expected ${entry.sessionHostId}, got ${requestingHostId}`)
87
+ return null
88
+ }
89
+
90
+ // Mark download as started
91
+ if (!entry.downloadStarted) {
92
+ entry.downloadStarted = true
93
+ clearTimeout(entry.timer)
94
+
95
+ // Set new timer for cleanup after completion
96
+ entry.timer = setTimeout(() => {
97
+ this.pendingDownloads.delete(hash)
98
+ console.log(`📦 Download cleaned up: ${hash}`)
99
+ }, this.completeTimeout)
100
+ }
101
+
102
+ return {
103
+ data: entry.data,
104
+ contentType: entry.contentType
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Register an expected upload
110
+ * @param {string} queryId - Query ID from WS message
111
+ * @param {string} pathHash - Hash of property path
112
+ * @param {string} sessionHostId - Host ID of the client session
113
+ * @returns {Promise<Buffer>} Resolves when upload is received
114
+ */
115
+ registerUpload(queryId, pathHash, sessionHostId) {
116
+ const key = `${queryId}/${pathHash}`
117
+
118
+ return new Promise((resolve, reject) => {
119
+ const entry = {
120
+ sessionHostId,
121
+ createdAt: Date.now(),
122
+ resolver: resolve,
123
+ rejector: reject,
124
+ data: null,
125
+ timer: setTimeout(() => {
126
+ this.pendingUploads.delete(key)
127
+ reject(new Error(`Upload timeout: ${key}`))
128
+ }, this.startTimeout)
129
+ }
130
+
131
+ this.pendingUploads.set(key, entry)
132
+ console.log(`📤 Registered upload expectation: ${key} for session ${sessionHostId}`)
133
+ })
134
+ }
135
+
136
+ /**
137
+ * Receive upload data (called by HTTP handler)
138
+ * @param {string} queryId - Query ID from URL
139
+ * @param {string} pathHash - Path hash from URL
140
+ * @param {Buffer} data - Uploaded binary data
141
+ * @param {string} requestingHostId - Host ID of uploader
142
+ * @returns {boolean} True if accepted
143
+ */
144
+ receiveUpload(queryId, pathHash, data, requestingHostId) {
145
+ const key = `${queryId}/${pathHash}`
146
+ const entry = this.pendingUploads.get(key)
147
+
148
+ if (!entry) {
149
+ console.warn(`📤 Upload not expected: ${key}`)
150
+ return false
151
+ }
152
+
153
+ // Session verification
154
+ if (entry.sessionHostId !== requestingHostId) {
155
+ console.warn(`📤 Session mismatch for upload ${key}: expected ${entry.sessionHostId}, got ${requestingHostId}`)
156
+ return false
157
+ }
158
+
159
+ // Clear timeout and resolve
160
+ clearTimeout(entry.timer)
161
+ entry.resolver(data)
162
+ this.pendingUploads.delete(key)
163
+ console.log(`📤 Upload received: ${key}`)
164
+
165
+ return true
166
+ }
167
+
168
+ /**
169
+ * Generate hash for download from queryId and property path
170
+ * @param {string} queryId - The query ID
171
+ * @param {string} propertyPath - The property path (e.g., 'files.0.data')
172
+ * @returns {string} Combined hash
173
+ */
174
+ static generateHash(queryId, propertyPath) {
175
+ // Simple hash combining queryId and path
176
+ // In production, could use crypto.createHash
177
+ const combined = `${queryId}:${propertyPath}`
178
+ let hash = 0
179
+ for (let i = 0; i < combined.length; i++) {
180
+ const char = combined.charCodeAt(i)
181
+ hash = ((hash << 5) - hash) + char
182
+ hash = hash & hash // Convert to 32bit integer
183
+ }
184
+ return Math.abs(hash).toString(36)
185
+ }
186
+
187
+ /**
188
+ * Cleanup expired entries
189
+ * @private
190
+ */
191
+ _cleanup() {
192
+ const now = Date.now()
193
+ const maxAge = this.startTimeout + this.completeTimeout
194
+
195
+ // Cleanup downloads
196
+ for (const [hash, entry] of this.pendingDownloads) {
197
+ if (now - entry.createdAt > maxAge) {
198
+ clearTimeout(entry.timer)
199
+ this.pendingDownloads.delete(hash)
200
+ console.log(`📦 Cleanup stale download: ${hash}`)
201
+ }
202
+ }
203
+
204
+ // Cleanup uploads
205
+ for (const [key, entry] of this.pendingUploads) {
206
+ if (now - entry.createdAt > maxAge) {
207
+ clearTimeout(entry.timer)
208
+ entry.rejector(new Error(`Upload expired: ${key}`))
209
+ this.pendingUploads.delete(key)
210
+ console.log(`📤 Cleanup stale upload: ${key}`)
211
+ }
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Shutdown cleanup
217
+ */
218
+ destroy() {
219
+ clearInterval(this._cleanupInterval)
220
+
221
+ // Clear all timers
222
+ for (const entry of this.pendingDownloads.values()) {
223
+ clearTimeout(entry.timer)
224
+ }
225
+ for (const entry of this.pendingUploads.values()) {
226
+ clearTimeout(entry.timer)
227
+ }
228
+
229
+ this.pendingDownloads.clear()
230
+ this.pendingUploads.clear()
231
+ }
232
+ }
233
+
234
+ // Singleton instance
235
+ let instance = null
236
+
237
+ function getFileTransferManager(options) {
238
+ if (!instance) {
239
+ instance = new FileTransferManager(options)
240
+ }
241
+ return instance
242
+ }
243
+
244
+ module.exports = {
245
+ FileTransferManager,
246
+ getFileTransferManager
247
+ }