api-ape 3.0.2 → 4.1.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 (186) hide show
  1. package/README.md +59 -572
  2. package/client/README.md +73 -14
  3. package/client/auth/crypto/aead.js +214 -0
  4. package/client/auth/crypto/constants.js +32 -0
  5. package/client/auth/crypto/encoding.js +104 -0
  6. package/client/auth/crypto/files.md +27 -0
  7. package/client/auth/crypto/kdf.js +217 -0
  8. package/client/auth/crypto-utils.js +118 -0
  9. package/client/auth/files.md +52 -0
  10. package/client/auth/key-recovery.js +288 -0
  11. package/client/auth/recovery/constants.js +37 -0
  12. package/client/auth/recovery/files.md +23 -0
  13. package/client/auth/recovery/key-derivation.js +61 -0
  14. package/client/auth/recovery/sss-browser.js +189 -0
  15. package/client/auth/share-storage.js +205 -0
  16. package/client/auth/storage/constants.js +18 -0
  17. package/client/auth/storage/db.js +132 -0
  18. package/client/auth/storage/files.md +27 -0
  19. package/client/auth/storage/keys.js +173 -0
  20. package/client/auth/storage/shares.js +200 -0
  21. package/client/browser.js +190 -23
  22. package/client/connectSocket.js +418 -988
  23. package/client/connection/README.md +23 -0
  24. package/client/connection/fileDownload.js +256 -0
  25. package/client/connection/fileHandling.js +450 -0
  26. package/client/connection/fileUtils.js +346 -0
  27. package/client/connection/files.md +71 -0
  28. package/client/connection/messageHandler.js +105 -0
  29. package/client/connection/network.js +350 -0
  30. package/client/connection/proxy.js +233 -0
  31. package/client/connection/sender.js +333 -0
  32. package/client/connection/state.js +321 -0
  33. package/client/connection/subscriptions.js +151 -0
  34. package/client/files.md +53 -0
  35. package/client/index.js +298 -142
  36. package/client/transports/README.md +50 -0
  37. package/client/transports/files.md +41 -0
  38. package/client/transports/streamParser.js +195 -0
  39. package/client/transports/streaming.js +555 -203
  40. package/dist/ape.js +6 -1
  41. package/dist/ape.js.map +4 -4
  42. package/index.d.ts +38 -16
  43. package/package.json +31 -6
  44. package/server/README.md +272 -67
  45. package/server/adapters/README.md +23 -14
  46. package/server/adapters/files.md +68 -0
  47. package/server/adapters/firebase.js +543 -160
  48. package/server/adapters/index.js +362 -112
  49. package/server/adapters/mongo.js +530 -140
  50. package/server/adapters/postgres.js +534 -155
  51. package/server/adapters/redis.js +508 -143
  52. package/server/adapters/supabase.js +555 -186
  53. package/server/client/README.md +43 -0
  54. package/server/client/connection.js +586 -0
  55. package/server/client/files.md +40 -0
  56. package/server/client/index.js +342 -0
  57. package/server/files.md +54 -0
  58. package/server/index.js +322 -71
  59. package/server/lib/README.md +26 -0
  60. package/server/lib/broadcast/clients.js +219 -0
  61. package/server/lib/broadcast/files.md +58 -0
  62. package/server/lib/broadcast/index.js +57 -0
  63. package/server/lib/broadcast/publishProxy.js +110 -0
  64. package/server/lib/broadcast/pubsub.js +137 -0
  65. package/server/lib/broadcast/sendProxy.js +103 -0
  66. package/server/lib/bun.js +315 -99
  67. package/server/lib/fileTransfer/README.md +63 -0
  68. package/server/lib/fileTransfer/files.md +30 -0
  69. package/server/lib/fileTransfer/streaming.js +435 -0
  70. package/server/lib/fileTransfer.js +710 -326
  71. package/server/lib/files.md +111 -0
  72. package/server/lib/httpUtils.js +283 -0
  73. package/server/lib/loader.js +208 -7
  74. package/server/lib/longPolling/README.md +63 -0
  75. package/server/lib/longPolling/files.md +44 -0
  76. package/server/lib/longPolling/getHandler.js +365 -0
  77. package/server/lib/longPolling/postHandler.js +327 -0
  78. package/server/lib/longPolling.js +174 -219
  79. package/server/lib/main.js +369 -532
  80. package/server/lib/runtimes/README.md +42 -0
  81. package/server/lib/runtimes/bun.js +586 -0
  82. package/server/lib/runtimes/files.md +56 -0
  83. package/server/lib/runtimes/node.js +511 -0
  84. package/server/lib/wiring.js +539 -98
  85. package/server/lib/ws/README.md +35 -0
  86. package/server/lib/ws/adapters/README.md +54 -0
  87. package/server/lib/ws/adapters/bun.js +538 -170
  88. package/server/lib/ws/adapters/deno.js +623 -149
  89. package/server/lib/ws/adapters/files.md +42 -0
  90. package/server/lib/ws/files.md +74 -0
  91. package/server/lib/ws/frames.js +532 -154
  92. package/server/lib/ws/index.js +207 -10
  93. package/server/lib/ws/server.js +385 -92
  94. package/server/lib/ws/socket.js +549 -181
  95. package/server/lib/wsProvider.js +363 -89
  96. package/server/plugins/binary.js +282 -0
  97. package/server/security/README.md +92 -0
  98. package/server/security/auth/README.md +319 -0
  99. package/server/security/auth/adapters/files.md +95 -0
  100. package/server/security/auth/adapters/ldap/constants.js +37 -0
  101. package/server/security/auth/adapters/ldap/files.md +19 -0
  102. package/server/security/auth/adapters/ldap/helpers.js +111 -0
  103. package/server/security/auth/adapters/ldap.js +353 -0
  104. package/server/security/auth/adapters/oauth2/constants.js +41 -0
  105. package/server/security/auth/adapters/oauth2/files.md +19 -0
  106. package/server/security/auth/adapters/oauth2/helpers.js +123 -0
  107. package/server/security/auth/adapters/oauth2.js +273 -0
  108. package/server/security/auth/adapters/opaque-handlers.js +314 -0
  109. package/server/security/auth/adapters/opaque.js +205 -0
  110. package/server/security/auth/adapters/saml/constants.js +52 -0
  111. package/server/security/auth/adapters/saml/files.md +19 -0
  112. package/server/security/auth/adapters/saml/helpers.js +74 -0
  113. package/server/security/auth/adapters/saml.js +173 -0
  114. package/server/security/auth/adapters/totp.js +703 -0
  115. package/server/security/auth/adapters/webauthn.js +625 -0
  116. package/server/security/auth/files.md +61 -0
  117. package/server/security/auth/framework/constants.js +27 -0
  118. package/server/security/auth/framework/files.md +23 -0
  119. package/server/security/auth/framework/handlers.js +272 -0
  120. package/server/security/auth/framework/socket-auth.js +177 -0
  121. package/server/security/auth/handlers/auth-messages.js +143 -0
  122. package/server/security/auth/handlers/files.md +28 -0
  123. package/server/security/auth/index.js +290 -0
  124. package/server/security/auth/mfa/crypto/aead.js +148 -0
  125. package/server/security/auth/mfa/crypto/constants.js +35 -0
  126. package/server/security/auth/mfa/crypto/files.md +27 -0
  127. package/server/security/auth/mfa/crypto/kdf.js +120 -0
  128. package/server/security/auth/mfa/crypto/utils.js +68 -0
  129. package/server/security/auth/mfa/crypto-utils.js +80 -0
  130. package/server/security/auth/mfa/files.md +77 -0
  131. package/server/security/auth/mfa/ledger/constants.js +75 -0
  132. package/server/security/auth/mfa/ledger/errors.js +73 -0
  133. package/server/security/auth/mfa/ledger/files.md +23 -0
  134. package/server/security/auth/mfa/ledger/share-record.js +32 -0
  135. package/server/security/auth/mfa/ledger.js +255 -0
  136. package/server/security/auth/mfa/recovery/constants.js +67 -0
  137. package/server/security/auth/mfa/recovery/files.md +19 -0
  138. package/server/security/auth/mfa/recovery/handlers.js +216 -0
  139. package/server/security/auth/mfa/recovery.js +191 -0
  140. package/server/security/auth/mfa/sss/constants.js +21 -0
  141. package/server/security/auth/mfa/sss/files.md +23 -0
  142. package/server/security/auth/mfa/sss/gf256.js +103 -0
  143. package/server/security/auth/mfa/sss/serialization.js +82 -0
  144. package/server/security/auth/mfa/sss.js +161 -0
  145. package/server/security/auth/mfa/two-of-three/constants.js +58 -0
  146. package/server/security/auth/mfa/two-of-three/files.md +23 -0
  147. package/server/security/auth/mfa/two-of-three/handlers.js +241 -0
  148. package/server/security/auth/mfa/two-of-three/helpers.js +71 -0
  149. package/server/security/auth/mfa/two-of-three.js +136 -0
  150. package/server/security/auth/nonce-manager.js +89 -0
  151. package/server/security/auth/state-machine-mfa.js +269 -0
  152. package/server/security/auth/state-machine.js +257 -0
  153. package/server/security/extractRootDomain.js +144 -16
  154. package/server/security/files.md +51 -0
  155. package/server/security/origin.js +197 -15
  156. package/server/security/reply.js +274 -16
  157. package/server/socket/README.md +119 -0
  158. package/server/socket/authMiddleware.js +299 -0
  159. package/server/socket/files.md +86 -0
  160. package/server/socket/open.js +154 -8
  161. package/server/socket/pluginHooks.js +334 -0
  162. package/server/socket/receive.js +184 -224
  163. package/server/socket/receiveContext.js +117 -0
  164. package/server/socket/send.js +416 -78
  165. package/server/socket/tagUtils.js +402 -0
  166. package/server/utils/README.md +19 -0
  167. package/server/utils/deepRequire.js +255 -30
  168. package/server/utils/files.md +57 -0
  169. package/server/utils/genId.js +182 -20
  170. package/server/utils/parseUserAgent.js +313 -251
  171. package/server/utils/userAgent/README.md +65 -0
  172. package/server/utils/userAgent/files.md +46 -0
  173. package/server/utils/userAgent/patterns.js +545 -0
  174. package/utils/README.md +21 -0
  175. package/utils/files.md +66 -0
  176. package/utils/jss/README.md +21 -0
  177. package/utils/jss/decode.js +471 -0
  178. package/utils/jss/encode.js +312 -0
  179. package/utils/jss/files.md +68 -0
  180. package/utils/jss/plugins.js +210 -0
  181. package/utils/jss.js +219 -273
  182. package/utils/messageHash.js +238 -35
  183. package/dist/api-ape.min.js +0 -2
  184. package/dist/api-ape.min.js.map +0 -7
  185. package/server/client.js +0 -311
  186. package/server/lib/broadcast.js +0 -146
