solid_errors-frontend 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b92029d68f265e946100c2e7b55f85f750a840ac9b94d50d4a1af9a17cbb31a1
4
+ data.tar.gz: 9887401d8ad11b40d3e29fd600fa3b2bd091bd483f3a35291b377e4952ee34d1
5
+ SHA512:
6
+ metadata.gz: 031c3ab7bde338a19fb2682590e5bdf9194cd7b985a27a40e84306017046127ef1d1a52d3aba7a62ceb470bc197ca8cb988183d5540065a945c4d11b7d33683a
7
+ data.tar.gz: 7e277694e98ee9bb4182b1a2fb0afd2a59b9b9da4fc174dac1c14795d5c0e217c730bbf320a9bafe0b8d372b6b910cb415d01cd9814dec253b2fcb330ce72beb
data/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release.
6
+
7
+ - Captures uncaught JavaScript exceptions, unhandled promise rejections, Stimulus
8
+ controller errors and Turbo failures in the browser and reports them through
9
+ `ActiveSupport::ErrorReporter`, so Solid Errors records them alongside
10
+ server-side errors.
11
+ - Resolves frames of your own assets back through the Propshaft digest and
12
+ re-emits them as Ruby backtrace lines, so Solid Errors marks them as
13
+ application code and renders the surrounding source.
14
+ - Normalises messages before reporting, so one recurring bug stays one row rather
15
+ than splitting across the ids and URLs in its text.
16
+ - Bounded on every axis, since the ingest endpoint is unauthenticated by design:
17
+ per-IP rate limit, body size, reports per request and per page, message length,
18
+ frame count, and the generated exception-class registry.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Armand Mégrot
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,170 @@
1
+ # solid_errors-frontend
2
+
3
+ Browser errors in your [Solid Errors](https://github.com/fractaledmind/solid_errors)
4
+ dashboard.
5
+
6
+ Uncaught JavaScript exceptions, unhandled promise rejections, Stimulus controller
7
+ errors and Turbo failures are captured in the browser, posted to a mounted
8
+ engine, and handed to `ActiveSupport::ErrorReporter` — the same path Solid Errors
9
+ already receives server-side errors on. They arrive as ordinary rows, grouped and
10
+ deduplicated like everything else, with frames pointing at your own source files.
11
+
12
+ ![A JavaScript TypeError in the Solid Errors dashboard, its backtrace resolved to
13
+ the Stimulus controller that raised it, with the failing line
14
+ highlighted](docs/dashboard-error.png)
15
+
16
+ > A third-party gem. Not affiliated with, or endorsed by, the solid_errors
17
+ > project; it shares the name prefix because that's what it pairs with.
18
+
19
+ **Requires Rails 8.0+, Propshaft and importmap** — see [Supported
20
+ stack](#supported-stack).
21
+
22
+ ## Why the backtraces are readable
23
+
24
+ With importmap + Propshaft there is no bundling and no minification, so a line
25
+ number in a served asset matches the source file exactly — no source maps
26
+ required. Frames are resolved back through the Propshaft digest
27
+ (`application-abc12345.js` → `application.js`) and re-emitted in Ruby's backtrace
28
+ shape:
29
+
30
+ ```
31
+ /rails/app/javascript/controllers/map_controller.js:42:in `connect'
32
+ ```
33
+
34
+ `SolidErrors::BacktraceLine` parses those, treats paths under the project root as
35
+ application code, and renders the surrounding source — so a browser error opens
36
+ in the dashboard showing the JavaScript that raised it. Frames that can't be
37
+ resolved (CDN scripts, extensions, inline handlers) pass through verbatim and
38
+ degrade to unparsed text rather than pointing somewhere wrong.
39
+
40
+ Because the file part of that format can't contain a colon and a trailing
41
+ `:column` breaks the match, columns are dropped from the frame and reported in
42
+ the context instead.
43
+
44
+ ## Install
45
+
46
+ ```ruby
47
+ gem "solid_errors-frontend"
48
+ ```
49
+
50
+ Mount the engine and add the config tag to your layout:
51
+
52
+ ```ruby
53
+ # config/routes.rb
54
+ mount SolidErrors::Frontend::Engine, at: "/frontend_errors"
55
+ ```
56
+
57
+ ```erb
58
+ <%# app/views/layouts/application.html.erb, in <head> %>
59
+ <%= frontend_errors_tag %>
60
+ ```
61
+
62
+ Start the reporter before your own code, so it's listening when that code runs:
63
+
64
+ ```js
65
+ // app/javascript/application.js
66
+ import { start } from "solid_errors_frontend"
67
+ start()
68
+ ```
69
+
70
+ And hook Stimulus, which otherwise swallows every controller error into
71
+ `console.error`:
72
+
73
+ ```js
74
+ // app/javascript/controllers/application.js
75
+ import { installStimulusErrorHandler } from "solid_errors_frontend"
76
+
77
+ const application = Application.start()
78
+ installStimulusErrorHandler(application)
79
+ ```
80
+
81
+ ## Supported stack
82
+
83
+ | | |
84
+ |---|---|
85
+ | **Rails 8.0+** | The ingest controller scopes its rate limit with `rate_limit(name:)`, which arrived in 8.0. |
86
+ | **Propshaft** | Required to resolve frames back to source. Under Sprockets or a bundler (esbuild, vite) everything still works — errors are reported, grouped and deduplicated — but every frame stays verbatim, because a minified line number doesn't correspond to a source line. |
87
+ | **importmap** | The JavaScript ships through the asset pipeline; there is no npm package. A bundled app has to reference the file directly rather than importing the bare specifier. |
88
+
89
+ Solid Errors itself is *not* a runtime dependency. Reports go through
90
+ `Rails.error`, so this works with any `ErrorReporter` subscriber — or none at
91
+ all, which is what makes it safe in development and test where Solid Errors is
92
+ often not installed. Everything below about grouping and fingerprints describes
93
+ Solid Errors specifically, because that is what it's built for.
94
+
95
+ ## Configuration
96
+
97
+ ```ruby
98
+ # config/initializers/solid_errors_frontend.rb
99
+ SolidErrors::Frontend.context = -> {
100
+ { user_id: Current.user&.id }
101
+ }
102
+ ```
103
+
104
+ | Option | Default | |
105
+ |---|---|---|
106
+ | `base_controller_class` | `"::ActionController::Base"` | Superclass of the ingest controller. Deliberately not the host's `ApplicationController`: the endpoint has to work without a session so errors on sign-in pages are captured. |
107
+ | `context` | `-> { {} }` | Lambda `instance_exec`'d in the controller; returns extra flat context. Has access to `request`, `cookies`, … |
108
+ | `allowed_context_keys` | url, referrer, viewport, identifier, frame_id, method, status | Client-supplied context keys to keep. Strings or symbols. |
109
+ | `rate_limit_to` / `rate_limit_within` | `30` / `1.minute` | Per-IP limit on the endpoint. |
110
+ | `max_body_bytes` | `64.kilobytes` | Larger requests get a 413. |
111
+ | `max_reports_per_request` | `20` | |
112
+ | `max_reports_per_page` | `10` | Browser-side budget, reset on each Turbo visit. |
113
+ | `max_message_length` | `500` | |
114
+ | `max_frames` | `30` | |
115
+ | `message_filters` | uuids, urls, digests | Applied before reporting — see below. |
116
+ | `log_reports` | `nil` (on outside production) | Log every report, which is how you see the pipeline work in an environment with no subscriber. |
117
+
118
+ ### Context
119
+
120
+ Two sources are merged. `SolidErrors::Frontend.context` is server-derived and
121
+ unrestricted — put identity there. Anything the browser sends is filtered through
122
+ `allowed_context_keys`, since the endpoint is unauthenticated; extend it with
123
+ whatever your own instrumentation reports:
124
+
125
+ ```ruby
126
+ SolidErrors::Frontend.allowed_context_keys += %w[activity_id]
127
+ ```
128
+
129
+ Values are coerced to short scalars whatever the list says, and **server-derived
130
+ context is merged last**, so a client can never overwrite a key the server
131
+ resolved for it — including one you add to the allowlist yourself.
132
+
133
+ ### Message normalisation
134
+
135
+ Solid Errors fingerprints on `exception_class + message + severity + source`, so
136
+ an id or URL inside the message would split one recurring bug across an unbounded
137
+ number of rows. `message_filters` collapses those before reporting.
138
+
139
+ ### What arrives in the dashboard
140
+
141
+ - **Exception class** — `JS::TypeError`, `JS::UnhandledRejection`, … built per
142
+ JavaScript error name so the grouping stays legible. Names are allowlisted and
143
+ the registry is capped, since the value comes from the client.
144
+ - **Source** — `javascript.window`, `javascript.promise`, `javascript.stimulus`,
145
+ `javascript.turbo`, so capture sites don't group together.
146
+ - **Severity** — `:error`, except Turbo failures which are `:warning`.
147
+ - **Context** — url, referrer, viewport, column, user agent, plus whatever
148
+ `SolidErrors::Frontend.context` adds and a small allowlist of per-source detail
149
+ (Stimulus identifier, Turbo frame id).
150
+
151
+ Which puts them in the same list as everything else, rather than a place you have
152
+ to remember to check:
153
+
154
+ ![The Solid Errors index listing a server-side NoMethodError, a
155
+ JS::TurboFrameMissing and a JS::TypeError together](docs/dashboard-index.png)
156
+
157
+ ## Endpoint exposure
158
+
159
+ `POST` to the mounted path is unauthenticated and CSRF-exempt: errors on the
160
+ sign-in page are worth having, and `navigator.sendBeacon` — the only transport
161
+ that survives a closing page — can't set a CSRF header. It is therefore bounded
162
+ on every axis: per-IP rate limit, body size, reports per request, message length,
163
+ and the generated exception-class registry.
164
+
165
+ One caveat on the rate limit: it counts through `Rails.cache#increment`, so it is
166
+ only as real as the configured store. Against a `:null_store` it counts nothing
167
+ and the limit never trips. The other bounds hold regardless.
168
+
169
+ If the noise ever outweighs the coverage, set `base_controller_class` to a
170
+ controller that requires a session and accept losing pre-login errors.
@@ -0,0 +1,222 @@
1
+ // Browser-side error reporter.
2
+ //
3
+ // Captures uncaught exceptions, unhandled promise rejections, Stimulus
4
+ // controller errors and Turbo failures, and posts them to the mounted engine,
5
+ // which reports them through Rails.error.
6
+ //
7
+ // Configuration comes from <meta name="solid-errors-frontend"> (see TagsHelper).
8
+
9
+ const DEFAULTS = {
10
+ endpoint: "/frontend_errors",
11
+ maxReports: 10,
12
+ maxMessageLength: 500,
13
+ maxFrames: 30,
14
+ flushDelay: 1000
15
+ }
16
+
17
+ // Messages that carry no actionable information. "Script error." is what
18
+ // browsers report for exceptions thrown by cross-origin scripts, and the
19
+ // ResizeObserver warnings are fired by layout timing rather than by a bug.
20
+ const IGNORED_MESSAGES = [
21
+ /^Script error\.?$/,
22
+ /^ResizeObserver loop/
23
+ ]
24
+
25
+ const EXTENSION_SCHEME = /^(chrome|moz|safari-web|safari)-extension:\/\//
26
+
27
+ const config = { ...DEFAULTS }
28
+ const queue = []
29
+ const seen = new Set()
30
+
31
+ let started = false
32
+ let reported = 0
33
+ let reporting = false
34
+ let flushTimer = null
35
+
36
+ function readConfig() {
37
+ const meta = document.querySelector("meta[name='solid-errors-frontend']")
38
+ if (!meta) return
39
+
40
+ try {
41
+ Object.assign(config, JSON.parse(meta.content))
42
+ } catch {
43
+ // A malformed tag shouldn't disable reporting; the defaults still work.
44
+ }
45
+ }
46
+
47
+ function truncate(value, length) {
48
+ const text = String(value ?? "")
49
+ return text.length > length ? `${text.slice(0, length - 1)}…` : text
50
+ }
51
+
52
+ // Cross-origin and extension frames tell us nothing about our own code, and
53
+ // extensions in particular generate a lot of noise on other people's pages.
54
+ function isNoise(message, stack) {
55
+ if (IGNORED_MESSAGES.some((pattern) => pattern.test(message))) return true
56
+ if (!stack) return false
57
+
58
+ const frames = stack.split("\n").filter((line) => /https?:|:\d+:\d+/.test(line))
59
+ return frames.length > 0 && frames.every((line) => EXTENSION_SCHEME.test(line.trim().replace(/^at\s+/, "")))
60
+ }
61
+
62
+ function pageContext() {
63
+ return {
64
+ url: window.location.href,
65
+ referrer: document.referrer || undefined,
66
+ viewport: `${window.innerWidth}x${window.innerHeight}`
67
+ }
68
+ }
69
+
70
+ export function report({ name, message, stack, source, context = {} }) {
71
+ // A failure inside the reporter must never re-enter it.
72
+ if (reporting || !started) return
73
+
74
+ try {
75
+ reporting = true
76
+
77
+ const text = truncate(message, config.maxMessageLength)
78
+ if (!text && !name) return
79
+ if (isNoise(text, stack)) return
80
+
81
+ const key = `${name}|${text}|${(stack || "").split("\n")[1] || ""}`
82
+ if (seen.has(key)) return
83
+ if (reported >= config.maxReports) return
84
+
85
+ seen.add(key)
86
+ reported += 1
87
+
88
+ queue.push({
89
+ name: name || "Error",
90
+ message: text,
91
+ stack: stack ? stack.split("\n").slice(0, config.maxFrames + 1).join("\n") : undefined,
92
+ source,
93
+ context: { ...pageContext(), ...context }
94
+ })
95
+
96
+ scheduleFlush()
97
+ } catch {
98
+ // Reporting is best-effort.
99
+ } finally {
100
+ reporting = false
101
+ }
102
+ }
103
+
104
+ function scheduleFlush() {
105
+ if (flushTimer) return
106
+ flushTimer = setTimeout(() => flush(), config.flushDelay)
107
+ }
108
+
109
+ function flush({ beacon = false } = {}) {
110
+ if (flushTimer) {
111
+ clearTimeout(flushTimer)
112
+ flushTimer = null
113
+ }
114
+ if (queue.length === 0) return
115
+
116
+ const body = JSON.stringify({ reports: queue.splice(0, queue.length) })
117
+
118
+ // On pagehide the document may be torn down before fetch resolves, so use
119
+ // sendBeacon there; it can't set headers, which is why the endpoint is
120
+ // CSRF-exempt.
121
+ if (beacon && navigator.sendBeacon) {
122
+ navigator.sendBeacon(config.endpoint, new Blob([body], { type: "application/json" }))
123
+ return
124
+ }
125
+
126
+ fetch(config.endpoint, {
127
+ method: "POST",
128
+ headers: { "Content-Type": "application/json" },
129
+ body,
130
+ keepalive: true,
131
+ credentials: "same-origin"
132
+ }).catch(() => {
133
+ // Dropping a report is preferable to an unhandled rejection here, which
134
+ // would come straight back through our own rejection handler.
135
+ })
136
+ }
137
+
138
+ function errorFrom(value) {
139
+ if (value instanceof Error) {
140
+ return { name: value.name, message: value.message, stack: value.stack }
141
+ }
142
+ return { name: "Error", message: String(value) }
143
+ }
144
+
145
+ function onError(event) {
146
+ if (event.error) {
147
+ report({ ...errorFrom(event.error), source: "window" })
148
+ } else if (event.message) {
149
+ // Cross-origin scripts give an ErrorEvent with no Error object. The
150
+ // location is still worth keeping, formatted so the server can resolve it.
151
+ report({
152
+ name: "Error",
153
+ message: event.message,
154
+ stack: event.filename ? ` at ${event.filename}:${event.lineno || 0}:${event.colno || 0}` : undefined,
155
+ source: "window"
156
+ })
157
+ }
158
+ }
159
+
160
+ function onUnhandledRejection(event) {
161
+ const { name, message, stack } = errorFrom(event.reason)
162
+ report({ name: name === "Error" ? "UnhandledRejection" : name, message, stack, source: "promise" })
163
+ }
164
+
165
+ function onTurboFetchError(event) {
166
+ const request = event.detail?.request
167
+ report({
168
+ name: "TurboFetchRequestError",
169
+ message: `${request?.method || "GET"} ${request?.url || "request"} failed`,
170
+ source: "turbo",
171
+ context: { method: request?.method, url: request?.url?.toString() }
172
+ })
173
+ }
174
+
175
+ function onTurboFrameMissing(event) {
176
+ report({
177
+ name: "TurboFrameMissing",
178
+ message: `Response missing turbo-frame #${event.target?.id || "unknown"}`,
179
+ source: "turbo",
180
+ context: { frame_id: event.target?.id, status: event.detail?.response?.status }
181
+ })
182
+ }
183
+
184
+ // Turbo Drive keeps the document alive across navigations, so a per-page-load
185
+ // budget has to be reset on visits rather than on load.
186
+ function onTurboLoad() {
187
+ reported = 0
188
+ }
189
+
190
+ export function start() {
191
+ if (started) return
192
+ started = true
193
+
194
+ readConfig()
195
+
196
+ window.addEventListener("error", onError)
197
+ window.addEventListener("unhandledrejection", onUnhandledRejection)
198
+ document.addEventListener("turbo:fetch-request-error", onTurboFetchError)
199
+ document.addEventListener("turbo:frame-missing", onTurboFrameMissing)
200
+ document.addEventListener("turbo:load", onTurboLoad)
201
+
202
+ window.addEventListener("pagehide", () => flush({ beacon: true }))
203
+ document.addEventListener("visibilitychange", () => {
204
+ if (document.visibilityState === "hidden") flush({ beacon: true })
205
+ })
206
+ }
207
+
208
+ // Stimulus routes every controller lifecycle and action error through
209
+ // handleError, which only logs by default — so without this the majority of
210
+ // application errors never surface.
211
+ export function installStimulusErrorHandler(application) {
212
+ const original = application.handleError.bind(application)
213
+
214
+ application.handleError = (error, message, detail) => {
215
+ report({
216
+ ...errorFrom(error),
217
+ source: "stimulus",
218
+ context: { identifier: detail?.identifier }
219
+ })
220
+ original(error, message, detail)
221
+ }
222
+ }
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidErrors::Frontend
4
+ class ReportsController < SolidErrors::Frontend.base_controller_class.constantize
5
+ # navigator.sendBeacon can't set an X-CSRF-Token header, and the beacon path is
6
+ # the one that survives a page being closed — which is exactly when we most
7
+ # want the queued errors. See the class comment below for what guards this
8
+ # instead.
9
+ skip_forgery_protection
10
+
11
+ # The endpoint is deliberately reachable without a session, so that errors on
12
+ # the sign-in and sign-up pages are captured too. That makes it publicly
13
+ # writable, so it's bounded on every axis: requests per IP here, body size and
14
+ # report count in #create, message length in MessageNormalizer, and the
15
+ # generated exception-class registry in SolidErrors::Frontend.error_class_for.
16
+ #
17
+ # The per-IP limit leans on Rails.cache#increment, so it is only as real as the
18
+ # configured store — with a :null_store it counts nothing. The other bounds
19
+ # hold regardless.
20
+ rate_limit to: SolidErrors::Frontend.rate_limit_to,
21
+ within: SolidErrors::Frontend.rate_limit_within,
22
+ name: "solid_errors_frontend"
23
+
24
+ def create
25
+ return head(:content_too_large) if request.content_length.to_i > SolidErrors::Frontend.max_body_bytes
26
+
27
+ source_mapper = SourceMapper.new(host: request.host)
28
+
29
+ Payload.parse(request.body.read).each do |report|
30
+ Reporter.new(report, context: report_context, source_mapper: source_mapper).call
31
+ end
32
+
33
+ head :no_content
34
+ end
35
+
36
+ private
37
+ # No `url` here: the client reports its own `location.href`, which is both
38
+ # more accurate than a Referer taken at flush time and unaffected by
39
+ # Referrer-Policy. Deriving one here would only fight it, since server
40
+ # context takes precedence.
41
+ def report_context
42
+ { user_agent: request.user_agent }.merge(host_context).compact
43
+ end
44
+
45
+ # Host-supplied identity/context. Evaluated in the controller so it can read
46
+ # cookies, headers and whatever else the app needs to resolve a user.
47
+ def host_context
48
+ context = instance_exec(&SolidErrors::Frontend.context)
49
+ context.is_a?(Hash) ? context : {}
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidErrors::Frontend
4
+ module TagsHelper
5
+ # Configuration for the browser-side reporter, read by
6
+ # solid_errors_frontend.js. A meta tag rather than an inline script so the
7
+ # module stays a plain, cacheable asset and nothing has to be exempted from a
8
+ # CSP.
9
+ def frontend_errors_tag(**overrides)
10
+ config = {
11
+ endpoint: solid_errors_frontend.reports_path,
12
+ maxReports: SolidErrors::Frontend.max_reports_per_page,
13
+ maxMessageLength: SolidErrors::Frontend.max_message_length,
14
+ maxFrames: SolidErrors::Frontend.max_frames
15
+ }.merge(overrides)
16
+
17
+ tag.meta(name: "solid-errors-frontend", content: config.to_json)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ pin "solid_errors_frontend", to: "solid_errors_frontend.js"
data/config/routes.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ SolidErrors::Frontend::Engine.routes.draw do
4
+ post "/" => "reports#create", as: :reports
5
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidErrors::Frontend
4
+ # Renders browser frames as Ruby backtrace lines.
5
+ #
6
+ # Error backends parse backtraces with a Ruby-shaped regex — roughly
7
+ #
8
+ # /^((?:[a-zA-Z]:)?[^:]+):(\d+)(?::in [`']([^']+)')?$/
9
+ #
10
+ # so a frame written as "/app/javascript/foo.js:42:in `connect'" is understood,
11
+ # linked to its source and (when the path is under the project root) treated as
12
+ # application code. Two constraints follow from that regex: the file part can't
13
+ # contain a colon, and a trailing ":column" makes the whole line fail to match —
14
+ # hence columns are dropped here and carried in the report context instead.
15
+ #
16
+ # Frames we can't resolve to a source file are emitted verbatim; backends fall
17
+ # back to showing the unparsed line, so nothing is lost.
18
+ class BacktraceFormatter
19
+ DEFAULT_METHOD = "<anonymous>"
20
+
21
+ def initialize(source_mapper)
22
+ @source_mapper = source_mapper
23
+ end
24
+
25
+ # @param frames [Array<StackParser::Frame>]
26
+ # @return [Array<String>]
27
+ def call(frames)
28
+ frames.filter_map { |frame| format_frame(frame) }
29
+ end
30
+
31
+ private
32
+ def format_frame(frame)
33
+ return frame.raw if frame.url.nil? || frame.line.nil?
34
+
35
+ path = @source_mapper.resolve(frame.url)
36
+ return frame.raw unless path
37
+
38
+ "#{path}:#{frame.line}:in `#{frame.function || DEFAULT_METHOD}'"
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidErrors::Frontend
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace SolidErrors::Frontend
6
+
7
+ # Serve app/assets/javascripts/solid_errors_frontend.js through the host's
8
+ # asset pipeline. Propshaft and Sprockets both serve it; only Propshaft can
9
+ # resolve the served URL back to a source file (see SourceMapper).
10
+ initializer "solid_errors_frontend.assets" do |app|
11
+ app.config.assets.paths << root.join("app/assets/javascripts") if app.config.respond_to?(:assets)
12
+ end
13
+
14
+ # Importmap::Engine's own "importmap" initializer draws every path in
15
+ # config.importmap.paths, so ours has to be appended before it runs.
16
+ initializer "solid_errors_frontend.importmap", before: "importmap" do |app|
17
+ next unless app.config.respond_to?(:importmap)
18
+
19
+ app.config.importmap.paths << root.join("config/importmap.rb")
20
+ app.config.importmap.cache_sweepers << root.join("app/assets/javascripts")
21
+ end
22
+
23
+ # isolate_namespace keeps engine helpers out of the host's views, but
24
+ # frontend_errors_tag has to be callable from the host layout.
25
+ initializer "solid_errors_frontend.helpers" do
26
+ ActiveSupport.on_load(:action_view) do
27
+ include SolidErrors::Frontend::TagsHelper
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidErrors::Frontend
4
+ # Collapses the parts of a message that vary between occurrences of the same bug.
5
+ #
6
+ # Error backends typically fingerprint on the message (Solid Errors hashes
7
+ # exception class + message + severity + source), so an id, a URL or a digest
8
+ # inside the text would split one recurring bug into an unbounded number of
9
+ # groups. Normalising first keeps one bug to one row.
10
+ class MessageNormalizer
11
+ def self.call(message, filters: SolidErrors::Frontend.message_filters, limit: SolidErrors::Frontend.max_message_length)
12
+ new(filters: filters, limit: limit).call(message)
13
+ end
14
+
15
+ def initialize(filters: SolidErrors::Frontend.message_filters, limit: SolidErrors::Frontend.max_message_length)
16
+ @filters = filters
17
+ @limit = limit
18
+ end
19
+
20
+ def call(message)
21
+ text = message.to_s.strip.gsub(/\s+/, " ")
22
+ text = @filters.reduce(text) { |result, (pattern, replacement)| result.gsub(pattern, replacement) }
23
+ truncate(text)
24
+ end
25
+
26
+ private
27
+ def truncate(text)
28
+ return text if text.length <= @limit
29
+
30
+ "#{text[0, @limit - 1]}…"
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SolidErrors::Frontend
6
+ # Parses the request body into Reports.
7
+ #
8
+ # This is the trust boundary: the endpoint is unauthenticated by design (errors
9
+ # on the sign-in page are worth having), so malformed or hostile input has to
10
+ # produce an empty list rather than an exception.
11
+ module Payload
12
+ def self.parse(body, limit: SolidErrors::Frontend.max_reports_per_request)
13
+ parsed = JSON.parse(body.to_s)
14
+ return [] unless parsed.is_a?(Hash)
15
+
16
+ reports = parsed["reports"]
17
+ return [] unless reports.is_a?(Array)
18
+
19
+ reports.first(limit).filter_map do |attributes|
20
+ Report.from(attributes) if attributes.is_a?(Hash)
21
+ end
22
+ rescue JSON::ParserError
23
+ []
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidErrors::Frontend
4
+ # One browser error, normalised into everything needed to report it.
5
+ #
6
+ # Every field here arrives from the client, so nothing is passed through
7
+ # untouched: the capture site picks the reported source and severity (rather
8
+ # than the payload naming them), extra context is allowlisted, and the message
9
+ # is normalised and truncated.
10
+ class Report
11
+ # Capture site => [reported source, severity, handled]. The source ends up in
12
+ # the fingerprint, so this both labels the report and keeps, say, a Turbo
13
+ # failure from grouping with an identically-worded uncaught exception.
14
+ CAPTURE_SITES = {
15
+ "window" => [ "javascript.window", :error, false ],
16
+ "promise" => [ "javascript.promise", :error, false ],
17
+ "stimulus" => [ "javascript.stimulus", :error, false ],
18
+ "turbo" => [ "javascript.turbo", :warning, true ]
19
+ }.freeze
20
+
21
+ DEFAULT_CAPTURE_SITE = [ "javascript", :error, false ].freeze
22
+
23
+ # Values are kept flat and short whatever SolidErrors::Frontend.allowed_context_keys
24
+ # permits: backends render context as a plain key/value list, and the payload
25
+ # is unauthenticated.
26
+ MAX_CONTEXT_VALUE_LENGTH = 200
27
+
28
+ attr_reader :name, :message, :stack, :source, :severity, :context
29
+
30
+ def self.from(attributes)
31
+ attributes = attributes.to_h { |key, value| [ key.to_s, value ] }
32
+
33
+ # A blank message would be dropped (or worse, raise) by backends that
34
+ # require one, so fall back to the error name before giving up.
35
+ message = MessageNormalizer.call(attributes["message"])
36
+ message = MessageNormalizer.call(attributes["name"]) if message.empty?
37
+ return nil if message.empty?
38
+
39
+ new(
40
+ name: attributes["name"],
41
+ message: message,
42
+ stack: attributes["stack"],
43
+ capture_site: attributes["source"],
44
+ context: attributes["context"]
45
+ )
46
+ end
47
+
48
+ def initialize(name:, message:, stack:, capture_site:, context: nil)
49
+ @name = name
50
+ @message = message
51
+ @stack = stack
52
+ @source, @severity, @handled = CAPTURE_SITES.fetch(capture_site.to_s, DEFAULT_CAPTURE_SITE)
53
+ @context = sanitize_context(context)
54
+ end
55
+
56
+ def handled? = @handled
57
+
58
+ def error_class = SolidErrors::Frontend.error_class_for(name)
59
+
60
+ def frames
61
+ @frames ||= StackParser.parse(stack)
62
+ end
63
+
64
+ private
65
+ def sanitize_context(context)
66
+ return {} unless context.respond_to?(:each_pair)
67
+
68
+ # Hosts configure the allowlist with whichever of strings or symbols come
69
+ # naturally; compare on strings.
70
+ allowed = SolidErrors::Frontend.allowed_context_keys.map(&:to_s)
71
+
72
+ context.each_pair.with_object({}) do |(key, value), result|
73
+ key = key.to_s
74
+ next unless allowed.include?(key)
75
+ next unless scalar?(value)
76
+
77
+ result[key.to_sym] = value.is_a?(String) ? value.strip[0, MAX_CONTEXT_VALUE_LENGTH] : value
78
+ end
79
+ end
80
+
81
+ def scalar?(value)
82
+ case value
83
+ when String, Numeric, TrueClass, FalseClass then true
84
+ else false
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidErrors::Frontend
4
+ # Hands a browser error to Rails' error reporter.
5
+ #
6
+ # SolidErrors::Subscriber is a plain ErrorSubscriber, so this is the way in:
7
+ # going to SolidErrors::Error directly would bypass its sanitizer, fingerprint
8
+ # dedup and occurrence creation. It also means solid_errors need not be loaded
9
+ # at all — in an environment with no subscriber the report simply goes nowhere,
10
+ # which is why the gem works in development and test.
11
+ class Reporter
12
+ def initialize(report, context: {}, source_mapper: SourceMapper.new)
13
+ @report = report
14
+ @context = context
15
+ @source_mapper = source_mapper
16
+ end
17
+
18
+ def call
19
+ error = build_error
20
+ log(error) if SolidErrors::Frontend.log_reports?
21
+
22
+ Rails.error.report(
23
+ error,
24
+ handled: @report.handled?,
25
+ severity: @report.severity,
26
+ source: @report.source,
27
+ context: context
28
+ )
29
+ end
30
+
31
+ private
32
+ def build_error
33
+ error = @report.error_class.new(@report.message)
34
+ error.set_backtrace(backtrace)
35
+ error
36
+ end
37
+
38
+ def backtrace
39
+ BacktraceFormatter.new(@source_mapper).call(@report.frames)
40
+ end
41
+
42
+ # Server-derived context is merged last, so it always wins: whatever a host
43
+ # adds to `allowed_context_keys`, a client must never be able to overwrite
44
+ # the identity we resolved for it.
45
+ def context
46
+ top_frame = @report.frames.first
47
+ @report.context
48
+ .merge(@context)
49
+ .merge(column: top_frame&.column)
50
+ .compact
51
+ end
52
+
53
+ def log(error)
54
+ Rails.logger.warn do
55
+ "[solid_errors_frontend] #{error.class.name}: #{error.message} " \
56
+ "(#{@report.source}) #{error.backtrace&.first}"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module SolidErrors::Frontend
6
+ # Resolves the URL of a served asset back to the source file it was built from.
7
+ #
8
+ # No source maps are involved and none are needed: with importmap + Propshaft
9
+ # there is no bundling or minification, so line numbers in the served asset
10
+ # match the source file exactly. All that has to be undone is the digest
11
+ # Propshaft splices into the filename (application-abc12345.js).
12
+ #
13
+ # Returning an absolute path under Rails.root is what lets an error backend
14
+ # recognise the frame as application code and show the surrounding source.
15
+ class SourceMapper
16
+ MAX_CACHE_SIZE = 500
17
+
18
+ def initialize(host: nil)
19
+ @host = host
20
+ @cache = {}
21
+ end
22
+
23
+ # @return [String, nil] absolute path of the source file, if it can be found
24
+ def resolve(url)
25
+ return nil if url.nil?
26
+
27
+ @cache.fetch(url) do
28
+ path = lookup(url)
29
+ @cache.clear if @cache.size >= MAX_CACHE_SIZE
30
+ @cache[url] = path
31
+ end
32
+ end
33
+
34
+ private
35
+ def lookup(url)
36
+ path = asset_path(url)
37
+ return nil unless path
38
+
39
+ logical_path, _digest = Propshaft::Asset.extract_path_and_digest(path)
40
+ asset = Rails.application.assets.load_path.find(logical_path)
41
+ asset&.path&.to_s
42
+ rescue StandardError
43
+ # Never let stack rewriting take down the report itself.
44
+ nil
45
+ end
46
+
47
+ # The path under the asset prefix, or nil when the URL isn't one of our
48
+ # assets (a CDN, an inline script, a browser extension, another origin).
49
+ def asset_path(url)
50
+ return nil unless defined?(Propshaft) && defined?(Rails) && Rails.respond_to?(:application)
51
+
52
+ uri = URI.parse(url)
53
+ return nil if uri.host && @host && uri.host != @host
54
+ return nil unless uri.path&.start_with?(assets_prefix)
55
+
56
+ uri.path.delete_prefix(assets_prefix)
57
+ rescue URI::InvalidURIError
58
+ nil
59
+ end
60
+
61
+ def assets_prefix
62
+ @assets_prefix ||= Rails.application.assets.prefix
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidErrors::Frontend
4
+ # Turns a browser stack trace string into structured frames.
5
+ #
6
+ # Two dialects have to be handled. V8 (Chrome, Edge, node) prefixes every frame
7
+ # with "at" and puts the location in parentheses when a function name is known:
8
+ #
9
+ # Error: boom
10
+ # at Object.connect (https://host/assets/x-abc123.js:42:15)
11
+ # at https://host/assets/x-abc123.js:1:1
12
+ # at new Foo (https://host/assets/x-abc123.js:7:9)
13
+ # at async load (https://host/assets/x-abc123.js:3:1)
14
+ #
15
+ # SpiderMonkey/JavaScriptCore (Firefox, Safari) use "function@location" with no
16
+ # prefix, and an empty function name for top-level frames:
17
+ #
18
+ # connect@https://host/assets/x-abc123.js:42:15
19
+ # @https://host/assets/x-abc123.js:1:1
20
+ class StackParser
21
+ Frame = Struct.new(:function, :url, :line, :column, :raw, keyword_init: true)
22
+
23
+ # "at fn (url:line:col)" / "at url:line:col". The location group is greedy up
24
+ # to the final two ":n" pairs so that URLs containing colons still split right.
25
+ V8 = /\A\s*at\s+(?:(?<function>.+?)\s+\()?(?<url>.+?):(?<line>\d+):(?<column>\d+)\)?\s*\z/
26
+
27
+ # "fn@url:line:col"
28
+ MOZ = /\A\s*(?<function>[^@]*)@(?<url>.+?):(?<line>\d+):(?<column>\d+)\s*\z/
29
+
30
+ def self.parse(stack, limit: SolidErrors::Frontend.max_frames)
31
+ new(stack).parse(limit: limit)
32
+ end
33
+
34
+ def initialize(stack)
35
+ @stack = stack.to_s
36
+ end
37
+
38
+ def parse(limit: SolidErrors::Frontend.max_frames)
39
+ frames = []
40
+
41
+ @stack.each_line do |raw_line|
42
+ line = raw_line.strip
43
+ next if line.empty?
44
+
45
+ if (frame = frame_from(line))
46
+ frames << frame
47
+ elsif frames.any?
48
+ # Something we can't decompose but that sits inside the trace, e.g.
49
+ # Safari's "[native code]". Worth keeping verbatim.
50
+ frames << Frame.new(raw: line)
51
+ end
52
+ # Anything unparseable *before* the first frame is the "Name: message"
53
+ # header V8 prepends, which we already have as the message.
54
+
55
+ break if frames.size >= limit
56
+ end
57
+
58
+ frames
59
+ end
60
+
61
+ private
62
+ def frame_from(line)
63
+ match = V8.match(line) || MOZ.match(line)
64
+ return unless match
65
+
66
+ Frame.new(
67
+ function: normalize_function(match[:function]),
68
+ url: match[:url],
69
+ line: match[:line].to_i,
70
+ column: match[:column].to_i,
71
+ raw: line.strip
72
+ )
73
+ end
74
+
75
+ # "new Foo" and "async load" carry a V8 modifier rather than a name we can
76
+ # show as-is; keep the name and drop the noise.
77
+ def normalize_function(function)
78
+ name = function.to_s.strip
79
+ name = name.delete_prefix("async ").delete_prefix("new ")
80
+ name.empty? ? nil : name
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidErrors
4
+ module Frontend
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/core_ext/module/attribute_accessors"
5
+ require "active_support/core_ext/numeric/bytes"
6
+ require "active_support/core_ext/numeric/time"
7
+
8
+ require "solid_errors/frontend/version"
9
+
10
+ # Defined here rather than assumed, because solid_errors itself is not a runtime
11
+ # dependency: reports go through ActiveSupport::ErrorReporter, so an app with no
12
+ # subscriber at all still works. When solid_errors *is* loaded it simply reopens
13
+ # this module.
14
+ module SolidErrors
15
+ module Frontend
16
+ # Base class for every error reported from the browser. Concrete classes are
17
+ # built per JavaScript error name by .error_class_for.
18
+ class BrowserError < StandardError; end
19
+
20
+ # JavaScript error names we're willing to turn into a Ruby class name. Anything
21
+ # else (including anything an attacker could POST) collapses to JS::Error.
22
+ NAME_PATTERN = /\A[A-Z][A-Za-z0-9_]{0,63}\z/
23
+
24
+ # Upper bound on the generated class registry, so a hostile client can't grow it
25
+ # without limit.
26
+ MAX_ERROR_CLASSES = 100
27
+
28
+ # Controller the ingest endpoint inherits from. Deliberately not the host's
29
+ # ApplicationController by default: the endpoint must stay reachable without a
30
+ # session (errors on the sign-in page matter too), and it shouldn't pick up
31
+ # whatever before_actions the host installs. Identity comes from `context`.
32
+ mattr_accessor :base_controller_class, default: "::ActionController::Base"
33
+
34
+ # Lambda instance_exec'd in the controller, returning a flat hash of extra
35
+ # context (user id, native flag, ...). It has access to `request`, `cookies`
36
+ # and anything else the controller exposes. Not subject to
37
+ # `allowed_context_keys` — that governs client input, this is server-derived.
38
+ mattr_accessor :context, default: -> { {} }
39
+
40
+ # Client-supplied context keys we're willing to store. An allowlist rather than
41
+ # a denylist because the payload is unauthenticated: add the keys your own
42
+ # instrumentation sends, and nothing else gets through. Values are still
43
+ # constrained to short scalars regardless of what's listed here.
44
+ mattr_accessor :allowed_context_keys, default: %w[url referrer viewport identifier frame_id method status]
45
+
46
+ # Per-IP rate limit on the ingest endpoint. Note this relies on Rails.cache
47
+ # supporting #increment — with a :null_store it silently does nothing.
48
+ mattr_accessor :rate_limit_to, default: 30
49
+ mattr_accessor :rate_limit_within, default: 1.minute
50
+
51
+ # Hard caps. Everything below is attacker-controlled, and `message` in particular
52
+ # feeds the error fingerprint Solid Errors computes for the report.
53
+ mattr_accessor :max_body_bytes, default: 64.kilobytes
54
+ mattr_accessor :max_reports_per_request, default: 20
55
+ mattr_accessor :max_message_length, default: 500
56
+ mattr_accessor :max_frames, default: 30
57
+
58
+ # Budget the browser gives itself per page view, so one error in a loop can't
59
+ # turn into a flood of requests. Reset on each Turbo visit.
60
+ mattr_accessor :max_reports_per_page, default: 10
61
+
62
+ # Substitutions applied to messages before reporting. Solid Errors fingerprints
63
+ # on the message, so anything that varies per occurrence (ids, urls, digests)
64
+ # would otherwise split one bug across many groups.
65
+ mattr_accessor :message_filters, default: [
66
+ [ /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, "<uuid>" ],
67
+ [ /\bblob:\S+/i, "<blob-url>" ],
68
+ [ %r{\bhttps?://\S+}i, "<url>" ],
69
+ [ /\b[0-9a-f]{16,}\b/i, "<hash>" ]
70
+ ]
71
+
72
+ # Whether to log every report. nil means "decide from the environment": on
73
+ # outside production, where solid_errors is often not installed and the log is
74
+ # the only way to see the pipeline work.
75
+ mattr_accessor :log_reports, default: nil
76
+
77
+ class << self
78
+ def log_reports?
79
+ return log_reports unless log_reports.nil?
80
+
81
+ defined?(Rails) && Rails.respond_to?(:env) && !Rails.env.production?
82
+ end
83
+
84
+ # Builds (and memoizes) an exception class whose #name reads "JS::TypeError".
85
+ # Solid Errors keys its grouping off the exception class name, so this is what
86
+ # makes browser errors legible next to server-side ones.
87
+ def error_class_for(js_name)
88
+ name = NAME_PATTERN.match?(js_name.to_s) ? js_name.to_s : "Error"
89
+
90
+ error_classes_lock.synchronize do
91
+ error_classes[name] ||=
92
+ if error_classes.size >= MAX_ERROR_CLASSES
93
+ BrowserError
94
+ else
95
+ Class.new(BrowserError) do
96
+ define_singleton_method(:name) { "JS::#{name}" }
97
+ end
98
+ end
99
+ end
100
+ end
101
+
102
+ def reset_error_classes! # :nodoc: for tests
103
+ error_classes_lock.synchronize { @error_classes = {} }
104
+ end
105
+
106
+ private
107
+ def error_classes
108
+ @error_classes ||= {}
109
+ end
110
+
111
+ def error_classes_lock
112
+ @error_classes_lock ||= Mutex.new
113
+ end
114
+ end
115
+ end
116
+ end
117
+
118
+ require "solid_errors/frontend/message_normalizer"
119
+ require "solid_errors/frontend/stack_parser"
120
+ require "solid_errors/frontend/source_mapper"
121
+ require "solid_errors/frontend/backtrace_formatter"
122
+ require "solid_errors/frontend/report"
123
+ require "solid_errors/frontend/payload"
124
+ require "solid_errors/frontend/reporter"
125
+ require "solid_errors/frontend/engine" if defined?(Rails::Engine)
metadata ADDED
@@ -0,0 +1,94 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: solid_errors-frontend
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Armand Mégrot
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: railties
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '8.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '8.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: actionpack
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '8.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '8.0'
40
+ description: Captures uncaught JavaScript exceptions, unhandled promise rejections,
41
+ Stimulus controller errors and Turbo failures in the browser and reports them through
42
+ ActiveSupport::ErrorReporter, so Solid Errors records them alongside server-side
43
+ errors — with frames resolved back to your source files. A third-party companion
44
+ gem, not affiliated with solid_errors.
45
+ email:
46
+ - armand.megrot@gmail.com
47
+ executables: []
48
+ extensions: []
49
+ extra_rdoc_files: []
50
+ files:
51
+ - CHANGELOG.md
52
+ - LICENSE
53
+ - README.md
54
+ - app/assets/javascripts/solid_errors_frontend.js
55
+ - app/controllers/solid_errors/frontend/reports_controller.rb
56
+ - app/helpers/solid_errors/frontend/tags_helper.rb
57
+ - config/importmap.rb
58
+ - config/routes.rb
59
+ - lib/solid_errors/frontend.rb
60
+ - lib/solid_errors/frontend/backtrace_formatter.rb
61
+ - lib/solid_errors/frontend/engine.rb
62
+ - lib/solid_errors/frontend/message_normalizer.rb
63
+ - lib/solid_errors/frontend/payload.rb
64
+ - lib/solid_errors/frontend/report.rb
65
+ - lib/solid_errors/frontend/reporter.rb
66
+ - lib/solid_errors/frontend/source_mapper.rb
67
+ - lib/solid_errors/frontend/stack_parser.rb
68
+ - lib/solid_errors/frontend/version.rb
69
+ homepage: https://github.com/armandmgt/solid_errors-frontend
70
+ licenses:
71
+ - MIT
72
+ metadata:
73
+ homepage_uri: https://github.com/armandmgt/solid_errors-frontend
74
+ changelog_uri: https://github.com/armandmgt/solid_errors-frontend/blob/main/CHANGELOG.md
75
+ bug_tracker_uri: https://github.com/armandmgt/solid_errors-frontend/issues
76
+ rubygems_mfa_required: 'true'
77
+ rdoc_options: []
78
+ require_paths:
79
+ - lib
80
+ required_ruby_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: 3.2.0
85
+ required_rubygems_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ requirements: []
91
+ rubygems_version: 3.6.9
92
+ specification_version: 4
93
+ summary: Browser errors in your Solid Errors dashboard
94
+ test_files: []