Commit Graph
3 Commits
Author SHA1 Message Date
wyucand杨慎 1498ca52b2 fix(persistence): stop corrupting binary response bodies in the route adapter (#1088)
* fix(persistence): stop corrupting binary response bodies in the route adapter

The embedded persistence route bridges a Node `RequestListener` to the
Fetch API by hand. Its response object is cast with `as unknown as
ServerResponse`, so the compiler checks none of that surface, and two
parts of it were wrong in ways only a byte-carrying handler would hit.

`end` decoded any `Uint8Array` chunk with `Buffer.from(chunk).toString()`,
which is UTF-8. `ServerResponse.end` accepts a `Uint8Array`, and those
bytes are not necessarily valid UTF-8, so every unpaired byte became
U+FFFD -- silent corruption with no error anywhere. The adapter now
buffers chunks as bytes and builds the response from them.

`write` was absent entirely, making any chunked handler a runtime
TypeError rather than a compile error. It is part of the surface this
object claims to implement, so it is now implemented.

Neither reaches a caller today: the document and runtime handlers only
ever end with JSON strings, which took the string branch. Both are traps
laid for the first handler that carries bytes.

Both cases are pinned by tests that fail before this change -- the
binary one on a byte comparison, the chunked one with a 500 from the
TypeError. The existing adapter round-trip test, whose comment already
called this the most bug-prone code in the route, covered only the
string path.

* fix(persistence): make the route adapter faithful where it carries bytes

The embedded persistence route bridges a Node `RequestListener` to the
Fetch API by hand, and its response object is cast
`as unknown as ServerResponse`, so the compiler checks none of that
surface. Five divergences are closed here; the first two are the ones
that corrupt data, the last three were raised in review.

**`end` decoded bytes as text.** It ran `Buffer.from(chunk).toString()`
on a `Uint8Array` chunk, which is UTF-8, so every byte outside that
encoding became U+FFFD -- silent corruption with no error anywhere. The
adapter now buffers chunks as bytes.

**`write` was absent**, making any chunked handler a runtime `TypeError`
rather than a compile error.

**`write` callbacks ran inline.** Node invokes them after the chunk is
handed off. A handler that writes its next chunk from each callback
therefore recursed synchronously and could overflow the stack, and a
throwing callback threw in the wrong execution phase. They are now
deferred.

**The encoding argument was ignored.** `write(s, 'latin1')` emitted
`0xc3 0xa9` where Node emits `0xe9`, corrupting the body and potentially
invalidating a `Content-Length` the handler had set. Both `write` and
`end` now parse their full overload set and pass the encoding through.
Node's own behaviour for an invalid encoding was established by probing
`ServerResponse` on Node 22 rather than assumed: it throws
`ERR_UNKNOWN_ENCODING` synchronously, so the adapter validates with
`Buffer.isEncoding` and lets `Buffer.from` raise the native error.

**Bodyless statuses were not suppressed.** Node discards writes after
`writeHead(204)` or `writeHead(304)`; this passed the buffered bytes to
the Fetch `Response` constructor, which throws for those statuses, so the
request became a 500 instead of the intended 204 or 304. Buffered data is
now discarded for 204, 205 and 304, and for `HEAD`.

None of the five reaches a caller today -- the document and runtime
handlers only ever end with JSON strings. They are traps laid for the
first handler that carries bytes, which is why they surfaced while
wiring the asset server backend, whose byte routes hit the first two
immediately.

Each is pinned by a test that fails when its fix is reverted.

---------

Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-08-10 16:14:42 +08:00
fc66cc0603 feat(runtime): add learner whiteboard RuntimeStore foundation (#1075)
* feat(runtime): add whiteboard RuntimeStore foundation

* Potential fix for pull request finding 'Comparison between inconvertible types'

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>

* fix(runtime): reject invalid whiteboard appends before commit

* fix(runtime): preserve special JSON keys during canonical clone

* Potential fix for pull request finding 'Invalid prototype value'

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>

* feat(runtime): add minimal learner whiteboard mutation

---------

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-08-09 12:32:28 -04:00
wyucandCodex e39c64cd9e feat(persistence): one-command server-backed stack (compose profile + embedded API + docs) (#982)
* feat(persistence): one-command server-backed stack — embedded API + compose postgres profile

docker compose --profile server-persistence up --build runs exactly two
containers: the app (persistence HTTP server embedded at /api/persistence,
enabled only when DATABASE_URL is set — unset keeps today's browser-only
behavior byte-for-byte) and PostgreSQL 16. Client bootstrap configures both
storage seams against the same-origin route when NEXT_PUBLIC_PERSISTENCE=1,
riding the existing lazy migrations so prior browser data reaches the
server per-course on first open. Dev auth (bearer token + client-asserted
learner header) lives in one overridable module and is loudly documented
as development-grade. Verified end-to-end against the real stack:
document PUT/GET and runtime session create land in Postgres; bad tokens 401.

Co-authored-by: Codex <codex@openai.com>

* fix(persistence): review round — structural bootstrap ordering, retryable init, honest failure surfaces

The bootstrap side-effect import moves into the two storage seam entry
modules, so any module graph that can resolve a store necessarily
evaluates it first — no client-entry ordering luck, and the two configure
calls are all-or-nothing. A failed server init clears its cached promise
(next request retries instead of permanent 500s). listStages rethrows
backend failures and the dashboard surfaces a persistence-unavailable
error instead of rendering an empty list that reads as data loss. Docs
state the build-time nature of NEXT_PUBLIC_PERSISTENCE, the real token
boundary (any visitor can extract it and impersonate any learner —
localhost/trusted-network only), and the correct postgres password
rotation. Dev-token compare is timing-safe; the handler singleton lives
on globalThis so HMR cannot leak pools.

Co-authored-by: Codex <codex@openai.com>

* fix(persistence): review response — no-confidentiality wording + adapter round-trip coverage

The token docs now state plainly that the NEXT_PUBLIC token is compiled
into the public bundle and provides no confidentiality or user isolation
(its only role is keeping unrelated scanners off a trusted network). The
Fetch/Node adapter — the route's most bug-prone code — gains a round-trip
integration test: PUT body streaming, 201 with multi-value headers, 204
empty body, and encoded path segments reaching the handler un-decoded.

---------

Co-authored-by: Codex <codex@openai.com>
2026-07-23 16:28:03 +08:00