@@ -1,561 +1,398 @@
1
- const loader = require('./loader')
2
- const wiring = require('./wiring')
3
- const { getWebSocketProvider, isBun, isDeno, getRuntime } = require('./wsProvider')
4
- const path = require('path')
5
- const fs = require('fs')
6
- const { getFileTransferManager } = require('./fileTransfer')
7
- const { createLongPollingHandler } = require('./longPolling')
8
- const { parse: parseUrl } = require('url')
9
-
10
- let created = false
11
-
12
1
  /**
13
- * Parse URL path parameters like /api/ape/data/:hash
14
- * Returns null if pattern doesn't match, or object with params if it does
2
+ * @fileoverview Main api-ape Server Entry Point
3
+ *
4
+ * This module provides the unified entry point for initializing api-ape on any
5
+ * supported server platform. It handles runtime detection, WebSocket setup,
6
+ * controller loading, and HTTP endpoint configuration.
7
+ *
8
+ * ## Supported Runtimes
9
+ *
10
+ * | Runtime | WebSocket Provider | Notes |
11
+ * |-----------|---------------------------|------------------------------|
12
+ * | Node.js | Polyfill or native (v24+) | Full feature support |
13
+ * | Bun | Native Bun WebSocket | High performance |
14
+ * | Deno | Native Deno WebSocket | Via adapter |
15
+ *
16
+ * ## Initialization Flow
17
+ *
18
+ * ```
19
+ * ape(server, options)
20
+ * │
21
+ * ▼
22
+ * ┌─────────────────────────────────────────────────────┐
23
+ * │ createApeCore(options) │
24
+ * │ ├── Load controllers from 'where' directory │
25
+ * │ ├── Initialize file transfer manager │
26
+ * │ ├── Create WebSocket wiring handler │
27
+ * │ └── Create HTTP long-polling handlers │
28
+ * └─────────────────────────────────────────────────────┘
29
+ * │
30
+ * ▼
31
+ * ┌─────────────────────────────────────────────────────┐
32
+ * │ Detect Server Type │
33
+ * │ ├── Bun.serve() server → initBunServerWithReload │
34
+ * │ └── Node.js http.Server → initNodeServer │
35
+ * └─────────────────────────────────────────────────────┘
36
+ * │
37
+ * ▼
38
+ * ┌─────────────────────────────────────────────────────┐
39
+ * │ Server Running │
40
+ * │ ├── WebSocket: /api/ape (or custom path) │
41
+ * │ ├── Long-poll: /api/ape/poll │
42
+ * │ ├── Ping: /api/ape/ping │
43
+ * │ ├── Client bundle: /api/ape.js │
44
+ * │ └── File transfer: /api/ape/data/:hash │
45
+ * └─────────────────────────────────────────────────────┘
46
+ * ```
47
+ *
48
+ * ## HTTP Endpoints Created
49
+ *
50
+ * | Endpoint | Method | Description |
51
+ * |-----------------------------|--------|--------------------------------|
52
+ * | `/{where}/ape` | WS | WebSocket connection endpoint |
53
+ * | `/{where}/ape/poll` | GET | HTTP streaming (long-poll) |
54
+ * | `/{where}/ape/poll` | POST | Send message via HTTP |
55
+ * | `/{where}/ape/ping` | GET | Connection health check |
56
+ * | `/{where}/ape.js` | GET | Client JavaScript bundle |
57
+ * | `/{where}/ape.js.map` | GET | Source map for debugging |
58
+ * | `/{where}/ape/data/:hash` | GET | Download binary data |
59
+ * | `/{where}/ape/data/:qid/:h` | PUT | Upload binary data |
60
+ *
61
+ * @module server/lib/main
62
+ * @see {@link module:server/lib/wiring} for WebSocket connection handling
63
+ * @see {@link module:server/lib/loader} for controller loading
64
+ * @see {@link module:server/lib/longPolling} for HTTP fallback
65
+ *
66
+ * @example <caption>Basic Node.js Setup</caption>
67
+ * const http = require('http')
68
+ * const ape = require('api-ape/server/lib/main')
69
+ *
70
+ * const server = http.createServer()
71
+ *
72
+ * ape(server, {
73
+ * where: 'api', // Load controllers from ./api directory
74
+ * onConnect: (socket, req, send) => ({
75
+ * embed: { userId: getUserFromRequest(req) }
76
+ * })
77
+ * })
78
+ *
79
+ * server.listen(3000)
80
+ *
81
+ * @example <caption>Express Integration</caption>
82
+ * const express = require('express')
83
+ * const ape = require('api-ape/server/lib/main')
84
+ *
85
+ * const app = express()
86
+ * const server = app.listen(3000)
87
+ *
88
+ * ape(server, {
89
+ * where: 'api',
90
+ * onConnect: async (socket, req, send) => {
91
+ * const session = await validateSession(req)
92
+ * return {
93
+ * embed: { user: session.user, permissions: session.permissions },
94
+ * onDisconnect: () => logDisconnect(session.user)
95
+ * }
96
+ * }
97
+ * })
98
+ *
99
+ * @example <caption>Bun Server</caption>
100
+ * const ape = require('api-ape/server/lib/main')
101
+ *
102
+ * const server = Bun.serve({
103
+ * port: 3000,
104
+ * fetch(req) { return new Response('Hello') },
105
+ * websocket: { message() {} } // Required for api-ape
106
+ * })
107
+ *
108
+ * ape(server, { where: 'api' })
15
109
  */
