From 2590e404f55868e500602eb41fd4386068200dcd Mon Sep 17 00:00:00 2001 From: Hein Date: Sat, 1 Aug 2026 15:20:22 +0200 Subject: [PATCH] feat: initial Go/JS/Rust Origin check-in client SDKs Standalone clients that resolve a machine unique ID (OS machine ID first, falling back to a generated UUID persisted to disk), detect hostname/IP/ container/orchestrator, and register+re-ping origin.warky.dev's public service-checkin endpoint on a daily interval. Ported from icy2's internal origincheckin package but extended to the full check-in contract and made dependency-light for external reuse. Co-Authored-By: Claude Sonnet 5 --- .gitea/workflows/ci.yml | 54 ++ .gitignore | 195 +----- LICENSE | 106 +++ README.md | 43 +- go/README.md | 45 ++ go/client.go | 213 ++++++ go/client_test.go | 148 +++++ go/go.mod | 15 + go/go.sum | 14 + go/hostinfo.go | 100 +++ go/hostinfo_test.go | 57 ++ go/logger.go | 20 + go/machineid.go | 87 +++ go/machineid_darwin.go | 29 + go/machineid_linux.go | 24 + go/machineid_other.go | 9 + go/machineid_test.go | 61 ++ go/machineid_windows.go | 21 + go/payload.go | 35 + js/README.md | 47 ++ js/package-lock.json | 1331 +++++++++++++++++++++++++++++++++++++ js/package.json | 31 + js/src/client.ts | 160 +++++ js/src/hostInfo.ts | 85 +++ js/src/index.ts | 6 + js/src/machineId.ts | 124 ++++ js/src/payload.ts | 35 + js/test/client.test.ts | 141 ++++ js/test/hostInfo.test.ts | 43 ++ js/test/machineId.test.ts | 44 ++ js/tsconfig.json | 16 + rust/Cargo.toml | 25 + rust/README.md | 61 ++ rust/src/client.rs | 284 ++++++++ rust/src/host_info.rs | 154 +++++ rust/src/lib.rs | 18 + rust/src/logger.rs | 30 + rust/src/machine_id.rs | 201 ++++++ rust/src/payload.rs | 50 ++ rust/tests/client.rs | 124 ++++ 40 files changed, 4105 insertions(+), 181 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 LICENSE create mode 100644 go/README.md create mode 100644 go/client.go create mode 100644 go/client_test.go create mode 100644 go/go.mod create mode 100644 go/go.sum create mode 100644 go/hostinfo.go create mode 100644 go/hostinfo_test.go create mode 100644 go/logger.go create mode 100644 go/machineid.go create mode 100644 go/machineid_darwin.go create mode 100644 go/machineid_linux.go create mode 100644 go/machineid_other.go create mode 100644 go/machineid_test.go create mode 100644 go/machineid_windows.go create mode 100644 go/payload.go create mode 100644 js/README.md create mode 100644 js/package-lock.json create mode 100644 js/package.json create mode 100644 js/src/client.ts create mode 100644 js/src/hostInfo.ts create mode 100644 js/src/index.ts create mode 100644 js/src/machineId.ts create mode 100644 js/src/payload.ts create mode 100644 js/test/client.test.ts create mode 100644 js/test/hostInfo.test.ts create mode 100644 js/test/machineId.test.ts create mode 100644 js/tsconfig.json create mode 100644 rust/Cargo.toml create mode 100644 rust/README.md create mode 100644 rust/src/client.rs create mode 100644 rust/src/host_info.rs create mode 100644 rust/src/lib.rs create mode 100644 rust/src/logger.rs create mode 100644 rust/src/machine_id.rs create mode 100644 rust/src/payload.rs create mode 100644 rust/tests/client.rs diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..439fe63 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + go: + runs-on: ubuntu-latest + defaults: + run: + working-directory: go + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go/go.mod + - run: go build ./... + - run: go vet ./... + - run: test -z "$(gofmt -l .)" + - run: go test ./... + + js: + runs-on: ubuntu-latest + defaults: + run: + working-directory: js + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: js/package-lock.json + - run: npm ci + - run: npm run typecheck + - run: npm run build + - run: npm test + + rust: + runs-on: ubuntu-latest + defaults: + run: + working-directory: rust + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - run: cargo fmt -- --check + - run: cargo clippy --all-targets -- -D warnings + - run: cargo build + - run: cargo test diff --git a/.gitignore b/.gitignore index 47283ef..894b19b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,183 +1,18 @@ -# ---> Go -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll -*.so -*.dylib +# Go +go/*.test +go/coverage.out -# Test binary, built with `go test -c` -*.test +# Node +js/node_modules/ +js/dist/ +js/coverage/ -# Output of the go coverage tool, specifically when used with LiteIDE -*.out - -# Dependency directories (remove the comment below to include it) -# vendor/ - -# Go workspace file -go.work -go.work.sum - -# env file -.env - -# ---> Rust -# Generated by Cargo -# will have compiled files and executables -debug/ -target/ - -# These are backup files generated by rustfmt -**/*.rs.bk - -# MSVC Windows builds of rustc generate these, which store debugging information -*.pdb - -# RustRover -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ -# ---> Node -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* -.pnpm-debug.log* - -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) -web_modules/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional stylelint cache -.stylelintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variable files -.env -.env.development.local -.env.test.local -.env.production.local -.env.local - -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# Next.js build output -.next -out - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and not Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# vuepress v2.x temp and cache directory -.temp -.cache - -# vitepress build output -**/.vitepress/dist - -# vitepress cache directory -**/.vitepress/cache - -# Docusaurus cache and generated files -.docusaurus - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# TernJS port file -.tern-port - -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -# yarn v2 -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* +# Rust +rust/target/ +Cargo.lock +# Editors/OS +.vscode/ +.idea/ +*.swp +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3105910 --- /dev/null +++ b/LICENSE @@ -0,0 +1,106 @@ +Copyright (c) 2026 Warky Devs Pty Ltd. All rights reserved. + +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, "control" means (i) the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising +permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +"Object" form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object +code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, +made available under the License, as indicated by a copyright notice that is +included in or attached to the work. + +"Derivative Works" shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. + +"Contribution" shall mean any work of authorship, including the original +version of the Work and any modifications or additions to that Work or +Derivative Works thereof, that is intentionally submitted to Licensor for +inclusion in the Work by the copyright owner or by an individual or Legal +Entity authorized to submit on behalf of the copyright owner. + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this +License, each Contributor hereby grants to You a perpetual, worldwide, +non-exclusive, no-charge, royalty-free, irrevocable copyright license to +reproduce, prepare Derivative Works of, publicly display, publicly perform, +sublicense, and distribute the Work and such Derivative Works in Source or +Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this +License, each Contributor hereby grants to You a perpetual, worldwide, +non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this +section) patent license to make, have made, use, offer to sell, sell, import, +and otherwise transfer the Work. + +4. Redistribution. You may reproduce and distribute copies of the Work or +Derivative Works thereof in any medium, with or without modifications, and in +Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a + copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating + that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You + distribute, all copyright, patent, trademark, and attribution notices + from the Source form of the Work; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, + then any Derivative Works that You distribute must include a readable + copy of the attribution notices contained within such NOTICE file. + +5. Submission of Contributions. Unless You explicitly state otherwise, any +Contribution intentionally submitted for inclusion in the Work by You to the +Licensor shall be under the terms and conditions of this License, without any +additional terms or conditions. + +6. Trademarks. This License does not grant permission to use the trade names, +trademarks, service marks, or product names of the Licensor. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in +writing, Licensor provides the Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. + +8. Limitation of Liability. In no event and under no legal theory shall any +Contributor be liable to You for damages arising as a result of this License +or out of the use or inability to use the Work. + +9. Accepting Warranty or Additional Liability. While redistributing the Work +or Derivative Works thereof, You may choose to offer, and charge a fee for, +acceptance of support, warranty, indemnity, or other liability obligations +and/or rights consistent with this License. + +END OF TERMS AND CONDITIONS diff --git a/README.md b/README.md index 900e2ca..3de1814 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,44 @@ # origin_client -origin client \ No newline at end of file +Client SDKs that register a running service with +[Origin](https://origin.warky.dev)'s public check-in endpoint and re-ping it +on an interval. One package per language, same wire contract, same behavior. + +## Packages + +| Language | Path | Module/package | +|---|---|---| +| Go | [`go/`](go/) | `git.warky.dev/wdevs/origin_client/go` | +| JavaScript/TypeScript | [`js/`](js/) | `@warkanum/origin-client` | +| Rust | [`rust/`](rust/) | `origin-client` | + +## What it does + +- Resolves a machine unique ID: real OS ID first (`/etc/machine-id` on + Linux, `IOPlatformUUID` on macOS, `MachineGuid` on Windows), falling back + to a generated UUID persisted to disk. +- Detects hostname, outbound IP, container ID (Docker/cgroup), and + orchestrator (Kubernetes/Docker) best-effort. +- POSTs a check-in payload to Origin immediately, then again every + `interval_hours` (default 24), until stopped. Network/server failures are + logged and swallowed — check-in never crashes or blocks the host app. + +## Wire contract + +`POST {base_url}` (default `https://origin.warky.dev/api/public/service-checkin`) +with header `X-Origin-Service-Key: `. + +Required: `type`, `version`, `name`, `unique_install_id`. +Optional: `site`, `environment`, `database_type`, `database_name`, +`app_type`, `hostname`, `port`, `base_url`, `internal_url`, `container_id`, +`orchestrator`, `description`. + +Response: `success`, `service_instance_id`, `unique_install_id`, `status`, +`pinged_at`. + +Full contract: see `origin/doc/service-checkin.md` in the Origin repo. + +## Status + +Structured for independent versioning/publishing per package. Not yet +published to any registry. diff --git a/go/README.md b/go/README.md new file mode 100644 index 0000000..0838cef --- /dev/null +++ b/go/README.md @@ -0,0 +1,45 @@ +# originclient (Go) + +``` +go get git.warky.dev/wdevs/origin_client/go +``` + +## Usage + +```go +c, err := originclient.New(originclient.Config{ + ServiceKey: "...", + Type: "myservice", + Version: "1.2.3", + Name: "myservice-nova", +}) +if err != nil { + log.Fatal(err) +} +go c.Run(ctx) // immediate check-in, then every 24h until ctx is done +``` + +Call `c.CheckinOnce(ctx)` directly instead of `Run` to send a single +check-in and observe the error/response. + +## Config + +| Field | Required | Default | +|---|---|---| +| `ServiceKey` | yes | — | +| `Type`, `Version`, `Name` | yes | — | +| `URL` | no | `DefaultURL` (`https://origin.warky.dev/api/public/service-checkin`) | +| `AppType` | no | `"go"` | +| `Hostname` | no | OS hostname | +| `ContainerID` | no | best-effort Docker/cgroup detection | +| `Orchestrator` | no | best-effort Kubernetes/Docker detection | +| `InstallID` | no | OS machine ID, else a generated UUID persisted to `InstallIDPath` | +| `IntervalHours` | no | `DefaultIntervalHours` (24) | +| `Logger` | no | discards warnings | + +`Site`, `Environment`, `DatabaseType`, `DatabaseName`, `Port`, `BaseURL`, +`InternalURL`, `Description` are optional metadata with no default. + +`Hostname()`, `OutboundIP()`, `ContainerID()`, `Orchestrator()`, and +`ResolveMachineID()` are exported standalone for callers that want the +detection logic without the HTTP client. diff --git a/go/client.go b/go/client.go new file mode 100644 index 0000000..5a25d42 --- /dev/null +++ b/go/client.go @@ -0,0 +1,213 @@ +// Package originclient registers a running service with Origin +// (origin.warky.dev)'s public check-in endpoint and re-pings it on an +// interval, so Origin can track which instances of a service are alive, +// their version, and where they run. A check-in failure is logged and +// otherwise ignored: it must never affect the host application's own +// availability. +package originclient + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" +) + +const ( + // DefaultURL is Origin's public service-checkin endpoint. + DefaultURL = "https://origin.warky.dev/api/public/service-checkin" + // DefaultIntervalHours is how often Run repeats when Config.IntervalHours is unset. + DefaultIntervalHours = 24 + + requestTimeout = 10 * time.Second +) + +// Config configures a Client. ServiceKey, Type, Version, and Name identify +// the caller to Origin and are required; everything else is optional +// metadata, or overrides for values Client would otherwise auto-detect. +type Config struct { + // URL is the check-in endpoint. Defaults to DefaultURL. + URL string + // ServiceKey is sent as the X-Origin-Service-Key header. Required. + ServiceKey string + + // Type is the Origin service type, which must already be registered in + // Origin (e.g. "icy2"). Required. + Type string + // Version is the caller's own version string. Required. + Version string + // Name identifies this deployment/instance to Origin, e.g. "icy2-nova". Required. + Name string + + Site string + Environment string + DatabaseType string + DatabaseName string + // AppType defaults to "go" when empty. + AppType string + // Hostname defaults to the OS hostname (see Hostname) when empty. + Hostname string + Port int + BaseURL string + InternalURL string + // ContainerID defaults to best-effort Docker/cgroup detection (see + // ContainerID) when empty. + ContainerID string + // Orchestrator defaults to best-effort Kubernetes/Docker detection (see + // Orchestrator) when empty. + Orchestrator string + Description string + + // InstallID, if set, overrides machine-ID resolution entirely. + InstallID string + // InstallIDPath is where a generated fallback install ID is persisted + // when no real OS machine ID is available. Defaults to the OS + // user-config directory (see ResolveMachineID). + InstallIDPath string + + // IntervalHours sets how often Run repeats. Defaults to DefaultIntervalHours. + IntervalHours int + + // Logger receives non-fatal warnings. Defaults to discarding them. + Logger Logger +} + +// Client checks a service in with Origin. +type Client struct { + cfg Config + http *http.Client + installID string +} + +// New validates cfg, resolves the machine/install ID, and returns a ready +// Client. Resolution happens once, at construction, so every check-in made +// by this Client reports the same UniqueInstallID. +func New(cfg Config) (*Client, error) { + if cfg.ServiceKey == "" || cfg.Type == "" || cfg.Version == "" || cfg.Name == "" { + return nil, errors.New("originclient: ServiceKey, Type, Version, and Name are required") + } + if cfg.URL == "" { + cfg.URL = DefaultURL + } + if cfg.AppType == "" { + cfg.AppType = "go" + } + if cfg.IntervalHours <= 0 { + cfg.IntervalHours = DefaultIntervalHours + } + if cfg.Logger == nil { + cfg.Logger = noopLogger{} + } + + return &Client{ + cfg: cfg, + http: &http.Client{Timeout: requestTimeout}, + installID: ResolveMachineID(cfg.InstallID, cfg.InstallIDPath, cfg.Logger), + }, nil +} + +// Run performs an immediate check-in, then repeats every +// Config.IntervalHours until ctx is done. Run blocks the calling goroutine, +// so callers invoke it with `go client.Run(ctx)`. Check-in errors are +// logged via Config.Logger and otherwise ignored; call CheckinOnce directly +// if the caller needs to observe failures. +func (c *Client) Run(ctx context.Context) { + _, _ = c.CheckinOnce(ctx) + + ticker := time.NewTicker(time.Duration(c.cfg.IntervalHours) * time.Hour) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + _, _ = c.CheckinOnce(ctx) + } + } +} + +// CheckinOnce sends a single check-in request and returns Origin's +// response. Failures are logged via Config.Logger and also returned, so +// callers that only want the fire-and-forget behavior of Run can ignore +// the returned error. +func (c *Client) CheckinOnce(ctx context.Context) (*Response, error) { + payload := c.buildPayload() + + body, err := json.Marshal(payload) + if err != nil { + c.cfg.Logger.Warn("origin checkin: encode payload failed", err) + return nil, fmt.Errorf("originclient: encode payload: %w", err) + } + + reqCtx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, c.cfg.URL, bytes.NewReader(body)) + if err != nil { + c.cfg.Logger.Warn("origin checkin: build request failed", err) + return nil, fmt.Errorf("originclient: build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Origin-Service-Key", c.cfg.ServiceKey) + + resp, err := c.http.Do(req) + if err != nil { + c.cfg.Logger.Warn("origin checkin: request failed", err) + return nil, fmt.Errorf("originclient: do request: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode >= 300 { + err := fmt.Errorf("originclient: non-2xx response: %d", resp.StatusCode) + c.cfg.Logger.Warn("origin checkin: non-2xx response", err) + return nil, err + } + + var result Response + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + c.cfg.Logger.Warn("origin checkin: decode response failed", err) + return nil, fmt.Errorf("originclient: decode response: %w", err) + } + + return &result, nil +} + +func (c *Client) buildPayload() Payload { + hostname := c.cfg.Hostname + if hostname == "" { + hostname = Hostname() + } + containerID := c.cfg.ContainerID + if containerID == "" { + containerID = ContainerID() + } + orchestrator := c.cfg.Orchestrator + if orchestrator == "" { + orchestrator = Orchestrator() + } + + return Payload{ + Type: c.cfg.Type, + Version: c.cfg.Version, + Name: c.cfg.Name, + UniqueInstallID: c.installID, + Site: c.cfg.Site, + Environment: c.cfg.Environment, + DatabaseType: c.cfg.DatabaseType, + DatabaseName: c.cfg.DatabaseName, + AppType: c.cfg.AppType, + Hostname: hostname, + Port: c.cfg.Port, + BaseURL: c.cfg.BaseURL, + InternalURL: c.cfg.InternalURL, + ContainerID: containerID, + Orchestrator: orchestrator, + Description: c.cfg.Description, + } +} diff --git a/go/client_test.go b/go/client_test.go new file mode 100644 index 0000000..334f757 --- /dev/null +++ b/go/client_test.go @@ -0,0 +1,148 @@ +package originclient + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testConfig(url string) Config { + return Config{ + URL: url, + ServiceKey: "test-key", + Type: "icy2", + Version: "v1.0.0", + Name: "icy2-test", + Site: "Test Site", + Environment: "test", + IntervalHours: 24, + InstallID: "test-install-id", + } +} + +func TestNew_RequiresFields(t *testing.T) { + _, err := New(Config{}) + assert.Error(t, err) +} + +func TestNew_AppliesDefaults(t *testing.T) { + c, err := New(Config{ServiceKey: "k", Type: "t", Version: "v", Name: "n", InstallID: "id"}) + require.NoError(t, err) + assert.Equal(t, DefaultURL, c.cfg.URL) + assert.Equal(t, "go", c.cfg.AppType) + assert.Equal(t, DefaultIntervalHours, c.cfg.IntervalHours) + assert.Equal(t, "id", c.installID) +} + +func TestBuildPayload(t *testing.T) { + c, err := New(testConfig("https://example.invalid/checkin")) + require.NoError(t, err) + + payload := c.buildPayload() + + assert.Equal(t, "icy2", payload.Type) + assert.Equal(t, "go", payload.AppType) + assert.Equal(t, "v1.0.0", payload.Version) + assert.Equal(t, "icy2-test", payload.Name) + assert.Equal(t, "Test Site", payload.Site) + assert.Equal(t, "test", payload.Environment) + assert.Equal(t, "test-install-id", payload.UniqueInstallID) +} + +func TestCheckinOnce_Success(t *testing.T) { + var gotKey string + var gotBody Payload + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotKey = r.Header.Get("X-Origin-Service-Key") + require.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody)) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(Response{ + Success: true, + ServiceInstanceID: 42, + UniqueInstallID: gotBody.UniqueInstallID, + Status: "provisioning", + PingedAt: "2026-08-01T00:00:00Z", + }) + })) + defer server.Close() + + c, err := New(testConfig(server.URL)) + require.NoError(t, err) + + resp, err := c.CheckinOnce(t.Context()) + require.NoError(t, err) + + assert.Equal(t, "test-key", gotKey) + assert.Equal(t, "icy2", gotBody.Type) + assert.Equal(t, "icy2-test", gotBody.Name) + assert.Equal(t, "test-install-id", gotBody.UniqueInstallID) + assert.True(t, resp.Success) + assert.Equal(t, int64(42), resp.ServiceInstanceID) +} + +func TestCheckinOnce_ServerErrorReturnsError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + c, err := New(testConfig(server.URL)) + require.NoError(t, err) + + resp, err := c.CheckinOnce(t.Context()) + assert.Error(t, err) + assert.Nil(t, resp) +} + +func TestCheckinOnce_UnreachableDoesNotPanic(t *testing.T) { + c, err := New(testConfig("http://127.0.0.1:1")) + require.NoError(t, err) + + assert.NotPanics(t, func() { + _, _ = c.CheckinOnce(t.Context()) + }) +} + +func TestCheckinOnce_LogsOnFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + cfg := testConfig(server.URL) + var warned bool + cfg.Logger = LoggerFunc(func(msg string, err error) { warned = true }) + + c, err := New(cfg) + require.NoError(t, err) + + _, _ = c.CheckinOnce(t.Context()) + assert.True(t, warned) +} + +func TestRun_ChecksInImmediatelyThenStopsOnContextDone(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(Response{Success: true}) + })) + defer server.Close() + + c, err := New(testConfig(server.URL)) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond) + defer cancel() + + c.Run(ctx) + + assert.Equal(t, int32(1), calls.Load()) +} diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..04f908e --- /dev/null +++ b/go/go.mod @@ -0,0 +1,15 @@ +module git.warky.dev/wdevs/origin_client/go + +go 1.24 + +require ( + github.com/google/uuid v1.6.0 + github.com/stretchr/testify v1.11.1 + golang.org/x/sys v0.29.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go/go.sum b/go/go.sum new file mode 100644 index 0000000..043036b --- /dev/null +++ b/go/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go/hostinfo.go b/go/hostinfo.go new file mode 100644 index 0000000..21a3de4 --- /dev/null +++ b/go/hostinfo.go @@ -0,0 +1,100 @@ +package originclient + +import ( + "net" + "os" + "strings" +) + +// Hostname returns the OS hostname, or "" if it cannot be determined. +func Hostname() string { + h, err := os.Hostname() + if err != nil { + return "" + } + return h +} + +// OutboundIP returns the first non-loopback IPv4 address bound to this +// host. It is a LAN-facing address, not the host's public internet +// address, and is not part of Origin's wire contract — it exists so +// callers can populate BaseURL/InternalURL/Description themselves. +func OutboundIP() string { + addrs, err := net.InterfaceAddrs() + if err != nil { + return "" + } + for _, addr := range addrs { + ipNet, ok := addr.(*net.IPNet) + if !ok || ipNet.IP.IsLoopback() { + continue + } + if ip4 := ipNet.IP.To4(); ip4 != nil { + return ip4.String() + } + } + return "" +} + +// ContainerID best-effort detects the Docker/Kubernetes container ID this +// process runs in. It returns "" outside a container or when detection +// fails; an empty container ID is treated as "not containerized" rather +// than an error. +func ContainerID() string { + if data, err := os.ReadFile("/proc/self/cgroup"); err == nil { + if id := parseCgroupContainerID(data); id != "" { + return id + } + } + if _, err := os.Stat("/.dockerenv"); err == nil { + if h := Hostname(); isContainerIDLike(h) { + return h + } + } + return "" +} + +// Orchestrator best-effort detects the orchestration environment: +// "kubernetes" under Kubernetes, "docker" in a plain Docker container, "" +// otherwise. +func Orchestrator() string { + if os.Getenv("KUBERNETES_SERVICE_HOST") != "" { + return "kubernetes" + } + if _, err := os.Stat("/.dockerenv"); err == nil { + return "docker" + } + return "" +} + +// parseCgroupContainerID extracts a container ID from /proc/self/cgroup +// content, handling both cgroup v1 (".../docker/") and cgroup v2 +// systemd-scope (".../docker-.scope") layouts. +func parseCgroupContainerID(data []byte) string { + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + idx := strings.LastIndex(line, "/") + if idx == -1 { + continue + } + segment := line[idx+1:] + segment = strings.TrimSuffix(segment, ".scope") + segment = strings.TrimPrefix(segment, "docker-") + if isContainerIDLike(segment) { + return segment + } + } + return "" +} + +func isContainerIDLike(s string) bool { + if len(s) < 12 || len(s) > 64 { + return false + } + for _, r := range s { + if (r < '0' || r > '9') && (r < 'a' || r > 'f') { + return false + } + } + return true +} diff --git a/go/hostinfo_test.go b/go/hostinfo_test.go new file mode 100644 index 0000000..4674fca --- /dev/null +++ b/go/hostinfo_test.go @@ -0,0 +1,57 @@ +package originclient + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseCgroupContainerID(t *testing.T) { + tests := []struct { + name string + data string + want string + }{ + { + name: "cgroup v1 docker path", + data: "12:memory:/docker/9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b\n", + want: "9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b", + }, + { + name: "cgroup v2 systemd scope", + data: "0::/system.slice/docker-9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b.scope\n", + want: "9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b", + }, + { + name: "non-container host", + data: "0::/user.slice/user-1000.slice/session-2.scope\n", + want: "", + }, + { + name: "empty", + data: "", + want: "", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, parseCgroupContainerID([]byte(tc.data))) + }) + } +} + +func TestIsContainerIDLike(t *testing.T) { + assert.True(t, isContainerIDLike("9f8b7c6d5e4f")) + assert.False(t, isContainerIDLike("short")) + assert.False(t, isContainerIDLike("session-2")) + assert.False(t, isContainerIDLike("")) +} + +func TestOrchestrator_Kubernetes(t *testing.T) { + t.Setenv("KUBERNETES_SERVICE_HOST", "10.0.0.1") + assert.Equal(t, "kubernetes", Orchestrator()) +} + +func TestHostname_NotEmpty(t *testing.T) { + assert.NotEmpty(t, Hostname()) +} diff --git a/go/logger.go b/go/logger.go new file mode 100644 index 0000000..ca5641d --- /dev/null +++ b/go/logger.go @@ -0,0 +1,20 @@ +package originclient + +// Logger receives non-fatal warnings from the client (encode/build/request +// failures, machine-id/persistence fallbacks). It intentionally mirrors the +// smallest common subset of structured loggers so callers can adapt zap, +// slog, logrus, or anything else with one line. +type Logger interface { + Warn(msg string, err error) +} + +// LoggerFunc adapts a function to Logger. +type LoggerFunc func(msg string, err error) + +// Warn implements Logger. +func (f LoggerFunc) Warn(msg string, err error) { f(msg, err) } + +// noopLogger discards every warning. +type noopLogger struct{} + +func (noopLogger) Warn(string, error) {} diff --git a/go/machineid.go b/go/machineid.go new file mode 100644 index 0000000..05c2d75 --- /dev/null +++ b/go/machineid.go @@ -0,0 +1,87 @@ +package originclient + +import ( + "errors" + "os" + "path/filepath" + "strings" + + "github.com/google/uuid" +) + +// errMachineIDUnavailable is returned by the OS-specific machineID() when +// no real machine ID could be read. +var errMachineIDUnavailable = errors.New("originclient: OS machine id unavailable") + +// machineIDFunc is a variable indirection over the OS-specific machineID() +// so tests can stub OS machine-id lookup without depending on the actual +// host's state. +var machineIDFunc = machineID + +// ResolveMachineID returns the unique install ID to report to Origin, in +// priority order: +// +// 1. explicit, if non-empty — a caller-supplied override. +// 2. the real OS machine ID (/etc/machine-id on Linux, IOPlatformUUID on +// macOS, MachineGuid on Windows), if readable. +// 3. the ID already persisted at path, if present. +// 4. a newly generated UUID, which ResolveMachineID attempts to persist to +// path for future runs. +// +// OS-ID and persistence failures are logged as warnings, not returned as +// errors: ResolveMachineID always returns a usable ID, falling back to one +// that only lives for this process if it cannot be read from the OS or +// saved to disk. logger may be nil. +func ResolveMachineID(explicit, path string, logger Logger) string { + if logger == nil { + logger = noopLogger{} + } + + if id := strings.TrimSpace(explicit); id != "" { + return id + } + + if id, err := machineIDFunc(); err == nil { + if id = strings.TrimSpace(id); id != "" { + return id + } + } else { + logger.Warn("machine id: OS machine id unavailable, falling back to a generated id", err) + } + + if path == "" { + path = defaultInstallIDPath() + } + + if data, err := os.ReadFile(path); err == nil { + if id := strings.TrimSpace(string(data)); id != "" { + return id + } + } + + id := uuid.NewString() + if err := persistInstallID(path, id); err != nil { + logger.Warn("machine id: failed to persist generated id, using an ephemeral id for this run", err) + } + return id +} + +func persistInstallID(path, id string) error { + if dir := filepath.Dir(path); dir != "." && dir != "" { + if err := os.MkdirAll(dir, 0o750); err != nil { + return err + } + } + return os.WriteFile(path, []byte(id+"\n"), 0o600) +} + +// defaultInstallIDPath returns the fallback-UUID persistence path used when +// Config.InstallIDPath is unset: the OS user-config directory, or the OS +// temp directory if that cannot be determined. +func defaultInstallIDPath() string { + dir, err := os.UserConfigDir() + if err != nil || dir == "" { + dir = os.TempDir() + } + return filepath.Join(dir, "origin-client", "install-id") +} diff --git a/go/machineid_darwin.go b/go/machineid_darwin.go new file mode 100644 index 0000000..1446b6a --- /dev/null +++ b/go/machineid_darwin.go @@ -0,0 +1,29 @@ +//go:build darwin + +package originclient + +import ( + "context" + "os/exec" + "regexp" + "time" +) + +var ioregUUIDPattern = regexp.MustCompile(`"IOPlatformUUID" = "([0-9A-Fa-f-]+)"`) + +// machineID reads the hardware IOPlatformUUID via ioreg, which is stable +// across reboots and unique per physical/virtual Mac. +func machineID() (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + out, err := exec.CommandContext(ctx, "ioreg", "-rd1", "-c", "IOPlatformExpertDevice").Output() + if err != nil { + return "", err + } + match := ioregUUIDPattern.FindSubmatch(out) + if match == nil { + return "", errMachineIDUnavailable + } + return string(match[1]), nil +} diff --git a/go/machineid_linux.go b/go/machineid_linux.go new file mode 100644 index 0000000..5a4af32 --- /dev/null +++ b/go/machineid_linux.go @@ -0,0 +1,24 @@ +//go:build linux + +package originclient + +import ( + "os" + "strings" +) + +// machineID reads the systemd/dbus machine ID, which is stable across +// reboots and unique per Linux install (but not per container, since it is +// typically inherited from the image unless explicitly reset). +func machineID() (string, error) { + for _, path := range []string{"/etc/machine-id", "/var/lib/dbus/machine-id"} { + data, err := os.ReadFile(path) + if err != nil { + continue + } + if id := strings.TrimSpace(string(data)); id != "" { + return id, nil + } + } + return "", errMachineIDUnavailable +} diff --git a/go/machineid_other.go b/go/machineid_other.go new file mode 100644 index 0000000..e817329 --- /dev/null +++ b/go/machineid_other.go @@ -0,0 +1,9 @@ +//go:build !linux && !darwin && !windows + +package originclient + +// machineID has no known OS-specific implementation on this platform; +// ResolveMachineID falls back to a generated, persisted UUID. +func machineID() (string, error) { + return "", errMachineIDUnavailable +} diff --git a/go/machineid_test.go b/go/machineid_test.go new file mode 100644 index 0000000..3819c56 --- /dev/null +++ b/go/machineid_test.go @@ -0,0 +1,61 @@ +package originclient + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func withStubMachineID(t *testing.T, id string, err error) { + t.Helper() + original := machineIDFunc + machineIDFunc = func() (string, error) { return id, err } + t.Cleanup(func() { machineIDFunc = original }) +} + +func TestResolveMachineID_ExplicitOverrideWins(t *testing.T) { + withStubMachineID(t, "os-machine-id", nil) + got := ResolveMachineID("explicit-id", filepath.Join(t.TempDir(), "install-id"), nil) + assert.Equal(t, "explicit-id", got) +} + +func TestResolveMachineID_UsesOSMachineIDWhenAvailable(t *testing.T) { + withStubMachineID(t, "os-machine-id", nil) + got := ResolveMachineID("", filepath.Join(t.TempDir(), "install-id"), nil) + assert.Equal(t, "os-machine-id", got) +} + +func TestResolveMachineID_FallsBackAndPersistsWhenOSIDUnavailable(t *testing.T) { + withStubMachineID(t, "", errMachineIDUnavailable) + path := filepath.Join(t.TempDir(), "nested", "install-id") + + first := ResolveMachineID("", path, nil) + assert.NotEmpty(t, first) + + second := ResolveMachineID("", path, nil) + assert.Equal(t, first, second, "second call should read the persisted id rather than generating a new one") +} + +func TestResolveMachineID_NilLoggerDoesNotPanic(t *testing.T) { + withStubMachineID(t, "", errMachineIDUnavailable) + assert.NotPanics(t, func() { + ResolveMachineID("", filepath.Join(t.TempDir(), "install-id"), nil) + }) +} + +func TestResolveMachineID_LogsWarningWhenOSIDUnavailable(t *testing.T) { + withStubMachineID(t, "", errMachineIDUnavailable) + var warned bool + logger := LoggerFunc(func(msg string, err error) { warned = true }) + + ResolveMachineID("", filepath.Join(t.TempDir(), "install-id"), logger) + + assert.True(t, warned) +} + +func TestPersistInstallID(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "dir", "install-id") + require.NoError(t, persistInstallID(path, "abc-123")) +} diff --git a/go/machineid_windows.go b/go/machineid_windows.go new file mode 100644 index 0000000..ee3fcbc --- /dev/null +++ b/go/machineid_windows.go @@ -0,0 +1,21 @@ +//go:build windows + +package originclient + +import "golang.org/x/sys/windows/registry" + +// machineID reads MachineGuid from the registry, which is generated at +// Windows install time and stable across reboots. +func machineID() (string, error) { + key, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Cryptography`, registry.QUERY_VALUE|registry.WOW64_64KEY) + if err != nil { + return "", err + } + defer key.Close() + + value, _, err := key.GetStringValue("MachineGuid") + if err != nil { + return "", err + } + return value, nil +} diff --git a/go/payload.go b/go/payload.go new file mode 100644 index 0000000..6845fbf --- /dev/null +++ b/go/payload.go @@ -0,0 +1,35 @@ +package originclient + +// Payload is the JSON body posted to Origin's public service-checkin +// endpoint. Field names and requiredness mirror +// POST /api/public/service-checkin as documented in +// origin/doc/service-checkin.md: Type, Version, Name, and UniqueInstallID +// are required; everything else is optional and, when omitted, does not +// overwrite existing instance data on Origin's side. +type Payload struct { + Type string `json:"type"` + Version string `json:"version"` + Name string `json:"name"` + UniqueInstallID string `json:"unique_install_id"` + Site string `json:"site,omitempty"` + Environment string `json:"environment,omitempty"` + DatabaseType string `json:"database_type,omitempty"` + DatabaseName string `json:"database_name,omitempty"` + AppType string `json:"app_type,omitempty"` + Hostname string `json:"hostname,omitempty"` + Port int `json:"port,omitempty"` + BaseURL string `json:"base_url,omitempty"` + InternalURL string `json:"internal_url,omitempty"` + ContainerID string `json:"container_id,omitempty"` + Orchestrator string `json:"orchestrator,omitempty"` + Description string `json:"description,omitempty"` +} + +// Response is returned by Origin after a successful check-in. +type Response struct { + Success bool `json:"success"` + ServiceInstanceID int64 `json:"service_instance_id"` + UniqueInstallID string `json:"unique_install_id"` + Status string `json:"status"` + PingedAt string `json:"pinged_at"` +} diff --git a/js/README.md b/js/README.md new file mode 100644 index 0000000..85c34be --- /dev/null +++ b/js/README.md @@ -0,0 +1,47 @@ +# @warkanum/origin-client (JS/TS) + +``` +npm install @warkanum/origin-client +``` + +Node >=18, ESM only. + +## Usage + +```ts +import { OriginClient } from "@warkanum/origin-client"; + +const client = new OriginClient({ + serviceKey: "...", + type: "myservice", + version: "1.2.3", + name: "myservice-nova", +}); + +client.start(); // immediate check-in, then every 24h until stop() +``` + +Call `client.checkinOnce()` directly instead of `start()` to send a single +check-in and observe the result/error. + +## Config + +| Field | Required | Default | +|---|---|---| +| `serviceKey` | yes | — | +| `type`, `version`, `name` | yes | — | +| `url` | no | `DEFAULT_URL` (`https://origin.warky.dev/api/public/service-checkin`) | +| `appType` | no | `"javascript"` | +| `hostname` | no | OS hostname | +| `containerId` | no | best-effort Docker/cgroup detection | +| `orchestrator` | no | best-effort Kubernetes/Docker detection | +| `installId` | no | OS machine ID, else a generated UUID persisted to `installIdPath` | +| `intervalHours` | no | `DEFAULT_INTERVAL_HOURS` (24) | +| `logger` | no | discards warnings | + +`site`, `environment`, `databaseType`, `databaseName`, `port`, `baseUrl`, +`internalUrl`, `description` are optional metadata with no default. + +`hostname()`, `outboundIp()`, `containerId()`, `orchestrator()`, and +`resolveMachineId()` are exported standalone for callers that want the +detection logic without the HTTP client. diff --git a/js/package-lock.json b/js/package-lock.json new file mode 100644 index 0000000..8789ed1 --- /dev/null +++ b/js/package-lock.json @@ -0,0 +1,1331 @@ +{ + "name": "@warkanum/origin-client", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@warkanum/origin-client", + "version": "0.1.0", + "license": "Apache-2.0", + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.7.0", + "vitest": "^4.1.10" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", + "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "2.0.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", + "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", + "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", + "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz", + "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz", + "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz", + "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz", + "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz", + "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz", + "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz", + "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz", + "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz", + "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz", + "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz", + "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz", + "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "2.0.0-alpha.3", + "@emnapi/runtime": "2.0.0-alpha.3", + "@napi-rs/wasm-runtime": "^1.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz", + "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz", + "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", + "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.142.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.1", + "@rolldown/binding-darwin-arm64": "1.2.1", + "@rolldown/binding-darwin-x64": "1.2.1", + "@rolldown/binding-freebsd-x64": "1.2.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", + "@rolldown/binding-linux-arm64-gnu": "1.2.1", + "@rolldown/binding-linux-arm64-musl": "1.2.1", + "@rolldown/binding-linux-ppc64-gnu": "1.2.1", + "@rolldown/binding-linux-s390x-gnu": "1.2.1", + "@rolldown/binding-linux-x64-gnu": "1.2.1", + "@rolldown/binding-linux-x64-musl": "1.2.1", + "@rolldown/binding-openharmony-arm64": "1.2.1", + "@rolldown/binding-wasm32-wasi": "1.2.1", + "@rolldown/binding-win32-arm64-msvc": "1.2.1", + "@rolldown/binding-win32-x64-msvc": "1.2.1" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/js/package.json b/js/package.json new file mode 100644 index 0000000..2ca70ea --- /dev/null +++ b/js/package.json @@ -0,0 +1,31 @@ +{ + "name": "@warkanum/origin-client", + "version": "0.1.0", + "description": "Origin service check-in client: registers this process with origin.warky.dev and re-pings it on an interval.", + "type": "module", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.7.0", + "vitest": "^4.1.10" + } +} diff --git a/js/src/client.ts b/js/src/client.ts new file mode 100644 index 0000000..372ddaa --- /dev/null +++ b/js/src/client.ts @@ -0,0 +1,160 @@ +import { containerId as detectContainerId, hostname as detectHostname, orchestrator as detectOrchestrator } from "./hostInfo.js"; +import type { Logger } from "./machineId.js"; +import { resolveMachineId } from "./machineId.js"; +import type { CheckinResponse, Payload } from "./payload.js"; + +export const DEFAULT_URL = "https://origin.warky.dev/api/public/service-checkin"; +export const DEFAULT_INTERVAL_HOURS = 24; +const REQUEST_TIMEOUT_MS = 10_000; + +export interface Config { + /** Check-in endpoint. Defaults to DEFAULT_URL. */ + url?: string; + /** Sent as the X-Origin-Service-Key header. Required. */ + serviceKey: string; + + /** Origin service type, which must already be registered in Origin (e.g. "icy2"). Required. */ + type: string; + /** Caller's own version string. Required. */ + version: string; + /** Identifies this deployment/instance to Origin, e.g. "icy2-nova". Required. */ + name: string; + + site?: string; + environment?: string; + databaseType?: string; + databaseName?: string; + /** Defaults to "javascript" when unset. */ + appType?: string; + /** Defaults to the OS hostname when unset. */ + hostname?: string; + port?: number; + baseUrl?: string; + internalUrl?: string; + /** Defaults to best-effort Docker/cgroup detection when unset. */ + containerId?: string; + /** Defaults to best-effort Kubernetes/Docker detection when unset. */ + orchestrator?: string; + description?: string; + + /** Overrides machine-ID resolution entirely when set. */ + installId?: string; + /** Where a generated fallback install ID is persisted when no real OS machine ID is available. */ + installIdPath?: string; + + /** How often start() repeats. Defaults to DEFAULT_INTERVAL_HOURS. */ + intervalHours?: number; + + /** Receives non-fatal warnings. Defaults to discarding them. */ + logger?: Logger; +} + +type ResolvedConfig = Config & { url: string; appType: string; intervalHours: number; logger: Logger }; + +/** OriginClient checks a service in with Origin. */ +export class OriginClient { + private readonly cfg: ResolvedConfig; + private readonly installId: string; + private timer?: ReturnType; + + /** + * Validates config, resolves the machine/install ID, and returns a ready + * client. Resolution happens once, at construction, so every check-in + * made by this client reports the same unique_install_id. + */ + constructor(config: Config) { + if (!config.serviceKey || !config.type || !config.version || !config.name) { + throw new Error("originclient: serviceKey, type, version, and name are required"); + } + const logger = config.logger ?? { warn: () => {} }; + this.cfg = { + ...config, + url: config.url || DEFAULT_URL, + appType: config.appType || "javascript", + intervalHours: config.intervalHours && config.intervalHours > 0 ? config.intervalHours : DEFAULT_INTERVAL_HOURS, + logger, + }; + this.installId = resolveMachineId(config.installId, config.installIdPath, logger); + } + + private buildPayload(): Payload { + const hostname = this.cfg.hostname || detectHostname(); + const containerId = this.cfg.containerId || detectContainerId(); + const orchestrator = this.cfg.orchestrator || detectOrchestrator(); + + return { + type: this.cfg.type, + version: this.cfg.version, + name: this.cfg.name, + unique_install_id: this.installId, + site: this.cfg.site, + environment: this.cfg.environment, + database_type: this.cfg.databaseType, + database_name: this.cfg.databaseName, + app_type: this.cfg.appType, + hostname: hostname || undefined, + port: this.cfg.port, + base_url: this.cfg.baseUrl, + internal_url: this.cfg.internalUrl, + container_id: containerId || undefined, + orchestrator: orchestrator || undefined, + description: this.cfg.description, + }; + } + + /** + * Sends a single check-in request and returns Origin's response. Throws + * on any encode/network/non-2xx failure, after logging it via + * config.logger. + */ + async checkinOnce(): Promise { + const payload = this.buildPayload(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + try { + const res = await fetch(this.cfg.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Origin-Service-Key": this.cfg.serviceKey, + }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + + if (!res.ok) { + throw new Error(`originclient: non-2xx response: ${res.status}`); + } + + return (await res.json()) as CheckinResponse; + } catch (err) { + this.cfg.logger.warn("origin checkin: request failed", err); + throw err; + } finally { + clearTimeout(timeout); + } + } + + /** + * Performs an immediate check-in, then repeats every intervalHours until + * stop() is called. Check-in failures are logged via config.logger and + * otherwise ignored; call checkinOnce() directly to observe them. + */ + start(): void { + void this.checkinOnce().catch(() => {}); + const intervalMs = this.cfg.intervalHours * 60 * 60 * 1000; + this.timer = setInterval(() => { + void this.checkinOnce().catch(() => {}); + }, intervalMs); + this.timer.unref?.(); + } + + /** Stops the interval started by start(). Safe to call even if start() was never called. */ + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = undefined; + } + } +} diff --git a/js/src/hostInfo.ts b/js/src/hostInfo.ts new file mode 100644 index 0000000..25c68d8 --- /dev/null +++ b/js/src/hostInfo.ts @@ -0,0 +1,85 @@ +import { existsSync, readFileSync } from "node:fs"; +import { hostname as osHostname, networkInterfaces } from "node:os"; + +/** hostname returns the OS hostname, or "" if it cannot be determined. */ +export function hostname(): string { + try { + return osHostname(); + } catch { + return ""; + } +} + +/** + * outboundIp returns the first non-loopback IPv4 address bound to this + * host. It is a LAN-facing address, not the host's public internet + * address, and is not part of Origin's wire contract - it exists so + * callers can populate base_url/internal_url/description themselves. + */ +export function outboundIp(): string { + const interfaces = networkInterfaces(); + for (const name of Object.keys(interfaces)) { + for (const info of interfaces[name] ?? []) { + if (info.family === "IPv4" && !info.internal) { + return info.address; + } + } + } + return ""; +} + +/** + * containerId best-effort detects the Docker/Kubernetes container ID this + * process runs in. It returns "" outside a container or when detection + * fails; an empty container ID is treated as "not containerized" rather + * than an error. + */ +export function containerId(): string { + try { + if (existsSync("/proc/self/cgroup")) { + const id = parseCgroupContainerId(readFileSync("/proc/self/cgroup", "utf8")); + if (id) return id; + } + } catch { + // fall through to dockerenv check + } + if (existsSync("/.dockerenv")) { + const h = hostname(); + if (isContainerIdLike(h)) return h; + } + return ""; +} + +/** + * orchestrator best-effort detects the orchestration environment: + * "kubernetes" under Kubernetes, "docker" in a plain Docker container, "" + * otherwise. + */ +export function orchestrator(): string { + if (process.env.KUBERNETES_SERVICE_HOST) return "kubernetes"; + if (existsSync("/.dockerenv")) return "docker"; + return ""; +} + +/** + * parseCgroupContainerId extracts a container ID from /proc/self/cgroup + * content, handling both cgroup v1 (".../docker/") and cgroup v2 + * systemd-scope (".../docker-.scope") layouts. + */ +export function parseCgroupContainerId(data: string): string { + for (const rawLine of data.split("\n")) { + const line = rawLine.trim(); + const idx = line.lastIndexOf("/"); + if (idx === -1) continue; + let segment = line.slice(idx + 1); + segment = segment.replace(/\.scope$/, ""); + segment = segment.replace(/^docker-/, ""); + if (isContainerIdLike(segment)) return segment; + } + return ""; +} + +export function isContainerIdLike(s: string): boolean { + if (s.length < 12 || s.length > 64) return false; + return /^[0-9a-f]+$/.test(s); +} diff --git a/js/src/index.ts b/js/src/index.ts new file mode 100644 index 0000000..4c4893a --- /dev/null +++ b/js/src/index.ts @@ -0,0 +1,6 @@ +export { DEFAULT_INTERVAL_HOURS, DEFAULT_URL, OriginClient } from "./client.js"; +export type { Config } from "./client.js"; +export { containerId, hostname, orchestrator, outboundIp } from "./hostInfo.js"; +export { defaultInstallIdPath, detectOsMachineId, resolveMachineId } from "./machineId.js"; +export type { Logger } from "./machineId.js"; +export type { CheckinResponse, Payload } from "./payload.js"; diff --git a/js/src/machineId.ts b/js/src/machineId.ts new file mode 100644 index 0000000..77607c6 --- /dev/null +++ b/js/src/machineId.ts @@ -0,0 +1,124 @@ +import { execFileSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir, platform, tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +export interface Logger { + warn(msg: string, err?: unknown): void; +} + +const noopLogger: Logger = { warn: () => {} }; + +function readLinuxMachineId(): string | undefined { + for (const path of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) { + try { + const id = readFileSync(path, "utf8").trim(); + if (id) return id; + } catch { + // try the next candidate path + } + } + return undefined; +} + +function readDarwinMachineId(): string | undefined { + try { + const out = execFileSync("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], { + encoding: "utf8", + timeout: 2000, + }); + return out.match(/"IOPlatformUUID"\s*=\s*"([0-9A-Fa-f-]+)"/)?.[1]; + } catch { + return undefined; + } +} + +function readWindowsMachineId(): string | undefined { + try { + const out = execFileSync( + "reg", + ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"], + { encoding: "utf8", timeout: 2000 }, + ); + return out.match(/MachineGuid\s+REG_SZ\s+([0-9A-Fa-f-]+)/)?.[1]; + } catch { + return undefined; + } +} + +/** detectOsMachineId reads the real OS machine ID, or undefined if unavailable/unsupported. */ +export function detectOsMachineId(): string | undefined { + switch (platform()) { + case "linux": + return readLinuxMachineId(); + case "darwin": + return readDarwinMachineId(); + case "win32": + return readWindowsMachineId(); + default: + return undefined; + } +} + +/** + * machineIdDetector is a mutable indirection over detectOsMachineId so + * tests can stub OS machine-id lookup without depending on the actual + * host's state. It's a settable-property holder rather than a plain + * exported `let` because ES module namespace bindings can't be reassigned + * from outside the module. + */ +export const machineIdDetector: { current: () => string | undefined } = { + current: detectOsMachineId, +}; + +/** defaultInstallIdPath returns the fallback-UUID persistence path used when no explicit path is given. */ +export function defaultInstallIdPath(): string { + const base = process.env.XDG_CONFIG_HOME || join(homedir() || tmpdir(), ".config"); + return join(base, "origin-client", "install-id"); +} + +/** + * resolveMachineId returns the unique install ID to report to Origin, in + * priority order: + * + * 1. explicit, if non-empty - a caller-supplied override. + * 2. the real OS machine ID (/etc/machine-id on Linux, IOPlatformUUID on + * macOS, MachineGuid on Windows), if readable. + * 3. the ID already persisted at path, if present. + * 4. a newly generated UUID, which resolveMachineId attempts to persist + * to path for future runs. + * + * OS-ID and persistence failures are logged as warnings, not thrown: + * resolveMachineId always returns a usable ID. + */ +export function resolveMachineId(explicit?: string, path?: string, logger: Logger = noopLogger): string { + const trimmedExplicit = explicit?.trim(); + if (trimmedExplicit) return trimmedExplicit; + + const osId = machineIdDetector.current(); + if (osId) { + const trimmed = osId.trim(); + if (trimmed) return trimmed; + } else { + logger.warn("machine id: OS machine id unavailable, falling back to a generated id"); + } + + const resolvedPath = path && path.length > 0 ? path : defaultInstallIdPath(); + + try { + const existing = readFileSync(resolvedPath, "utf8").trim(); + if (existing) return existing; + } catch { + // no persisted id yet + } + + const id = randomUUID(); + try { + mkdirSync(dirname(resolvedPath), { recursive: true, mode: 0o750 }); + writeFileSync(resolvedPath, id + "\n", { mode: 0o600 }); + } catch (err) { + logger.warn("machine id: failed to persist generated id, using an ephemeral id for this run", err); + } + return id; +} diff --git a/js/src/payload.ts b/js/src/payload.ts new file mode 100644 index 0000000..78869dd --- /dev/null +++ b/js/src/payload.ts @@ -0,0 +1,35 @@ +/** + * Payload is the JSON body posted to Origin's public service-checkin + * endpoint. Field names and requiredness mirror + * POST /api/public/service-checkin as documented in + * origin/doc/service-checkin.md: type, version, name, and + * unique_install_id are required; everything else is optional and, when + * omitted, does not overwrite existing instance data on Origin's side. + */ +export interface Payload { + type: string; + version: string; + name: string; + unique_install_id: string; + site?: string; + environment?: string; + database_type?: string; + database_name?: string; + app_type?: string; + hostname?: string; + port?: number; + base_url?: string; + internal_url?: string; + container_id?: string; + orchestrator?: string; + description?: string; +} + +/** CheckinResponse is returned by Origin after a successful check-in. */ +export interface CheckinResponse { + success: boolean; + service_instance_id: number; + unique_install_id: string; + status: string; + pinged_at: string; +} diff --git a/js/test/client.test.ts b/js/test/client.test.ts new file mode 100644 index 0000000..79d69bb --- /dev/null +++ b/js/test/client.test.ts @@ -0,0 +1,141 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { describe, expect, it } from "vitest"; +import { OriginClient } from "../src/client.js"; + +function withServer( + handler: (req: IncomingMessage, res: ServerResponse) => void, +): Promise<{ server: Server; url: string }> { + return new Promise((resolve) => { + const server = createServer(handler); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + resolve({ server, url: `http://127.0.0.1:${port}` }); + }); + }); +} + +describe("OriginClient", () => { + it("requires serviceKey/type/version/name", () => { + expect(() => new OriginClient({ serviceKey: "", type: "", version: "", name: "" })).toThrow(); + }); + + it("sends the expected payload and headers on checkinOnce", async () => { + let gotKey = ""; + let gotBody: Record = {}; + const { server, url } = await withServer((req, res) => { + gotKey = req.headers["x-origin-service-key"] as string; + let raw = ""; + req.on("data", (chunk) => { + raw += chunk; + }); + req.on("end", () => { + gotBody = JSON.parse(raw); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + success: true, + service_instance_id: 42, + unique_install_id: gotBody.unique_install_id, + status: "provisioning", + pinged_at: "2026-08-01T00:00:00Z", + }), + ); + }); + }); + + try { + const client = new OriginClient({ + url, + serviceKey: "test-key", + type: "icy2", + version: "v1.0.0", + name: "icy2-test", + installId: "test-install-id", + }); + + const resp = await client.checkinOnce(); + + expect(gotKey).toBe("test-key"); + expect(gotBody.type).toBe("icy2"); + expect(gotBody.name).toBe("icy2-test"); + expect(gotBody.unique_install_id).toBe("test-install-id"); + expect(resp.success).toBe(true); + expect(resp.service_instance_id).toBe(42); + } finally { + server.close(); + } + }); + + it("throws on a non-2xx response", async () => { + const { server, url } = await withServer((_req, res) => { + res.writeHead(500); + res.end(); + }); + + try { + const client = new OriginClient({ + url, + serviceKey: "test-key", + type: "icy2", + version: "v1.0.0", + name: "icy2-test", + installId: "test-install-id", + }); + + await expect(client.checkinOnce()).rejects.toThrow(); + } finally { + server.close(); + } + }); + + it("does not throw for an unreachable server", async () => { + const client = new OriginClient({ + url: "http://127.0.0.1:1", + serviceKey: "test-key", + type: "icy2", + version: "v1.0.0", + name: "icy2-test", + installId: "test-install-id", + }); + + await expect(client.checkinOnce()).rejects.toThrow(); + }); + + it("start() checks in immediately and stop() prevents further calls", async () => { + let calls = 0; + const { server, url } = await withServer((_req, res) => { + calls += 1; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + success: true, + service_instance_id: 1, + unique_install_id: "x", + status: "provisioning", + pinged_at: "2026-08-01T00:00:00Z", + }), + ); + }); + + try { + const client = new OriginClient({ + url, + serviceKey: "test-key", + type: "icy2", + version: "v1.0.0", + name: "icy2-test", + installId: "test-install-id", + intervalHours: 24, + }); + + client.start(); + await new Promise((resolve) => setTimeout(resolve, 50)); + client.stop(); + + expect(calls).toBe(1); + } finally { + server.close(); + } + }); +}); diff --git a/js/test/hostInfo.test.ts b/js/test/hostInfo.test.ts new file mode 100644 index 0000000..ba690c2 --- /dev/null +++ b/js/test/hostInfo.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { hostname, isContainerIdLike, parseCgroupContainerId } from "../src/hostInfo.js"; + +describe("parseCgroupContainerId", () => { + it("parses a cgroup v1 docker path", () => { + const data = "12:memory:/docker/9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b\n"; + expect(parseCgroupContainerId(data)).toBe("9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b"); + }); + + it("parses a cgroup v2 systemd scope", () => { + const data = "0::/system.slice/docker-9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b.scope\n"; + expect(parseCgroupContainerId(data)).toBe("9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b"); + }); + + it("returns empty on a non-container host", () => { + expect(parseCgroupContainerId("0::/user.slice/user-1000.slice/session-2.scope\n")).toBe(""); + }); + + it("returns empty for empty input", () => { + expect(parseCgroupContainerId("")).toBe(""); + }); +}); + +describe("isContainerIdLike", () => { + it("accepts a hex id of valid length", () => { + expect(isContainerIdLike("9f8b7c6d5e4f")).toBe(true); + }); + it("rejects short strings", () => { + expect(isContainerIdLike("short")).toBe(false); + }); + it("rejects non-hex strings", () => { + expect(isContainerIdLike("session-2")).toBe(false); + }); + it("rejects empty strings", () => { + expect(isContainerIdLike("")).toBe(false); + }); +}); + +describe("hostname", () => { + it("returns a non-empty value", () => { + expect(hostname().length).toBeGreaterThan(0); + }); +}); diff --git a/js/test/machineId.test.ts b/js/test/machineId.test.ts new file mode 100644 index 0000000..bc6245e --- /dev/null +++ b/js/test/machineId.test.ts @@ -0,0 +1,44 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as machineId from "../src/machineId.js"; + +function tempInstallIdPath(): string { + const dir = mkdtempSync(join(tmpdir(), "origin-client-test-")); + return join(dir, "install-id"); +} + +describe("resolveMachineId", () => { + afterEach(() => { + machineId.machineIdDetector.current = machineId.detectOsMachineId; + }); + + it("prefers an explicit override", () => { + machineId.machineIdDetector.current = () => "os-machine-id"; + expect(machineId.resolveMachineId("explicit-id", tempInstallIdPath())).toBe("explicit-id"); + }); + + it("uses the OS machine id when available", () => { + machineId.machineIdDetector.current = () => "os-machine-id"; + expect(machineId.resolveMachineId(undefined, tempInstallIdPath())).toBe("os-machine-id"); + }); + + it("falls back to a generated id and persists it across calls", () => { + machineId.machineIdDetector.current = () => undefined; + const path = tempInstallIdPath(); + + const first = machineId.resolveMachineId(undefined, path); + expect(first.length).toBeGreaterThan(0); + + const second = machineId.resolveMachineId(undefined, path); + expect(second).toBe(first); + }); + + it("logs a warning when the OS id is unavailable", () => { + machineId.machineIdDetector.current = () => undefined; + const warn = vi.fn(); + machineId.resolveMachineId(undefined, tempInstallIdPath(), { warn }); + expect(warn).toHaveBeenCalled(); + }); +}); diff --git a/js/tsconfig.json b/js/tsconfig.json new file mode 100644 index 0000000..a9f41fa --- /dev/null +++ b/js/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"] +} diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..48a664c --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "origin-client" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Origin service check-in client: registers this process with origin.warky.dev and re-pings it on an interval." +repository = "https://git.warky.dev/wdevs/origin_client" + +[dependencies] +dirs = "5" +gethostname = "0.5" +if-addrs = "0.13" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["rt", "time", "macros", "sync"] } +tokio-util = "0.7" +uuid = { version = "1", features = ["v4"] } + +[target.'cfg(windows)'.dependencies] +winreg = "0.52" + +[dev-dependencies] +mockito = "1" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 0000000..ba9b9c2 --- /dev/null +++ b/rust/README.md @@ -0,0 +1,61 @@ +# origin-client (Rust) + +``` +cargo add origin-client +``` + +Async, built on `tokio` + `reqwest` (rustls, no OpenSSL dependency). + +## Usage + +```rust +use origin_client::{Client, Config}; +use tokio_util::sync::CancellationToken; + +let client = Client::new(Config { + service_key: "...".into(), + type_: "myservice".into(), + version: "1.2.3".into(), + name: "myservice-nova".into(), + ..Default::default() +})?; + +let cancel = CancellationToken::new(); +tokio::spawn({ + let cancel = cancel.clone(); + async move { client.run(cancel).await } +}); // immediate check-in, then every 24h until cancel.cancel() is called +``` + +Call `client.checkin_once().await` directly instead of `run` to send a +single check-in and observe the result/error. + +## Config + +| Field | Required | Default | +|---|---|---| +| `service_key` | yes | — | +| `type_`, `version`, `name` | yes | — | +| `url` | no | `DEFAULT_URL` (`https://origin.warky.dev/api/public/service-checkin`) | +| `app_type` | no | `"rust"` | +| `hostname` | no | OS hostname | +| `container_id` | no | best-effort Docker/cgroup detection | +| `orchestrator` | no | best-effort Kubernetes/Docker detection | +| `install_id` | no | OS machine ID, else a generated UUID persisted to `install_id_path` | +| `interval_hours` | no | `DEFAULT_INTERVAL_HOURS` (24) | +| `logger` | no | discards warnings (`NoopLogger`) | + +`site`, `environment`, `database_type`, `database_name`, `port`, +`base_url`, `internal_url`, `description` are optional metadata with no +default. `Config` implements `Default`, so use struct-update syntax +(`..Default::default()`) as shown above. + +`hostname()`, `outbound_ip()`, `container_id()`, `orchestrator()`, and +`resolve_machine_id()` are exported standalone for callers that want the +detection logic without the HTTP client. + +## Platform notes + +macOS/Windows machine-ID detection is compiled but not exercised by CI +(this repo's CI runs on Linux). `cargo build --target ...` for those +targets is a reasonable spot-check before release. diff --git a/rust/src/client.rs b/rust/src/client.rs new file mode 100644 index 0000000..40e8ae1 --- /dev/null +++ b/rust/src/client.rs @@ -0,0 +1,284 @@ +use std::fmt; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use tokio_util::sync::CancellationToken; + +use crate::host_info::{container_id, hostname, orchestrator}; +use crate::logger::{Logger, NoopLogger}; +use crate::machine_id::resolve_machine_id; +use crate::payload::{CheckinResponse, Payload}; + +/// Origin's public service-checkin endpoint. +pub const DEFAULT_URL: &str = "https://origin.warky.dev/api/public/service-checkin"; +/// How often `Client::run` repeats when `Config::interval_hours` is unset. +pub const DEFAULT_INTERVAL_HOURS: u32 = 24; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); + +/// Config configures a Client. `service_key`, `type_`, `version`, and +/// `name` identify the caller to Origin and are required; everything else +/// is optional metadata, or overrides for values Client would otherwise +/// auto-detect. +#[derive(Clone)] +pub struct Config { + /// Check-in endpoint. Defaults to [`DEFAULT_URL`] when `None`. + pub url: Option, + /// Sent as the `X-Origin-Service-Key` header. Required. + pub service_key: String, + + /// Origin service type, which must already be registered in Origin + /// (e.g. `"icy2"`). Required. + pub type_: String, + /// Caller's own version string. Required. + pub version: String, + /// Identifies this deployment/instance to Origin, e.g. `"icy2-nova"`. Required. + pub name: String, + + pub site: Option, + pub environment: Option, + pub database_type: Option, + pub database_name: Option, + /// Defaults to `"rust"` when `None`. + pub app_type: Option, + /// Defaults to the OS hostname when `None`. + pub hostname: Option, + pub port: Option, + pub base_url: Option, + pub internal_url: Option, + /// Defaults to best-effort Docker/cgroup detection when `None`. + pub container_id: Option, + /// Defaults to best-effort Kubernetes/Docker detection when `None`. + pub orchestrator: Option, + pub description: Option, + + /// Overrides machine-ID resolution entirely when set. + pub install_id: Option, + /// Where a generated fallback install ID is persisted when no real OS + /// machine ID is available. + pub install_id_path: Option, + + /// How often `run` repeats. Defaults to [`DEFAULT_INTERVAL_HOURS`] when `None`. + pub interval_hours: Option, + + /// Receives non-fatal warnings. Defaults to discarding them. + pub logger: Arc, +} + +impl Default for Config { + fn default() -> Self { + Self { + url: None, + service_key: String::new(), + type_: String::new(), + version: String::new(), + name: String::new(), + site: None, + environment: None, + database_type: None, + database_name: None, + app_type: None, + hostname: None, + port: None, + base_url: None, + internal_url: None, + container_id: None, + orchestrator: None, + description: None, + install_id: None, + install_id_path: None, + interval_hours: None, + logger: Arc::new(NoopLogger), + } + } +} + +/// Error returned by [`Client::new`] and [`Client::checkin_once`]. +#[derive(Debug)] +pub enum Error { + MissingRequiredConfig, + BuildClient(reqwest::Error), + Request(reqwest::Error), + Status(reqwest::StatusCode), + Decode(reqwest::Error), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::MissingRequiredConfig => { + write!( + f, + "origin-client: service_key, type, version, and name are required" + ) + } + Error::BuildClient(e) => write!(f, "origin-client: build http client: {e}"), + Error::Request(e) => write!(f, "origin-client: request failed: {e}"), + Error::Status(code) => write!(f, "origin-client: non-2xx response: {code}"), + Error::Decode(e) => write!(f, "origin-client: decode response: {e}"), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Error::BuildClient(e) | Error::Request(e) | Error::Decode(e) => Some(e), + Error::MissingRequiredConfig | Error::Status(_) => None, + } + } +} + +/// Client checks a service in with Origin. +pub struct Client { + cfg: Config, + http: reqwest::Client, + install_id: String, +} + +impl Client { + /// Validates `cfg`, resolves the machine/install ID, and returns a + /// ready Client. Resolution happens once, at construction, so every + /// check-in made by this Client reports the same `unique_install_id`. + pub fn new(cfg: Config) -> Result { + if cfg.service_key.is_empty() + || cfg.type_.is_empty() + || cfg.version.is_empty() + || cfg.name.is_empty() + { + return Err(Error::MissingRequiredConfig); + } + + let http = reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + .build() + .map_err(Error::BuildClient)?; + + let install_id = resolve_machine_id( + cfg.install_id.as_deref(), + cfg.install_id_path.as_deref(), + cfg.logger.as_ref(), + ); + + Ok(Self { + cfg, + http, + install_id, + }) + } + + fn url(&self) -> &str { + self.cfg.url.as_deref().unwrap_or(DEFAULT_URL) + } + + fn app_type(&self) -> String { + self.cfg + .app_type + .clone() + .unwrap_or_else(|| "rust".to_string()) + } + + fn interval_hours(&self) -> u32 { + self.cfg + .interval_hours + .filter(|&h| h > 0) + .unwrap_or(DEFAULT_INTERVAL_HOURS) + } + + fn build_payload(&self) -> Payload { + let hostname_value = self.cfg.hostname.clone().unwrap_or_else(hostname); + let container_id_value = self.cfg.container_id.clone().unwrap_or_else(container_id); + let orchestrator_value = self.cfg.orchestrator.clone().unwrap_or_else(orchestrator); + + Payload { + type_: self.cfg.type_.clone(), + version: self.cfg.version.clone(), + name: self.cfg.name.clone(), + unique_install_id: self.install_id.clone(), + site: self.cfg.site.clone(), + environment: self.cfg.environment.clone(), + database_type: self.cfg.database_type.clone(), + database_name: self.cfg.database_name.clone(), + app_type: Some(self.app_type()), + hostname: non_empty(hostname_value), + port: self.cfg.port, + base_url: self.cfg.base_url.clone(), + internal_url: self.cfg.internal_url.clone(), + container_id: non_empty(container_id_value), + orchestrator: non_empty(orchestrator_value), + description: self.cfg.description.clone(), + } + } + + /// Sends a single check-in request and returns Origin's response. + /// Failures are logged via `Config::logger` and also returned, so + /// callers that only want the fire-and-forget behavior of `run` can + /// ignore the returned error. + pub async fn checkin_once(&self) -> Result { + let payload = self.build_payload(); + + let response = match self + .http + .post(self.url()) + .header("X-Origin-Service-Key", &self.cfg.service_key) + .json(&payload) + .send() + .await + { + Ok(r) => r, + Err(err) => { + self.cfg + .logger + .warn("origin checkin: request failed", Some(&err)); + return Err(Error::Request(err)); + } + }; + + if !response.status().is_success() { + let status = response.status(); + self.cfg + .logger + .warn("origin checkin: non-2xx response", None); + return Err(Error::Status(status)); + } + + match response.json::().await { + Ok(body) => Ok(body), + Err(err) => { + self.cfg + .logger + .warn("origin checkin: decode response failed", Some(&err)); + Err(Error::Decode(err)) + } + } + } + + /// Performs an immediate check-in, then repeats every + /// `Config::interval_hours` until `cancel` is cancelled. Intended to be + /// spawned: `tokio::spawn(async move { client.run(cancel).await })`. + /// Check-in errors are logged via `Config::logger` and otherwise + /// ignored; call [`Client::checkin_once`] directly to observe them. + pub async fn run(&self, cancel: CancellationToken) { + let _ = self.checkin_once().await; + + let interval = Duration::from_secs(u64::from(self.interval_hours()) * 3600); + loop { + tokio::select! { + () = tokio::time::sleep(interval) => { + let _ = self.checkin_once().await; + } + () = cancel.cancelled() => { + return; + } + } + } + } +} + +fn non_empty(s: String) -> Option { + if s.is_empty() { + None + } else { + Some(s) + } +} diff --git a/rust/src/host_info.rs b/rust/src/host_info.rs new file mode 100644 index 0000000..6b357f4 --- /dev/null +++ b/rust/src/host_info.rs @@ -0,0 +1,154 @@ +use std::env; +use std::fs; +use std::net::IpAddr; +use std::path::Path; + +/// Returns the OS hostname, or "" if it cannot be determined. +pub fn hostname() -> String { + gethostname::gethostname().to_string_lossy().into_owned() +} + +/// Returns the first non-loopback IPv4 address bound to this host. It is a +/// LAN-facing address, not the host's public internet address, and is not +/// part of Origin's wire contract - it exists so callers can populate +/// `base_url`/`internal_url`/`description` themselves. +pub fn outbound_ip() -> String { + if_addrs::get_if_addrs() + .unwrap_or_default() + .into_iter() + .filter(|iface| !iface.is_loopback()) + .find_map(|iface| match iface.ip() { + IpAddr::V4(v4) => Some(v4.to_string()), + IpAddr::V6(_) => None, + }) + .unwrap_or_default() +} + +/// Best-effort detects the Docker/Kubernetes container ID this process +/// runs in. Returns "" outside a container or when detection fails; an +/// empty container ID is treated as "not containerized" rather than an +/// error. +pub fn container_id() -> String { + if let Ok(data) = fs::read_to_string("/proc/self/cgroup") { + let id = parse_cgroup_container_id(&data); + if !id.is_empty() { + return id; + } + } + if Path::new("/.dockerenv").exists() { + let h = hostname(); + if is_container_id_like(&h) { + return h; + } + } + String::new() +} + +/// Best-effort detects the orchestration environment: "kubernetes" under +/// Kubernetes, "docker" in a plain Docker container, "" otherwise. +pub fn orchestrator() -> String { + if env::var("KUBERNETES_SERVICE_HOST").is_ok_and(|v| !v.is_empty()) { + return "kubernetes".to_string(); + } + if Path::new("/.dockerenv").exists() { + return "docker".to_string(); + } + String::new() +} + +/// Extracts a container ID from /proc/self/cgroup content, handling both +/// cgroup v1 (".../docker/") and cgroup v2 systemd-scope +/// (".../docker-.scope") layouts. +pub fn parse_cgroup_container_id(data: &str) -> String { + for raw_line in data.lines() { + let line = raw_line.trim(); + let idx = match line.rfind('/') { + Some(i) => i, + None => continue, + }; + let mut segment = &line[idx + 1..]; + segment = segment.strip_suffix(".scope").unwrap_or(segment); + segment = segment.strip_prefix("docker-").unwrap_or(segment); + if is_container_id_like(segment) { + return segment.to_string(); + } + } + String::new() +} + +pub fn is_container_id_like(s: &str) -> bool { + if s.len() < 12 || s.len() > 64 { + return false; + } + s.chars() + .all(|c| c.is_ascii_digit() || (c.is_ascii_lowercase() && c.is_ascii_hexdigit())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_cgroup_v1_docker_path() { + let data = + "12:memory:/docker/9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b\n"; + assert_eq!( + parse_cgroup_container_id(data), + "9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b" + ); + } + + #[test] + fn parses_cgroup_v2_systemd_scope() { + let data = "0::/system.slice/docker-9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b.scope\n"; + assert_eq!( + parse_cgroup_container_id(data), + "9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b" + ); + } + + #[test] + fn returns_empty_on_non_container_host() { + assert_eq!( + parse_cgroup_container_id("0::/user.slice/user-1000.slice/session-2.scope\n"), + "" + ); + } + + #[test] + fn returns_empty_for_empty_input() { + assert_eq!(parse_cgroup_container_id(""), ""); + } + + #[test] + fn is_container_id_like_accepts_valid_hex_id() { + assert!(is_container_id_like("9f8b7c6d5e4f")); + } + + #[test] + fn is_container_id_like_rejects_short_strings() { + assert!(!is_container_id_like("short")); + } + + #[test] + fn is_container_id_like_rejects_non_hex_strings() { + assert!(!is_container_id_like("session-2")); + } + + #[test] + fn is_container_id_like_rejects_empty_strings() { + assert!(!is_container_id_like("")); + } + + #[test] + fn hostname_is_not_empty() { + assert!(!hostname().is_empty()); + } + + #[test] + fn orchestrator_detects_kubernetes() { + std::env::set_var("KUBERNETES_SERVICE_HOST", "10.0.0.1"); + assert_eq!(orchestrator(), "kubernetes"); + std::env::remove_var("KUBERNETES_SERVICE_HOST"); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs new file mode 100644 index 0000000..25c125d --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1,18 @@ +//! origin-client registers a running service with Origin +//! (origin.warky.dev)'s public check-in endpoint and re-pings it on an +//! interval, so Origin can track which instances of a service are alive, +//! their version, and where they run. A check-in failure is logged and +//! otherwise ignored: it must never affect the host application's own +//! availability. + +mod client; +mod host_info; +mod logger; +mod machine_id; +mod payload; + +pub use client::{Client, Config, Error, DEFAULT_INTERVAL_HOURS, DEFAULT_URL}; +pub use host_info::{container_id, hostname, orchestrator, outbound_ip}; +pub use logger::{FnLogger, Logger, NoopLogger}; +pub use machine_id::{default_install_id_path, detect_os_machine_id, resolve_machine_id}; +pub use payload::{CheckinResponse, Payload}; diff --git a/rust/src/logger.rs b/rust/src/logger.rs new file mode 100644 index 0000000..7a7642c --- /dev/null +++ b/rust/src/logger.rs @@ -0,0 +1,30 @@ +use std::error::Error as StdError; + +/// Logger receives non-fatal warnings from the client (request failures, +/// machine-id/persistence fallbacks). Implement it to route warnings into +/// your own logging framework. +pub trait Logger: Send + Sync { + fn warn(&self, msg: &str, err: Option<&(dyn StdError + Send + Sync)>); +} + +/// NoopLogger discards every warning. The default when no logger is configured. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopLogger; + +impl Logger for NoopLogger { + fn warn(&self, _msg: &str, _err: Option<&(dyn StdError + Send + Sync)>) {} +} + +/// FnLogger adapts a closure to Logger. +pub struct FnLogger(pub F) +where + F: Fn(&str, Option<&(dyn StdError + Send + Sync)>) + Send + Sync; + +impl Logger for FnLogger +where + F: Fn(&str, Option<&(dyn StdError + Send + Sync)>) + Send + Sync, +{ + fn warn(&self, msg: &str, err: Option<&(dyn StdError + Send + Sync)>) { + (self.0)(msg, err) + } +} diff --git a/rust/src/machine_id.rs b/rust/src/machine_id.rs new file mode 100644 index 0000000..f3a8237 --- /dev/null +++ b/rust/src/machine_id.rs @@ -0,0 +1,201 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use uuid::Uuid; + +use crate::logger::Logger; + +/// Reads the real OS machine ID, or `None` if unavailable/unsupported. +#[cfg(target_os = "linux")] +pub fn detect_os_machine_id() -> Option { + for path in ["/etc/machine-id", "/var/lib/dbus/machine-id"] { + if let Ok(data) = fs::read_to_string(path) { + let id = data.trim(); + if !id.is_empty() { + return Some(id.to_string()); + } + } + } + None +} + +/// Reads the hardware IOPlatformUUID via `ioreg`, which is stable across +/// reboots and unique per physical/virtual Mac. +#[cfg(target_os = "macos")] +pub fn detect_os_machine_id() -> Option { + use std::process::Command; + + let output = Command::new("ioreg") + .args(["-rd1", "-c", "IOPlatformExpertDevice"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8_lossy(&output.stdout); + let marker = "\"IOPlatformUUID\" = \""; + let start = text.find(marker)? + marker.len(); + let rest = &text[start..]; + let end = rest.find('"')?; + Some(rest[..end].to_string()) +} + +/// Reads `MachineGuid` from the registry, which is generated at Windows +/// install time and stable across reboots. +#[cfg(target_os = "windows")] +pub fn detect_os_machine_id() -> Option { + use winreg::enums::HKEY_LOCAL_MACHINE; + use winreg::RegKey; + + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let key = hklm.open_subkey("SOFTWARE\\Microsoft\\Cryptography").ok()?; + key.get_value("MachineGuid").ok() +} + +/// No known OS-specific implementation on this platform; `resolve_machine_id` +/// falls back to a generated, persisted UUID. +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +pub fn detect_os_machine_id() -> Option { + None +} + +/// Returns the fallback-UUID persistence path used when no explicit path +/// is given: the OS user-config directory, or the OS temp directory if +/// that cannot be determined. +pub fn default_install_id_path() -> PathBuf { + let base = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(dirs::config_dir) + .unwrap_or_else(std::env::temp_dir); + base.join("origin-client").join("install-id") +} + +/// Returns the unique install ID to report to Origin, in priority order: +/// +/// 1. `explicit`, if non-empty - a caller-supplied override. +/// 2. the real OS machine ID (`/etc/machine-id` on Linux, `IOPlatformUUID` +/// on macOS, `MachineGuid` on Windows), if readable. +/// 3. the ID already persisted at `path`, if present. +/// 4. a newly generated UUID, persisted to `path` for future runs. +/// +/// OS-ID and persistence failures are logged as warnings, not returned as +/// errors: this always returns a usable ID. +pub fn resolve_machine_id( + explicit: Option<&str>, + path: Option<&Path>, + logger: &dyn Logger, +) -> String { + resolve_machine_id_with(detect_os_machine_id, explicit, path, logger) +} + +pub(crate) fn resolve_machine_id_with( + detect: impl Fn() -> Option, + explicit: Option<&str>, + path: Option<&Path>, + logger: &dyn Logger, +) -> String { + if let Some(id) = explicit.map(str::trim).filter(|s| !s.is_empty()) { + return id.to_string(); + } + + match detect() { + Some(id) if !id.trim().is_empty() => return id.trim().to_string(), + _ => logger.warn( + "machine id: OS machine id unavailable, falling back to a generated id", + None, + ), + } + + let resolved_path: PathBuf = path + .map(Path::to_path_buf) + .unwrap_or_else(default_install_id_path); + + if let Ok(data) = fs::read_to_string(&resolved_path) { + let existing = data.trim(); + if !existing.is_empty() { + return existing.to_string(); + } + } + + let id = Uuid::new_v4().to_string(); + if let Err(err) = persist_install_id(&resolved_path, &id) { + logger.warn( + "machine id: failed to persist generated id, using an ephemeral id for this run", + Some(&err), + ); + } + id +} + +fn persist_install_id(path: &Path, id: &str) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + if !dir.as_os_str().is_empty() { + fs::create_dir_all(dir)?; + } + } + fs::write(path, format!("{id}\n")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::logger::FnLogger; + use std::sync::atomic::{AtomicBool, Ordering}; + + fn temp_install_id_path() -> PathBuf { + let dir = std::env::temp_dir().join(format!("origin-client-test-{}", Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + dir.join("install-id") + } + + #[test] + fn explicit_override_wins() { + let got = resolve_machine_id_with( + || Some("os-machine-id".to_string()), + Some("explicit-id"), + Some(&temp_install_id_path()), + &crate::logger::NoopLogger, + ); + assert_eq!(got, "explicit-id"); + } + + #[test] + fn uses_os_machine_id_when_available() { + let got = resolve_machine_id_with( + || Some("os-machine-id".to_string()), + None, + Some(&temp_install_id_path()), + &crate::logger::NoopLogger, + ); + assert_eq!(got, "os-machine-id"); + } + + #[test] + fn falls_back_and_persists_when_os_id_unavailable() { + let path = temp_install_id_path(); + + let first = resolve_machine_id_with(|| None, None, Some(&path), &crate::logger::NoopLogger); + assert!(!first.is_empty()); + + let second = + resolve_machine_id_with(|| None, None, Some(&path), &crate::logger::NoopLogger); + assert_eq!( + first, second, + "second call should read the persisted id rather than generating a new one" + ); + } + + #[test] + fn logs_warning_when_os_id_unavailable() { + let warned = AtomicBool::new(false); + let logger = FnLogger( + |_msg: &str, _err: Option<&(dyn std::error::Error + Send + Sync)>| { + warned.store(true, Ordering::SeqCst); + }, + ); + + resolve_machine_id_with(|| None, None, Some(&temp_install_id_path()), &logger); + + assert!(warned.load(Ordering::SeqCst)); + } +} diff --git a/rust/src/payload.rs b/rust/src/payload.rs new file mode 100644 index 0000000..1bf9ddf --- /dev/null +++ b/rust/src/payload.rs @@ -0,0 +1,50 @@ +use serde::{Deserialize, Serialize}; + +/// Payload is the JSON body posted to Origin's public service-checkin +/// endpoint. Field names and requiredness mirror +/// `POST /api/public/service-checkin` as documented in +/// `origin/doc/service-checkin.md`: `type`, `version`, `name`, and +/// `unique_install_id` are required; everything else is optional and, when +/// omitted, does not overwrite existing instance data on Origin's side. +#[derive(Debug, Clone, Default, Serialize)] +pub struct Payload { + #[serde(rename = "type")] + pub type_: String, + pub version: String, + pub name: String, + pub unique_install_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub site: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub environment: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub database_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub database_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub app_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hostname: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub port: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub base_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub internal_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub container_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub orchestrator: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +/// CheckinResponse is returned by Origin after a successful check-in. +#[derive(Debug, Clone, Deserialize)] +pub struct CheckinResponse { + pub success: bool, + pub service_instance_id: i64, + pub unique_install_id: String, + pub status: String, + pub pinged_at: String, +} diff --git a/rust/tests/client.rs b/rust/tests/client.rs new file mode 100644 index 0000000..ad79aac --- /dev/null +++ b/rust/tests/client.rs @@ -0,0 +1,124 @@ +use std::sync::Arc; +use std::time::Duration; + +use origin_client::{Client, Config, FnLogger}; +use tokio_util::sync::CancellationToken; + +fn test_config(url: String) -> Config { + Config { + url: Some(url), + service_key: "test-key".to_string(), + type_: "icy2".to_string(), + version: "v1.0.0".to_string(), + name: "icy2-test".to_string(), + site: Some("Test Site".to_string()), + environment: Some("test".to_string()), + install_id: Some("test-install-id".to_string()), + ..Default::default() + } +} + +#[tokio::test] +async fn new_requires_fields() { + assert!(Client::new(Config::default()).is_err()); +} + +#[tokio::test] +async fn checkin_once_success() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("POST", "/") + .match_header("x-origin-service-key", "test-key") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"success":true,"service_instance_id":42,"unique_install_id":"test-install-id","status":"provisioning","pinged_at":"2026-08-01T00:00:00Z"}"#, + ) + .create_async() + .await; + + let client = Client::new(test_config(server.url())).unwrap(); + let resp = client.checkin_once().await.unwrap(); + + mock.assert_async().await; + assert!(resp.success); + assert_eq!(resp.service_instance_id, 42); + assert_eq!(resp.unique_install_id, "test-install-id"); +} + +#[tokio::test] +async fn checkin_once_server_error_returns_error() { + let mut server = mockito::Server::new_async().await; + server + .mock("POST", "/") + .with_status(500) + .create_async() + .await; + + let client = Client::new(test_config(server.url())).unwrap(); + assert!(client.checkin_once().await.is_err()); +} + +#[tokio::test] +async fn checkin_once_unreachable_does_not_panic() { + let client = Client::new(test_config("http://127.0.0.1:1".to_string())).unwrap(); + let result = client.checkin_once().await; + assert!(result.is_err()); +} + +#[tokio::test] +async fn checkin_once_logs_on_failure() { + let mut server = mockito::Server::new_async().await; + server + .mock("POST", "/") + .with_status(500) + .create_async() + .await; + + let warned = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let warned_clone = warned.clone(); + + let mut cfg = test_config(server.url()); + cfg.logger = Arc::new(FnLogger( + move |_msg: &str, _err: Option<&(dyn std::error::Error + Send + Sync)>| { + warned_clone.store(true, std::sync::atomic::Ordering::SeqCst); + }, + )); + + let client = Client::new(cfg).unwrap(); + let _ = client.checkin_once().await; + + assert!(warned.load(std::sync::atomic::Ordering::SeqCst)); +} + +#[tokio::test] +async fn run_checks_in_immediately_then_stops_on_cancel() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("POST", "/") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"success":true,"service_instance_id":1,"unique_install_id":"x","status":"provisioning","pinged_at":"2026-08-01T00:00:00Z"}"#, + ) + .expect(1) + .create_async() + .await; + + let client = Client::new(test_config(server.url())).unwrap(); + let cancel = CancellationToken::new(); + let cancel_clone = cancel.clone(); + + let handle = tokio::spawn(async move { + client.run(cancel_clone).await; + }); + + // The immediate check-in happens synchronously at the start of run(); + // give the spawned task a moment to execute it, then cancel before the + // (24h) interval could ever fire again. + tokio::time::sleep(Duration::from_millis(50)).await; + cancel.cancel(); + handle.await.unwrap(); + + mock.assert_async().await; +}