api-ape 0.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +261 -0
  2. package/client/README.md +69 -0
  3. package/client/browser.js +17 -0
  4. package/client/connectSocket.js +260 -0
  5. package/dist/ape.js +454 -0
  6. package/example/ExpressJs/README.md +97 -0
  7. package/example/ExpressJs/api/message.js +11 -0
  8. package/example/ExpressJs/backend.js +37 -0
  9. package/example/ExpressJs/index.html +88 -0
  10. package/example/ExpressJs/package-lock.json +834 -0
  11. package/example/ExpressJs/package.json +10 -0
  12. package/example/ExpressJs/styles.css +128 -0
  13. package/example/NextJs/.dockerignore +29 -0
  14. package/example/NextJs/Dockerfile +52 -0
  15. package/example/NextJs/Dockerfile.dev +27 -0
  16. package/example/NextJs/README.md +113 -0
  17. package/example/NextJs/ape/client.js +66 -0
  18. package/example/NextJs/ape/embed.js +12 -0
  19. package/example/NextJs/ape/index.js +23 -0
  20. package/example/NextJs/ape/logic/chat.js +62 -0
  21. package/example/NextJs/ape/onConnect.js +69 -0
  22. package/example/NextJs/ape/onDisconnect.js +13 -0
  23. package/example/NextJs/ape/onError.js +9 -0
  24. package/example/NextJs/ape/onReceive.js +15 -0
  25. package/example/NextJs/ape/onSend.js +15 -0
  26. package/example/NextJs/api/message.js +44 -0
  27. package/example/NextJs/docker-compose.yml +22 -0
  28. package/example/NextJs/next-env.d.ts +5 -0
  29. package/example/NextJs/next.config.js +8 -0
  30. package/example/NextJs/package-lock.json +5107 -0
  31. package/example/NextJs/package.json +25 -0
  32. package/example/NextJs/pages/_app.tsx +6 -0
  33. package/example/NextJs/pages/index.tsx +182 -0
  34. package/example/NextJs/public/favicon.ico +0 -0
  35. package/example/NextJs/public/vercel.svg +4 -0
  36. package/example/NextJs/server.js +40 -0
  37. package/example/NextJs/styles/Chat.module.css +194 -0
  38. package/example/NextJs/styles/Home.module.css +129 -0
  39. package/example/NextJs/styles/globals.css +26 -0
  40. package/example/NextJs/tsconfig.json +20 -0
  41. package/example/README.md +66 -0
  42. package/index.d.ts +179 -0
  43. package/index.js +11 -0
  44. package/package.json +11 -4
  45. package/server/README.md +93 -0
  46. package/server/index.js +6 -0
  47. package/server/lib/broadcast.js +63 -0
  48. package/server/lib/loader.js +10 -0
  49. package/server/lib/main.js +23 -0
  50. package/server/lib/wiring.js +94 -0
  51. package/server/security/extractRootDomain.js +21 -0
  52. package/server/security/origin.js +13 -0
  53. package/server/security/reply.js +21 -0
  54. package/server/socket/open.js +10 -0
  55. package/server/socket/receive.js +66 -0
  56. package/server/socket/send.js +55 -0
  57. package/server/utils/deepRequire.js +45 -0
  58. package/server/utils/genId.js +24 -0
  59. package/todo.md +85 -0
  60. package/utils/jss.js +273 -0
  61. package/utils/jss.test.js +261 -0
  62. package/utils/messageHash.js +43 -0
  63. package/utils/messageHash.test.js +56 -0