16
- function matchRoute(pathname, pattern) {
17
- const patternParts = pattern.split('/')
18
- const pathParts = pathname.split('/')
19
-
20
- if (patternParts.length !== pathParts.length) {
21
- return null
22
- }
23
110
 
24
- const params = {}
25
- for (let i = 0; i < patternParts.length; i++) {
26
- if (patternParts[i].startsWith(':')) {
27
- params[patternParts[i].slice(1)] = pathParts[i]
28
- } else if (patternParts[i] !== pathParts[i]) {
29
- return null
30
- }
31
- }
32
- return params
33
- }
34
-
35
- /**
36
- * Send JSON response (Node.js style)
37
- */
38
- function sendJson(res, statusCode, data) {
39
- res.writeHead(statusCode, { 'Content-Type': 'application/json' })
40
- res.end(JSON.stringify(data))
41
- }
42
-
43
- /**
44
- * Get cookie value from request headers
45
- */
46
- function getCookie(headers, name) {
47
- const cookies = typeof headers.get === 'function'
48
- ? headers.get('cookie')
49
- : headers.cookie
50
- if (!cookies) return null
51
- const match = cookies.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`))
52
- return match ? match[1] : null
53
- }
111
+ const loader = require("./loader");
112
+ const wiring = require("./wiring");
113
+ const { isBun, isDeno, getRuntime } = require("./wsProvider");
114
+ const { getFileTransferManager } = require("./fileTransfer");
115
+ const { createLongPollingHandler } = require("./longPolling");
116
+ const { initNodeServer } = require("./runtimes/node");
117
+ const { isBunServer, initBunServerWithReload } = require("./runtimes/bun");
54
118
 
55
119
  /**
56
- * Check if request is from localhost
120
+ * Flag to track whether api-ape has been initialized
121
+ *
122
+ * Prevents multiple initializations which could cause conflicts
123
+ * with WebSocket handlers and HTTP routes.
124
+ *
125
+ * @type {boolean}
126
+ * @private
57
127
  */
58
- function isLocalhost(host) {
59
- const hostname = host?.split(':')[0] || ''
60
- return ['localhost', '127.0.0.1', '[::1]'].includes(hostname)
61
- }
128
+ let created = false;
62
129
 
63
130
  /**
64
- * Check if connection is secure (HTTPS)
131
+ * Reset the singleton state for testing purposes.
132
+ * This allows creating multiple server instances in test environments.
133
+ *
134
+ * @private
135
+ * @function _resetForTesting
65
136
  */
66
- function isSecure(req) {
67
- if (typeof req.headers?.get === 'function') {
68
- return req.headers.get('x-forwarded-proto') === 'https'
69
- }
70
- return req.socket?.encrypted || req.headers?.['x-forwarded-proto'] === 'https'
137
+ function _resetForTesting() {
138
+ created = false;
71
139
  }
72
140
 
73
141
  /**
74
- * Create core api-ape handlers (shared between runtimes)
142
+ * Create the core api-ape handlers shared between all runtimes
143
+ *
144
+ * This function initializes the components needed regardless of the
145
+ * server runtime (Node.js, Bun, Deno):
146
+ * - Controller loading from the specified directory
147
+ * - File transfer manager for binary data
148
+ * - WebSocket message handler (wiring)
149
+ * - HTTP long-polling handlers for fallback transport
150
+ * - URL path patterns for all endpoints
151
+ *
152
+ * @param {Object} options - Configuration options
153
+ * @param {string} options.where - Directory containing API controllers (relative to CWD)
154
+ * @param {Function} [options.onConnect] - Connection lifecycle callback
155
+ * @param {Object} [options.fileTransferOptions] - File transfer configuration
156
+ * @param {number} [options.fileTransferOptions.startTimeout=60000] - Timeout before upload starts
157
+ * @param {number} [options.fileTransferOptions.completeTimeout=60000] - Timeout for upload completion
158
+ * @param {Object} [options.longPollingOptions] - Long polling configuration
159
+ * @param {number} [options.longPollingOptions.heartbeatInterval=20000] - Interval in ms for heartbeat pings
160
+ * @param {number} [options.longPollingOptions.recycleTimeout=25000] - Timeout in ms before recycling connection
161
+ * @returns {ApeCore} Core handlers and path patterns
162
+ *
163
+ * @typedef {Object} ApeCore
164
+ * @property {Object} controllers - Loaded controller functions keyed by endpoint path
165
+ * @property {FileTransferManager} fileTransfer - Binary data transfer manager
166
+ * @property {Function} wiringHandler - WebSocket connection handler
167
+ * @property {Function} handleStreamGet - HTTP GET handler for long-polling
168
+ * @property {Function} handleStreamPost - HTTP POST handler for sending messages
169
+ * @property {string} wsPath - WebSocket endpoint path (e.g., '/api/ape')
170
+ * @property {string} pollPath - Long-polling endpoint path
171
+ * @property {string} pingPath - Health check endpoint path
172
+ * @property {string} clientPath - Client bundle endpoint path
173
+ * @property {string} clientMapPath - Source map endpoint path
174
+ * @property {string} downloadPattern - Binary download URL pattern
175
+ * @property {string} uploadPattern - Binary upload URL pattern
176
+ *
177
+ * @private
178
+ *
179
+ * @example
180
+ * const core = createApeCore({
181
+ * where: 'api',
182
+ * onConnect: myConnectHandler,
183
+ * fileTransferOptions: { startTimeout: 30000 }
184
+ * })
185
+ *
186
+ * // core.controllers = { 'users': [Function], 'chat': [Function], ... }
187
+ * // core.wsPath = '/api/ape'
75
188
  */
76
- function createApeCore({ where, onConnect, fileTransferOptions }) {
77
- const controllers = loader(where)
78
- const fileTransfer = getFileTransferManager(fileTransferOptions)
79
- const wiringHandler = wiring(controllers, onConnect, fileTransfer)
80
- const { handleStreamGet, handleStreamPost } = createLongPollingHandler(controllers, onConnect, fileTransfer)
81
-
82
- const wsPath = `/${where}/ape`
83
- const pollPath = `/${where}/ape/poll`
84
- const pingPath = `/${where}/ape/ping`
85
- const clientPath = `/${where}/ape.js`
86
- const clientMapPath = `/${where}/ape.js.map`
87
- const downloadPattern = `/${where}/ape/data/:hash`
88
- const uploadPattern = `/${where}/ape/data/:queryId/:pathHash`
89
-
90
- return {
91
- controllers,
92
- fileTransfer,
93
- wiringHandler,
94
- handleStreamGet,
95
- handleStreamPost,
96
- wsPath,
97
- pollPath,
98
- pingPath,
99
- clientPath,
100
- clientMapPath,
101
- downloadPattern,
102
- uploadPattern
103
- }
189
+ function createApeCore({ where, onConnect, fileTransferOptions, longPollingOptions }) {
190
+ const controllers = loader(where);
191
+ const fileTransfer = getFileTransferManager(fileTransferOptions);
192
+ const wiringHandler = wiring(controllers, onConnect, fileTransfer);
193
+ const { handleStreamGet, handleStreamPost } = createLongPollingHandler(
194
+ controllers,
195
+ onConnect,
196
+ fileTransfer,
197
+ longPollingOptions,
198
+ );
199
+
200
+ return {
201
+ controllers,
202
+ fileTransfer,
203
+ wiringHandler,
204
+ handleStreamGet,
205
+ handleStreamPost,
206
+ wsPath: `/${where}/ape`,
207
+ pollPath: `/${where}/ape/poll`,
208
+ pingPath: `/${where}/ape/ping`,
209
+ clientPath: `/${where}/ape.js`,
210
+ clientMapPath: `/${where}/ape.js.map`,
211
+ downloadPattern: `/${where}/ape/data/:hash`,
212
+ uploadPattern: `/${where}/ape/data/:queryId/:pathHash`,
213
+ };
104
214
  }
105
215
 
106
216
  /**
107
- * Node.js / Express integration
108
- * Uses server.on('upgrade') and server.on('request')
217
+ * Initialize api-ape on an HTTP server
218
+ *
219
+ * This is the main entry point for setting up api-ape. It:
220
+ * 1. Validates that api-ape hasn't already been initialized
221
+ * 2. Creates core handlers (controllers, WebSocket, HTTP)
222
+ * 3. Detects the server type (Node.js, Bun)
223
+ * 4. Initializes the appropriate runtime-specific handlers
224
+ *
225
+ * ## Options
226
+ *
227
+ * | Option | Type | Description |
228
+ * |----------------------|----------|--------------------------------------------|
229
+ * | `where` | string | Directory containing API controllers |
230
+ * | `onConnect` | Function | Called when a client connects |
231
+ * | `fileTransferOptions`| Object | Binary file transfer configuration |
232
+ * | `transport` | string | Force transport: 'websocket' or 'longpolling' |
233
+ *
234
+ * ## onConnect Callback
235
+ *
236
+ * The `onConnect` function is called for each new WebSocket connection.
237
+ * It receives `(socket, req, send)` and should return an options object:
238
+ *
239
+ * ```javascript
240
+ * onConnect: (socket, req, send) => ({
241
+ * embed: { userId: '123' }, // Values available in all controllers as `this`
242
+ * onReceive: (queryId, data, type) => { }, // Called when message received
243
+ * onSend: (data, type) => { }, // Called when message sent
244
+ * onError: (errorString) => { }, // Called on errors
245
+ * onDisconnect: () => { } // Called when client disconnects
246
+ * })
247
+ * ```
248
+ *
249
+ * @param {http.Server|Object} server - HTTP server instance (Node.js or Bun)
250
+ * @param {Object} options - Configuration options
251
+ * @param {string} options.where - Directory containing API controller files
252
+ * @param {Function} [options.onConnect] - Connection lifecycle callback
253
+ * @param {Object} [options.fileTransferOptions] - File transfer settings
254
+ * @param {string} [options.transport] - Force specific transport mode
255
+ * @returns {Object} Server information including WebSocket server instance
256
+ * @throws {Error} If api-ape has already been initialized
257
+ * @throws {Error} If server type is not supported
258
+ *
259
+ * @example <caption>Minimal Setup</caption>
260
+ * const http = require('http')
261
+ * const ape = require('api-ape/server/lib/main')
262
+ *
263
+ * const server = http.createServer()
264
+ * ape(server, { where: 'api' })
265
+ * server.listen(3000)
266
+ *
267
+ * @example <caption>Full Configuration</caption>
268
+ * ape(server, {
269
+ * where: 'api',
270
+ *
271
+ * onConnect: async (socket, req, send) => {
272
+ * // Authenticate user from request
273
+ * const token = req.headers.cookie?.match(/token=([^;]+)/)?.[1]
274
+ * const user = await verifyToken(token)
275
+ *
276
+ * if (!user) {
277
+ * socket.close(4001, 'Unauthorized')
278
+ * return null
279
+ * }
280
+ *
281
+ * // Send welcome message
282
+ * send('welcome', { message: `Hello, ${user.name}!` })
283
+ *
284
+ * return {
285
+ * embed: {
286
+ * user,
287
+ * permissions: user.roles
288
+ * },
289
+ * onReceive: (queryId, data, type) => {
290
+ * console.log(`[${user.name}] ${type}:`, data)
291
+ * },
292
+ * onDisconnect: () => {
293
+ * console.log(`[${user.name}] disconnected`)
294
+ * }
295
+ * }
296
+ * },
297
+ *
298
+ * fileTransferOptions: {
299
+ * startTimeout: 30000,
300
+ * completeTimeout: 120000
301
+ * }
302
+ * })
303
+ *
304
+ * @example <caption>Controller File Structure</caption>
305
+ * // api/users.js - handles /users endpoint
306
+ * module.exports = async function(data) {
307
+ * // `this` contains embed values from onConnect
308
+ * const { user, permissions } = this
309
+ *
310
+ * if (!permissions.includes('users:read')) {
311
+ * throw new Error('Permission denied')
312
+ * }
313
+ *
314
+ * return await db.users.find(data.query)
315
+ * }
316
+ *
317
+ * // api/users/profile.js - handles /users/profile endpoint
318
+ * module.exports = async function(data) {
319
+ * return await db.users.findById(this.user.id)
320
+ * }
109
321
  */
110
- function initNodeServer(server, options) {
111
- const { WebSocketServer } = getWebSocketProvider()
112
- const core = createApeCore(options)
113
- const { where } = options
114
-
115
- const wss = new WebSocketServer({ noServer: true })
116
- wss.on('connection', core.wiringHandler)
117
-
118
- // Handle HTTP upgrade requests for WebSocket
119
- server.on('upgrade', (req, socket, head) => {
120
- const { pathname } = parseUrl(req.url)
121
-
122
- if (pathname === core.wsPath) {
123
- wss.handleUpgrade(req, socket, head, (ws) => {
124
- wss.emit('connection', ws, req)
125
- })
126
- } else {
127
- socket.destroy()
128
- }
129
- })
130
-
131
- // Store original request listeners to chain after api-ape handlers
132
- const originalListeners = server.listeners('request').slice()
133
- server.removeAllListeners('request')
134
-
135
- // Handle HTTP requests for api-ape routes
136
- server.on('request', (req, res) => {
137
- const { pathname } = parseUrl(req.url)
138
-
139
- // Serve bundled client
140
- if (pathname === core.clientPath) {
141
- const filePath = path.join(__dirname, '../../dist/ape.js')
142
- fs.readFile(filePath, (err, data) => {
143
- if (err) {
144
- sendJson(res, 500, { error: 'Failed to read client bundle' })
145
- return
146
- }
147
- res.writeHead(200, { 'Content-Type': 'application/javascript' })
148
- res.end(data)
149
- })
150
- return
151
- }
152
-
153
- // Serve source map for debugging
154
- if (pathname === core.clientMapPath) {
155
- const filePath = path.join(__dirname, '../../dist/ape.js.map')
156
- fs.readFile(filePath, (err, data) => {
157
- if (err) {
158
- sendJson(res, 404, { error: 'Source map not found' })
159
- return
160
- }
161
- res.writeHead(200, { 'Content-Type': 'application/json' })
162
- res.end(data)
163
- })
164
- return
165
- }
166
-
167
- // Ping endpoint for captive portal detection
168
- if (pathname === core.pingPath && req.method === 'GET') {
169
- return sendJson(res, 200, { ok: true, ts: Date.now() })
170
- }
171
-
172
- // Long polling - GET
173
- if (pathname === core.pollPath && req.method === 'GET') {
174
- core.handleStreamGet(req, res)
175
- return
176
- }
177
-
178
- // Long polling - POST
179
- if (pathname === core.pollPath && req.method === 'POST') {
180
- core.handleStreamPost(req, res, core.controllers)
181
- return
182
- }
183
-
184
- // File download
185
- const downloadMatch = matchRoute(pathname, core.downloadPattern)
186
- if (req.method === 'GET' && downloadMatch) {
187
- const { hash } = downloadMatch
188
-
189
- // Check for streaming file first (client-to-client sharing, no session check)
190
- const streamingFile = core.fileTransfer.getStreamingFile(hash)
191
- if (streamingFile) {
192
- if (!isLocalhost(req.headers.host) && !isSecure(req)) {
193
- return sendJson(res, 403, { error: 'HTTPS required for file transfers' })
194
- }
195
-
196
- // Return available data (may be partial if upload still in progress)
197
- res.writeHead(200, {
198
- 'Content-Type': 'application/octet-stream',
199
- 'Content-Length': streamingFile.data.length,
200
- 'X-Ape-Complete': streamingFile.isComplete ? '1' : '0',
201
- 'X-Ape-Total-Received': String(streamingFile.totalReceived)
202
- })
203
- res.end(streamingFile.data)
204
- return
205
- }
206
-
207
- // Session-bound download (original behavior)
208
- const clientId = getCookie(req.headers, 'apeClientId') || req.headers['x-ape-client-id']
209
-
210
- if (!clientId) {
211
- return sendJson(res, 401, { error: 'Missing session identifier' })
212
- }
213
-
214
- if (!isLocalhost(req.headers.host) && !isSecure(req)) {
215
- return sendJson(res, 403, { error: 'HTTPS required for file transfers' })
216
- }
217
-
218
- const result = core.fileTransfer.getDownload(hash, clientId)
219
-
220
- if (!result) {
221
- return sendJson(res, 404, { error: 'Download not found or unauthorized' })
222
- }
223
-
224
- res.writeHead(200, {
225
- 'Content-Type': result.contentType,
226
- 'Content-Length': result.data.length || result.data.byteLength
227
- })
228
- res.end(result.data)
229
- return
230
- }
231
-
232
- // File upload
233
- const uploadMatch = matchRoute(pathname, core.uploadPattern)
234
- if (req.method === 'PUT' && uploadMatch) {
235
- const { queryId, pathHash } = uploadMatch
236
-
237
- if (!isLocalhost(req.headers.host) && !isSecure(req)) {
238
- return sendJson(res, 403, { error: 'HTTPS required for file transfers' })
239
- }
240
-
241
- const chunks = []
242
- req.on('data', chunk => chunks.push(chunk))
243
- req.on('end', () => {
244
- const data = Buffer.concat(chunks)
245
-
246
- // Check if this is a streaming file upload (client-to-client)
247
- // For streaming files, queryId might be the fileId directly
248
- if (core.fileTransfer.isStreamingFile(pathHash)) {
249
- const success = core.fileTransfer.completeStreamingUpload(pathHash, data)
250
- if (success) {
251
- return sendJson(res, 200, { success: true, streaming: true })
252
- }
253
- return sendJson(res, 404, { error: 'Streaming file not found' })
254
- }
255
-
256
- // Regular upload (session-bound)
257
- const clientId = getCookie(req.headers, 'apeClientId') || req.headers['x-ape-client-id']
258
- if (!clientId) {
259
- return sendJson(res, 401, { error: 'Missing session identifier' })
260
- }
261
-
262
- const success = core.fileTransfer.receiveUpload(queryId, pathHash, data, clientId)
263
-
264
- if (success) {
265
- sendJson(res, 200, { success: true })
266
- } else {
267
- sendJson(res, 404, { error: 'Upload not expected or unauthorized' })
268
- }
269
- })
270
- req.on('error', (err) => {
271
- sendJson(res, 500, { error: err.message })
272
- })
273
- return
274
- }
275
-
276
- // Not an api-ape route - pass to original handlers
277
- for (const listener of originalListeners) {
278
- listener.call(server, req, res)
279
- }
280
- })
281
-
282
- return { wss, core }
283
- }
322
+ module.exports = function (server, options) {
323
+ /* istanbul ignore next 3 - would break test isolation to test "already started" */
324
+ if (created) {
325
+ throw new Error("Api-Ape already started");
326
+ }
327
+ created = true;
328
+
329
+ const core = createApeCore(options);
330
+
331
+ // Check for Bun server first
332
+ /* istanbul ignore next 3 - only reachable in Bun runtime */
333
+ if (isBunServer(server)) {
334
+ return initBunServerWithReload(server, options, core);
335
+ }
336
+
337
+ // Check for Node.js http.Server (or Express, Koa, etc.)
338
+ if (server && typeof server.on === "function") {
339
+ return initNodeServer(server, options, core);
340
+ }
341
+
342
+ /* istanbul ignore next 3 - requires passing invalid server type */
343
+ throw new Error(
344
+ "Unsupported server type. Expected http.Server (Node.js) or Bun.serve() server.",
345
+ );
346
+ };
284
347
 
285
348
  /**
286
- * Bun integration
287
- * Returns fetch and websocket handlers to spread into Bun.serve()
349
+ * Check if running in Bun runtime
350
+ *
351
+ * Useful for conditional logic based on runtime capabilities.
352
+ *
353
+ * @function isBun
354
+ * @returns {boolean} True if running in Bun
355
+ *
356
+ * @example
357
+ * const { isBun } = require('api-ape/server/lib/main')
358
+ *
359
+ * if (isBun()) {
360
+ * console.log('Running on Bun - native WebSocket available')
361
+ * }
288
362
  */
289
- function initBunServer(options) {
290
- const { BunWebSocket } = require('./ws/adapters/bun')
291
- const core = createApeCore(options)
292
- const clients = new Map()
293
-
294
- /**
295
- * Fetch handler for Bun.serve()
296
- * Handles all api-ape routes, returns null for non-ape routes
297
- */
298
- function fetch(req, server) {
299
- const url = new URL(req.url)
300
- const pathname = url.pathname
301
-
302
- // WebSocket upgrade
303
- if (pathname === core.wsPath) {
304
- const upgrade = req.headers.get('upgrade')
305
- if (upgrade?.toLowerCase() === 'websocket') {
306
- const success = server.upgrade(req, { data: { req } })
307
- if (success) return undefined
308
- return new Response('WebSocket upgrade failed', { status: 500 })
309
- }
310
- }
311
-
312
- // Serve client bundle
313
- if (pathname === core.clientPath) {
314
- try {
315
- const filePath = path.join(__dirname, '../../dist/ape.js')
316
- const data = fs.readFileSync(filePath)
317
- return new Response(data, {
318
- headers: { 'Content-Type': 'application/javascript' }
319
- })
320
- } catch {
321
- return new Response('Client bundle not found', { status: 500 })
322
- }
323
- }
324
-
325
- // Serve source map for debugging
326
- if (pathname === core.clientMapPath) {
327
- try {
328
- const filePath = path.join(__dirname, '../../dist/ape.js.map')
329
- const data = fs.readFileSync(filePath)
330
- return new Response(data, {
331
- headers: { 'Content-Type': 'application/json' }
332
- })
333
- } catch {
334
- return new Response('Source map not found', { status: 404 })
335
- }
336
- }
337
-
338
- // Ping endpoint for captive portal detection
339
- if (pathname === core.pingPath && req.method === 'GET') {
340
- return new Response(JSON.stringify({ ok: true, ts: Date.now() }), {
341
- headers: { 'Content-Type': 'application/json' }
342
- })
343
- }
344
-
345
- // Not an api-ape route
346
- return null
347
- }
348
-
349
- /**
350
- * WebSocket handlers for Bun.serve()
351
- */
352
- const websocket = {
353
- open(ws) {
354
- const wrapper = new BunWebSocket(ws)
355
- clients.set(ws, wrapper)
356
- const { req } = ws.data || {}
357
- core.wiringHandler(wrapper, req)
358
- },
359
-
360
- message(ws, message) {
361
- const wrapper = clients.get(ws)
362
- if (wrapper) {
363
- wrapper._onMessage(message)
364
- }
365
- },
366
-
367
- close(ws, code, reason) {
368
- const wrapper = clients.get(ws)
369
- if (wrapper) {
370
- wrapper._onClose(code, reason)
371
- clients.delete(ws)
372
- }
373
- },
374
-
375
- error(ws, error) {
376
- const wrapper = clients.get(ws)
377
- if (wrapper) {
378
- wrapper._onError(error)
379
- }
380
- }
381
- }
382
-
383
- return { fetch, websocket, clients, core }
384
- }
363
+ module.exports.isBun = isBun;
385
364
 
386
365
  /**
387
- * Check if server is a Bun server (has reload method and Bun globals)
366
+ * Reset singleton state (for testing only)
367
+ * @private
388
368
  */
389
- function isBunServer(server) {
390
- return isBun() && typeof server?.reload === 'function'
391
- }
369
+ module.exports._resetForTesting = _resetForTesting;
392
370
 
393
371
  /**
394
- * Initialize Bun server using server.reload() to hook in
395
- * This allows same signature: ape(server, { where: 'api' })
372
+ * Check if running in Deno runtime
373
+ *
374
+ * @function isDeno
375
+ * @returns {boolean} True if running in Deno
376
+ *
377
+ * @example
378
+ * const { isDeno } = require('api-ape/server/lib/main')
379
+ *
380
+ * if (isDeno()) {
381
+ * console.log('Running on Deno')
382
+ * }
396
383
  */
397
- function initBunServerWithReload(server, options) {
398
- const { BunWebSocket } = require('./ws/adapters/bun')
399
- const core = createApeCore(options)
400
- const clients = new Map()
401
-
402
- // Check if WebSocket support is enabled on Bun server
403
- // Bun requires websocket handlers to be defined at Bun.serve() creation
404
- const hasWebSocketSupport = typeof server.upgrade === 'function'
405
-
406
- if (!hasWebSocketSupport && options.transport !== 'longpolling') {
407
- throw new Error(`
408
- 🦍 api-ape: Bun WebSocket support not enabled!
409
-
410
- To enable WebSocket support in Bun, add a 'websocket' property when creating your server:
411
-
412
- const server = Bun.serve({
413
- port: 3000,
414
- fetch(req) { ... },
415
- websocket: { message() {} } // <-- Required for api-ape
416
- })
417
-
418
- ape(server, { where: 'api' })
419
-
420
- If you only want HTTP long-polling (no WebSocket), pass:
421
- ape(server, { where: 'api', transport: 'longpolling' })
422
- `)
423
- }
424
-
425
- // Store original fetch handler
426
- const originalFetch = server.fetch
427
-
428
- /**
429
- * Wrapped fetch handler that handles api-ape routes first
430
- */
431
- function wrappedFetch(req, server) {
432
- const url = new URL(req.url)
433
- const pathname = url.pathname
434
-
435
- // WebSocket upgrade
436
- if (pathname === core.wsPath) {
437
- const upgrade = req.headers.get('upgrade')
438
- if (upgrade?.toLowerCase() === 'websocket') {
439
- const success = server.upgrade(req, { data: { req } })
440
- if (success) return undefined
441
- return new Response('WebSocket upgrade failed', { status: 500 })
442
- }
443
- }
444
-
445
- // Serve client bundle
446
- if (pathname === core.clientPath) {
447
- try {
448
- const filePath = path.join(__dirname, '../../dist/ape.js')
449
- const data = fs.readFileSync(filePath)
450
- return new Response(data, {
451
- headers: { 'Content-Type': 'application/javascript' }
452
- })
453
- } catch {
454
- return new Response('Client bundle not found', { status: 500 })
455
- }
456
- }
457
-
458
- // Serve source map for debugging
459
- if (pathname === core.clientMapPath) {
460
- try {
461
- const filePath = path.join(__dirname, '../../dist/ape.js.map')
462
- const data = fs.readFileSync(filePath)
463
- return new Response(data, {
464
- headers: { 'Content-Type': 'application/json' }
465
- })
466
- } catch {
467
- return new Response('Source map not found', { status: 404 })
468
- }
469
- }
470
-
471
- // Ping endpoint for captive portal detection
472
- if (pathname === core.pingPath && req.method === 'GET') {
473
- return new Response(JSON.stringify({ ok: true, ts: Date.now() }), {
474
- headers: { 'Content-Type': 'application/json' }
475
- })
476
- }
477
-
478
- // Pass to original fetch handler
479
- if (originalFetch) {
480
- return originalFetch(req, server)
481
- }
482
-
483
- return new Response('Not Found', { status: 404 })
484
- }
485
-
486
- /**
487
- * WebSocket handlers
488
- */
489
- const websocket = {
490
- open(ws) {
491
- const wrapper = new BunWebSocket(ws)
492
- clients.set(ws, wrapper)
493
- const { req } = ws.data || {}
494
- core.wiringHandler(wrapper, req)
495
- },
496
-
497
- message(ws, message) {
498
- const wrapper = clients.get(ws)
499
- if (wrapper) {
500
- wrapper._onMessage(message)
501
- }
502
- },
503
-
504
- close(ws, code, reason) {
505
- const wrapper = clients.get(ws)
506
- if (wrapper) {
507
- wrapper._onClose(code, reason)
508
- clients.delete(ws)
509
- }
510
- },
511
-
512
- error(ws, error) {
513
- const wrapper = clients.get(ws)
514
- if (wrapper) {
515
- wrapper._onError(error)
516
- }
517
- }
518
- }
519
-
520
- // Use server.reload() to hook in our handlers
521
- server.reload({
522
- fetch: wrappedFetch,
523
- websocket
524
- })
525
-
526
- return { clients, core }
527
- }
384
+ module.exports.isDeno = isDeno;
528
385
 
529
386
  /**
530
- * Main api-ape entry point
531
- * Unified signature for all runtimes:
532
- * ape(server, { where: 'api' })
533
- *
534
- * Works with:
535
- * - Node.js http.Server
536
- * - Express server
537
- * - Bun.serve() server
387
+ * Get the detected runtime type
388
+ *
389
+ * @function getRuntime
390
+ * @returns {'deno'|'bun'|'node'|'unknown'} The detected runtime
391
+ *
392
+ * @example
393
+ * const { getRuntime } = require('api-ape/server/lib/main')
394
+ *
395
+ * console.log(`Running on: ${getRuntime()}`)
396
+ * // Output: 'node', 'bun', 'deno', or 'unknown'
538
397
  */
539
- module.exports = function (server, options) {
540
- if (created) {
541
- throw new Error("Api-Ape already started")
542
- }
543
- created = true
544
-
545
- // Bun server - use server.reload() to hook in
546
- if (isBunServer(server)) {
547
- return initBunServerWithReload(server, options)
548
- }
549
-
550
- // Node.js / Express - server is an http.Server with .on() method
551
- if (server && typeof server.on === 'function') {
552
- return initNodeServer(server, options)
553
- }
554
-
555
- throw new Error('Unsupported server type. Expected http.Server (Node.js) or Bun.serve() server.')
556
- }
557
-
558
- // Export runtime detection utilities
559
- module.exports.isBun = isBun
560
- module.exports.isDeno = isDeno
561
- module.exports.getRuntime = getRuntime
398
+ module.exports.getRuntime = getRuntime;