ci.yaml installed golang.org/x/lint/golint but never invoked it; golint has been deprecated and frozen since 2021. The CI Go version (1.24.2) also lagged the go.mod directive (1.25). Remove the dead step, align both workflows to Go 1.25, and bump the remaining actions/checkout@v3 usages to v4 (release.yaml was already on v4).
install.sh always downloaded the linux_amd64 binary, so arm64/386/mips machines got a binary that does not run. Detect the architecture via uname and pick the matching release artifact; releases already ship those builds.
The release job ran .cross_compile.sh, which builds all 12 targets sequentially in a single runner. Split the build into a matrix so each GOOS/GOARCH pair builds concurrently on its own runner, then a dependent Release job collects the artifacts and publishes them in one release. Output filenames are unchanged.
Return a generic "Translation failed" message instead of echoing
err.Error() in the HTTP response, and log the underlying error
server-side via log.Printf. Avoids leaking internal details (proxy
address, upstream URL, etc.) to API clients while keeping the 500
behavior introduced in #224.
The two handlers ignored the error from c.BindJSON, so a malformed JSON body was silently processed as an empty payload. Check the error and return 400, consistent with /v2/translate.
The /translate, /v1/translate and /v2/translate handlers called log.Fatalf when TranslateByDeepLX returned an error. log.Fatalf calls os.Exit, so a single failed translation would tear down the whole server process. Return a 500 JSON response and keep serving instead.
Closes#221.
- Run as non-root using an Alpine system account (UID auto-assigned from the 100-999 system range, referenced by name to avoid hard-coding).
- Replace CMD with ENTRYPOINT so args passed to `docker run` flow through to the deeplx binary.
- EXPOSE 1188 for documentation.
* fix(translate): enforce 1500-char limit upfront and add request timeout
Two related stability issues hit during real-world use:
1. **Hung requests** — without an explicit timeout the upstream HTTP
call could dangle indefinitely on a stuck connection. Browser
extensions calling /translate would sit on a spinner forever with
no error to surface to the user (reported in the field).
2. **No-feedback on oversized input** — the oneshot endpoint caps the
total text length at 1500 characters (matches the extension's own
\`G.notLoggedIn = 1500\` constant). We were forwarding the request
anyway and letting DeepL 400 it, which a) wasted an upstream round
trip and b) the caller had no way to distinguish from other 400s.
Changes:
- Pre-validate \`text\` length in characters (utf8.RuneCountInString,
not byte length — verified the cap is rune-based: 1500 Chinese
characters / 4500 bytes is accepted, 1501 is rejected). Return
HTTP 413 Payload Too Large with a clear message naming both the
observed length and the limit.
- Set a 20s timeout on the oneshot HTTP client (req.SetTimeout).
On timeout return HTTP 504 Gateway Timeout — distinguishes a slow
DeepL from other 503 failure modes (DNS, TLS, etc.). The check
catches both context.DeadlineExceeded and url.Error{Timeout()=true}.
- Set a separate 5s timeout on the cookie-jar warmup GET to
www.deepl.com. Warmup is best-effort; we'd rather a slow warmup
(cookies still seed eventually next time) than block the very first
translation behind a hung GET.
Behaviour verified against the live oneshot endpoint:
- 1500 ASCII chars → 200
- 1501 ASCII chars → 413 (upstream not contacted)
- 1500 Chinese chars (4500 bytes) → 200
- 1501 Chinese chars → 413
- Pathological "your"*1500 → 504 at 20s (was hanging without timeout)
- Realistic 245-char Chinese → 200 in ~13s
* perf(translate): share oneshot req.Client across requests + eager warmup
Each TranslateByDeepLX call was building a brand-new req.Client via
newOneshotClient(), which meant a fresh TLS handshake + HTTP/2 SETTINGS
negotiation per request — ~200-400ms of pure overhead on top of DeepL's
own ~1.5s processing latency. Share one client per proxy URL
(sync.Map) so subsequent requests reuse the kept-alive HTTP/2
connection in the underlying http.Transport's pool.
Also flip the cookie-jar warmup from synchronous-on-first-call to
fire-and-forget at first client creation. Same sync.Once semantics
(runs exactly once per process), but in a background goroutine so the
first translate request runs in parallel with the TLS handshake to
www.deepl.com rather than serially behind it.
Measured against the live oneshot endpoint (Tokyo → Frankfurt):
before, 5 sequential requests: 3.19s, 2.05s, 2.07s, 2.89s, 2.22s
after, 5 sequential requests: 2.20s, 1.27s, 1.26s, 1.42s, 1.34s
└─ first └────────── warm path ─────┘
The warm-path 1.3s is also faster than a bare \`curl\` to oneshot
(~1.9s, every call doing its own TLS handshake) — proof the
connection-pool reuse is now actually paying off.
Previously TranslateByDeepLX silently mapped any caller-supplied code
through toOneshotLang(), falling back to a lowercased pass-through for
unknown codes. The oneshot endpoint accepts unknown codes with a 200
but echoes the source text back untranslated, leaving callers to
distinguish "translated, identical to source" from "language not
supported" without a clear signal.
Validate strictly against the language table the extension bundles in
background.js (array `y` for target-capable codes, `A` for the
source-only EN / PT aliases) and return HTTP 400 with a list of
supported codes on mismatch. This also catches:
- target_lang = "" → "target_lang is required"
- target_lang = "auto" → "target_lang cannot be \"auto\"; pick one of: ..."
- source_lang = ""/"auto" → allowed, server autodetects
- case-insensitive → strings.ToUpper before lookup
Pick up languages the previous map missed:
+ ES-419 (Latin American Spanish)
+ HE (Hebrew)
+ VI (Vietnamese)
Fix the EN / PT source-lang mapping: the extension's `A` array maps
both to the generic langCodeForIta ("en"/"pt"), not the regional
default. As a target they continue to resolve to en-US / pt-BR for
backward compat with callers that historically passed "EN" / "PT".
Verified end-to-end:
- 5 valid codes (DE, ZH-HANT, HE, VI, ES-419) → 200 + translated text
- Invalid target "XX" → 400, message lists 38 supported codes
- Invalid source "ZZ" → 400, message lists 38 codes + "auto"
- target_lang "auto" → 400
- source autodetect (empty / "auto") + valid target → 200
- Lowercase input "de" → 200 (case-insensitive)
* feat(translate): migrate to oneshot endpoint to bypass www2 anti-bot
The www2.deepl.com/jsonrpc backends behind LMT_handle_texts /
LMT_handle_jobs now sit behind aggressive WAF + per-IP throttling that
returns HTTP 429 (code 1042911 "Too many requests") within a handful of
calls from any single host — making the free path effectively unusable.
The official DeepL browser extension and iOS app skip that backend
entirely for stateless single-shot translation and POST to a separate
"oneshot" endpoint on a different host pool with its own (much looser)
rate limit. It accepts anonymous traffic with a literal
`Authorization: None` header, returns plain JSON, and supports the same
language pairs.
Switch the free path to:
POST https://oneshot-free.www.deepl.com/v1/translate
Authorization: None
{"text": ["..."], "target_lang": "de", "source_lang": "en"}
Pro users continue to hit oneshot-pro.www.deepl.com with their bearer
token (the `-s` flag now carries an OAuth access token rather than the
legacy dl_session cookie).
This removes:
- the JSON-RPC envelope (jsonrpc/method/id/params/timestamp wrapper)
- the `i`-count timestamp trick (getICount + getTimeStamp)
- the random-id body-spacing trick (handlerBodyMethod)
- the whatlanggo client-side detection (oneshot detects server-side)
The DeepLXTranslationResult contract is unchanged for service handlers;
Alternatives is now always nil because the oneshot endpoint does not
return alternative translations.
Verified against /translate, /v1/translate and /v2/translate routes
end-to-end (EN/DE/ZH/JA/FR pairs, multi-sentence input, autodetect, 10x
burst) — all 200 OK on an IP that was concurrently being 429'd by www2.
* fix(translate): align oneshot request bytes with the real extension
After capturing the exact bytes the Chrome extension's service-worker
fetch() emits (via an offline echo server pointed at deeplx in place of
oneshot-free.www.deepl.com) and diffing them against what we were
sending, several distinguishable signals remained. Close them all.
Headers
-------
- Origin: chrome-extension://cofdbpoegempjloogbagkncekinflcnj
(was https://www.deepl.com — a request from www.deepl.com itself
never lands on the oneshot endpoint, so that origin is unusual.
The extension ID is the canonical sender.)
- Sec-Fetch-Site: cross-site
(was same-site — wrong; chrome-extension -> www.deepl.com IS cross-site)
- Drop Referer entirely (extension SW fetch sends none)
- Drop Pragma / Cache-Control / Upgrade-Insecure-Requests / Sec-Fetch-User
(req.ImpersonateChrome() sets these for top-level navigation; a
fetch() never sends them — leaving them in is a strong nav-vs-XHR tell)
- Accept-Encoding: gzip, deflate, br
(was just gzip, Go stdlib default — Chrome 120's fetch() sends all
three; zstd only landed as a default in Chrome 123+ so leave it off)
Body
----
- Add usage_type: "Translate" and the full app_information object
(os/os_version/app_version/app_build/instance_id) so the JSON the
server sees is structurally identical to what background.js IN()
assembles. Field order in oneshotRequest matches the extension's
object-literal order so encoding/json produces byte-identical output.
- instance_id is a v4 UUID generated once at process start and reused,
mirroring the extension's chrome.storage-pinned ID rather than
rotating per-request (rotation would be a far stronger signal).
- All version strings (TLS handshake, User-Agent, sec-ch-ua,
app_information.os_version) are pinned to Chrome 120 so they tell
one consistent story.
Transport
---------
- SetBodyBytes instead of bytes.NewReader so Content-Length is set
(an io.Reader body forces Transfer-Encoding: chunked, which a
fetch() with JSON.stringify body never emits)
- Once we set Accept-Encoding manually, the Go stdlib disables its
transparent decompression and req hands us raw compressed bytes.
Handle gzip / deflate / br by hand from Content-Encoding.
- DisableAutoReadResponse so we own the body stream end-to-end.
The Chrome 120 TLS ClientHello, HTTP/2 SETTINGS frame, pseudo-header
order and sec-ch-ua claim continue to come from ImpersonateChrome()
unchanged.
Verified end-to-end:
- Outbound bytes (against a local echo server) diff-match the
extension's observed profile on every header and on body JSON order.
- Live oneshot-free.www.deepl.com calls: 4 language pairs OK,
/v2/translate official-API compat OK, 10x burst 10/10 200.
* chore(deps): upgrade to latest compatible versions
Run `go get -u ./...` + `go mod tidy`. Direct upgrades:
- github.com/andybalholm/brotli 1.2.0 → 1.2.1
- github.com/tidwall/gjson 1.18.0 → 1.19.0
Indirect (notable):
- github.com/bytedance/sonic 1.15.0 → 1.15.1
- github.com/bytedance/sonic/loader 0.5.0 → 0.5.1
- github.com/bytedance/gopkg 0.1.3 → 0.1.4
- github.com/cloudwego/base64x 0.1.6 → 0.1.7
- github.com/gin-contrib/sse 1.1.0 → 1.1.1
- github.com/go-playground/validator/v10 10.30.1 → 10.30.2
- github.com/goccy/go-json 0.10.5 → 0.10.6
- github.com/klauspost/compress 1.18.4 → 1.18.6
- github.com/mattn/go-isatty 0.0.20 → 0.0.22
- github.com/pelletier/go-toml/v2 2.2.4 → 2.3.1
- golang.org/x/arch 0.24.0 → 0.27.0
- golang.org/x/crypto 0.48.0 → 0.52.0
- golang.org/x/net 0.51.0 → 0.55.0
- golang.org/x/sys 0.41.0 → 0.45.0
- golang.org/x/text 0.34.0 → 0.37.0
github.com/imroc/req/v3 (the HTTP client we depend on for Chrome
impersonation) is already on its latest tag v3.57.0 and pins
github.com/quic-go/quic-go to <= v0.57.x — newer quic-go removed
ConnectionTracingID/ConnectionTracingKey, which req's internal/http3
still references. That constraint also holds gin-gonic/gin at v1.11.0
and gin-contrib/cors at v1.7.6 (their later versions pull quic-go
≥ 0.58 transitively). Pin quic-go to v0.57.1 to keep the build green;
revisit when req publishes a release compatible with quic-go ≥ 0.58.
Build + live oneshot end-to-end: 4 language pairs OK, /v2/translate
official-API compat OK, 8x burst 8/8 200.
* fix(translate): seed cookie jar from www.deepl.com on first call
A real chrome-extension fetch() to oneshot-free.www.deepl.com inherits
whatever cookies the browser has on .deepl.com — at minimum
`userCountry=<iso2>` and `verifiedBot=false`, both of which the
deepl.com server sets on any page load. Our outbound bytes were
otherwise extension-identical but went out cookieless, which is a
distinguishable signal.
Wire a process-wide net/http/cookiejar onto the req.Client and trigger
a single warmup GET to https://www.deepl.com/translator on the first
translate call (sync.Once). The Set-Cookie response (userCountry,
verifiedBot) lands on .deepl.com, which the jar then automatically
echoes back on every subsequent POST to oneshot-free.www.deepl.com
(cookies set on .deepl.com match any *.deepl.com subdomain).
Verified outbound:
Cookie: userCountry=JP; verifiedBot=false
Latency cost: first call after process start pays one extra HTTP GET
(~1s warmup); subsequent calls are unaffected (sync.Once + connection
keep-alive).
Note: we cannot replicate the _ga / _ga_<id> cookies a real user
would also carry — those are set client-side by GA's JS, which a
non-browser HTTP client can't execute. The userCountry+verifiedBot
pair already matches the "first-time visitor with JS disabled" profile,
which is the closest plausible non-browser approximation.
* Initial plan
* Update Dockerfile to use golang:1.25 to match go.mod requirement
Co-authored-by: thedavidweng <95214375+thedavidweng@users.noreply.github.com>
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Reverts quic-go from v0.57.0 back to v0.49.1 and qpack from v0.6.0 to v0.5.1
due to breaking API changes that are incompatible with req/v3. The req/v3
library (downgraded to v3.50.0) has not been updated to support the newer
quic-go APIs yet.
Build was failing with undefined quic.Connection, quic.EarlyConnection, and
incompatible qpack.Decoder method signatures.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Renamed makeRequest to makeRequestWithBody for clarity.
- Introduced new TextItem and TextResponse types for better structure in translation requests and responses.
- Updated translation logic to handle multiple texts and alternatives.
- Enhanced error handling for blocked requests and translation failures.
- Adjusted timestamp and random number generation for improved request uniqueness.