mithril-lynx 0.0.8 → 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 (61) hide show
  1. package/.omo/plans/m-request-fetch-lynx.md +306 -0
  2. package/.omo/plans/m-route-en-memoria.md +397 -0
  3. package/.omo/plans/mithril-lynx-v2-desde-cero.md +548 -0
  4. package/FETCH_INVESTIGATION.md +307 -0
  5. package/README.md +32 -284
  6. package/REQUEST.md +71 -0
  7. package/ROUTE.md +71 -0
  8. package/package.json +24 -80
  9. package/plugin.d.ts +4 -27
  10. package/plugin.js +142 -359
  11. package/rstest.config.ts +27 -0
  12. package/src/apply-patch.js +179 -0
  13. package/src/backends/virtual-backend.js +80 -0
  14. package/src/background.d.ts +11 -0
  15. package/src/background.js +79 -0
  16. package/src/channel.js +41 -0
  17. package/src/commit.js +67 -0
  18. package/src/dev-reload-client.js +245 -0
  19. package/src/dev-transport-noop.js +10 -0
  20. package/src/fake-dom.js +374 -0
  21. package/src/main-thread.d.ts +1 -0
  22. package/src/main-thread.js +68 -0
  23. package/src/mount-redraw.js +67 -0
  24. package/src/patch-protocol.js +40 -0
  25. package/src/reload/version.js +28 -0
  26. package/src/request.d.ts +37 -0
  27. package/src/request.js +181 -0
  28. package/src/route.d.ts +33 -0
  29. package/src/route.js +207 -0
  30. package/test/end-to-end.test.ts +86 -0
  31. package/test/reload-version.test.ts +17 -0
  32. package/test/request.test.ts +182 -0
  33. package/test/route-hot-reload.test.ts +40 -0
  34. package/test/route.test.ts +152 -0
  35. package/test/setup.ts +25 -0
  36. package/test/structural-reload.test.ts +95 -0
  37. package/CONTRACT.md +0 -151
  38. package/LICENSE +0 -21
  39. package/background.d.ts +0 -54
  40. package/background.js +0 -169
  41. package/element.d.ts +0 -34
  42. package/element.js +0 -83
  43. package/gesture.d.ts +0 -40
  44. package/gesture.js +0 -117
  45. package/internal/constants.js +0 -26
  46. package/internal/virtual-node.js +0 -388
  47. package/list.d.ts +0 -31
  48. package/list.js +0 -185
  49. package/main-thread.d.ts +0 -43
  50. package/main-thread.js +0 -165
  51. package/navigation.d.ts +0 -35
  52. package/navigation.js +0 -76
  53. package/renderer/background.d.ts +0 -21
  54. package/renderer/background.js +0 -84
  55. package/renderer/main-thread.d.ts +0 -12
  56. package/renderer/main-thread.js +0 -175
  57. package/src/lynx-mithril-shim.d.ts +0 -16
  58. package/src/lynx-mithril-shim.js +0 -1505
  59. package/src/worklet-runtime.js +0 -82
  60. package/testing.d.ts +0 -10
  61. package/testing.js +0 -91
