ruact 0.0.14 → 0.0.15
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -1
- data/lib/ruact/controller/format_negotiation.rb +83 -0
- data/lib/ruact/controller/page_rendering.rb +1 -1
- data/lib/ruact/controller.rb +7 -0
- data/lib/ruact/flight/js_date.rb +22 -0
- data/lib/ruact/flight/renderer.rb +8 -3
- data/lib/ruact/flight/serializer.rb +10 -4
- data/lib/ruact/flight/utf8.rb +36 -0
- data/lib/ruact/flight.rb +2 -0
- data/lib/ruact/redirect_path.rb +16 -0
- data/lib/ruact/server.rb +6 -0
- data/lib/ruact/testing/component_query.rb +23 -1
- data/lib/ruact/testing/flight_extractor.rb +6 -1
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +43 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.js +19 -10
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +7 -0
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +12 -0
- data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +131 -0
- data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +84 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +167 -5
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +10 -19
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ab61a580bf64e964d0356fae822cc80a83d31eaaa38fdfb5398e6534158772f0
|
|
4
|
+
data.tar.gz: c66021ed18cba60a650a6f1f4e77fc60b848079e2c125aa4f34f2868f6af2101
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 053037bfb91205f625613499e5b98c14da4e886be7a94ef60239a1da934d44a35dbf104208494cc951eb7048c9bedbf1b8792c46ed9428e2a455f18d6141f729
|
|
7
|
+
data.tar.gz: '0800f0c8d28ea88c91b7d40b488ac6931b7548811cbfa0bd56458dbc9d0d4a09ab8f2db5a0884217045072b9a3889fa168cd83d969cb9f778bf22aab0ddc1cc8'
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.0.15] - 2026-10-03
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
|
|
14
|
+
- **Text in a prop could run as script on the page (stored XSS).** On a plain browser load, the page's Flight payload is inlined in a `<script>` element. It was embedded as a Ruby string literal with only a lowercase `</script>` escaped. A value containing `</SCRIPT>`, `</script >` or another casing closed the element, and the rest ran as HTML. A post titled `</SCRIPT><img src=x onerror=…>` executed its handler for everyone who opened the list. Any string a ruact page passes as a prop, such as user-written record content, could do it. The payload is now embedded as a JSON string literal with `<`, `>`, `&`, U+2028 and U+2029 escaped, so no character of it can end the element. Control characters such as `\e`, which the Ruby literal also delivered wrongly to JavaScript, now arrive intact. **Affects every version up to and including 0.0.14; upgrade.** `Ruact::Testing`'s matchers read both forms.
|
|
15
|
+
|
|
16
|
+
- **A same-origin redirect could send the browser to another site.** `redirect_to "#{root_url}#{params[:path]}"` with `path=/evil.com` gives `https://your.app//evil.com`, which is your own host, so Rails' open-redirect check accepts it. ruact then shortened every same-origin URL to its path, `//evil.com`, and a browser reads that as a URL to `evil.com`. A server function answered `{"$redirect":"//evil.com"}` and the runtime navigated there. A page redirect produced a Flight row the router refused, so the navigation silently did nothing. A path that a browser would read as another host now keeps its absolute, same-origin URL.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- **Any text of 1 KB or more blanked the page.** The server sends every string of 1024 bytes or more as a separate row framed by its byte length, with no newline after it. The browser read the payload line by line, so it lost the row that holds the page and showed nothing: `[flight-client] No root row (id=0) found in payload`. A scaffolded post whose body was one long paragraph made the whole list disappear. The first load and the router now read the payload with one parser that works on bytes, so text arrives exactly as it left, including multibyte characters and newlines, wherever the network splits it. A truncated or malformed payload, or a reference to text that never arrived, is now an error that says so instead of a blank page. Long text inside Suspense content was also never sent at all; it is now, before the row that uses it. `Ruact::Testing`'s matchers compare a long prop by its text.
|
|
21
|
+
|
|
22
|
+
- **Dates and large integers reached React as marker strings.** A Ruby `Time`, `DateTime` or `ActiveSupport::TimeWithZone` prop arrived as `"$D2026-09-08T12:30:45.123Z"`, and an integer beyond JavaScript's safe range (±(2^53 − 1)) as `"$n9007199254740993"`. A component had to know ruact's transport to use them. They now arrive as a `Date`, with the same instant and millisecond precision, and as a `BigInt`; integers inside the safe range stay numbers. This holds on the first load and after a navigation. A `BigInt` is not a `number`: `JSON.stringify` refuses it and some libraries expect numbers, so format it where you use it (`String(id)`). A `Date` is not text, so `{createdAt}` as a child now raises "Objects are not valid as a React child"; format it (`createdAt.toLocaleDateString()`). Both can go back: a server function sends a `BigInt` argument as its decimal string, and `useQuery` takes a `BigInt` or `Date` param (the generated query accessors' TypeScript parameter types do not list them yet). Years outside 0–9999 are written in the form JavaScript reads (`+010000`). A calendar `Date` (`Date.today`) arrives as `"YYYY-MM-DD"`; it used to raise `Ruact::SerializationError` in production, where `strict_serialization` is on. A symbol whose name starts with `$` is escaped like a string.
|
|
23
|
+
|
|
24
|
+
- **`respond_to` answered 406 to every navigation and form submit of the client router.** The router asks for a page with `Accept: text/x-component`, which is not a Rails MIME type, so the request negotiated no format and `respond_to { |format| format.html { … } }` raised `ActionController::UnknownFormat`. That is the controller Rails' own scaffold writes: with `include Ruact::Controller`, its `create`, `update` and `destroy` failed whenever the router submitted the form. A router request now negotiates `format.html`, the same page a browser asks for: the `format.html` branch runs, and its render, status or redirect reaches the browser as a ruact answer. A URL that names a format (`/posts/1.json`) and an `Accept: application/json` request still get `format.json`. The format is negotiated before every callback, so an authentication `before_action` that answers with `respond_to` gets it too. A `respond_to` with no `format.html` still answers 406, now with a message naming the missing branch and the `data-ruact="false"` way out. A partial that exists only in another format (`.text.erb`) is no longer found by a ruact page during a router request.
|
|
25
|
+
|
|
26
|
+
- **`bin/rails server` without Vite showed an empty page and no error.** Once Vite has run once, the component manifest is on disk, so a page renders without the dev server, but its JavaScript is not served: the page answered 200 with nothing in it, and the only clue was a 404 in the browser's network tab. In development, when the Vite dev server is not running and there is no build to serve, the page now shows a banner that says so and to start the app with `bin/dev`, and the Rails log gets the same `[ruact]` line. The banner is plain HTML, so it does not need the JavaScript that failed to load. Nothing changes with Vite running, with a build present, or outside development.
|
|
27
|
+
|
|
28
|
+
- **Text that is not valid UTF-8 broke the page.** A string holding Latin-1 bytes, or bytes that are not text at all, made the page answer 500 for every viewer, because JSON refuses invalid UTF-8. A long one, 1024 bytes or more, was sent with a byte count that did not match what the browser decoded. Strings now leave as valid UTF-8: text in another encoding is transcoded, binary-tagged UTF-8 (`File.binread`, an HTTP body) is read as the text it is, and bytes that are not text become U+FFFD, as a browser shows them.
|
|
29
|
+
|
|
10
30
|
## [0.0.14] - 2026-10-02
|
|
11
31
|
|
|
12
32
|
### Fixed
|
|
@@ -104,7 +124,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
104
124
|
|
|
105
125
|
|
|
106
126
|
- **The README no longer claims props are an allowlist.** It said *"Props are an allowlist — other columns never cross"*, describing the opt-in rather than the default. The default is `as_json`: every attribute of a model prop crosses to the client, and the gem says so in the log (`ALL attributes exposed to client`). A reader who took the bullet at face value would have believed the safe behaviour was already on. The bullet now leads with what actually happens, then names `ruact_props` as the thing that makes it an allowlist and `strict_serialization` as what turns the permissive path into an error in production.
|
|
127
|
+
|
|
107
128
|
- **The server-functions bullet no longer calls the whole generated module "typed".** Only queries carry an exact signature (one property per declared keyword, since 0.0.5). An action's accessor is typed to be callable and to satisfy `<form action>`, but its arguments and resolved value are `Record<string, unknown>` and `unknown` — useful, and not the autocomplete the word "typed" promises. Both are now described as what they are.
|
|
129
|
+
|
|
108
130
|
- **The mechanism is stated where the reader meets it.** "Capitalized tag means React. Lowercase stays HTML." now sits directly under the ERB/TSX pair, and the paragraph that follows explains the wire in plain terms — the view renders server-side as it always did, the result travels as a React tree in the format React uses for Server Components, the data is inlined so React renders without a fetch, and Node builds the bundle and does nothing else.
|
|
109
131
|
|
|
110
132
|
### Fixed
|
|
@@ -144,6 +166,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
144
166
|
It is referenced by absolute URL and **nothing binary is committed here** — so neither `gem install ruact` nor a clone of this repository carries a multi-megabyte file, permanently, for a picture. Two things keep it from aging into a lie: the URL belongs to the site's URL contract, and `spec/readme_demo_message_spec.rb` pins the message the recording shows against a fixture the gem itself produced. If the wording changes, that spec goes red and names the recording as the thing to redo.
|
|
145
167
|
|
|
146
168
|
- **`LICENSE.txt`** — the gemspec has declared `spec.license = "MIT"` since the first commit while the repository contained no licence file, so both GitHub and the packaged gem shipped a promise with nothing behind it.
|
|
169
|
+
|
|
147
170
|
- **`spec.description` and `metadata["documentation_uri"]` in the gemspec** — RubyGems rendered the one-line `summary` as the whole description and fell through to rubydoc.info for documentation.
|
|
148
171
|
|
|
149
172
|
### Fixed
|
|
@@ -300,6 +323,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
300
323
|
### Tooling
|
|
301
324
|
|
|
302
325
|
- **Code coverage instrumentation** (Story 6.7). Added `simplecov` and `simplecov-lcov` as development dependencies; gem CI uploads coverage from the canonical matrix cell (Ruby 3.3 × Rails 7.2) to Codecov on every push and PR with the `gem` flag. **Baseline at merge: 88.30% line (581/658), 72.25% branch (239 specs).** (Corrects the earlier 87.89% figure recorded at story-merge time, which was a typo of the SimpleCov output for 567/644 = 88.04%; the current numbers reflect the post-review state after Story 6.7 review F1 refactored `html_converter.rb#convert_element` into helpers, which added a few lines and one new spec.) Coverage is informativo (not a CI gate); see Codecov PR comments for diff coverage on individual changes. Diff coverage target per project DoD: ≥ 90% line / ≥ 80% branch on new code.
|
|
326
|
+
|
|
303
327
|
- **Spec `rails_stub.rb` fix.** The previous `$LOADED_FEATURES.any? { |f| f.end_with?("/rails.rb") }` heuristic for skipping the `LOADED_FEATURES` insertion was unreliable — unrelated gems ship files at `*/rails.rb` (e.g. SimpleCov's `simplecov/profiles/rails.rb`). Replaced with an unconditional insertion guarded only by the existing `return if defined?(Rails)` early-exit, which is the correct invariant.
|
|
304
328
|
|
|
305
329
|
### Renamed
|
|
@@ -327,6 +351,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
327
351
|
|
|
328
352
|
- Rake task descriptions and internal `require` statements migrated from `rails_rsc` to `ruact`. Public API is unchanged; this is a documentation and tooling rename only.
|
|
329
353
|
- **Render context now passed explicitly** (Story 7.1). `Ruact::ComponentRegistry` (which used `Thread.current`) has been removed; the per-render component list is now an instance of `Ruact::RenderContext` passed explicitly through `Controller#ruact_render → RenderPipeline → HtmlConverter`. The `Ruact/NoSharedState` cop now passes with no exceptions in `lib/ruact/`. **No public API change.** Note: `Ruact::Flight::*`, `Ruact::Internal::*`, and `Ruact::RenderContext` are not part of the public API and may change between minors. Hosts upgrading need no application code changes. See decision note for rationale and contributor guidance.
|
|
354
|
+
|
|
330
355
|
- **`RenderPipeline` entry points consolidated** (Story 7.2). `RenderPipeline#call`, `#stream`, and `#from_html` have been removed and replaced with a single `#render(input, mode:)` entry point. `input` selects the source — `{ erb: String, binding: Binding }` for ERB templates or `{ html: String, render_context: Ruact::RenderContext }` for pre-rendered HTML; `mode:` selects the output shape — `:string` returns a `String` (deferred chunks inlined eagerly), `:stream` returns an `Enumerator` of Flight rows (deferred chunks delay). Conflicting input keys, missing siblings, and unknown modes raise `ArgumentError` with the offending input named. **No public API change** — `Ruact::Controller#ruact_render` is unchanged. Note: `Ruact::Flight::*`, `Ruact::Internal::*`, and `Ruact::RenderPipeline` are not part of the public API and may change between minors. See decision note for rationale and contributor guidance.
|
|
331
356
|
|
|
332
357
|
_Migration for any external code that may have reached into `Ruact::RenderPipeline`:_ `pipeline.call(erb, binding)` → `pipeline.render({ erb: erb, binding: binding }, mode: :string)`; `pipeline.stream(erb, binding)` → `pipeline.render({ erb: erb, binding: binding }, mode: :stream)`; `pipeline.from_html(html, render_context: ctx)` → `pipeline.render({ html: html, render_context: ctx }, mode: :string)`; `pipeline.from_html(html, render_context: ctx, streaming: true)` → `pipeline.render({ html: html, render_context: ctx }, mode: :stream)`.
|
|
@@ -360,23 +385,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
360
385
|
### Added
|
|
361
386
|
|
|
362
387
|
- **ERB preprocessor** — PascalCase RSC component tags (`<Button />`, `<LikeButton postId={@post.id} />`) are transformed to Flight placeholders in ERB templates before Ruby evaluation.
|
|
388
|
+
|
|
363
389
|
- **`<Suspense>` support** — `<Suspense fallback="Loading...">` in ERB templates maps to React Suspense boundaries in the Flight payload.
|
|
390
|
+
|
|
364
391
|
- **React Flight wire format serializer** — Full Ruby-to-Flight protocol implementation covering: nil, booleans, integers, floats (NaN/Infinity/-0), strings (with `$` escaping), arrays, hashes, `Time`/`DateTime`, large strings (T rows), `ReactElement`, `SuspenseElement`, and `ClientReference`.
|
|
392
|
+
|
|
365
393
|
- **`Ruact::Controller` concern** — Include in `ApplicationController` to enable RSC rendering. Provides `ruact_render`, RSC request detection (`text/x-component` / `Ruact-Request: 1` header), HTML shell generation with inline `__FLIGHT_DATA`, and Flight-aware `redirect_to`.
|
|
394
|
+
|
|
366
395
|
- **Streaming mode** — When `ActionController::Live` is included, Flight rows are streamed to the client as they are produced (Suspense-aware).
|
|
396
|
+
|
|
367
397
|
- **Client component resolution** — `Ruact::ClientManifest` reads `public/react-client-manifest.json` (generated by the Vite plugin) and resolves component names to `ClientReference` objects via a dual-path resolver.
|
|
398
|
+
|
|
368
399
|
- **`Ruact::Serializable` mixin** — `ruact_props` DSL for declaring safe prop attributes on Ruby model objects.
|
|
400
|
+
|
|
369
401
|
- **Install generator** — `rails generate ruact:install` scaffolds the initializer, Vite config patch, and JavaScript entry point.
|
|
402
|
+
|
|
370
403
|
- **`ruact:doctor` Rake task** — Checks manifest presence, Vite server accessibility, controller setup, and streaming mode configuration.
|
|
404
|
+
|
|
371
405
|
- **`vite-plugin-ruact`** — Vite plugin (npm package, co-versioned) that scans `"use client"` components and emits `public/react-client-manifest.json`.
|
|
406
|
+
|
|
372
407
|
- **Client-side navigation** — JavaScript `ruact-router.js` intercepts same-origin link clicks and form submissions, fetches Flight payloads, and updates the React tree without full-page reloads.
|
|
408
|
+
|
|
373
409
|
- **Error overlay** — Development-mode React error boundary with dismissible overlay for Flight parse and rendering errors.
|
|
410
|
+
|
|
374
411
|
- **RSpec test suite** — 223 examples covering all modules: Flight serializer, ERB preprocessor, HTML converter, render pipeline, controller, client manifest, serializable, install generator, and `ruact:doctor`.
|
|
412
|
+
|
|
375
413
|
- **Memory benchmark** — `rake benchmark:memory` enforces a 120% allocation regression gate against `spec/benchmarks/baseline.json`.
|
|
414
|
+
|
|
376
415
|
- **CI matrix** — GitHub Actions: RSpec across Ruby 3.2 × 3.3 × Rails 7.0 × 7.1 × 7.2 × 8.0; RuboCop; YARD docs; memory benchmark; E2E system tests against React 19.0.0 and 19.x (Capybara + Cuprite); non-blocking React@next job with auto-issue on failure.
|
|
416
|
+
|
|
377
417
|
- **E2E test app** — `e2e/` Rails app (no DB, in-memory Post model) with full CRUD system tests validating the complete request cycle.
|
|
378
418
|
|
|
379
|
-
[Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.
|
|
419
|
+
[Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.15...HEAD
|
|
420
|
+
[0.0.15]: https://github.com/luizcg/ruact/releases/tag/v0.0.15
|
|
380
421
|
[0.0.14]: https://github.com/luizcg/ruact/releases/tag/v0.0.14
|
|
381
422
|
[0.0.13]: https://github.com/luizcg/ruact/releases/tag/v0.0.13
|
|
382
423
|
[0.0.12]: https://github.com/luizcg/ruact/releases/tag/v0.0.12
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
module Controller
|
|
5
|
+
# Story 17-0j — a router request answers as `format.html`.
|
|
6
|
+
#
|
|
7
|
+
# The ruact router asks for a page with `Accept: text/x-component`, which is
|
|
8
|
+
# not a Rails MIME type: the request negotiated no format at all, and every
|
|
9
|
+
# `respond_to` raised `ActionController::UnknownFormat` (406) on a
|
|
10
|
+
# navigation or a form submit. That is the controller Rails' own scaffold
|
|
11
|
+
# writes. A router request asks for the PAGE, the same thing a browser's
|
|
12
|
+
# `text/html` asks for, so it now negotiates `:html` — unless the URL names
|
|
13
|
+
# a format (`/posts/1.json`), which wins as it always has. Whether the
|
|
14
|
+
# answer is Flight or an HTML document is still ruact's decision, read from
|
|
15
|
+
# the Accept header, not from the negotiated format.
|
|
16
|
+
module FormatNegotiation
|
|
17
|
+
extend ActiveSupport::Concern
|
|
18
|
+
|
|
19
|
+
# Rails' negotiated-formats slot on the request. Writing it directly,
|
|
20
|
+
# rather than `request.format =`, leaves `params[:format]` alone.
|
|
21
|
+
FORMATS_KEY = "action_dispatch.request.formats"
|
|
22
|
+
|
|
23
|
+
# `respond_to` with no `format.html` still answers 406 to a router
|
|
24
|
+
# request, as Rails does to a browser — with the reason and the fix. Only
|
|
25
|
+
# when THIS respond_to found no format: an UnknownFormat raised inside a
|
|
26
|
+
# branch (a nested respond_to, an explicit raise), or one for a format
|
|
27
|
+
# the URL named, keeps Rails' own message.
|
|
28
|
+
#
|
|
29
|
+
# @see ActionController::MimeResponds#respond_to
|
|
30
|
+
def respond_to(*mimes, &block)
|
|
31
|
+
collector = nil
|
|
32
|
+
recording = block && lambda { |given|
|
|
33
|
+
collector = given
|
|
34
|
+
yield(given)
|
|
35
|
+
}
|
|
36
|
+
super(*mimes, &recording)
|
|
37
|
+
rescue ActionController::UnknownFormat
|
|
38
|
+
raise unless __ruact_router_found_no_format?(collector, mimes)
|
|
39
|
+
|
|
40
|
+
raise ActionController::UnknownFormat,
|
|
41
|
+
"#{self.class.name}##{action_name} was asked for its page by the ruact router, which " \
|
|
42
|
+
"negotiates format.html, and its respond_to has no format.html branch. Add one, or " \
|
|
43
|
+
"link to this action with data-ruact=\"false\" so the browser loads it."
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Before every callback — a host's own `prepend_before_action` included —
|
|
49
|
+
# since this sits above AbstractController::Callbacks in the chain. An
|
|
50
|
+
# auth callback that answers with `respond_to` gets the format too.
|
|
51
|
+
def process_action(...)
|
|
52
|
+
__ruact_negotiate_router_request_as_html
|
|
53
|
+
super
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def __ruact_negotiate_router_request_as_html
|
|
57
|
+
return unless ruact_request?
|
|
58
|
+
return if __ruact_url_format.present?
|
|
59
|
+
|
|
60
|
+
request.set_header(FORMATS_KEY, [Mime[:html]])
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The format the URL names (`/posts/1.json`, `?format=json`). Read from
|
|
64
|
+
# the path and query only: this runs before Rails' rescue and logging,
|
|
65
|
+
# where parsing a malformed request BODY must not raise.
|
|
66
|
+
def __ruact_url_format
|
|
67
|
+
request.path_parameters[:format] || request.query_parameters[:format]
|
|
68
|
+
rescue ActionController::BadRequest, ActionDispatch::Http::Parameters::ParseError
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def __ruact_router_found_no_format?(collector, mimes)
|
|
73
|
+
return false unless ruact_request? && __ruact_url_format.blank?
|
|
74
|
+
|
|
75
|
+
if collector
|
|
76
|
+
collector.format.nil?
|
|
77
|
+
else
|
|
78
|
+
request.negotiate_mime(mimes.map { |mime| Mime[mime] }).nil?
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -98,7 +98,7 @@ module Ruact
|
|
|
98
98
|
# Whether Rails, left alone, would render `page` for `action`: the lookup
|
|
99
99
|
# it does itself, with the formats it negotiated for this request (and a
|
|
100
100
|
# render's own `formats:` / `variants:` / `handlers:` / `locale:`). A Flight request
|
|
101
|
-
# negotiates
|
|
101
|
+
# negotiates HTML (Ruact::Controller::FormatNegotiation) — the page; a `.json` request or a
|
|
102
102
|
# `format.json` branch negotiates JSON — the JSON template, or Rails' own
|
|
103
103
|
# MissingTemplate.
|
|
104
104
|
def __ruact_rails_would_render?(action, details, page)
|
data/lib/ruact/controller.rb
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "socket"
|
|
5
5
|
require "uri"
|
|
6
|
+
require_relative "redirect_path"
|
|
6
7
|
require_relative "view_helper"
|
|
7
8
|
require_relative "validation_errors_collector"
|
|
8
9
|
require_relative "controller/document_rendering"
|
|
9
10
|
require_relative "controller/pages"
|
|
10
11
|
require_relative "controller/page_rendering"
|
|
12
|
+
require_relative "controller/format_negotiation"
|
|
11
13
|
|
|
12
14
|
module Ruact
|
|
13
15
|
# Include in the controllers whose pages render through ruact (island mode,
|
|
@@ -43,6 +45,9 @@ module Ruact
|
|
|
43
45
|
# An explicit `render` of a ruact page goes through ruact (Story 17.0i).
|
|
44
46
|
# See Ruact::Controller::PageRendering.
|
|
45
47
|
include Ruact::Controller::PageRendering
|
|
48
|
+
# A router request negotiates `format.html`, so `respond_to` answers it
|
|
49
|
+
# (Story 17-0j). See Ruact::Controller::FormatNegotiation.
|
|
50
|
+
include Ruact::Controller::FormatNegotiation
|
|
46
51
|
|
|
47
52
|
# Story 17.0f — "is this action a ruact PAGE?", answered at CLASS level so the
|
|
48
53
|
# navigation boundary (Ruact::NavigationBoundary) can ask it before any action
|
|
@@ -266,6 +271,8 @@ module Ruact
|
|
|
266
271
|
redirect_url = uri.path.nil? || uri.path.empty? ? "/" : uri.path
|
|
267
272
|
redirect_url += "?#{uri.query}" if uri.query
|
|
268
273
|
redirect_url += "##{uri.fragment}" if uri.fragment
|
|
274
|
+
# Same-origin `https://app//evil.com` must not shrink to `//evil.com`.
|
|
275
|
+
redirect_url = url if Ruact::RedirectPath.protocol_relative?(redirect_url)
|
|
269
276
|
rescue ::URI::InvalidURIError
|
|
270
277
|
return super
|
|
271
278
|
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
module Flight
|
|
5
|
+
# The ISO 8601 form a JavaScript `Date` reads back (Story 17-0c).
|
|
6
|
+
module JsDate
|
|
7
|
+
# JavaScript parses years 0–9999 in the plain form and the others only in
|
|
8
|
+
# the expanded one (`+010000`, `-000001`); Ruby writes `10000` / `-0001`,
|
|
9
|
+
# which `new Date` turns into an Invalid Date.
|
|
10
|
+
#
|
|
11
|
+
# @param value [Time, DateTime]
|
|
12
|
+
# @return [String] ISO 8601 with milliseconds
|
|
13
|
+
def self.iso8601(value)
|
|
14
|
+
iso = value.iso8601(3)
|
|
15
|
+
year = value.year
|
|
16
|
+
return iso unless year.negative? || year > 9999
|
|
17
|
+
|
|
18
|
+
format("%<sign>s%<year>06d", sign: year.negative? ? "-" : "+", year: year.abs) + iso.sub(/\A-?\d+/, "")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -56,12 +56,17 @@ module Ruact
|
|
|
56
56
|
sleep(deferred[:delay])
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
# Serialize deferred content — may produce new import rows
|
|
60
|
-
|
|
59
|
+
# Serialize deferred content — may produce new import rows, and new
|
|
60
|
+
# regular rows (a string of 1024+ bytes becomes a `T` row the deferred
|
|
61
|
+
# model row references as `$T<id>`).
|
|
62
|
+
import_count_before = @request.completed_import_chunks.length
|
|
63
|
+
regular_count_before = @request.completed_regular_chunks.length
|
|
61
64
|
deferred_value = serializer.serialize_model(deferred[:element])
|
|
62
65
|
|
|
63
|
-
# Yield
|
|
66
|
+
# Yield the rows discovered during deferred serialization BEFORE the
|
|
67
|
+
# row that references them.
|
|
64
68
|
@request.completed_import_chunks[import_count_before..].each(&block)
|
|
69
|
+
@request.completed_regular_chunks[regular_count_before..].each(&block)
|
|
65
70
|
|
|
66
71
|
yield RowEmitter.model(deferred[:id], JSON.generate(deferred_value))
|
|
67
72
|
end
|
|
@@ -33,6 +33,10 @@ module Ruact
|
|
|
33
33
|
serialize_symbol(value)
|
|
34
34
|
when Time, DateTime
|
|
35
35
|
serialize_date(value)
|
|
36
|
+
when Date
|
|
37
|
+
# A calendar date has no instant to rebuild: it travels as
|
|
38
|
+
# "YYYY-MM-DD", in strict mode too (Story 17-0c review).
|
|
39
|
+
serialize_string(value.iso8601)
|
|
36
40
|
when ClientReference
|
|
37
41
|
serialize_client_reference(value)
|
|
38
42
|
when SuspenseElement
|
|
@@ -53,6 +57,7 @@ module Ruact
|
|
|
53
57
|
# --- Primitives ---
|
|
54
58
|
|
|
55
59
|
def serialize_string(value)
|
|
60
|
+
value = Utf8.text(value)
|
|
56
61
|
# Large strings get their own T row
|
|
57
62
|
if value.bytesize >= LARGE_TEXT_THRESHOLD
|
|
58
63
|
id = @request.allocate_id
|
|
@@ -89,12 +94,13 @@ module Ruact
|
|
|
89
94
|
# Only :undefined is special for now
|
|
90
95
|
return "$undefined" if value == :undefined
|
|
91
96
|
|
|
92
|
-
#
|
|
93
|
-
|
|
97
|
+
# Any other symbol is its name — escaped like a string, so `:"$nope"`
|
|
98
|
+
# cannot pose as a "$n…" marker.
|
|
99
|
+
serialize_string(value.to_s)
|
|
94
100
|
end
|
|
95
101
|
|
|
96
102
|
def serialize_date(value)
|
|
97
|
-
"$D#{
|
|
103
|
+
"$D#{JsDate.iso8601(value)}"
|
|
98
104
|
end
|
|
99
105
|
|
|
100
106
|
# --- Collections ---
|
|
@@ -104,7 +110,7 @@ module Ruact
|
|
|
104
110
|
end
|
|
105
111
|
|
|
106
112
|
def serialize_hash(value)
|
|
107
|
-
value.transform_keys(
|
|
113
|
+
value.transform_keys { |key| Utf8.text(key.to_s) }.transform_values { |v| serialize_model(v) }
|
|
108
114
|
end
|
|
109
115
|
|
|
110
116
|
# --- React Element ---
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
module Flight
|
|
5
|
+
# Every string leaves the serializer as valid UTF-8. A `T` row is framed by
|
|
6
|
+
# its byte length, and JSON (a model row, the inline page payload) refuses
|
|
7
|
+
# invalid UTF-8: one record holding Latin-1 bytes made its page answer 500
|
|
8
|
+
# for every viewer.
|
|
9
|
+
module Utf8
|
|
10
|
+
REPLACEMENT = "\uFFFD"
|
|
11
|
+
|
|
12
|
+
# Binary-tagged text (`File.binread`, an HTTP body) is retagged; another
|
|
13
|
+
# encoding is transcoded; bytes that are not text in that encoding become
|
|
14
|
+
# U+FFFD, as a browser shows them.
|
|
15
|
+
#
|
|
16
|
+
# @param value [String]
|
|
17
|
+
# @return [String] valid UTF-8 (the same object when it already was)
|
|
18
|
+
def self.text(value)
|
|
19
|
+
return value if value.encoding == Encoding::UTF_8 && value.valid_encoding?
|
|
20
|
+
|
|
21
|
+
text =
|
|
22
|
+
begin
|
|
23
|
+
if [Encoding::BINARY, Encoding::UTF_8].include?(value.encoding)
|
|
24
|
+
value.dup.force_encoding(Encoding::UTF_8)
|
|
25
|
+
else
|
|
26
|
+
value.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: REPLACEMENT)
|
|
27
|
+
end
|
|
28
|
+
rescue Encoding::ConverterNotFoundError
|
|
29
|
+
# An encoding Ruby cannot convert from (UTF-7, say): its bytes.
|
|
30
|
+
value.dup.force_encoding(Encoding::UTF_8)
|
|
31
|
+
end
|
|
32
|
+
text.valid_encoding? ? text : text.scrub(REPLACEMENT)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/ruact/flight.rb
CHANGED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
# Reducing a same-origin redirect URL to its path is what lets the client
|
|
5
|
+
# navigate in place. One path must not be produced that way: `//host` (or
|
|
6
|
+
# `/\host`), which a browser reads as a URL to ANOTHER host. Shared by the
|
|
7
|
+
# page redirect ({Ruact::Controller}) and the server-function `$redirect`
|
|
8
|
+
# ({Ruact::Server}); neither loads the other.
|
|
9
|
+
module RedirectPath
|
|
10
|
+
# @param path [String] a redirect target already reduced to a path
|
|
11
|
+
# @return [Boolean] whether a browser would read it as another host
|
|
12
|
+
def self.protocol_relative?(path)
|
|
13
|
+
path.match?(%r{\A[/\\]{2}})
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
data/lib/ruact/server.rb
CHANGED
|
@@ -14,6 +14,7 @@ require_relative "../ruact"
|
|
|
14
14
|
require_relative "server_functions/error_rendering"
|
|
15
15
|
require_relative "server_functions/bucket_two_payload"
|
|
16
16
|
require_relative "server_functions/name_bridge"
|
|
17
|
+
require_relative "redirect_path"
|
|
17
18
|
require_relative "validation_errors_collector"
|
|
18
19
|
|
|
19
20
|
module Ruact
|
|
@@ -327,6 +328,11 @@ module Ruact
|
|
|
327
328
|
path = uri.path.nil? || uri.path.empty? ? "/" : uri.path
|
|
328
329
|
path += "?#{uri.query}" if uri.query
|
|
329
330
|
path += "##{uri.fragment}" if uri.fragment
|
|
331
|
+
# `https://app//evil.com` is same-origin, and Rails lets it through; as a
|
|
332
|
+
# path, `//evil.com` is a protocol-relative URL to ANOTHER host. Keep
|
|
333
|
+
# the absolute URL for any path a browser would read that way.
|
|
334
|
+
return url if Ruact::RedirectPath.protocol_relative?(path)
|
|
335
|
+
|
|
330
336
|
path
|
|
331
337
|
rescue ::URI::InvalidURIError
|
|
332
338
|
url
|
|
@@ -26,10 +26,14 @@ module Ruact
|
|
|
26
26
|
ELEMENT_HEAD = "$"
|
|
27
27
|
# A client-component element's type is "$L" followed by the import's hex id.
|
|
28
28
|
CLIENT_REF = /\A\$L(?<hex>\h+)\z/
|
|
29
|
+
# A string of 1024+ bytes travels as a `T` row, and the props hold a
|
|
30
|
+
# `"$T<hex>"` reference to it (Story 17-0d).
|
|
31
|
+
TEXT_REF = /\A\$T(?<hex>\h+)\z/
|
|
29
32
|
|
|
30
33
|
# @param wire [String] the raw Flight wire byte string.
|
|
31
34
|
def initialize(wire)
|
|
32
35
|
@rows = FlightWireParser.parse(wire)
|
|
36
|
+
@texts = index_texts(@rows)
|
|
33
37
|
@imports = index_imports(@rows)
|
|
34
38
|
@instances = collect_instances(@rows)
|
|
35
39
|
end
|
|
@@ -74,6 +78,24 @@ module Ruact
|
|
|
74
78
|
end
|
|
75
79
|
end
|
|
76
80
|
|
|
81
|
+
def index_texts(rows)
|
|
82
|
+
rows.each_with_object({}) do |row, acc|
|
|
83
|
+
acc[row[:id]] = row[:payload].dup.force_encoding(Encoding::UTF_8) if row[:class] == :text
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Props as the component receives them: text references resolved.
|
|
88
|
+
def resolve_texts(node)
|
|
89
|
+
case node
|
|
90
|
+
when String
|
|
91
|
+
ref = node.match(TEXT_REF)
|
|
92
|
+
ref && @texts.key?(ref[:hex].to_i(16)) ? @texts[ref[:hex].to_i(16)] : node
|
|
93
|
+
when Array then node.map { |child| resolve_texts(child) }
|
|
94
|
+
when Hash then node.transform_values { |child| resolve_texts(child) }
|
|
95
|
+
else node
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
77
99
|
def import_ids_for(name)
|
|
78
100
|
@imports.select do |_id, meta|
|
|
79
101
|
meta[:export_name] == name || meta[:basename] == name
|
|
@@ -95,7 +117,7 @@ module Ruact
|
|
|
95
117
|
def walk(node, instances)
|
|
96
118
|
if element?(node)
|
|
97
119
|
ref = node[1].match(CLIENT_REF)
|
|
98
|
-
instances << { ref_id: ref[:hex].to_i(16), props: node[3] } if ref
|
|
120
|
+
instances << { ref_id: ref[:hex].to_i(16), props: resolve_texts(node[3]) } if ref
|
|
99
121
|
# An element's props/children may themselves contain nested elements.
|
|
100
122
|
walk(node[3], instances)
|
|
101
123
|
elsif node.is_a?(Array)
|
|
@@ -133,7 +133,12 @@ module Ruact
|
|
|
133
133
|
"This response did not render a ruact component (no Flight data was inlined)."
|
|
134
134
|
end
|
|
135
135
|
|
|
136
|
-
|
|
136
|
+
literal = match[:literal]
|
|
137
|
+
# The literal is JSON (ruact >= 0.0.15). The Ruby-literal decoder stays
|
|
138
|
+
# for documents captured from earlier versions.
|
|
139
|
+
JSON.parse(literal)
|
|
140
|
+
rescue JSON::ParserError
|
|
141
|
+
decode_ruby_string_literal(literal)
|
|
137
142
|
end
|
|
138
143
|
|
|
139
144
|
def json_body?(body)
|
data/lib/ruact/version.rb
CHANGED
data/lib/ruact/view_helper.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "erb"
|
|
3
4
|
require "json"
|
|
4
5
|
require "socket"
|
|
5
6
|
|
|
@@ -214,15 +215,31 @@ module Ruact
|
|
|
214
215
|
|
|
215
216
|
# The `__FLIGHT_DATA` inline bootstrap `<script>` — pushes the per-render
|
|
216
217
|
# Flight payload onto the global queue the bootstrap entry drains on boot.
|
|
217
|
-
#
|
|
218
|
-
#
|
|
218
|
+
#
|
|
219
|
+
# The payload carries application data (props, record content), so it is
|
|
220
|
+
# embedded as a JSON string literal with `<`, `>`, `&`, U+2028 and U+2029
|
|
221
|
+
# escaped as `\uXXXX`. No byte of the payload can then close the `<script>`
|
|
222
|
+
# — `</SCRIPT>`, `</script >` and `<!--` included — and every character
|
|
223
|
+
# reaches JavaScript intact (a Ruby `String#inspect` literal did neither:
|
|
224
|
+
# it escaped only a lowercase `</script>`, and its `\e`-style escapes mean
|
|
225
|
+
# something else in JS).
|
|
226
|
+
SCRIPT_UNSAFE = /[<>&\u2028\u2029]/
|
|
227
|
+
private_constant :SCRIPT_UNSAFE
|
|
228
|
+
|
|
229
|
+
# A JSON string literal that is also safe inside a `<script>` element.
|
|
230
|
+
# The payload is UTF-8 by construction (the serializer normalizes every
|
|
231
|
+
# string); a binary-tagged buffer is retagged, not transcoded.
|
|
232
|
+
def __ruact_script_safe_json(string)
|
|
233
|
+
utf8 = string.encoding == Encoding::UTF_8 ? string : string.dup.force_encoding(Encoding::UTF_8)
|
|
234
|
+
JSON.generate(utf8).gsub(SCRIPT_UNSAFE) { |char| format("\\u%04x", char.ord) }
|
|
235
|
+
end
|
|
236
|
+
|
|
219
237
|
def ruact_flight_data_script(flight_payload)
|
|
220
|
-
escaped_payload = flight_payload.gsub("</script>", '<\/script>')
|
|
221
238
|
<<~HTML.strip
|
|
222
239
|
<script>
|
|
223
240
|
(function() {
|
|
224
241
|
var d = (self.__FLIGHT_DATA = self.__FLIGHT_DATA || []);
|
|
225
|
-
d.push(#{
|
|
242
|
+
d.push(#{__ruact_script_safe_json(flight_payload)});
|
|
226
243
|
})();
|
|
227
244
|
</script>
|
|
228
245
|
HTML
|
|
@@ -257,10 +274,31 @@ module Ruact
|
|
|
257
274
|
# virtual entry id (the manifest key Vite emits for the virtual input).
|
|
258
275
|
entry = vite_manifest_entry(Ruact.bootstrap_virtual_id)
|
|
259
276
|
src = entry ? "/assets/#{entry['file']}" : "/assets/application.js"
|
|
260
|
-
%(<script type="module" src="#{src}"></script>)
|
|
277
|
+
tag = %(<script type="module" src="#{src}"></script>)
|
|
278
|
+
# Story 17-0k — in development with neither the Vite dev server nor a
|
|
279
|
+
# build, that script 404s and the page stays empty with no error.
|
|
280
|
+
Rails.env.development? && entry.nil? ? "#{ruact_vite_down_warning}\n#{tag}" : tag
|
|
261
281
|
end
|
|
262
282
|
end
|
|
263
283
|
|
|
284
|
+
# Story 17-0k — `bin/rails server` on its own, once Vite has run before:
|
|
285
|
+
# the component manifest is on disk, so the render succeeds, but the page's
|
|
286
|
+
# JavaScript is not served and `#root` stays empty — a blank 200 whose only
|
|
287
|
+
# clue was a 404 in the network tab. Development only. Plain HTML with
|
|
288
|
+
# inline style: the warning cannot depend on the bundle that did not load.
|
|
289
|
+
def ruact_vite_down_warning
|
|
290
|
+
server = Ruact.config.vite_dev_server.chomp("/")
|
|
291
|
+
message = "The Vite dev server is not running (#{server}), so this page has no JavaScript " \
|
|
292
|
+
"and its React components cannot render. Start the app with bin/dev, which runs " \
|
|
293
|
+
"Rails and Vite together."
|
|
294
|
+
Rails.logger&.warn("[ruact] #{message}")
|
|
295
|
+
<<~HTML.strip
|
|
296
|
+
<div role="alert" data-ruact-vite-down style="position:fixed;inset:0 0 auto 0;z-index:2147483647;padding:12px 16px;background:#7f1d1d;color:#fff;font:14px/1.4 system-ui,sans-serif">
|
|
297
|
+
<strong>[ruact]</strong> #{ERB::Util.html_escape(message)}
|
|
298
|
+
</div>
|
|
299
|
+
HTML
|
|
300
|
+
end
|
|
301
|
+
|
|
264
302
|
# The Vite dev-server URL for the virtual bootstrap. Vite serves virtual
|
|
265
303
|
# modules under `/@id/`, encoding the resolved id's leading NUL (`\0`) as
|
|
266
304
|
# `__x00__` — so `\0virtual:ruact/bootstrap` is served at
|
|
@@ -270,6 +270,16 @@ let inflightQueries = new WeakMap();
|
|
|
270
270
|
// is still invoked and the error surfaces (Story 9.5 behavior), instead of an
|
|
271
271
|
// invalid call wrongly joining an in-flight no-param request. `null`/no params
|
|
272
272
|
// both serialize to "".
|
|
273
|
+
// Story 17-0c — a query param as it goes on the wire, or `undefined` when it
|
|
274
|
+
// cannot be one. Pages now hand components a `BigInt` (an id beyond JS's safe
|
|
275
|
+
// range) and a `Date` (a Ruby Time); both must be able to go back as params.
|
|
276
|
+
function wireParam(value) {
|
|
277
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
|
278
|
+
if (typeof value === "bigint") return value.toString();
|
|
279
|
+
if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString();
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
|
|
273
283
|
function canonicalParamsKey(params) {
|
|
274
284
|
if (params == null) return "";
|
|
275
285
|
if (typeof params !== "object" || Array.isArray(params)) return null;
|
|
@@ -279,8 +289,8 @@ function canonicalParamsKey(params) {
|
|
|
279
289
|
if (value === undefined) continue;
|
|
280
290
|
if (value === null) {
|
|
281
291
|
tokens.push(encodeURIComponent(key)); // bare key — mirrors buildQueryUrl
|
|
282
|
-
} else if (
|
|
283
|
-
tokens.push(`${encodeURIComponent(key)}=${encodeURIComponent(
|
|
292
|
+
} else if (wireParam(value) !== undefined) {
|
|
293
|
+
tokens.push(`${encodeURIComponent(key)}=${encodeURIComponent(wireParam(value))}`);
|
|
284
294
|
} else {
|
|
285
295
|
return null; // array/object value — buildQueryUrl throws; non-shareable
|
|
286
296
|
}
|
|
@@ -536,16 +546,12 @@ function buildQueryUrl(path, params) {
|
|
|
536
546
|
if (value === undefined) continue;
|
|
537
547
|
if (value === null) {
|
|
538
548
|
bareKeys.push(encodeURIComponent(key));
|
|
539
|
-
} else if (
|
|
540
|
-
|
|
541
|
-
typeof value === "number" ||
|
|
542
|
-
typeof value === "boolean"
|
|
543
|
-
) {
|
|
544
|
-
search.append(key, String(value));
|
|
549
|
+
} else if (wireParam(value) !== undefined) {
|
|
550
|
+
search.append(key, wireParam(value));
|
|
545
551
|
} else {
|
|
546
552
|
throw new TypeError(
|
|
547
553
|
`ruact useQuery for ${path}: param "${key}" must be a string, number, ` +
|
|
548
|
-
"boolean, or null — arrays and objects are rejected",
|
|
554
|
+
"bigint, Date, boolean, or null — arrays and objects are rejected",
|
|
549
555
|
);
|
|
550
556
|
}
|
|
551
557
|
}
|
|
@@ -684,7 +690,10 @@ function buildFetchInit(args, method = "POST") {
|
|
|
684
690
|
body = args;
|
|
685
691
|
} else {
|
|
686
692
|
headers["Content-Type"] = "application/json";
|
|
687
|
-
|
|
693
|
+
// A BigInt (an id beyond JS's safe range, Story 17-0c) has no JSON form:
|
|
694
|
+
// it goes as its decimal string, which Rails reads as it reads any id.
|
|
695
|
+
// A Date already serializes to its ISO string.
|
|
696
|
+
body = JSON.stringify(args ?? {}, (_key, value) => (typeof value === "bigint" ? value.toString() : value));
|
|
688
697
|
}
|
|
689
698
|
|
|
690
699
|
return {
|
|
@@ -491,6 +491,13 @@ describe("mutation core — configureRuactRuntime", () => {
|
|
|
491
491
|
});
|
|
492
492
|
|
|
493
493
|
describe("Story 8.1 — __internals (test-only surface)", () => {
|
|
494
|
+
// Story 17-0c — a BigInt id from a page has no JSON form; it goes as its
|
|
495
|
+
// decimal string instead of throwing.
|
|
496
|
+
it("sends a BigInt argument as its decimal string", () => {
|
|
497
|
+
const init = __internals.buildFetchInit({ id: 9007199254740993n, at: new Date("2026-09-08T00:00:00.000Z") });
|
|
498
|
+
expect(JSON.parse(init.body)).toEqual({ id: "9007199254740993", at: "2026-09-08T00:00:00.000Z" });
|
|
499
|
+
});
|
|
500
|
+
|
|
494
501
|
it("exposes buildFetchInit, resolveCsrfToken, parseResponse for granular asserts", () => {
|
|
495
502
|
expect(typeof __internals.buildFetchInit).toBe("function");
|
|
496
503
|
expect(typeof __internals.resolveCsrfToken).toBe("function");
|
|
@@ -203,6 +203,18 @@ describe("Story 9.5 — _makeQuery / buildQueryUrl wire format (FR88)", () => {
|
|
|
203
203
|
expect(() => buildQueryUrl("/q/search", { q: { deep: 1 } })).toThrow(/arrays and objects are rejected/);
|
|
204
204
|
});
|
|
205
205
|
|
|
206
|
+
// Story 17-0c — pages now hand components BigInt ids and Date values; they
|
|
207
|
+
// must be able to go back as query params.
|
|
208
|
+
it("sends a BigInt as its decimal string and a Date as its ISO string", () => {
|
|
209
|
+
expect(buildQueryUrl("/q/search", { id: 9007199254740993n })).toBe("/q/search?id=9007199254740993");
|
|
210
|
+
expect(buildQueryUrl("/q/search", { at: new Date("2026-09-08T12:30:45.123Z") }))
|
|
211
|
+
.toBe("/q/search?at=2026-09-08T12%3A30%3A45.123Z");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("rejects an Invalid Date rather than sending 'Invalid Date'", () => {
|
|
215
|
+
expect(() => buildQueryUrl("/q/search", { at: new Date("nope") })).toThrow(/arrays and objects are rejected/);
|
|
216
|
+
});
|
|
217
|
+
|
|
206
218
|
it("rejects a top-level array of params", () => {
|
|
207
219
|
expect(() => buildQueryUrl("/q/search", [1, 2])).toThrow(/plain object/);
|
|
208
220
|
});
|
|
@@ -36,6 +36,7 @@ import path from "node:path";
|
|
|
36
36
|
import {
|
|
37
37
|
buildTree,
|
|
38
38
|
createFromFlightPayload,
|
|
39
|
+
createRowParser,
|
|
39
40
|
clearPendingChunks,
|
|
40
41
|
} from "./runtime/flight-client.js";
|
|
41
42
|
|
|
@@ -319,3 +320,133 @@ describe("Story 17.0a — the contract, driven by a gem-produced fixture", () =>
|
|
|
319
320
|
expect(tree.props.posts).toEqual([{ id: 1, title: "$5 plan" }]);
|
|
320
321
|
});
|
|
321
322
|
});
|
|
323
|
+
|
|
324
|
+
// Story 17-0d — every string of 1024+ bytes travels as a `T` row framed by its
|
|
325
|
+
// BYTE length, with no trailing newline. The decoder split on "\n" and so lost
|
|
326
|
+
// the root row: one long post body blanked the whole page. The fixtures are
|
|
327
|
+
// what the Ruby serializer emits (spec/ruact/flight/text_framing_fixtures_spec.rb
|
|
328
|
+
// writes and guards them).
|
|
329
|
+
describe("text rows (Story 17-0d)", () => {
|
|
330
|
+
const FIXTURES = path.join(import.meta.dirname, "../../../spec/fixtures/flight");
|
|
331
|
+
const read = (name) => fs.readFileSync(path.join(FIXTURES, name));
|
|
332
|
+
const expected = JSON.parse(read("text_framing_expected.json").toString("utf8"));
|
|
333
|
+
|
|
334
|
+
const rowsOf = (chunks) => {
|
|
335
|
+
const rows = new Map();
|
|
336
|
+
const parser = createRowParser(({ id, row }) => rows.set(id, row));
|
|
337
|
+
for (const chunk of chunks) parser.push(chunk);
|
|
338
|
+
parser.end();
|
|
339
|
+
return rows;
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
it("decodes every value the serializer framed, byte for byte", () => {
|
|
343
|
+
const tree = createFromFlightPayload(read("text_framing.txt").toString("utf8"), MODULE_REGISTRY);
|
|
344
|
+
expect(tree).toEqual(expected);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("puts long text inside Suspense content where the deferred row points", () => {
|
|
348
|
+
const tree = createFromFlightPayload(read("text_framing_suspense.txt").toString("utf8"), MODULE_REGISTRY);
|
|
349
|
+
const deferred = tree.props.children.type; // the already-arrived deferred row, wrapped
|
|
350
|
+
expect(deferred().props.children).toBe(expected.multibyte);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("does not depend on where the network splits the bytes", () => {
|
|
354
|
+
const bytes = new Uint8Array(read("text_framing.txt"));
|
|
355
|
+
const whole = rowsOf([bytes]);
|
|
356
|
+
|
|
357
|
+
// Every single split point: inside ids, `T` lengths, text bodies and
|
|
358
|
+
// multibyte code points, and between rows.
|
|
359
|
+
for (let cut = 1; cut < bytes.length; cut += 1) {
|
|
360
|
+
const split = rowsOf([bytes.subarray(0, cut), bytes.subarray(cut)]);
|
|
361
|
+
expect(split).toEqual(whole);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// And one byte at a time.
|
|
365
|
+
expect(rowsOf([...bytes].map((b) => Uint8Array.of(b)))).toEqual(whole);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("refuses a truncated text row instead of rendering part of the page", () => {
|
|
369
|
+
const bytes = new Uint8Array(read("text_framing.txt"));
|
|
370
|
+
const firstText = Buffer.from(bytes).indexOf(":T") + 10;
|
|
371
|
+
expect(() => rowsOf([bytes.subarray(0, firstText)])).toThrow(/Truncated Flight payload/);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("refuses a malformed text length", () => {
|
|
375
|
+
const enc = new TextEncoder();
|
|
376
|
+
expect(() => rowsOf([enc.encode("1:Tzz,abc0:\"$T1\"\n")])).toThrow(/Malformed text row length/);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("names a text reference whose row never arrived", () => {
|
|
380
|
+
expect(() => createFromFlightPayload('0:{"body":"$T7"}\n', MODULE_REGISTRY))
|
|
381
|
+
.toThrow(/Text row 7 is referenced but was never received/);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("skips a stray line without a colon instead of losing the row after it", () => {
|
|
385
|
+
const rows = rowsOf([new TextEncoder().encode('garbage\n0:{"a":1}\n')]);
|
|
386
|
+
expect(rows.get(0)).toEqual({ kind: "model", value: { a: 1 } });
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("reads a large row arriving in small chunks in linear time", () => {
|
|
390
|
+
const big = JSON.stringify({ items: Array.from({ length: 60000 }, (_, i) => ({ id: i, name: `item ${i}` })) });
|
|
391
|
+
const bytes = new TextEncoder().encode(`0:${big}\n`);
|
|
392
|
+
const chunks = [];
|
|
393
|
+
for (let i = 0; i < bytes.length; i += 4096) chunks.push(bytes.subarray(i, i + 4096));
|
|
394
|
+
const started = performance.now();
|
|
395
|
+
const rows = rowsOf(chunks);
|
|
396
|
+
expect(rows.get(0).value.items).toHaveLength(60000);
|
|
397
|
+
// ~2.3 MB in 4 KB chunks; quadratic re-copying took seconds.
|
|
398
|
+
expect(performance.now() - started).toBeLessThan(1000);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("still reads a last row that lost its trailing newline", () => {
|
|
402
|
+
const rows = rowsOf([new TextEncoder().encode('0:{"a":1}')]);
|
|
403
|
+
expect(rows.get(0)).toEqual({ kind: "model", value: { a: 1 } });
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// Story 17-0c — the serializer emits Ruby Time/DateTime as "$D<ISO>" and
|
|
408
|
+
// integers beyond ±(2^53 − 1) as "$n<decimal>". Components got the marker
|
|
409
|
+
// strings. The fixture is the Ruby serializer's own output
|
|
410
|
+
// (spec/ruact/flight/scalar_fixtures_spec.rb writes and guards it).
|
|
411
|
+
describe("dates and big integers (Story 17-0c)", () => {
|
|
412
|
+
const FIXTURES = path.join(import.meta.dirname, "../../../spec/fixtures/flight");
|
|
413
|
+
const payload = fs.readFileSync(path.join(FIXTURES, "scalar_round_trip.txt"), "utf8");
|
|
414
|
+
const expected = JSON.parse(fs.readFileSync(path.join(FIXTURES, "scalar_round_trip_expected.json"), "utf8"));
|
|
415
|
+
|
|
416
|
+
// Expected JSON tags what JSON cannot hold: { date: ISO } and { bigint: "…" }.
|
|
417
|
+
const revive = (value) => {
|
|
418
|
+
if (Array.isArray(value)) return value.map(revive);
|
|
419
|
+
if (value && typeof value === "object") {
|
|
420
|
+
if ("date" in value) return new Date(value.date);
|
|
421
|
+
if ("bigint" in value) return BigInt(value.bigint);
|
|
422
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, revive(v)]));
|
|
423
|
+
}
|
|
424
|
+
return value;
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
it("rebuilds every value with its JS type, nested ones included", () => {
|
|
428
|
+
const tree = createFromFlightPayload(payload, MODULE_REGISTRY);
|
|
429
|
+
expect(tree).toEqual(revive(expected));
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("gives a Date the same instant, offset applied, to the millisecond", () => {
|
|
433
|
+
const tree = createFromFlightPayload(payload, MODULE_REGISTRY);
|
|
434
|
+
expect(tree.time).toBeInstanceOf(Date);
|
|
435
|
+
expect(tree.time.toISOString()).toBe("2026-09-08T12:30:45.123Z");
|
|
436
|
+
expect(tree.zoned.toISOString()).toBe("2026-09-08T12:30:00.000Z");
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("keeps safe integers as numbers and only the unsafe ones as BigInt", () => {
|
|
440
|
+
const tree = createFromFlightPayload(payload, MODULE_REGISTRY);
|
|
441
|
+
expect(typeof tree.max_safe).toBe("number");
|
|
442
|
+
expect(typeof tree.min_safe).toBe("number");
|
|
443
|
+
expect(typeof tree.big).toBe("bigint");
|
|
444
|
+
expect(tree.big).toBe(9007199254740993n);
|
|
445
|
+
expect(tree.negative_big).toBe(-18446744073709551616n);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("leaves a literal string that starts with $D a string", () => {
|
|
449
|
+
const tree = createFromFlightPayload(payload, MODULE_REGISTRY);
|
|
450
|
+
expect(tree.literal).toBe("$D2026-01-01 is a string");
|
|
451
|
+
});
|
|
452
|
+
});
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// classifier is never a silent no-op; and a Flight response still renders.
|
|
12
12
|
|
|
13
13
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
14
16
|
import { setupRouter, teardownRouter } from "./runtime/ruact-router.js";
|
|
15
17
|
|
|
16
18
|
const ORIGIN = "http://localhost:3000";
|
|
@@ -431,3 +433,85 @@ describe("ruact-router — the navigation boundary (Story 17.0f)", () => {
|
|
|
431
433
|
});
|
|
432
434
|
});
|
|
433
435
|
});
|
|
436
|
+
|
|
437
|
+
// Story 17-0d — a navigation reads the same byte parser as the initial load.
|
|
438
|
+
// The stream is cut inside a `T` length, inside a multibyte code point and
|
|
439
|
+
// inside a text body; the page must still render with the exact text.
|
|
440
|
+
describe("ruact-router — long text in a streamed navigation (Story 17-0d)", () => {
|
|
441
|
+
const FIXTURES = path.join(import.meta.dirname, "../../../spec/fixtures/flight");
|
|
442
|
+
let dom;
|
|
443
|
+
let onNavigate;
|
|
444
|
+
let onError;
|
|
445
|
+
|
|
446
|
+
beforeEach(() => {
|
|
447
|
+
dom = installDom();
|
|
448
|
+
onNavigate = vi.fn();
|
|
449
|
+
onError = vi.fn();
|
|
450
|
+
globalThis.fetch = vi.fn();
|
|
451
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
452
|
+
setupRouter({ onNavigate, moduleRegistry: {}, onError });
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
afterEach(() => {
|
|
456
|
+
teardownRouter();
|
|
457
|
+
vi.restoreAllMocks();
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("renders every framed string, whatever the chunk boundaries", async () => {
|
|
461
|
+
const bytes = new Uint8Array(fs.readFileSync(path.join(FIXTURES, "text_framing.txt")));
|
|
462
|
+
const expected = JSON.parse(fs.readFileSync(path.join(FIXTURES, "text_framing_expected.json"), "utf8"));
|
|
463
|
+
const firstLength = Buffer.from(bytes).indexOf(":T") + 3; // inside the first hex length
|
|
464
|
+
const multibyte = Buffer.from(bytes).indexOf(Buffer.from("ç")) + 1; // between the two bytes of "ç"
|
|
465
|
+
const cuts = [0, firstLength, multibyte, multibyte + 700, bytes.length];
|
|
466
|
+
const body = new ReadableStream({
|
|
467
|
+
start(controller) {
|
|
468
|
+
for (let i = 0; i < cuts.length - 1; i += 1) controller.enqueue(bytes.slice(cuts[i], cuts[i + 1]));
|
|
469
|
+
controller.close();
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
const headers = new Headers({ "content-type": "text/x-component" });
|
|
473
|
+
fetch.mockResolvedValue({ ok: true, status: 200, statusText: "200", url: `${ORIGIN}/posts`, headers, body });
|
|
474
|
+
|
|
475
|
+
click(dom.listeners, "/posts");
|
|
476
|
+
await settle();
|
|
477
|
+
|
|
478
|
+
expect(onError).not.toHaveBeenCalled();
|
|
479
|
+
expect(onNavigate).toHaveBeenCalledTimes(1);
|
|
480
|
+
expect(onNavigate.mock.calls[0][0]).toEqual(expected);
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// Story 17-0c — a navigation decodes dates and big integers like the first load.
|
|
485
|
+
describe("ruact-router — dates and big integers in a navigation (Story 17-0c)", () => {
|
|
486
|
+
const FIXTURES = path.join(import.meta.dirname, "../../../spec/fixtures/flight");
|
|
487
|
+
let dom;
|
|
488
|
+
let onNavigate;
|
|
489
|
+
|
|
490
|
+
beforeEach(() => {
|
|
491
|
+
dom = installDom();
|
|
492
|
+
onNavigate = vi.fn();
|
|
493
|
+
globalThis.fetch = vi.fn();
|
|
494
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
495
|
+
setupRouter({ onNavigate, moduleRegistry: {}, onError: vi.fn() });
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
afterEach(() => {
|
|
499
|
+
teardownRouter();
|
|
500
|
+
vi.restoreAllMocks();
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("hands the component a Date and a BigInt", async () => {
|
|
504
|
+
const body = fs.readFileSync(path.join(FIXTURES, "scalar_round_trip.txt"), "utf8");
|
|
505
|
+
fetch.mockResolvedValue(respond({ body, url: `${ORIGIN}/events` }));
|
|
506
|
+
|
|
507
|
+
click(dom.listeners, "/events");
|
|
508
|
+
await settle();
|
|
509
|
+
|
|
510
|
+
const tree = onNavigate.mock.calls[0][0];
|
|
511
|
+
expect(tree.time).toBeInstanceOf(Date);
|
|
512
|
+
expect(tree.time.toISOString()).toBe("2026-09-08T12:30:45.123Z");
|
|
513
|
+
expect(tree.big).toBe(9007199254740993n);
|
|
514
|
+
expect(tree.nested.ids).toEqual([1152921504606846976n, 7]);
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
|
|
@@ -4,6 +4,19 @@
|
|
|
4
4
|
* Handles the subset we emit from the Ruby server:
|
|
5
5
|
* - Model rows: <hex_id>:<json>\n
|
|
6
6
|
* - Import rows: <hex_id>:I[moduleId, exportName, chunks]\n
|
|
7
|
+
* - Error rows: <hex_id>:E<json>\n
|
|
8
|
+
* - Text rows: <hex_id>:T<hex_byte_length>,<utf-8 text> (NO newline)
|
|
9
|
+
*
|
|
10
|
+
* Every string of 1024 bytes or more travels as a text row, framed by its
|
|
11
|
+
* byte length, and is referenced from a model row as "$T<hex_id>". Both the
|
|
12
|
+
* initial load and the router read rows through ONE incremental byte parser
|
|
13
|
+
* ({@link createRowParser}), so a payload decodes the same whichever way it
|
|
14
|
+
* arrives and wherever the network splits it.
|
|
15
|
+
*
|
|
16
|
+
* Values JSON cannot hold travel as "$"-prefixed strings and are rebuilt:
|
|
17
|
+
* "$D<ISO 8601>" → Date, "$n<decimal>" → BigInt, "$NaN"/"$Infinity"/… →
|
|
18
|
+
* the number, "$undefined" → undefined. A literal string that starts with
|
|
19
|
+
* "$" arrives escaped as "$$…".
|
|
7
20
|
*
|
|
8
21
|
* Returns a React element tree by recursively converting
|
|
9
22
|
* ["$", type, key, props] tuples into React.createElement calls.
|
|
@@ -72,6 +85,7 @@ export function parseLine(line) {
|
|
|
72
85
|
|
|
73
86
|
const colonIdx = line.indexOf(":");
|
|
74
87
|
if (colonIdx === -1) return null;
|
|
88
|
+
if (colonIdx === 0) return null; // hint row (":H…") — a preload signal, no id
|
|
75
89
|
|
|
76
90
|
const id = parseInt(line.slice(0, colonIdx), 16);
|
|
77
91
|
const rest = line.slice(colonIdx + 1);
|
|
@@ -97,6 +111,140 @@ export function parseLine(line) {
|
|
|
97
111
|
}
|
|
98
112
|
}
|
|
99
113
|
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Incremental row parser (bytes in, rows out)
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
const COLON = 0x3a;
|
|
119
|
+
const COMMA = 0x2c;
|
|
120
|
+
const NEWLINE = 0x0a;
|
|
121
|
+
const TAG_T = 0x54;
|
|
122
|
+
const HEX = /^[0-9a-fA-F]+$/;
|
|
123
|
+
|
|
124
|
+
const utf8 = new TextDecoder("utf-8");
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* An incremental Flight row parser. Feed it the payload's bytes in any number
|
|
128
|
+
* of chunks — split anywhere, including inside a header, a length or a UTF-8
|
|
129
|
+
* code point — and it calls `onRow({ id, row })` once per complete row.
|
|
130
|
+
*
|
|
131
|
+
* Text rows are framed by their BYTE length, so the parser works on bytes,
|
|
132
|
+
* never on decoded strings: a string's `length` counts UTF-16 units and would
|
|
133
|
+
* misread any non-ASCII text.
|
|
134
|
+
*
|
|
135
|
+
* `end()` must be called when the input is complete. A truncated text row or
|
|
136
|
+
* a malformed length throws instead of yielding a partial payload.
|
|
137
|
+
*
|
|
138
|
+
* @param {(parsed: { id: number, row: object }) => void} onRow
|
|
139
|
+
* @returns {{ push(bytes: Uint8Array): void, end(): void }}
|
|
140
|
+
*/
|
|
141
|
+
export function createRowParser(onRow) {
|
|
142
|
+
// A growable byte buffer: `buf[pos, len)` is what is not parsed yet. It
|
|
143
|
+
// grows by doubling and is compacted only when full, so a large row arriving
|
|
144
|
+
// in many small chunks costs linear time, not a copy per chunk.
|
|
145
|
+
let buf = new Uint8Array(4096);
|
|
146
|
+
let len = 0;
|
|
147
|
+
let pos = 0;
|
|
148
|
+
// How far the newline search for the row at `pos` already got, so a long
|
|
149
|
+
// model row is not re-scanned from its start on every chunk.
|
|
150
|
+
let scanned = 0;
|
|
151
|
+
|
|
152
|
+
const append = (bytes) => {
|
|
153
|
+
if (len + bytes.length > buf.length) {
|
|
154
|
+
const rest = len - pos;
|
|
155
|
+
if (rest + bytes.length <= buf.length) {
|
|
156
|
+
buf.copyWithin(0, pos, len);
|
|
157
|
+
} else {
|
|
158
|
+
let capacity = buf.length * 2;
|
|
159
|
+
while (capacity < rest + bytes.length) capacity *= 2;
|
|
160
|
+
const next = new Uint8Array(capacity);
|
|
161
|
+
next.set(buf.subarray(pos, len));
|
|
162
|
+
buf = next;
|
|
163
|
+
}
|
|
164
|
+
scanned = Math.max(0, scanned - pos);
|
|
165
|
+
len = rest;
|
|
166
|
+
pos = 0;
|
|
167
|
+
}
|
|
168
|
+
buf.set(bytes, len);
|
|
169
|
+
len += bytes.length;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const advance = (to) => {
|
|
173
|
+
pos = to;
|
|
174
|
+
scanned = 0;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// Parses one row starting at `pos`. Returns false when it needs more bytes.
|
|
178
|
+
const step = () => {
|
|
179
|
+
const view = buf.subarray(0, len);
|
|
180
|
+
const colon = view.indexOf(COLON, pos);
|
|
181
|
+
|
|
182
|
+
// A line with no colon before its end is not a row (a stray line from a
|
|
183
|
+
// proxy, say): skip it rather than let it swallow the row after it.
|
|
184
|
+
const headerEnd = colon === -1 ? len : colon;
|
|
185
|
+
const stray = view.subarray(pos, headerEnd).indexOf(NEWLINE);
|
|
186
|
+
if (stray !== -1) {
|
|
187
|
+
advance(pos + stray + 1);
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
if (colon === -1 || colon + 1 >= len) return false;
|
|
191
|
+
|
|
192
|
+
const header = utf8.decode(view.subarray(pos, colon)).trim();
|
|
193
|
+
|
|
194
|
+
if (HEX.test(header) && view[colon + 1] === TAG_T) {
|
|
195
|
+
const comma = view.indexOf(COMMA, colon + 2);
|
|
196
|
+
if (comma === -1) return false;
|
|
197
|
+
const lengthHex = utf8.decode(view.subarray(colon + 2, comma));
|
|
198
|
+
if (!HEX.test(lengthHex)) {
|
|
199
|
+
throw new Error(`[flight-client] Malformed text row length "${lengthHex}" for row ${header}`);
|
|
200
|
+
}
|
|
201
|
+
const length = parseInt(lengthHex, 16);
|
|
202
|
+
const start = comma + 1;
|
|
203
|
+
if (start + length > len) return false;
|
|
204
|
+
onRow({ id: parseInt(header, 16), row: { kind: "text", value: utf8.decode(view.subarray(start, start + length)) } });
|
|
205
|
+
advance(start + length);
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const newline = view.indexOf(NEWLINE, Math.max(colon + 1, scanned));
|
|
210
|
+
if (newline === -1) {
|
|
211
|
+
scanned = len;
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
const parsed = parseLine(utf8.decode(view.subarray(pos, newline)));
|
|
215
|
+
if (parsed) onRow(parsed);
|
|
216
|
+
advance(newline + 1);
|
|
217
|
+
return true;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
push(bytes) {
|
|
222
|
+
append(bytes);
|
|
223
|
+
while (pos < len) {
|
|
224
|
+
// Blank separators between rows carry nothing.
|
|
225
|
+
while (pos < len && (buf[pos] === NEWLINE || buf[pos] === 0x0d)) advance(pos + 1);
|
|
226
|
+
if (pos >= len || !step()) break;
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
end() {
|
|
231
|
+
const rest = utf8.decode(buf.subarray(pos, len));
|
|
232
|
+
if (!rest.trim()) return;
|
|
233
|
+
// A last model/import/error row without its trailing newline is complete
|
|
234
|
+
// as far as JSON can tell; a cut-off text row or header is not.
|
|
235
|
+
const colon = rest.indexOf(":");
|
|
236
|
+
const header = colon === -1 ? "" : rest.slice(0, colon).trim();
|
|
237
|
+
if (colon !== -1 && HEX.test(header) && rest[colon + 1] === "T") {
|
|
238
|
+
throw new Error(`[flight-client] Truncated Flight payload: text row ${header} is incomplete`);
|
|
239
|
+
}
|
|
240
|
+
const parsed = colon === -1 ? null : parseLine(rest);
|
|
241
|
+
if (!parsed) throw new Error(`[flight-client] Truncated Flight payload: "${rest.slice(0, 40)}"`);
|
|
242
|
+
onRow(parsed);
|
|
243
|
+
advance(len);
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
100
248
|
// ---------------------------------------------------------------------------
|
|
101
249
|
// Tree building
|
|
102
250
|
// ---------------------------------------------------------------------------
|
|
@@ -136,11 +284,10 @@ export function buildTree(value, rows, moduleRegistry) {
|
|
|
136
284
|
* @param {Object} moduleRegistry
|
|
137
285
|
*/
|
|
138
286
|
export function createFromFlightPayload(payload, moduleRegistry) {
|
|
139
|
-
const rows
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
287
|
+
const rows = new Map();
|
|
288
|
+
const parser = createRowParser(({ id, row }) => rows.set(id, row));
|
|
289
|
+
parser.push(new TextEncoder().encode(payload));
|
|
290
|
+
parser.end();
|
|
144
291
|
return buildTreeFromRows(rows, moduleRegistry);
|
|
145
292
|
}
|
|
146
293
|
|
|
@@ -159,6 +306,21 @@ function _buildTree(value, rows, moduleRegistry) {
|
|
|
159
306
|
if (value === "$Infinity") return Infinity;
|
|
160
307
|
if (value === "$-Infinity") return -Infinity;
|
|
161
308
|
if (value === "$-0") return -0;
|
|
309
|
+
if (value.startsWith("$T")) {
|
|
310
|
+
const refId = parseInt(value.slice(2), 16);
|
|
311
|
+
const row = rows.get(refId);
|
|
312
|
+
if (!row || row.kind !== "text") {
|
|
313
|
+
throw new Error(`[flight-client] Text row ${value.slice(2)} is referenced but was never received`);
|
|
314
|
+
}
|
|
315
|
+
return row.value;
|
|
316
|
+
}
|
|
317
|
+
// Story 17-0c — the serializer emits Ruby Time/DateTime as "$D<ISO 8601>"
|
|
318
|
+
// and integers outside JS's safe range as "$n<decimal>". The component
|
|
319
|
+
// gets the type, not the marker. (A literal string starting with "$" was
|
|
320
|
+
// escaped to "$$…" on the server and is handled above.)
|
|
321
|
+
if (value.startsWith("$D")) return new Date(value.slice(2));
|
|
322
|
+
// Guarded: a malformed marker must not throw and take the page down.
|
|
323
|
+
if (value.startsWith("$n") && /^-?\d+$/.test(value.slice(2))) return BigInt(value.slice(2));
|
|
162
324
|
if (value.startsWith("$L")) {
|
|
163
325
|
const refId = parseInt(value.slice(2), 16);
|
|
164
326
|
const row = rows.get(refId);
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import {
|
|
13
|
-
|
|
13
|
+
createRowParser,
|
|
14
14
|
buildTreeFromRows,
|
|
15
15
|
buildTree,
|
|
16
16
|
resolvePendingChunk,
|
|
@@ -564,11 +564,10 @@ async function _processFlightResponse(response, {
|
|
|
564
564
|
let initialTreeSet = false;
|
|
565
565
|
let redirected = false;
|
|
566
566
|
|
|
567
|
-
const
|
|
568
|
-
const parsed = parseLine(line);
|
|
569
|
-
if (!parsed) return;
|
|
570
|
-
|
|
567
|
+
const processRow = (parsed) => {
|
|
571
568
|
rows.set(parsed.id, parsed.row);
|
|
569
|
+
// A text row is data a model row points at; nothing renders on its own.
|
|
570
|
+
if (parsed.row.kind === "text") return;
|
|
572
571
|
|
|
573
572
|
if (!initialTreeSet && rows.has(0)) {
|
|
574
573
|
const rootRow = rows.get(0);
|
|
@@ -606,25 +605,17 @@ async function _processFlightResponse(response, {
|
|
|
606
605
|
}
|
|
607
606
|
};
|
|
608
607
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
608
|
+
// The same byte parser the initial load uses: text rows are framed by byte
|
|
609
|
+
// length, so the stream is never decoded line by line.
|
|
610
|
+
const parser = createRowParser(processRow);
|
|
611
|
+
const reader = response.body.getReader();
|
|
612
612
|
|
|
613
613
|
while (true) {
|
|
614
614
|
const { done, value } = await reader.read();
|
|
615
615
|
if (done) break;
|
|
616
|
-
|
|
617
|
-
buffer += decoder.decode(value, { stream: true });
|
|
618
|
-
|
|
619
|
-
let newlineIdx;
|
|
620
|
-
while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
|
|
621
|
-
processLine(buffer.slice(0, newlineIdx));
|
|
622
|
-
buffer = buffer.slice(newlineIdx + 1);
|
|
623
|
-
}
|
|
616
|
+
parser.push(value);
|
|
624
617
|
}
|
|
625
|
-
|
|
626
|
-
// Handle any remaining content without a trailing newline
|
|
627
|
-
if (buffer.trim()) processLine(buffer);
|
|
618
|
+
parser.end();
|
|
628
619
|
|
|
629
620
|
// Fallback: if row 0 never triggered (shouldn't happen with valid server)
|
|
630
621
|
if (!initialTreeSet && !redirected && rows.has(0)) {
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruact
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.15
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Luiz Garcia
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-03 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: nokogiri
|
|
@@ -76,6 +76,7 @@ files:
|
|
|
76
76
|
- lib/ruact/configuration.rb
|
|
77
77
|
- lib/ruact/controller.rb
|
|
78
78
|
- lib/ruact/controller/document_rendering.rb
|
|
79
|
+
- lib/ruact/controller/format_negotiation.rb
|
|
79
80
|
- lib/ruact/controller/page_rendering.rb
|
|
80
81
|
- lib/ruact/controller/pages.rb
|
|
81
82
|
- lib/ruact/doctor.rb
|
|
@@ -83,11 +84,13 @@ files:
|
|
|
83
84
|
- lib/ruact/erb_preprocessor_hook.rb
|
|
84
85
|
- lib/ruact/errors.rb
|
|
85
86
|
- lib/ruact/flight.rb
|
|
87
|
+
- lib/ruact/flight/js_date.rb
|
|
86
88
|
- lib/ruact/flight/react_element.rb
|
|
87
89
|
- lib/ruact/flight/renderer.rb
|
|
88
90
|
- lib/ruact/flight/request.rb
|
|
89
91
|
- lib/ruact/flight/row_emitter.rb
|
|
90
92
|
- lib/ruact/flight/serializer.rb
|
|
93
|
+
- lib/ruact/flight/utf8.rb
|
|
91
94
|
- lib/ruact/html_converter.rb
|
|
92
95
|
- lib/ruact/layout_source.rb
|
|
93
96
|
- lib/ruact/manifest_resolver.rb
|
|
@@ -95,6 +98,7 @@ files:
|
|
|
95
98
|
- lib/ruact/packaging.rb
|
|
96
99
|
- lib/ruact/query.rb
|
|
97
100
|
- lib/ruact/railtie.rb
|
|
101
|
+
- lib/ruact/redirect_path.rb
|
|
98
102
|
- lib/ruact/render_context.rb
|
|
99
103
|
- lib/ruact/render_pipeline.rb
|
|
100
104
|
- lib/ruact/routing.rb
|