package/README.md ADDED
@@ -0,0 +1,261 @@
1
+ # 🦍 api-ape
2
+
3
+ **Remote Procedure Events (RPE)** — A lightweight WebSocket framework for real-time APIs.
4
+
5
+ Call server functions from the browser like local methods. Get real-time broadcasts with zero setup.
6
+
7
+ ```js
8
+ // Client: call server function, get result
9
+ const pets = await ape.pets.list()
10
+
11
+ // Client: listen for broadcasts
12
+ ape.on('newPet', ({ data }) => console.log('New pet:', data))
13
+ ```
14
+
15
+ file: api/pets/list.js
16
+ ```js
17
+ // Server: define function, broadcast to others
18
+ module.exports = function list() {
19
+ return getPetList()
20
+ }
21
+ ```
22
+
23
+ file: api/pets/newPet.js
24
+ ```js
25
+ // Server: define function, broadcast to others
26
+ module.exports = function newPet(data) {
27
+ // broadcast to all other clients
28
+ this.broadcastOthers('newPet', data)
29
+ return savePet(data)
30
+ }
31
+ ```
32
+
33
+ ---
34
+
35
+ ## Features
36
+
37
+ - **🔌 Auto-wiring** — Drop JS files in a folder, they become API endpoints
38
+ - **📡 Real-time** — Built-in broadcast to all or other clients
39
+ - **🔄 Reconnection** — Client auto-reconnects on disconnect
40
+ - **📦 JJS Encoding** — Supports Date, RegExp, Error, Set, Map, undefined over the wire
41
+ - **🎯 Simple API** — Promise-based calls with chainable paths
42
+
43
+ ---
44
+
45
+ ## Installation
46
+
47
+ ```bash
48
+ npm install api-ape
49
+ ```
50
+
51
+ ---
52
+
53
+ ## Quick Start
54
+
55
+ ### Server (Express.js)
56
+
57
+ ```js
58
+ const express = require('express')
59
+ const ape = require('api-ape')
60
+
61
+ const app = express()
62
+
63
+ // Wire up api-ape - loads controllers from ./api folder
64
+ ape(app, { where: 'api' })
65
+
66
+ app.listen(3000)
67
+ ```
68
+
69
+ ### Controllers
70
+
71
+ Create files in your `api/` folder. Each export becomes an endpoint:
72
+
73
+ ```js
74
+ // api/hello.js
75
+ module.exports = function(name) {
76
+ return `Hello, ${name}!`
77
+ }
78
+ ```
79
+
80
+ ```js
81
+ // api/message.js
82
+ module.exports = function(data) {
83
+ // Broadcast to all OTHER connected clients
84
+ this.broadcastOthers('message', data)
85
+ return data
86
+ }
87
+ ```
88
+
89
+ ### Client (Browser)
90
+
91
+ Include the bundled client:
92
+
93
+ ```html
94
+ <script src="/api/ape.js"></script>
95
+ <script>
96
+ // Call server functions
97
+ ape.hello('World').then(result => console.log(result)) // "Hello, World!"
98
+
99
+ // Listen for broadcasts
100
+ ape.on('message', ({ data }) => {
101
+ console.log('New message:', data)
102
+ })
103
+ </script>
104
+ ```
105
+
106
+ ---
107
+
108
+ ## API Reference
109
+
110
+ ### Server
111
+
112
+ #### `ape(app, options)`
113
+
114
+ Initialize api-ape on an Express app.
115
+
116
+ | Option | Type | Description |
117
+ |--------|------|-------------|
118
+ | `where` | `string` | Directory containing controller files |
119
+ | `onConnent` | `function` | Connection lifecycle hook (see below) |
120
+
121
+ #### Controller Context (`this`)
122
+
123
+ Inside controller functions, `this` provides:
124
+
125
+ | Property | Description |
126
+ |----------|-------------|
127
+ | `this.broadcast(type, data)` | Send to ALL connected clients |
128
+ | `this.broadcastOthers(type, data)` | Send to all EXCEPT the caller |
129
+ | `this.online()` | Get count of connected clients |
130
+ | `this.getClients()` | Get array of connected hostIds |
131
+ | `this.hostId` | Unique ID of the calling client |
132
+ | `this.req` | Original HTTP request |
133
+ | `this.socket` | WebSocket instance |
134
+ | `this.agent` | Parsed user-agent (browser, OS, device) |
135
+
136
+ #### Connection Lifecycle
137
+
138
+ ```js
139
+ ape(app, {
140
+ where: 'api',
141
+ onConnent(socket, req, send) {
142
+ return {
143
+ // Embed values into `this` for all controllers
144
+ embed: {
145
+ userId: req.session?.userId,
146
+ clientId: send + '' // hostId as string
147
+ },
148
+
149
+ // Before/after hooks
150
+ onReceive: (queryId, data, type) => {
151
+ console.log(`→ ${type}`)
152
+ return (err, result) => console.log(`← ${type}`, err || result)
153
+ },
154
+
155
+ onSend: (data, type) => {
156
+ console.log(`⇐ ${type}`)
157
+ return (err, result) => console.log(`Sent: ${type}`)
158
+ },
159
+
160
+ onError: (errStr) => console.error(errStr),
161
+ onDisconnent: () => console.log('Client left')
162
+ }
163
+ }
164
+ })
165
+ ```
166
+
167
+ ### Client
168
+
169
+ #### `ape.<path>.<method>(data)`
170
+
171
+ Call a server function. Returns a Promise.
172
+
173
+ ```js
174
+ // Calls api/users/list.js
175
+ ape.users.list().then(users => ...)
176
+
177
+ // Calls api/users/create.js with data
178
+ ape.users.create({ name: 'Alice' }).then(user => ...)
179
+
180
+ // Nested paths work too
181
+ ape.admin.users.delete(userId).then(() => ...)
182
+ ```
183
+
184
+ #### `ape.on(type, handler)`
185
+
186
+ Listen for server broadcasts.
187
+
188
+ ```js
189
+ ape.on('notification', ({ data, err, type }) => {
190
+ console.log('Received:', data)
191
+ })
192
+ ```
193
+
194
+ ---
195
+
196
+ ## JJS Encoding
197
+
198
+ api-ape uses **JJS (JSON SuperSet)** encoding, which extends JSON to support:
199
+
200
+ | Type | Supported |
201
+ |------|-----------|
202
+ | `Date` | ✅ Preserved as Date objects |
203
+ | `RegExp` | ✅ Preserved as RegExp |
204
+ | `Error` | ✅ Preserved with name, message, stack |
205
+ | `undefined` | ✅ Preserved (not converted to null) |
206
+ | `Set` | ✅ Preserved as Set |
207
+ | `Map` | ✅ Preserved as Map |
208
+ | Circular refs | ✅ Handled via pointers |
209
+
210
+ This is automatic — send a Date, receive a Date.
211
+
212
+ ---
213
+
214
+ ## Examples
215
+
216
+ See the [`example/`](./example) folder:
217
+
218
+ - **ExpressJs/** — Simple chat app with broadcast
219
+ - **NextJs/** — Integration with Next.js
220
+
221
+ Run the Express example:
222
+
223
+ ```bash
224
+ cd example/ExpressJs
225
+ npm install
226
+ npm start
227
+ ```
228
+
229
+ ---
230
+
231
+ ## Project Structure
232
+
233
+ ```
234
+ api-ape/
235
+ ├── client/
236
+ │ ├── browser.js # Browser entry point (window.ape)
237
+ │ └── connectSocket.js # WebSocket client with auto-reconnect
238
+ ├── server/
239
+ │ ├── lib/
240
+ │ │ ├── main.js # Express integration
241
+ │ │ ├── loader.js # Auto-loads controller files
242
+ │ │ ├── broadcast.js # Client tracking & broadcast
243
+ │ │ └── wiring.js # WebSocket handler setup
244
+ │ ├── socket/
245
+ │ │ ├── receive.js # Incoming message handler
246
+ │ │ └── send.js # Outgoing message handler
247
+ │ └── security/
248
+ │ └── reply.js # Duplicate request protection
249
+ ├── utils/
250
+ │ ├── jss.js # JSON SuperSet encoder/decoder
251
+ │ └── messageHash.js # Request deduplication
252
+ └── example/
253
+ ├── ExpressJs/ # Chat app example
254
+ └── NextJs/ # Next.js integration
255
+ ```
256
+
257
+ ---
258
+
259
+ ## License
260
+
261
+ MIT © [Brian Shannon](https://github.com/codemeasandwich)
@@ -0,0 +1,69 @@
1
+ # 🦍 api-ape Client
2
+
3
+ WebSocket client library with auto-reconnection and proxy-based API calls.
4
+
5
+ ## Files
6
+
7
+ | File | Description |
8
+ |------|-------------|
9
+ | `browser.js` | Browser entry point - exposes `window.ape` |
10
+ | `connectSocket.js` | WebSocket client with auto-reconnect, queuing, and JJS encoding |
11
+
12
+ ## Usage
13
+
14
+ ### Browser (via script tag)
15
+
16
+ ```html
17
+ <script src="/api/ape.js"></script>
18
+ <script>
19
+ // Call server functions
20
+ ape.hello('World').then(result => console.log(result))
21
+
22
+ // Listen for broadcasts
23
+ ape.on('message', ({ data }) => console.log(data))
24
+ </script>
25
+ ```
26
+
27
+ ### ES Module Import
28
+
29
+ ```bash
30
+ npm i api-ape
31
+ ```
32
+
33
+ ```js
34
+ import ape from 'api-ape'
35
+
36
+ // Configure
37
+ ape.configure({ port: 3000 })
38
+
39
+ // Connect and enable auto-reconnect
40
+ const { sender, setOnReciver } = ape()
41
+ ape.autoReconnect()
42
+
43
+ // Use sender as API
44
+ sender.users.list().then(users => ...)
45
+
46
+ // Listen for broadcasts
47
+ setOnReciver('newUser', ({ data }) => ...)
48
+ ```
49
+
50
+ ## Features
51
+
52
+ - **Proxy-based API** — `ape.path.method(data)` converts to WebSocket calls
53
+ - **Auto-reconnect** — Reconnects on disconnect with queued messages
54
+ - **Promise-based** — All calls return promises with matched responses via queryId
55
+ - **JJS encoding** — Supports Date, RegExp, Error, Set, Map, undefined over the wire
56
+ - **Request timeout** — Configurable timeout (default: 10s)
57
+
58
+ ## Configuration
59
+
60
+ ```js
61
+ ape.configure({
62
+ port: 3000, // WebSocket port
63
+ host: 'api.example.com' // WebSocket host
64
+ })
65
+ ```
66
+
67
+ Default port detection:
68
+ - Local (`localhost`, `127.0.0.1`): `9010`
69
+ - Remote: Uses current page port or `443`/`80`
@@ -0,0 +1,17 @@
1
+ import connectSocket from './connectSocket.js'
2
+
3
+ // Auto-configure for current page
4
+ const port = window.location.port || (window.location.protocol === 'https:' ? 443 : 80)
5
+ connectSocket.configure({ port: parseInt(port, 10) })
6
+
7
+ const { sender, setOnReciver } = connectSocket()
8
+ connectSocket.autoReconnect()
9
+
10
+ // Global API - use defineProperty to bypass Proxy interception
11
+ window.ape = sender
12
+ Object.defineProperty(window.ape, 'on', {
13
+ value: setOnReciver,
14
+ writable: false,
15
+ enumerable: false,
16
+ configurable: false
17
+ })
@@ -0,0 +1,260 @@
1
+ import messageHash from '../utils/messageHash'
2
+ import jss from '../utils/jss'
3
+
4
+
5
+ let connect;
6
+
7
+ // Configuration
8
+ let configuredPort = null
9
+ let configuredHost = null
10
+
11
+ /**
12
+ * Configure api-ape client connection
13
+ * @param {object} opts
14
+ * @param {number} [opts.port] - WebSocket port (default: 9010 for local, 443/80 for remote)
15
+ * @param {string} [opts.host] - WebSocket host (default: auto-detect from window.location)
16
+ */
17
+ function configure(opts = {}) {
18
+ if (opts.port) configuredPort = opts.port
19
+ if (opts.host) configuredHost = opts.host
20
+ }
21
+
22
+ /**
23
+ * Get WebSocket URL - auto-detects from window.location, keeps /api/ape path
24
+ */
25
+ function getSocketUrl() {
26
+ const hostname = configuredHost || window.location.hostname
27
+ const localServers = ["localhost", "127.0.0.1", "[::1]"]
28
+ const isLocal = localServers.includes(hostname)
29
+ const isHttps = window.location.protocol === "https:"
30
+
31
+ // Default port: 9010 for local dev, otherwise use window.location.port or implicit 443/80
32
+ const defaultPort = isLocal ? 9010 : (window.location.port || (isHttps ? 443 : 80))
33
+ const port = configuredPort || defaultPort
34
+
35
+ // Build URL - keep /api/ape path
36
+ const protocol = isHttps ? "wss" : "ws"
37
+ const portSuffix = (isLocal || port !== 80 && port !== 443) ? `:${port}` : ""
38
+
39
+ return `${protocol}://${hostname}${portSuffix}/api/ape`
40
+ }
41
+
42
+ let reconnect = false
43
+ const connentTimeout = 5000
44
+ const totalRequestTimeout = 10000
45
+ //const location = window.location
46
+
47
+ const joinKey = "/"
48
+ // Properties accessed directly on `ape` that should NOT be intercepted
49
+ const reservedKeys = new Set(['on'])
50
+ const handler = {
51
+ get(fn, key) {
52
+ // Skip proxy interception for reserved keys - return actual property
53
+ if (reservedKeys.has(key)) {
54
+ return fn[key]
55
+ }
56
+ const wrapperFn = function (a, b) {
57
+ let path = joinKey + key, body;
58
+ if (2 === arguments.length) {
59
+ path += a
60
+ body = b
61
+ } else {
62
+ body = a
63
+ }
64
+ return fn(path, body)
65
+ }
66
+ return new Proxy(wrapperFn, handler)
67
+ } // END get
68
+ }
69
+
70
+ function wrap(api) {
71
+ return new Proxy(api, handler)
72
+ }
73
+
74
+ let __socket = false, ready = false, wsSend = false;
75
+ const waitingOn = {};
76
+ const reciverOn = [];
77
+
78
+ let aWaitingSend = []
79
+ const reciverOnAr = [];
80
+ const ofTypesOb = {};
81
+
82
+ function connectSocket() {
83
+
84
+ if (!__socket) {
85
+ __socket = new WebSocket(getSocketUrl())
86
+
87
+ __socket.onopen = event => {
88
+ //console.log('socket connected()');
89
+ ready = true;
90
+ aWaitingSend.forEach(({ type, data, next, err, waiting, createdAt, timer }) => {
91
+ clearTimeout(timer)
92
+ //TODO: clear throw of wait for server
93
+ const resultPromise = wsSend(type, data, createdAt)
94
+ if (waiting) {
95
+ resultPromise.then(next)
96
+ .catch(err)
97
+ }
98
+ })
99
+ // cloudfler drops the connetion and the client has to remake,
100
+ // we clear the array as we dont need this info every RE-connent
101
+ aWaitingSend = []
102
+ } // END onopen
103
+
104
+ __socket.onmessage = function (event) {
105
+ //console.log('WebSocket message:', event);
106
+ const { err, type, queryId, data } = jss.parse(event.data)
107
+
108
+ // Messages with queryId must fulfill matching promise
109
+ if (queryId) {
110
+ if (waitingOn[queryId]) {
111
+ waitingOn[queryId](err, data)
112
+ delete waitingOn[queryId]
113
+ } else {
114
+ // No matching promise - error and ignore
115
+ console.error(`🦍 No matching queryId: ${queryId}`)
116
+ }
117
+ return
118
+ }
119
+
120
+ // Only messages WITHOUT queryId go to setOnReciver
121
+ if (ofTypesOb[type]) {
122
+ ofTypesOb[type].forEach(worker => worker({ err, type, data }))
123
+ } // if ofTypesOb[type]
124
+ reciverOnAr.forEach(worker => worker({ err, type, data }))
125
+
126
+ } // END onmessage
127
+
128
+ __socket.onerror = function (err) {
129
+ console.error('socket ERROR:', err);
130
+ } // END onerror
131
+
132
+ __socket.onclose = function (event) {
133
+ console.warn('socket disconnect:', event);
134
+ __socket = false
135
+ ready = false;
136
+ setTimeout(() => reconnect && connectSocket(), 500);
137
+ } // END onclose
138
+
139
+ } // END if ! __socket
140
+ wsSend = function (type, data, createdAt, dirctCall) {
141
+ let rej, promiseIsLive = false;
142
+ const timeLetForReqToBeMade = (createdAt + totalRequestTimeout) - Date.now()
143
+
144
+ const timer = setTimeout(() => {
145
+ if (promiseIsLive) {
146
+ rej(new Error("Request Timedout for :" + type))
147
+ }
148
+ }, timeLetForReqToBeMade);
149
+ const payload = {
150
+ type,
151
+ data,
152
+ //referer:window.location.href,
153
+ createdAt: new Date(createdAt),
154
+ requestedAt: dirctCall ? undefined
155
+ : new Date()
156
+ }
157
+ const message = jss.stringify(payload)
158
+ const queryId = messageHash(message);
159
+
160
+ const replyPromise = new Promise((resolve, reject) => {
161
+ rej = reject
162
+ waitingOn[queryId] = (err, result) => {
163
+ clearTimeout(timer)
164
+ replyPromise.then = next.bind(replyPromise)
165
+ if (err) {
166
+ reject(err)
167
+ } else {
168
+ resolve(result)
169
+ }
170
+ }
171
+ __socket.send(message);
172
+ });
173
+ const next = replyPromise.then;
174
+ replyPromise.then = worker => {
175
+ promiseIsLive = true;
176
+ replyPromise.then = next.bind(replyPromise)
177
+ replyPromise.catch = err.bind(replyPromise)
178
+ return next.call(replyPromise, worker)
179
+ }
180
+ const err = replyPromise.catch;
181
+ replyPromise.catch = worker => {
182
+ promiseIsLive = true;
183
+ replyPromise.catch = err.bind(replyPromise)
184
+ replyPromise.then = next.bind(replyPromise)
185
+ return err.call(replyPromise, worker)
186
+ }
187
+ return replyPromise
188
+ } // END wsSend
189
+
190
+
191
+ const sender = (type, data) => {
192
+ if ("string" !== typeof type) {
193
+ throw new Error("Missing Path vaule")
194
+ }
195
+
196
+ const createdAt = Date.now()
197
+
198
+ if (ready) {
199
+ return wsSend(type, data, createdAt, true)
200
+ }
201
+
202
+ const timeLetForReqToBeMade = (createdAt + connentTimeout) - Date.now() // 5sec for reconnent
203
+
204
+ const timer = setTimeout(() => {
205
+ const errMessage = "Request not sent for :" + type
206
+ if (payload.waiting) {
207
+ payload.err(new Error(errMessage))
208
+ } else {
209
+ throw new Error(errMessage)
210
+ }
211
+ }, timeLetForReqToBeMade);
212
+
213
+ const payload = { type, data, next: undefined, err: undefined, waiting: false, createdAt, timer };
214
+ const waitingOnOpen = new Promise((res, er) => { payload.next = res; payload.err = er; })
215
+
216
+ const waitingOnOpenThen = waitingOnOpen.then;
217
+ const waitingOnOpenCatch = waitingOnOpen.catch;
218
+ waitingOnOpen.then = worker => {
219
+ payload.waiting = true;
220
+ waitingOnOpen.then = waitingOnOpenThen.bind(waitingOnOpen)
221
+ waitingOnOpen.catch = waitingOnOpenCatch.bind(waitingOnOpen)
222
+ return waitingOnOpenThen.call(waitingOnOpen, worker)
223
+ }
224
+ waitingOnOpen.catch = worker => {
225
+ payload.waiting = true;
226
+ waitingOnOpen.catch = waitingOnOpenCatch.bind(waitingOnOpen)
227
+ waitingOnOpen.then = waitingOnOpenThen.bind(waitingOnOpen)
228
+ return waitingOnOpenCatch.call(waitingOnOpen, worker)
229
+ }
230
+
231
+ aWaitingSend.push(payload)
232
+ if (!__socket) {
233
+ connectSocket()
234
+ }
235
+
236
+ return waitingOnOpen
237
+ } // END sender
238
+
239
+ return {
240
+ sender: wrap(sender),
241
+ setOnReciver: (onTypeStFn, handlerFn) => {
242
+ if ("string" === typeof onTypeStFn) {
243
+ // Replace handler for this type (prevents duplicates in React StrictMode)
244
+ ofTypesOb[onTypeStFn] = [handlerFn]
245
+ } else {
246
+ // For general receivers, prevent duplicates by checking
247
+ if (!reciverOnAr.includes(onTypeStFn)) {
248
+ reciverOnAr.push(onTypeStFn)
249
+ }
250
+ }
251
+ } // END setOnReciver
252
+ } // END return
253
+ } // END connectSocket
254
+
255
+ connectSocket.autoReconnect = () => reconnect = true
256
+ connectSocket.configure = configure
257
+ connect = connectSocket
258
+
259
+ export default connect;
260
+ export { configure };