@@ -0,0 +1,307 @@
1
+ # `m.request` vs el `fetch` de Lynx — investigación completa
2
+
3
+ Documento de referencia, no un plan — el plan que consumió esta
4
+ investigación (con su narrativa de spikes y decisiones) está en
5
+ [`.omo/plans/m-request-fetch-lynx.md`](.omo/plans/m-request-fetch-lynx.md).
6
+ Este archivo es el consolidado técnico: qué es cada API, qué dice cada
7
+ fuente, y qué se confirmó de verdad en un device real — para consultar
8
+ sin tener que reconstruir la investigación de nuevo.
9
+
10
+ **Metodología**: tres fuentes independientes, en orden de confiabilidad
11
+ creciente — (1) prosa de documentación oficial, (2) `.d.ts` reales
12
+ instalados de `@lynx-js/types` (evidencia de tipos), (3) **pruebas
13
+ ejecutadas en un device Android real** (`adb`, `agent-lynx evaluate`
14
+ contra el hilo background de una app Lynx corriendo). Las fuentes 1 y 2
15
+ se equivocaron dos veces cada una — ver §4 — así que ninguna afirmación
16
+ de este documento se da por buena solo por estar documentada o tipada:
17
+ donde hay una fila marcada "confirmado en device", es porque se ejecutó
18
+ código real y se leyó el resultado real.
19
+
20
+ ---
21
+
22
+ ## 1. El spec: `m.request` real
23
+
24
+ Fuente: [`mithril.js.org/request.html`](https://mithril.js.org/request.html)
25
+ + el código fuente real, `node_modules/mithril/request/request.js`
26
+ (mithril 2.3.8, 199 líneas — el mismo checkout que usa `mithril-runtime`).
27
+
28
+ ```js
29
+ promise = m.request(options)
30
+ promise = m.request(url, options)
31
+ ```
32
+
33
+ | Opción | Tipo | Qué hace |
34
+ |---|---|---|
35
+ | `method` | string | GET por defecto |
36
+ | `url` | string | soporta interpolación `:param` |
37
+ | `params` | object | interpola en la URL y/o query string |
38
+ | `body` | object/FormData/URLSearchParams | serializado al request body |
39
+ | `async` | boolean | `xhr.open(..., async, ...)` — default `true` |
40
+ | `user`/`password` | string | HTTP Basic Auth vía `xhr.open(...)` |
41
+ | `withCredentials` | boolean | cookies cross-origin |
42
+ | `timeout` | number | ms, aborta la conexión real vía `xhr.timeout` |
43
+ | `responseType` | string | default `"json"` |
44
+ | `headers` | object | por header, `xhr.setRequestHeader` |
45
+ | `serialize`/`deserialize` | function | por default JSON |
46
+ | `type` | function | constructor aplicado al resultado |
47
+ | `extract` | function | `(xhr, options) => any`, se salta deserialize |
48
+ | `config` | function | `(xhr, options, url) => xhr\|void` — mutar el XHR vivo antes de enviarlo |
49
+ | `background` | boolean | si `true`, no dispara redraw al completar |
50
+
51
+ **El mecanismo real es `XMLHttpRequest`, no `fetch`.** Esto es textual en
52
+ la doc, y se confirma leyendo el código: `xhr.open`, `xhr.send`,
53
+ `xhr.abort`, `xhr.responseType`, `xhr.withCredentials`, `xhr.timeout`,
54
+ `xhr.onreadystatechange`. El error resultante trae `error.code` (status
55
+ HTTP), `error.message` (texto de la respuesta), `error.response`
56
+ (cuerpo ya parseado).
57
+
58
+ ---
59
+
60
+ ## 2. El `fetch` de Lynx
61
+
62
+ Fuentes: doc oficial
63
+ [`lynxjs.org/api/lynx-api/global/fetch.html`](https://lynxjs.org/api/lynx-api/global/fetch.html)
64
+ y los `.d.ts` reales instalados en
65
+ `@lynx-js/types/types/background-thread/fetch.d.ts` +
66
+ `.../lynx.d.ts`.
67
+
68
+ ### 2.1 Dónde vive
69
+
70
+ `fetch` está declarado como método del objeto `Lynx` dentro de
71
+ `types/background-thread/` — **solo existe en el hilo background**, no
72
+ en `common/` ni `main-thread/`. Encaja con la arquitectura entera de
73
+ `mithril-lynx-v2` (toda la vista corre en background) — no hace falta
74
+ ningún puente cross-thread para esto.
75
+
76
+ ### 2.2 La firma real, completa, sin recortar
77
+
78
+ ```ts
79
+ export interface RequestInit {
80
+ body?: BodyInit | null;
81
+ headers?: HeadersInit;
82
+ method?: string;
83
+ lynxExtension?: { useStreaming?: boolean };
84
+ }
85
+ ```
86
+
87
+ Eso es todo el `RequestInit`. El comentario del propio archivo de tipos
88
+ dice, textual: **`"@description subset of Fetch API"`** — es la fuente
89
+ oficial de Lynx reconociendo que es un subconjunto, no una afirmación
90
+ mía.
91
+
92
+ Comparado con el `RequestInit` real de un navegador (que además tiene
93
+ `mode`, `credentials`, `cache`, `redirect`, `referrer`, `referrerPolicy`,
94
+ `integrity`, `keepalive`, `signal`, `window`) — Lynx implementa 3 campos
95
+ más una extensión propia.
96
+
97
+ `Body` (que `Request`/`Response` extienden): `arrayBuffer()`, `json()`,
98
+ `text()`. **Sin `.blob()`.**
99
+
100
+ ### 2.3 Lo que la doc dice en texto plano
101
+
102
+ > "Lynx does not support Web-only features like: CORS, redirect,
103
+ > keepalive related APIs. FormData/Blob related APIs are not supported."
104
+
105
+ ---
106
+
107
+ ## 3. Comparación completa, opción por opción
108
+
109
+ | Opción de `m.request` | ¿Se puede replicar sobre `lynx.fetch`? | Evidencia |
110
+ |---|---|---|
111
+ | `method`, `url`, `params` | **Sí** | `fetch(url, {method})`; interpolación reusa `mithril-runtime/pathname/build.js` |
112
+ | `body` (objeto → JSON) | **Sí** | `body: JSON.stringify(body)` |
113
+ | `body` (`URLSearchParams`) | **Sí** | Confirmado en device: `Content-Type: application/x-www-form-urlencoded` automático, campos bien parseados del otro lado |
114
+ | `body` (`FormData`) | **No** | Confirmado en device: `typeof FormData === "undefined"` |
115
+ | `headers` (escribir) | **Sí** | `new Headers({...})` o objeto plano, ambos llegan bien al servidor (confirmado en device contra `httpbin.org/headers`) |
116
+ | `headers` (leer con `.get()`/`.has()`) | **Parcial** | Confirmado en device: **case-sensitive**, no case-insensitive como el spec real (`h.set("X-Test",...); h.get("x-test")` → `null`) |
117
+ | `responseType: "json"/"text"` | **Sí** | `response.json()` / `.text()` |
118
+ | `responseType: "blob"` | **No** | Sin `.blob()` en `Body` (tipos) |
119
+ | `deserialize` | **Sí** | misma función sobre el resultado de `.json()`/`.text()` |
120
+ | `extract` | **Parcial** | firma cambia: `(response, options)` en vez de `(xhr, options)` — el propósito se preserva, el código portado literal no |
121
+ | `type` | **Sí** | sin cambios |
122
+ | `background` | **Sí** | lógica pura, no toca fetch/XHR para nada |
123
+ | Forma del error | **Sí** | `response.status`, `.statusText`, body ya extraído |
124
+ | `config(xhr)` | **No** | sin equivalente — `fetch` no da un objeto vivo para mutar a mitad de vuelo |
125
+ | `timeout` (corte real) | **Sí** | confirmado en device: `AbortController` + `lynx.setTimeout` corta la conexión real (ver §4.4) |
126
+ | Cancelación (`.abort()`) | **Sí** | `AbortController`/`AbortSignal` existen y funcionan pese a no estar tipados |
127
+ | Redirects (3xx) | **Sí, transparente** | confirmado en device: sigue el redirect solo, como un browser real |
128
+ | `response.url`/`.redirected` tras un redirect | **No confiable** | confirmado en device: quedan con la URL/estado de ANTES del redirect |
129
+ | `withCredentials` | **No aplica** | Lynx no tiene modelo de origen/CORS |
130
+ | `user`/`password` (Basic Auth) | **No hay equivalente directo** | requeriría armar el header a mano — y sin `btoa` (confirmado ausente), sin una implementación propia de base64 |
131
+ | `async: false` (modo síncrono) | **Imposible** | no existe un fetch síncrono en ningún entorno |
132
+
133
+ ---
134
+
135
+ ## 4. Evidencia de device — el detalle de cada corrida
136
+
137
+ Device: `adb R8YYC0VV0PV` (Samsung SM-A075M), sesión de
138
+ `mithril-lynx-v2-app` vía `agent-lynx evaluate` (background thread).
139
+ Fecha: 2026-09-18.
140
+
141
+ **Nota de herramienta**: `agent-lynx evaluate` espera una única
142
+ EXPRESIÓN — cualquier cosa con `;` a nivel superior tira
143
+ `SyntaxError: expecting ')'` desde el wrapper interno de la herramienta,
144
+ no del código evaluado. Solución: envolver todo en un IIFE
145
+ `(function(){ ...; return valor; })()`.
146
+
147
+ ### 4.1 Primitivas: existen o no
148
+
149
+ ```js
150
+ JSON.stringify({
151
+ Headers: typeof Headers, AbortController: typeof AbortController,
152
+ AbortSignal: typeof AbortSignal, btoa: typeof btoa, atob: typeof atob,
153
+ fetch: typeof fetch, Request: typeof Request, Response: typeof Response,
154
+ })
155
+ // → {"Headers":"function","AbortController":"function","AbortSignal":"function",
156
+ // "btoa":"undefined","atob":"undefined","fetch":"undefined",
157
+ // "Request":"function","Response":"function"}
158
+ ```
159
+
160
+ ```js
161
+ JSON.stringify({FormData: typeof FormData, Blob: typeof Blob, URLSearchParams: typeof URLSearchParams})
162
+ // → {"FormData":"undefined","Blob":"undefined","URLSearchParams":"function"}
163
+ ```
164
+
165
+ ```js
166
+ JSON.stringify({lynxFetch: typeof lynx.fetch, globalThisFetch: typeof globalThis.fetch})
167
+ // → {"lynxFetch":"function","globalThisFetch":"undefined"}
168
+ ```
169
+
170
+ **Conclusión**: `Headers`, `AbortController`, `AbortSignal`,
171
+ `URLSearchParams`, `Request`, `Response` existen. `btoa`, `atob`,
172
+ `FormData`, `Blob`, y el `fetch` global (sin `lynx.`) NO existen. Hay que
173
+ llamar siempre `lynx.fetch(...)`.
174
+
175
+ ### 4.2 Redirect (3xx)
176
+
177
+ ```js
178
+ lynx.fetch("https://httpbin.org/redirect-to?url=https://example.com")
179
+ // status: 200, ok: true, body: el HTML real de example.com
180
+ // response.url: sigue siendo la URL de httpbin (la ORIGINAL, no la final)
181
+ // response.redirected: undefined
182
+ ```
183
+
184
+ El redirect se sigue solo, transparente. Los metadatos sobre el redirect
185
+ (`url`, `redirected`) no reflejan la realidad.
186
+
187
+ ### 4.3 Headers: escribir funciona, leer con case distinta no
188
+
189
+ ```js
190
+ var h = new Headers();
191
+ h.set("X-Test", "abc");
192
+ h.get("X-Test") // "abc"
193
+ h.get("x-test") // null <-- debería ser "abc", Headers es case-insensitive en el spec real
194
+ h.has("x-test") // false
195
+ ```
196
+
197
+ Enviar headers a un servidor real (`httpbin.org/headers`) funciona
198
+ perfecto — el servidor recibe la clave y el valor correctos. El problema
199
+ es específico de leer de vuelta una instancia de `Headers` con una
200
+ capitalización de clave distinta a la usada para escribirla.
201
+
202
+ ### 4.4 `AbortController`: existe y cancela de verdad
203
+
204
+ Abort inmediato:
205
+ ```js
206
+ var ctrl = new AbortController();
207
+ lynx.fetch(url, {signal: ctrl.signal}).catch(e => ...); // e.name === "AbortError"
208
+ ctrl.abort();
209
+ // → {name: "AbortError", message: "This operation was aborted"}
210
+ ```
211
+
212
+ Abort a mitad de vuelo (la prueba que de verdad importa — que la
213
+ conexión se corte, no que la promesa simplemente deje de esperar):
214
+ ```js
215
+ var ctrl = new AbortController();
216
+ var t0 = Date.now();
217
+ lynx.fetch("https://httpbin.org/delay/5", {signal: ctrl.signal})
218
+ .catch(e => { /* elapsedMs = Date.now() - t0 */ });
219
+ lynx.setTimeout(() => ctrl.abort(), 800);
220
+ // → rechazó a los 805ms, NO a los 5000ms del delay real del servidor
221
+ ```
222
+
223
+ **La conexión se cortó de verdad** — no fue una promesa que se rindió
224
+ mientras la red seguía trabajando de fondo.
225
+
226
+ ### 4.5 `URLSearchParams` como body
227
+
228
+ ```js
229
+ lynx.fetch("https://httpbin.org/post", {
230
+ method: "POST",
231
+ body: new URLSearchParams({foo: "bar", baz: "42"}),
232
+ })
233
+ // el servidor recibió: form: {foo:"bar", baz:"42"}
234
+ // Content-Type enviado: application/x-www-form-urlencoded;charset=UTF-8 (automático)
235
+ ```
236
+
237
+ Funciona exactamente como en un browser real.
238
+
239
+ ### 4.6 `lynx.setTimeout`/`requestAnimationFrame` NO esperan a que drene la cola de microtasks
240
+
241
+ Este no es un hallazgo de `fetch` en sí, sino algo descubierto implementando
242
+ el redraw automático de `request.js` (`src/mount-redraw.js`) — documentado
243
+ acá porque cualquier wrapper sobre una promesa de `lynx.fetch` que dispare
244
+ un timer choca con lo mismo.
245
+
246
+ En un motor JS spec-compliant, un macrotask (`setTimeout`, `requestAnimationFrame`)
247
+ SIEMPRE corre después de que la cola de microtasks actual drena por
248
+ completo, sin importar cuántos `.then()` encadenados haya. En este runtime
249
+ de Lynx (hilo background) eso NO se cumple:
250
+
251
+ ```js
252
+ Promise.resolve()
253
+ .then(() => console.log("microtask 1"))
254
+ .then(() => console.log("microtask 2"))
255
+ .then(() => console.log("microtask 3"));
256
+ lynx.setTimeout(() => console.log("timeout"), 0);
257
+ // orden real observado: timeout, microtask 1, microtask 2, microtask 3
258
+ ```
259
+
260
+ Confirmado con delays de 0, 1, 4 y 16ms — todos perdieron la carrera contra
261
+ el primer microtask. `lynx.requestAnimationFrame` tiene el mismo problema
262
+ (mismo test, mismo resultado: `raf-fired` antes que `microtask 1`).
263
+
264
+ **Por qué importa**: `request()` resuelve internamente (`bodyPromise.then(...)`)
265
+ y llama `sharedRedraw()` en su propio `.then(onSuccess)` — que corre UN
266
+ microtask antes que el `.then()` que el caller encadena sobre la promesa
267
+ que `request()` le devuelve. Si el redraw se dispara síncrono (o vía un
268
+ timer de 0-16ms), el render ocurre ANTES de que el caller haya guardado la
269
+ respuesta en su propio estado — la UI queda congelada mostrando el estado
270
+ "loading" para siempre, sin ningún error en consola. Confirmado en device
271
+ con la demo `fetch-demo.ts`: `status` se actualizaba a `"done"` en memoria
272
+ (logueado) pero la pantalla seguía mostrando `"loading"` indefinidamente.
273
+
274
+ **Mitigación aplicada**: `mount-redraw.js` usa un delay de 50ms — el primer
275
+ valor que ganó la carrera de forma repetible contra el caso realista (un
276
+ solo `.then()` encadenado). No es una garantía formal como la de un
277
+ macrotask real, es un margen empírico. Documentado acá en vez de
278
+ escondido, siguiendo el mismo criterio del resto de este archivo.
279
+
280
+ ---
281
+
282
+ ## 5. Veredicto
283
+
284
+ **El gap es real pero acotado, y más chico de lo que sugerían los tipos
285
+ instalados.** Los puntos genuinamente irreparables son pocos y bien
286
+ delimitados: el hook `config(xhr)` (ningún equivalente posible con
287
+ `fetch`), `FormData`/`Blob`, auth básica inline (`user`/`password`, sin
288
+ `btoa`), y el modo síncrono (`async: false`). Todo lo demás — incluida
289
+ cancelación y timeout reales, que en la primera pasada de esta
290
+ investigación parecían imposibles — funciona.
291
+
292
+ **Recomendación**: implementar el subconjunto confirmado como
293
+ `mithril-lynx-v2/request`, documentando explícitamente (no escondiendo)
294
+ los 4-5 puntos sin equivalente, con una sugerencia directa de usar
295
+ `lynx.fetch` nativo para esos casos puntuales.
296
+
297
+ ---
298
+
299
+ ## 6. Referencias
300
+
301
+ - Spec real: <https://mithril.js.org/request.html>
302
+ - `fetch` de Lynx (doc): <https://lynxjs.org/api/lynx-api/global/fetch.html>
303
+ - Tipos reales: `@lynx-js/types/types/background-thread/{fetch,lynx}.d.ts`
304
+ - Código fuente real de `m.request`: `node_modules/mithril/request/request.js`
305
+ - Plan que usa esta investigación: [`.omo/plans/m-request-fetch-lynx.md`](.omo/plans/m-request-fetch-lynx.md)
306
+ - Implementación resultante: `src/request.js` (ver ese archivo para el
307
+ estado actual de qué de esta tabla ya está construido)