joinstick 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md ADDED
@@ -0,0 +1,374 @@
1
+ # Joinstick for coding agents
2
+
3
+ Joinstick turns phones into gamepads for browser games. The game page (the
4
+ **host**) shows a QR code; each player scans it and their phone becomes a
5
+ controller in the first free slot (per-slot QR codes exist too). Read this
6
+ file before integrating; it is the whole contract. Wire format: [PROTOCOL.md](PROTOCOL.md). Types:
7
+ [`public/host.d.ts`](public/host.d.ts), [`public/client.d.ts`](public/client.d.ts).
8
+
9
+ ## 1. Run the server
10
+
11
+ Node 22+:
12
+
13
+ ```sh
14
+ npx joinstick --static ./my-game # serves the game AND Joinstick on :3000
15
+ # or add it to your project: npm i joinstick, then npx joinstick --static ./my-game
16
+ ```
17
+
18
+ Open `http://localhost:3000/` on the computer. Phones must be on the same
19
+ Wi-Fi. Without `--static`, Joinstick runs alone and your game is served by
20
+ something else (see "Game served elsewhere" below).
21
+
22
+ ## 2. Integrate (copy-paste)
23
+
24
+ ```js
25
+ import { host } from '/joinstick/host.js';
26
+
27
+ const room = await host({ slots: 2, layout: { stick: 'dpad', buttons: [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }] } });
28
+ showQr(room.qr(), room.joinUrl()); // <img src=qr> + the URL as text; each phone gets the first free slot (room.qr(n): a fixed slot)
29
+
30
+ function tick() {
31
+ for (let n = 1; n <= room.slots; n++) {
32
+ const { x, y, buttons, pressed } = room.input(n); // poll once per frame per slot
33
+ // move with x/y (and held buttons); trigger one-shot actions with pressed.a
34
+ }
35
+ requestAnimationFrame(tick);
36
+ }
37
+ tick();
38
+ ```
39
+
40
+ Slots are **1-based** everywhere. `room.input(0)` throws `RangeError`.
41
+
42
+ ## 3. Pause when a player is missing
43
+
44
+ The simplest correct recipe is to check every frame:
45
+
46
+ ```js
47
+ const everyone = () => [1, 2].every((n) => room.connected(n));
48
+ // in your frame loop:
49
+ const paused = !everyone();
50
+ ```
51
+
52
+ With events, also check once at start, because pads that were already
53
+ connected when `host()` resolved do not get a `join` event (see below):
54
+
55
+ ```js
56
+ room.on('leave', ({ slot, reason }) => pause(`Waiting for player ${slot}`));
57
+ room.on('join', () => { if (everyone()) resume(); });
58
+ if (!everyone()) pause('Waiting for players');
59
+ ```
60
+
61
+ `leave` fires immediately when a phone is locked, backgrounded, loses Wi-Fi,
62
+ holds "Leave", or is kicked. The same phone rejoins the same slot by itself
63
+ when it comes back; a new person can also scan that slot's QR.
64
+
65
+ Exact ordering in each reconnect case:
66
+
67
+ | Case | What the game sees |
68
+ |---|---|
69
+ | Game page reloaded (same tab) | `host()` resumes the room. Pads already connected show as `connected(n) === true` **when `host()` resolves**; no `join` event for them. Pads that arrive later get `join`. Held input is restored. |
70
+ | Host loses the server briefly (Wi-Fi blip) | `leave` (reason `'disconnected'`) for every connected slot, then `join` with `rejoin: true` for each once back. |
71
+ | Server restarted | `leave` (`'disconnected'`) for every slot; the host recreates the same code; each phone rejoins with a `join` whose `rejoin` is **false** (the server forgot the tokens). |
72
+ | Phone locked / Wi-Fi lost, then back | `leave` (`'disconnected'`), later `join` with `rejoin: true`. |
73
+
74
+ ## 4. Input contract
75
+
76
+ `room.input(n)` returns `{ x, y, buttons, pressed }` and is **never undefined**.
77
+
78
+ | Field | Meaning |
79
+ |---|---|
80
+ | `x` | -1 left .. 1 right. The d-pad gives -1, 0 or 1. |
81
+ | `y` | -1 **up** .. 1 down (screen convention, like the Gamepad API). |
82
+ | `buttons` | Held state of every layout button: `{ a: false, b: true }`. |
83
+ | `pressed` | Buttons that went down since your previous `input(n)` call: `{ b: true }`. Latched and **cleared on read**, so a tap shorter than one frame is never lost and never counted twice. |
84
+
85
+ - Use `pressed` for one-shot actions (attack, jump, confirm, pause). **Never
86
+ use held state (`buttons`) to trigger attacks**: you would fire every frame
87
+ or miss fast taps.
88
+ - Use `buttons` for things that last while held (block, run, charge).
89
+ - Neutral (`x = y = 0`, all buttons false, `pressed` empty) when the slot is
90
+ empty and right after `leave`. After a `join` or a game reload, a direction
91
+ or button the player is already holding shows in `x`/`y`/`buttons` but never
92
+ in `pressed`.
93
+ - Read `input(n)` exactly once per frame per slot; a second read in the same
94
+ frame has an empty `pressed`.
95
+ - The d-pad is 8-way. Up and the up-diagonals need the thumb clearly above
96
+ horizontal (30° or more), so a resting thumb does not jump by accident.
97
+ - There is no input event; polling is the only way to read input.
98
+ - **Keep calling `input(n)` every frame while paused** and ignore the result.
99
+ Otherwise presses made during the pause are still latched and fire the
100
+ moment you resume.
101
+
102
+ ## 5. Layout
103
+
104
+ Passed to `host({ layout })`. Validated by the server; an invalid layout
105
+ makes `host()` reject with `code: 'bad-message'` and a message saying why.
106
+
107
+ ```js
108
+ {
109
+ stick: 'dpad', // 'dpad' (default) or 'none'
110
+ orientation: 'landscape', // 'landscape' (default), 'portrait' or 'any'
111
+ arrangement: 'grid', // 'grid' (default), 'diamond' or 'arc'
112
+ haptics: true, // tap feedback on the phone (default true)
113
+ buttons: [ // 0-8 buttons; default: [{id:'a'},{id:'b'}]
114
+ { id: 'punch', label: 'P', color: '#e5484d' },
115
+ { id: 'kick', label: 'K', haptic: 20 },
116
+ { id: 'super', label: 'SUPER', size: 'large' },
117
+ { id: 'block', label: 'BLOCK', size: 'wide' },
118
+ { id: 'start', label: 'START', system: true },
119
+ ],
120
+ }
121
+ ```
122
+
123
+ - `id`: `/^[A-Za-z0-9_-]{1,16}$/`, unique, and not an `Object.prototype`
124
+ name (`constructor`, `toString`, `__proto__`...). It is the key in `buttons`/`pressed`.
125
+ - `label`: ≤ 8 chars (defaults to the id); the pad shrinks long labels to fit
126
+ the round button, so short labels read best. `color`: `/^#[0-9a-f]{3,8}$/i`.
127
+ - `size`: `'large'` (bigger round button), `'wide'` (a long bar under the
128
+ others, easy to find blind and rest a thumb on: good for a held action) or omitted.
129
+ - `system: true`: a small pill in the middle of the pad's top bar, away from
130
+ the thumbs (START, MENU, PAUSE). It reports in `buttons`/`pressed` like any
131
+ other button. The 8-button limit counts it.
132
+ - `orientation`: with `'landscape'` (the default) a phone held upright shows
133
+ the pad rotated 90°, so the player turns the phone sideways; it works even
134
+ with the rotation lock on, and the d-pad ends up under the left thumb. On
135
+ Android Chrome the first tap also goes fullscreen and locks landscape.
136
+ `'portrait'` is the mirror case; `'any'` follows the phone.
137
+ - `arrangement` (gameplay buttons only; order matters):
138
+ - `'grid'`: rows and columns, in order.
139
+ - `'diamond'`: buttons 1-4 as face buttons (bottom, left, right, top), the
140
+ rest in a column beside them.
141
+ - `'arc'`: button 1 is big and sits where the right thumb rests; the rest
142
+ fan out around it (left, then over the top). Put the most used action first.
143
+ - Taps between buttons go to the nearest one: there are no dead gaps.
144
+
145
+ **Haptics.** The pad gives a short tick on every button press and d-pad
146
+ direction change (`haptics: false` turns it off; `haptic: ms` per button,
147
+ 0 = none). What actually works:
148
+
149
+ | Phone | Tap feedback | `room.pad(n, { vibrate })` and highlight buzz |
150
+ |---|---|---|
151
+ | Android Chrome/Firefox | yes (`navigator.vibrate`) | yes |
152
+ | iOS 18+ Safari | best effort: one fixed system tick per tap, via a hidden switch control; length is ignored | no |
153
+ | iOS ≤ 17, desktop | no (fails silently) | no |
154
+
155
+ ## 6. Change a pad while playing
156
+
157
+ ```js
158
+ room.pad(1, { title: 'Ryu', color: '#e5484d' }); // header text (≤ 20 chars) and accent color
159
+ room.pad(1, { highlight: ['a'] }); // glow and make the phone vibrate briefly (Android only)
160
+ room.pad(1, { disabled: ['super'] }); // greyed out, cannot be pressed
161
+ room.pad(1, { vibrate: 200 }); // one-shot, ms (0-2000), Android only
162
+ room.pad(1, { highlight: [], disabled: [] }); // clear
163
+ ```
164
+
165
+ Patches merge; the server remembers the last state per slot and re-sends it
166
+ when a phone (re)joins. A button that is both highlighted and disabled is
167
+ shown disabled, without glow (disabled wins).
168
+
169
+ A **game page reload** (any new `host()` call that resumes the room) resets
170
+ every pad to the default state, because the game starts from scratch. In-session
171
+ reconnects (Wi-Fi blips, server restarts) keep the pad state. Pads still
172
+ connected after a reload get no `join` event, so set their state at start too:
173
+
174
+ ```js
175
+ const greet = (slot) => room.pad(slot, { title: `Player ${slot}` });
176
+ room.on('join', ({ slot }) => greet(slot));
177
+ for (let n = 1; n <= room.slots; n++) if (room.connected(n)) greet(n);
178
+ ```
179
+
180
+ ## 7. Full host API
181
+
182
+ ```ts
183
+ const room = await host({ slots?: 1-8 = 2, layout?, server?: string });
184
+ room.code // 'KQZX' (4 consonants)
185
+ room.slots; room.status // 'connecting' | 'connected' | 'reconnecting' | 'closed'
186
+ room.input(n) // see section 4
187
+ room.connected(n) // boolean
188
+ room.qr(n?) // URL of an SVG QR code for <img src>; no n: first free slot
189
+ room.joinUrl(n?) // the URL inside the QR: show it as text too
190
+ room.pad(n, patch) // section 6
191
+ room.kick(n) // disconnect that phone; it gets a "removed" screen
192
+ room.send(n, data) // any JSON to one phone (custom controllers)
193
+ room.broadcast(data) // any JSON to every phone
194
+ room.on(event, fn) // returns an unsubscribe function
195
+ room.close() // leave and forget the room (refresh keeps it otherwise)
196
+ ```
197
+
198
+ Events: `join {slot, name, rejoin}`, `leave {slot, reason}` where reason is
199
+ `'left' | 'disconnected' | 'timeout' | 'kicked'`, `message {slot, data}`,
200
+ `status` (string), `error {code, message}` (server errors after `host()`
201
+ resolved).
202
+
203
+ `host()` rejects with an `Error` whose `code` is `'bad-message'` (invalid
204
+ options), `'server-full'` or `'version'`, or with "Cannot reach the Joinstick
205
+ server" when nothing answers. It never hangs on an error.
206
+
207
+ A page refresh or a server restart is transparent: the room code and hostToken
208
+ are kept in `sessionStorage`, the host reconnects with backoff and recreates
209
+ the same code, and phones rejoin. QR codes stay valid. A stored room is only
210
+ resumed with the same server URL and the same `slots`; otherwise `host()`
211
+ creates a new room.
212
+
213
+ **Duplicated tabs:** Chrome's "Duplicate tab" copies `sessionStorage`, so the
214
+ new tab resumes the same room and the original tab gets an `error` event with
215
+ `code: 'replaced'`, then `status: 'closed'` (it stops reconnecting). Show a
216
+ "this game is open in another tab" message on that event.
217
+
218
+ ## 8. CLI and embedding
219
+
220
+ ```
221
+ joinstick [--port 3000] [--static ./dir] [--public-url URL] [--client-ip-header NAME]
222
+ ```
223
+
224
+ | Flag | Env | Purpose |
225
+ |---|---|---|
226
+ | `--port` | `PORT` | Port (default 3000). |
227
+ | `--static <dir>` | | Serve your game from the same server (recommended). |
228
+ | `--public-url <url>` | `PUBLIC_URL` | Base URL phones should open (tunnels, Docker, reverse proxies). Must be an origin like `https://abc.example.com`, without a path: Joinstick's routes live at the root. |
229
+ | `--client-ip-header <name>` | `CLIENT_IP_HEADER` | Behind a reverse proxy: the header it sets to the visitor's address (`fly-client-ip`, `x-real-ip`; first value used). The 20-rooms and failed-join limits per address then use it. Never set it without a proxy that overwrites the header: clients can fake it. `attach()` option: `clientIpHeader`. |
230
+
231
+ The join URL base is chosen as: `--public-url`, else the `Host` header the
232
+ game used if it is not loopback, else the detected LAN IPv4 (192.168.x
233
+ preferred; Docker/VPN/virtual interfaces skipped). All candidates are printed
234
+ at startup.
235
+
236
+ `--static` refuses dotfiles (`.env`, `.git/...`) and reads each file whole,
237
+ without HTTP Range support: fine for game assets, but Safari needs Range
238
+ requests to play `<video>`/`<audio>`, so serve media elsewhere if you need it.
239
+
240
+ Embed in an existing Node server instead of using the CLI:
241
+
242
+ ```js
243
+ import http from 'node:http';
244
+ import { attach } from 'joinstick';
245
+ const server = http.createServer(app); // your handler keeps every other path
246
+ attach(server, { static: './dist', publicUrl: process.env.PUBLIC_URL });
247
+ server.listen(3000);
248
+ ```
249
+
250
+ Call `attach()` after every other `'request'` listener is registered: it wraps
251
+ the listeners that exist at that moment, and later ones would also answer
252
+ Joinstick's routes.
253
+
254
+ Append `?name=Ana` to a join URL to give the player a name (shown to the host
255
+ in the `join` event).
256
+
257
+ Routes: WebSocket `/joinstick/ws`; SDKs `/joinstick/host.js`,
258
+ `/joinstick/client.js` (CORS `*`); pad page `/j`, `/j/ROOM` (first free slot) and `/j/ROOM/SLOT`; QR
259
+ `/joinstick/qr.svg?room=&slot=` (existing rooms only, slot optional); `/llms.txt`.
260
+
261
+ ### Game served elsewhere (Vite, another port)
262
+
263
+ ```js
264
+ import { host } from 'joinstick/host'; // npm i joinstick (types included)
265
+ const room = await host({ slots: 2, server: `http://${location.hostname}:3000` });
266
+ ```
267
+
268
+ Pass `server` whenever host.js is bundled; by default it connects to the
269
+ origin it was loaded from. Or import it at runtime from
270
+ `http://localhost:3000/joinstick/host.js` (CORS is open).
271
+
272
+ ## 9. Custom controllers and remote play
273
+
274
+ `/joinstick/client.js` is what the default pad page is built on. Use it for a
275
+ custom phone UI or a remote client:
276
+
277
+ ```js
278
+ import { join } from '/joinstick/client.js';
279
+ const pad = await join({ room: 'KQZX', slot: 1, name: 'Ana' }); // slot omitted = first free
280
+ pad.setInput({ x: 0, y: -1, buttons: { a: true } }); // full state on every change
281
+ pad.send({ chat: 'gg' }); // host gets room.on('message')
282
+ pad.on('message', (data) => {}); // from room.send / broadcast
283
+ pad.on('pad', (state, patch) => {}); // title/color/highlight/disabled/vibrate
284
+ pad.on('error', (err) => {}); // kicked, room-closed, replaced...
285
+ pad.on('status', (s) => {});
286
+ pad.leave();
287
+ ```
288
+
289
+ `join()` rejects with `err.code` in `room-not-found`, `slot-taken` (with
290
+ `err.free`, the free slots), `bad-slot`, `version`, `unreachable`. Tokens are
291
+ kept in `localStorage` (`joinstick:ROOM:SLOT`) so the same browser rejoins the
292
+ same slot.
293
+
294
+ Remote players over the internet:
295
+
296
+ ```sh
297
+ cloudflared tunnel --url http://localhost:3000 # prints https://xyz.trycloudflare.com
298
+ node joinstick/src/cli.js --static ./my-game --public-url https://xyz.trycloudflare.com
299
+ ```
300
+
301
+ QR codes and `joinUrl()` then point at the tunnel; phones use `wss://` automatically.
302
+
303
+ Room codes are 4 letters (160,000 combinations), so on a public URL anyone
304
+ patient can guess one and take a free slot. That is fine for playing with
305
+ friends; there is no authentication beyond the code. The server caps rooms at
306
+ 1000 in total and 20 per client address (behind a tunnel all hosts share one
307
+ address).
308
+
309
+ ## 10. Error codes
310
+
311
+ | Code | Meaning |
312
+ |---|---|
313
+ | `room-not-found` | No room with that code (typo, or the host is gone). |
314
+ | `slot-taken` | Another phone holds that slot. Comes with `free: [..]`. |
315
+ | `bad-slot` | Slot is not an integer in 1..slots. |
316
+ | `bad-message` | Malformed or invalid message; `message` says why. Not fatal. |
317
+ | `replaced` | The same player (or host) connected again elsewhere; this connection is closed. |
318
+ | `kicked` | The host called `room.kick(n)`. |
319
+ | `room-closed` | The host was gone for 60 s; the room was deleted. |
320
+ | `version` | Protocol version mismatch. |
321
+ | `server-full` | 1000 rooms already exist, or this address already created 20. |
322
+ | `rate-limited` | 20 failed joins from this address in a minute (code guessing); wait a minute. |
323
+ | `unreachable` | Client SDK only: the server could not be reached. |
324
+
325
+ ## 11. Pitfalls
326
+
327
+ - **First run on macOS/Windows** shows a firewall prompt for Node. Allow it, or
328
+ phones cannot connect.
329
+ - **Guest / hotel / office Wi-Fi** often isolates clients (AP isolation):
330
+ phones cannot reach the laptop. Use a phone hotspot or `--public-url` with a tunnel.
331
+ - **Plain HTTP on a LAN** works, but phones get no wake lock and no fullscreen
332
+ on iOS. Host-driven `vibrate` only works on Android (see Haptics in section 5).
333
+ - **Mixed content:** a game page served over HTTPS cannot open `ws://` to a LAN
334
+ server. Serve the game with `--static`, or put both behind HTTPS (tunnel +
335
+ `--public-url`).
336
+ - **Docker** breaks LAN IP detection (you get the container IP). Pass
337
+ `--public-url http://<host-lan-ip>:3000`.
338
+ - Check the printed "Phones join at" URL: if it is not reachable from a phone,
339
+ nothing else will be.
340
+ - A pad in a **background tab** leaves its slot (that is the lock-screen
341
+ behavior). When testing with pads in the browser, keep them in the visible
342
+ game tab or a separate window, or use a Node script (next section).
343
+
344
+ ## 12. Testing without phones
345
+
346
+ Drive pads from code with the client SDK; it is how you verify an
347
+ integration end to end. In the game tab's DevTools console (the room code is
348
+ `room.code`; use the full `http://localhost:3000/joinstick/client.js` URL if
349
+ the game is served from another origin):
350
+
351
+ ```js
352
+ const { join } = await import('/joinstick/client.js');
353
+ const pad = await join({ room: 'KQZX', slot: 1 });
354
+ pad.setInput({ x: 1 }); // hold right
355
+ pad.setInput({ x: 1, buttons: { a: true } }); // press A...
356
+ pad.setInput({ x: 1, buttons: { a: false } }); // ...and release: pressed.a once
357
+ pad.leave(); // the game should pause
358
+ ```
359
+
360
+ Or from a Node 22+ script next to the checkout (`node pads.mjs KQZX`):
361
+
362
+ ```js
363
+ import { join } from './joinstick/public/client.js';
364
+ const pad = await join({ server: 'http://localhost:3000', room: process.argv[2], slot: 2 });
365
+ setInterval(() => pad.setInput({ x: Math.sign(Math.random() - 0.5) }), 500);
366
+ ```
367
+
368
+ ## 13. Don't
369
+
370
+ - Don't use `innerHTML` with player names, messages or anything from the
371
+ network. Use `textContent`.
372
+ - Don't trigger one-shot actions from held state; use `pressed`.
373
+ - Don't call `input(n)` more than once per frame per slot (it consumes `pressed`).
374
+ - Don't assume a slot is connected; check `room.connected(n)` or listen to `leave`.
package/CHANGELOG.md ADDED
@@ -0,0 +1,64 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html). While the version
6
+ is 0.x, a minor release may change the API; the changelog says how.
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.2.0] - 2026-10-16
11
+
12
+ ### Added
13
+
14
+ - One QR for everyone: `room.qr()` and `room.joinUrl()` without a slot give a
15
+ link that puts each phone in the first free slot (`/j/ROOM`);
16
+ `/joinstick/qr.svg` accepts a missing `slot`.
17
+ - `examples/paint-party`: a party game for 1-8 phones.
18
+ - `--client-ip-header <name>` (`CLIENT_IP_HEADER`, `attach()` option
19
+ `clientIpHeader`): behind a reverse proxy, the per-address room cap and
20
+ failed-join limit use the header the proxy sets instead of the proxy's
21
+ address. Off by default, since clients can send the header themselves.
22
+
23
+ ### Changed
24
+
25
+ - The player number on the pad's join form is optional; blank takes the first
26
+ free slot.
27
+ - The pad's top bar is tinted and underlined in the pad color, so players can
28
+ match their phone to the screen at a glance.
29
+
30
+ ### Fixed
31
+
32
+ - `--static` redirects a directory requested without its trailing slash
33
+ (`/demo/game` -> `/demo/game/`, query kept) instead of answering 404.
34
+
35
+ ## [0.1.0] - 2026-10-04
36
+
37
+ First public release.
38
+
39
+ ### Added
40
+
41
+ - `joinstick` CLI: one Node server for rooms, the phone pad page at `/j`, the
42
+ browser SDKs at `/joinstick/*`, QR codes and `/llms.txt`; `--static` serves
43
+ your game on the same port, `--public-url` sets the address in QR codes.
44
+ - `attach(server, opts)` to embed Joinstick in an existing Node HTTP server.
45
+ - Game SDK (`host.js`): rooms with up to 8 slots, per-frame `input(n)` with
46
+ held `buttons` and one-shot `pressed`, join/leave events, QR and join
47
+ URLs, live pad changes (title, color, highlight, disabled, vibrate),
48
+ messages both ways.
49
+ - Phone SDK (`client.js`) for custom controllers and scripted test pads.
50
+ - Pad page: d-pad and up to 8 buttons; `grid`, `diamond` and `arc`
51
+ arrangements; `large` and `wide` buttons; `system` buttons (START/MENU) in
52
+ the top bar; landscape by default with CSS rotation when the phone is held
53
+ upright; tap haptics (Android, best effort on iOS 18+); multi-touch.
54
+ - Reconnection: phones that lock or drop Wi-Fi rejoin their slot; game page
55
+ reloads and server restarts keep the room code (AGENTS.md has the details).
56
+ - Room safety: 20 failed joins per minute per client address, then
57
+ `rate-limited`; per-address room cap; 4-letter room codes.
58
+ - `--version` flag.
59
+ - JSON/WebSocket protocol v1 (PROTOCOL.md), AGENTS.md for coding agents,
60
+ TypeScript declarations, Dockerfile.
61
+
62
+ [Unreleased]: https://github.com/raprav/joinstick/compare/v0.2.0...HEAD
63
+ [0.2.0]: https://github.com/raprav/joinstick/compare/v0.1.0...v0.2.0
64
+ [0.1.0]: https://github.com/raprav/joinstick/releases/tag/v0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rafa Prats
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.