stringist 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # Stringist CLI
2
+
3
+ Upload Xcode String Catalogs to Stringist from your terminal or CI. Requires Node.js 22 or newer. The npm package includes compiled JavaScript and has no runtime dependencies; Deno is not required.
4
+
5
+ ## Install and configure
6
+
7
+ After publication:
8
+
9
+ ```sh
10
+ npm install --global stringist
11
+ cd your-app
12
+ stringist init
13
+ ```
14
+
15
+ You can also run `npx stringist init`. The wizard asks for the project (`org/proj`), destination app slug, and source format (`xcstrings`, the default and currently the only supported format). It validates each answer and creates `.stringist/settings.json` in the current directory:
16
+
17
+ ```json
18
+ {
19
+ "project": "acme/mobile",
20
+ "app": "ios",
21
+ "sourceFormat": "xcstrings"
22
+ }
23
+ ```
24
+
25
+ The destination project and app must already exist. Settings contain no credentials and can be committed to your app repository. Running `init` again leaves an existing settings file intact; edit it directly to change the destination. EOF or Ctrl+C cancels setup without saving partial settings. For CI, create the JSON file directly or pipe one answer per line to `init`.
26
+
27
+ ## Upload
28
+
29
+ Run from the directory containing `.stringist/settings.json`:
30
+
31
+ ```sh
32
+ # Import string keys and definitions, without importing translations.
33
+ stringist upload
34
+
35
+ # Import French translations as well.
36
+ stringist upload --upload-translations fr
37
+
38
+ # Select several languages and attach version metadata.
39
+ stringist upload --upload-translations fr --upload-translations pt-BR \
40
+ --version "1.2.0" --vcs-ref "release/1.2.0"
41
+ ```
42
+
43
+ `upload` recursively finds all `.xcstrings` files under the current directory, including hidden directories. It skips `.git`, `.stringist`, `node_modules`, and symbolic links. All catalogs are sent together in one request, sorted by path. Two catalogs with the same filename cannot be uploaded together because their table namespaces would collide. Missing files, malformed JSON, invalid settings, duplicate table names, and requests exceeding API limits produce a nonzero exit status.
44
+
45
+ Each upload creates a **complete app version**: the API marks previously active keys missing from this upload as removed, including keys from omitted catalogs. Run from your app root so all its catalogs are included. Language selection does not change this snapshot behavior.
46
+
47
+ | Option | Behavior |
48
+ | --- | --- |
49
+ | `--upload-translations <lang>` | Import values only for this BCP 47 language tag, e.g. `fr` or `pt-BR`. Repeat to select more languages. Tags are canonicalized and deduplicated. |
50
+ | `--version <name>` | Optional app version name, up to 200 characters. |
51
+ | `--vcs-ref <ref>` | Optional VCS reference, up to 2,000 characters. Defaults to `git rev-parse --verify HEAD`, including from repository subdirectories. Omitted when Git or a commit is unavailable. |
52
+ | `-h`, `--help` | Show usage. |
53
+
54
+ The default language selection is empty, including the source language. To import source-language values, explicitly select that language too, such as `--upload-translations en`. Unselected localization nodes retain their plural/device/substitution structure but their `stringUnit` text is replaced with empty untranslated placeholders **before transmission**. Existing server translations are preserved; local files are never modified. Comments and catalog metadata are retained. The API archives this filtered catalog, not the unfiltered original.
55
+
56
+ `stringist --version` (or `stringist -V`) prints the CLI package version. `stringist upload --version <name>` names the app version.
57
+
58
+ ## API and CI
59
+
60
+ The default origin is `https://api.stringi.st`. Set `STRINGIST_TOKEN` to send an optional bearer token; credentials are read only from the environment and are not written into settings. The current development backend has no authentication requirement.
61
+
62
+ ```sh
63
+ export STRINGIST_TOKEN="your-token"
64
+ stringist upload --version "$RELEASE_VERSION"
65
+ ```
66
+
67
+ For local development, set `STRINGIST_API_URL=http://127.0.0.1:8787`. This variable takes an origin with no path, credentials, query, or fragment. HTTPS is required except on localhost. Redirects are rejected.
68
+
69
+ The CLI uses a Fetch client generated by `@hey-api/openapi-ts` from the [deployed OpenAPI schema](https://api.stringi.st/openapi.json). The generated operation handles the route, path encoding, request serialization, and response parsing. The CLI configures authentication, timeouts, and user-facing errors. The import request uses slug references:
70
+
71
+ ```text
72
+ POST https://api.stringi.st/v1/projects/acme%2Fmobile/apps/ios/imports/xcstrings
73
+ Content-Type: application/json
74
+ Idempotency-Key: <generated UUID>
75
+
76
+ {
77
+ "files": [{ "filename": "Localizable.xcstrings", "content": { ... } }],
78
+ "languages": [],
79
+ "name": "1.2.0",
80
+ "vcsRef": "<commit or explicit reference>"
81
+ }
82
+ ```
83
+
84
+ `name` and `vcsRef` are omitted when unavailable. The deployed API supports qualified project references, app slugs, and empty JSON language selections. UUID references remain supported by the API. Its current public test organization is `test`.
85
+
86
+ Requests time out after 60 seconds. Errors include the API message and request ID when available. Uploads are not automatically retried; after a connection failure, check the destination's version list before uploading again, since each invocation creates a new version. The API accepts at most 100 catalogs and 20 MiB per request.
87
+
88
+ ## Development and publishing
89
+
90
+ This package lives at `packages/cli` in the pnpm workspace and is published as `stringist`. Run the following development commands from the repository root.
91
+
92
+ The OpenAPI snapshot lives in `packages/cli/openapi/stringist.json`; generated files live in `packages/cli/src/generated` and should not be edited manually. Both are committed so building, testing, and publishing do not depend on the deployed API being available.
93
+
94
+ ```sh
95
+ # Regenerate from the committed snapshot.
96
+ pnpm generate:api
97
+
98
+ # Fetch https://api.stringi.st/openapi.json and regenerate.
99
+ pnpm update:api
100
+ ```
101
+
102
+ Review and commit both the snapshot and generated changes after refreshing. `packages/cli/openapi-ts.config.ts` currently selects the xcstrings import operation; extend its operation filter as CLI commands are added. The deployed document has two unresolved recursive references on the separate translation-update operation. The generator reports these warnings while reading the document; that operation is excluded from this CLI's generated client. The import response's `data` schema is unconstrained, so the CLI still checks the returned version before reporting success.
103
+
104
+ The generator is pinned to an exact version. TypeScript 5.9 is used because the generator needs the JavaScript compiler API that TypeScript 7 no longer exposes in the same way. The root `pnpm-workspace.yaml` `js-yaml` override keeps the generator's transitive parser on a patched version. These are development dependencies; the published CLI still has no runtime dependencies.
105
+
106
+ ```sh
107
+ pnpm install --frozen-lockfile
108
+ pnpm check
109
+ pnpm test
110
+ pnpm test:package
111
+
112
+ # Run locally, or install the generated archive to try the npm executable.
113
+ node packages/cli/dist/cli.js --help
114
+ pnpm --filter stringist exec npm pack
115
+ npm install --global ./packages/cli/stringist-0.1.0.tgz
116
+
117
+ # Publish when ready, using an npm account with access to the package name.
118
+ npm publish ./packages/cli/stringist-0.1.0.tgz
119
+ ```
120
+
121
+ The package uses npm's [`bin`, `files`, and lifecycle script configuration](https://docs.npmjs.com/cli/v11/configuring-npm/package-json/). `prepack` compiles TypeScript and marks the executable; `prepublishOnly` runs type checking and tests. Only `dist` (including the generated client), this README, and npm's package metadata are shipped. `test:package` installs a tarball into a temporary directory and runs the CLI tests against that installation, including the npm command shim. CI installs with the root frozen pnpm lockfile, covers Node 22 and 24 on Linux, macOS, and Windows and checks that generated files match the committed snapshot.
122
+
123
+ ### Manual GitHub Actions release
124
+
125
+ The **Publish to npm** workflow in `.github/workflows/publish.yml` runs only through `workflow_dispatch`:
126
+
127
+ 1. Open **Actions → Publish to npm → Run workflow** and choose the branch or tag containing the release code. The workflow must be present on the repository's default branch to appear in Actions.
128
+ 2. Enter an exact version such as `0.1.0` or `0.2.0-beta.1` and an npm dist-tag (`latest` for stable releases; `next` or `beta` for prereleases).
129
+ 3. The workflow verifies generated code, sets the requested version only in `packages/cli/package.json` in its temporary checkout, checks types, runs tests, validates an installed tarball, saves a package artifact, and publishes the release tarball to npm. It sets package repository metadata from the GitHub repository running the job, including `repository.directory: "packages/cli"`, and does not push version commits or Git tags. Update the repository's version separately when desired.
130
+
131
+ Configure [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/) for the `stringist` package with your GitHub owner, repository, and workflow filename **`publish.yml`**, and permit the `npm publish` action. The workflow grants `id-token: write` and uses a GitHub-hosted runner; no npm token is needed for configured trusted publishing. There is no GitHub environment name to enter. npm automatically attaches provenance when its requirements are met.
132
+
133
+ The GitHub repository can remain private while the CLI is public on npm: the package and workflow both specify public access. npm trusted publishing supports private repositories, but does not generate provenance for them. The workflow publishes an explicitly local `./release/…tgz` path; omitting `./` can make npm interpret it as a GitHub repository shorthand.
134
+
135
+ For a first publication before trusted publishing is configured, an optional repository secret **`NPM_TOKEN`** can supply a granular npm token with publish permission for `stringist` and bypass 2FA enabled. Remove that secret after configuring trusted publishing to switch to OIDC. The npm account must own or have publish access to the package name. A version already present on npm cannot be published again; choose a new version when rerunning after a successful release.
136
+
137
+ The package currently declares `UNLICENSED` (no license grant). Set the intended license before distributing it under an open-source license. No registry publication is performed by the build or tests.
package/dist/api.js ADDED
@@ -0,0 +1,44 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { createClient } from './generated/client/index.js';
3
+ import { postV1ProjectsByProjectIdAppsByAppIdImportsXcstrings } from './generated/sdk.gen.js';
4
+ export const API_ORIGIN = 'https://api.stringi.st';
5
+ export class Api {
6
+ client;
7
+ constructor(origin = API_ORIGIN, token) {
8
+ const url = new URL(origin);
9
+ if (url.username || url.password || url.search || url.hash || url.pathname !== '/') {
10
+ throw new Error('STRINGIST_API_URL must be an origin without credentials, a path, query, or fragment.');
11
+ }
12
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) {
13
+ throw new Error('STRINGIST_API_URL must use HTTPS (HTTP is allowed for localhost).');
14
+ }
15
+ this.client = createClient({
16
+ baseUrl: url.origin,
17
+ headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
18
+ redirect: 'error',
19
+ parseAs: 'json',
20
+ });
21
+ }
22
+ async importCatalogs(settings, body) {
23
+ const { data, error, response } = await postV1ProjectsByProjectIdAppsByAppIdImportsXcstrings({
24
+ client: this.client,
25
+ path: { projectId: settings.project, appId: settings.app },
26
+ body,
27
+ headers: { 'Idempotency-Key': randomUUID() },
28
+ signal: AbortSignal.timeout(60_000),
29
+ throwOnError: false,
30
+ });
31
+ if (!response) {
32
+ throw new Error(`API request failed: ${error instanceof Error ? error.message : 'connection failed'}. The upload outcome may be unknown; check the app’s versions before uploading again.`);
33
+ }
34
+ if (typeof error === 'string' || error instanceof SyntaxError)
35
+ throw new Error(`API returned HTTP ${response.status} with a non-JSON response.`);
36
+ if (!response.ok) {
37
+ const requestId = error?.error?.requestId ?? response.headers.get('x-request-id');
38
+ throw new Error(`API returned HTTP ${response.status}: ${error?.error?.message ?? response.statusText}${requestId ? ` (request ${requestId})` : ''}`);
39
+ }
40
+ if (!data || typeof data !== 'object' || !('data' in data))
41
+ throw new Error('API returned an invalid response: missing data.');
42
+ return data.data;
43
+ }
44
+ }
@@ -0,0 +1,115 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import { basename, join, relative } from 'node:path';
3
+ export const MAX_BYTES = 20 * 1024 * 1024;
4
+ function object(value) {
5
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
6
+ }
7
+ export function locale(value) {
8
+ try {
9
+ if (value.length > 100)
10
+ throw new Error();
11
+ const result = Intl.getCanonicalLocales(value);
12
+ if (result.length !== 1)
13
+ throw new Error();
14
+ return result[0];
15
+ }
16
+ catch {
17
+ throw new Error(`Invalid language ${JSON.stringify(value)}. Use a BCP 47 tag such as fr or pt-BR.`);
18
+ }
19
+ }
20
+ // Preserve plural/device/substitution definitions without sending unselected text.
21
+ function clearText(node, path, depth = 0) {
22
+ if (depth > 64 || !object(node))
23
+ throw new Error(`Invalid localization structure in ${path}.`);
24
+ if (node.stringUnit !== undefined) {
25
+ if (!object(node.stringUnit))
26
+ throw new Error(`Invalid stringUnit in ${path}.`);
27
+ node.stringUnit.value = '';
28
+ node.stringUnit.state = 'new';
29
+ }
30
+ if (node.variations !== undefined) {
31
+ if (!object(node.variations))
32
+ throw new Error(`Invalid variations in ${path}.`);
33
+ for (const cases of Object.values(node.variations)) {
34
+ if (!object(cases))
35
+ throw new Error(`Invalid variation cases in ${path}.`);
36
+ for (const child of Object.values(cases))
37
+ clearText(child, path, depth + 1);
38
+ }
39
+ }
40
+ if (node.substitutions !== undefined) {
41
+ if (!object(node.substitutions))
42
+ throw new Error(`Invalid substitutions in ${path}.`);
43
+ for (const child of Object.values(node.substitutions))
44
+ clearText(child, path, depth + 1);
45
+ }
46
+ }
47
+ export async function collectCatalogs(cwd, languages) {
48
+ const paths = [];
49
+ async function walk(directory) {
50
+ const entries = await readdir(directory, { withFileTypes: true });
51
+ entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
52
+ for (const entry of entries) {
53
+ const path = join(directory, entry.name);
54
+ if (entry.isDirectory()) {
55
+ if (!['.git', '.stringist', 'node_modules'].includes(entry.name))
56
+ await walk(path);
57
+ }
58
+ else if (entry.isFile() && entry.name.endsWith('.xcstrings')) {
59
+ paths.push(path);
60
+ if (paths.length > 100)
61
+ throw new Error('The import API accepts at most 100 catalogs per upload.');
62
+ }
63
+ }
64
+ }
65
+ await walk(cwd);
66
+ if (!paths.length)
67
+ throw new Error(`No .xcstrings files found under ${cwd}.`);
68
+ const names = new Map();
69
+ const selected = new Set(languages);
70
+ const files = [];
71
+ let totalBytes = 0;
72
+ for (const path of paths) {
73
+ const filename = basename(path);
74
+ const namespace = filename.slice(0, -10);
75
+ if (!namespace || namespace.length > 200)
76
+ throw new Error(`Invalid catalog table name: ${relative(cwd, path)}.`);
77
+ const previous = names.get(namespace);
78
+ if (previous)
79
+ throw new Error(`Duplicate catalog table ${namespace}: ${relative(cwd, previous)} and ${relative(cwd, path)}. The API requires unique filenames.`);
80
+ names.set(namespace, path);
81
+ totalBytes += (await stat(path)).size;
82
+ if (totalBytes > MAX_BYTES)
83
+ throw new Error('Catalogs exceed the API’s 20 MiB upload limit.');
84
+ let content;
85
+ try {
86
+ content = JSON.parse((await readFile(path, 'utf8')).replace(/^\uFEFF/, ''));
87
+ }
88
+ catch (error) {
89
+ throw new Error(`Cannot read catalog ${relative(cwd, path)}: ${error.message}`);
90
+ }
91
+ if (!object(content) || typeof content.sourceLanguage !== 'string' || typeof content.version !== 'string' || !content.version || !object(content.strings)) {
92
+ throw new Error(`Invalid xcstrings catalog ${relative(cwd, path)}: expected sourceLanguage, version, and strings.`);
93
+ }
94
+ locale(content.sourceLanguage);
95
+ for (const [key, entry] of Object.entries(content.strings)) {
96
+ if (!object(entry))
97
+ throw new Error(`Invalid string entry ${JSON.stringify(key)} in ${filename}.`);
98
+ if (entry.localizations === undefined)
99
+ continue;
100
+ if (!object(entry.localizations))
101
+ throw new Error(`Invalid localizations in ${filename}.`);
102
+ const seen = new Set();
103
+ for (const [language, node] of Object.entries(entry.localizations)) {
104
+ const canonical = locale(language);
105
+ if (seen.has(canonical))
106
+ throw new Error(`Colliding language tags for ${canonical} in ${filename}.`);
107
+ seen.add(canonical);
108
+ if (!selected.has(canonical))
109
+ clearText(node, filename);
110
+ }
111
+ }
112
+ files.push({ filename, content });
113
+ }
114
+ return files;
115
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, lstat } from 'node:fs/promises';
3
+ import { createInterface } from 'node:readline';
4
+ import { parseArgs } from 'node:util';
5
+ import { Api, API_ORIGIN } from './api.js';
6
+ import { locale } from './catalogs.js';
7
+ import { project, settingsPath, slug, sourceFormat, writeSettings } from './settings.js';
8
+ import { upload } from './upload.js';
9
+ const help = `Usage: stringist <command> [options]
10
+
11
+ Commands:
12
+ init Create .stringist/settings.json interactively
13
+ upload Upload all catalogs below the current directory
14
+
15
+ Upload options:
16
+ --upload-translations <lang> Import translations for a language; repeat for more
17
+ --version <name> Name the imported app version
18
+ --vcs-ref <ref> VCS reference (defaults to the current Git commit)
19
+
20
+ General options:
21
+ -h, --help Show help
22
+ -V, --version Show CLI version (outside the upload command)
23
+
24
+ Environment:
25
+ STRINGIST_TOKEN Optional API bearer token
26
+ STRINGIST_API_URL API origin (default: https://api.stringi.st)
27
+
28
+ Uploads import keys only unless --upload-translations is supplied.
29
+ Examples:
30
+ stringist init
31
+ stringist upload
32
+ stringist upload --upload-translations fr --version 1.2.0
33
+ `;
34
+ async function init(cwd) {
35
+ try {
36
+ await lstat(settingsPath(cwd));
37
+ throw new Error(`${settingsPath(cwd)} already exists. Edit it to change the destination.`);
38
+ }
39
+ catch (error) {
40
+ if (error.code !== 'ENOENT')
41
+ throw error;
42
+ }
43
+ const lines = createInterface({ input: process.stdin, output: process.stdout, terminal: Boolean(process.stdin.isTTY) });
44
+ const iterator = lines[Symbol.asyncIterator]();
45
+ let interrupted = false;
46
+ const interrupt = () => { interrupted = true; lines.close(); };
47
+ lines.on('SIGINT', interrupt);
48
+ process.once('SIGINT', interrupt);
49
+ async function ask(label, validate, fallback) {
50
+ while (true) {
51
+ process.stdout.write(`${label}${fallback ? ` [${fallback}]` : ''}: `);
52
+ const next = await iterator.next();
53
+ if (next.done || interrupted)
54
+ throw new Error('Initialization cancelled; settings were not written.');
55
+ try {
56
+ return validate(next.value.trim() || fallback || '');
57
+ }
58
+ catch (error) {
59
+ console.error(error.message);
60
+ }
61
+ }
62
+ }
63
+ try {
64
+ const settings = {
65
+ project: await ask('Project (org/proj)', project),
66
+ app: await ask('Destination app slug', (value) => slug(value, 'App')),
67
+ sourceFormat: await ask('Source format', sourceFormat, 'xcstrings'),
68
+ };
69
+ await writeSettings(cwd, settings);
70
+ console.log(`Created ${settingsPath(cwd)}. Run stringist upload to upload your catalogs.`);
71
+ }
72
+ finally {
73
+ lines.close();
74
+ process.removeListener('SIGINT', interrupt);
75
+ if (interrupted)
76
+ process.exitCode = 130;
77
+ }
78
+ }
79
+ function textOption(value, label, max) {
80
+ if (value !== undefined && (!value.trim() || value.length > max))
81
+ throw new Error(`${label} must contain 1–${max} characters.`);
82
+ return value;
83
+ }
84
+ async function main(args) {
85
+ const command = args[0];
86
+ if (!command || command === '--help' || command === '-h') {
87
+ console.log(help);
88
+ return;
89
+ }
90
+ if (command === '--version' || command === '-V') {
91
+ if (args.length !== 1)
92
+ throw new Error('The CLI version flag does not take arguments.');
93
+ console.log(JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')).version);
94
+ return;
95
+ }
96
+ if (command !== 'init' && command !== 'upload')
97
+ throw new Error(`Unknown command ${command}. Run stringist --help.`);
98
+ const { values, tokens } = parseArgs({
99
+ args: args.slice(1), strict: true, allowPositionals: false, tokens: true,
100
+ options: {
101
+ help: { type: 'boolean', short: 'h' },
102
+ ...(command === 'upload' ? {
103
+ 'upload-translations': { type: 'string', multiple: true },
104
+ version: { type: 'string' },
105
+ 'vcs-ref': { type: 'string' },
106
+ } : {}),
107
+ },
108
+ });
109
+ if (values.help) {
110
+ console.log(help);
111
+ return;
112
+ }
113
+ for (const name of ['version', 'vcs-ref']) {
114
+ if (tokens.filter((token) => token.kind === 'option' && token.name === name).length > 1)
115
+ throw new Error(`--${name} may only be provided once.`);
116
+ }
117
+ if (command === 'init') {
118
+ await init(process.cwd());
119
+ return;
120
+ }
121
+ const languages = [...new Set((values['upload-translations'] ?? []).map(locale))];
122
+ if (languages.length > 200)
123
+ throw new Error('At most 200 languages may be selected.');
124
+ await upload(process.cwd(), {
125
+ languages,
126
+ version: textOption(values.version, '--version', 200),
127
+ vcsRef: textOption(values['vcs-ref'], '--vcs-ref', 2000),
128
+ }, new Api(process.env.STRINGIST_API_URL || API_ORIGIN, process.env.STRINGIST_TOKEN), console.log);
129
+ }
130
+ main(process.argv.slice(2)).catch((error) => {
131
+ console.error(`stringist: ${error instanceof Error ? error.message : String(error)}`);
132
+ process.exitCode ||= 1;
133
+ });
@@ -0,0 +1,216 @@
1
+ // This file is auto-generated by @hey-api/openapi-ts
2
+ import { createSseClient } from '../core/serverSentEvents.gen.js';
3
+ import { getValidRequestBody } from '../core/utils.gen.js';
4
+ import { buildUrl, createConfig, createInterceptors, getParseAs, mergeConfigs, mergeHeaders, setAuthParams, } from './utils.gen.js';
5
+ export const createClient = (config = {}) => {
6
+ let _config = mergeConfigs(createConfig(), config);
7
+ const getConfig = () => ({ ..._config });
8
+ const setConfig = (config) => {
9
+ _config = mergeConfigs(_config, config);
10
+ return getConfig();
11
+ };
12
+ const interceptors = createInterceptors();
13
+ const beforeRequest = async (options) => {
14
+ const opts = {
15
+ ..._config,
16
+ ...options,
17
+ fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
18
+ headers: mergeHeaders(_config.headers, options.headers),
19
+ serializedBody: undefined,
20
+ };
21
+ if (opts.security) {
22
+ await setAuthParams(opts);
23
+ }
24
+ if (opts.requestValidator) {
25
+ await opts.requestValidator(opts);
26
+ }
27
+ if (opts.body !== undefined && opts.bodySerializer) {
28
+ opts.serializedBody = opts.bodySerializer(opts.body);
29
+ }
30
+ // remove Content-Type header if body is empty to avoid sending invalid requests
31
+ if (opts.body === undefined || opts.serializedBody === '') {
32
+ opts.headers.delete('Content-Type');
33
+ }
34
+ const resolvedOpts = opts;
35
+ const url = buildUrl(resolvedOpts);
36
+ return { opts: resolvedOpts, url };
37
+ };
38
+ const request = async (options) => {
39
+ const throwOnError = options.throwOnError ?? _config.throwOnError;
40
+ const responseStyle = options.responseStyle ?? _config.responseStyle;
41
+ let request;
42
+ let response;
43
+ try {
44
+ const { opts, url } = await beforeRequest(options);
45
+ const requestInit = {
46
+ redirect: 'follow',
47
+ ...opts,
48
+ body: getValidRequestBody(opts),
49
+ };
50
+ request = new Request(url, requestInit);
51
+ for (const fn of interceptors.request.fns) {
52
+ if (fn) {
53
+ request = await fn(request, opts);
54
+ }
55
+ }
56
+ // fetch must be assigned here, otherwise it would throw the error:
57
+ // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
58
+ const _fetch = opts.fetch;
59
+ response = await _fetch(request);
60
+ for (const fn of interceptors.response.fns) {
61
+ if (fn) {
62
+ response = await fn(response, request, opts);
63
+ }
64
+ }
65
+ const result = {
66
+ request,
67
+ response,
68
+ };
69
+ if (response.ok) {
70
+ const parseAs = (opts.parseAs === 'auto'
71
+ ? getParseAs(response.headers.get('Content-Type'))
72
+ : opts.parseAs) ?? 'json';
73
+ if (response.status === 204 || response.headers.get('Content-Length') === '0') {
74
+ let emptyData;
75
+ switch (parseAs) {
76
+ case 'arrayBuffer':
77
+ case 'blob':
78
+ case 'text':
79
+ emptyData = await response[parseAs]();
80
+ break;
81
+ case 'formData':
82
+ emptyData = new FormData();
83
+ break;
84
+ case 'stream':
85
+ emptyData = response.body;
86
+ break;
87
+ case 'json':
88
+ default:
89
+ emptyData = {};
90
+ break;
91
+ }
92
+ return opts.responseStyle === 'data'
93
+ ? emptyData
94
+ : {
95
+ data: emptyData,
96
+ ...result,
97
+ };
98
+ }
99
+ let data;
100
+ switch (parseAs) {
101
+ case 'arrayBuffer':
102
+ case 'blob':
103
+ case 'formData':
104
+ case 'text':
105
+ data = await response[parseAs]();
106
+ break;
107
+ case 'json': {
108
+ // Some servers return 200 with no Content-Length and empty body.
109
+ // response.json() would throw; read as text and parse if non-empty.
110
+ const text = await response.text();
111
+ data = text ? JSON.parse(text) : {};
112
+ break;
113
+ }
114
+ case 'stream':
115
+ return opts.responseStyle === 'data'
116
+ ? response.body
117
+ : {
118
+ data: response.body,
119
+ ...result,
120
+ };
121
+ }
122
+ if (parseAs === 'json') {
123
+ if (opts.responseValidator) {
124
+ await opts.responseValidator(data);
125
+ }
126
+ if (opts.responseTransformer) {
127
+ data = await opts.responseTransformer(data);
128
+ }
129
+ }
130
+ return opts.responseStyle === 'data'
131
+ ? data
132
+ : {
133
+ data,
134
+ ...result,
135
+ };
136
+ }
137
+ const textError = await response.text();
138
+ let jsonError;
139
+ try {
140
+ jsonError = JSON.parse(textError);
141
+ }
142
+ catch {
143
+ // noop
144
+ }
145
+ throw jsonError ?? textError;
146
+ }
147
+ catch (error) {
148
+ let finalError = error;
149
+ for (const fn of interceptors.error.fns) {
150
+ if (fn) {
151
+ finalError = await fn(finalError, response, request, options);
152
+ }
153
+ }
154
+ finalError = finalError || {};
155
+ if (throwOnError) {
156
+ throw finalError;
157
+ }
158
+ // TODO: we probably want to return error and improve types
159
+ return responseStyle === 'data'
160
+ ? undefined
161
+ : {
162
+ error: finalError,
163
+ request,
164
+ response,
165
+ };
166
+ }
167
+ };
168
+ const makeMethodFn = (method) => (options) => request({ ...options, method });
169
+ const makeSseFn = (method) => async (options) => {
170
+ const { opts, url } = await beforeRequest(options);
171
+ return createSseClient({
172
+ ...opts,
173
+ body: opts.body,
174
+ method,
175
+ onRequest: async (url, init) => {
176
+ let request = new Request(url, init);
177
+ for (const fn of interceptors.request.fns) {
178
+ if (fn) {
179
+ request = await fn(request, opts);
180
+ }
181
+ }
182
+ return request;
183
+ },
184
+ serializedBody: getValidRequestBody(opts),
185
+ url,
186
+ });
187
+ };
188
+ const _buildUrl = (options) => buildUrl({ ..._config, ...options });
189
+ return {
190
+ buildUrl: _buildUrl,
191
+ connect: makeMethodFn('CONNECT'),
192
+ delete: makeMethodFn('DELETE'),
193
+ get: makeMethodFn('GET'),
194
+ getConfig,
195
+ head: makeMethodFn('HEAD'),
196
+ interceptors,
197
+ options: makeMethodFn('OPTIONS'),
198
+ patch: makeMethodFn('PATCH'),
199
+ post: makeMethodFn('POST'),
200
+ put: makeMethodFn('PUT'),
201
+ request,
202
+ setConfig,
203
+ sse: {
204
+ connect: makeSseFn('CONNECT'),
205
+ delete: makeSseFn('DELETE'),
206
+ get: makeSseFn('GET'),
207
+ head: makeSseFn('HEAD'),
208
+ options: makeSseFn('OPTIONS'),
209
+ patch: makeSseFn('PATCH'),
210
+ post: makeSseFn('POST'),
211
+ put: makeSseFn('PUT'),
212
+ trace: makeSseFn('TRACE'),
213
+ },
214
+ trace: makeMethodFn('TRACE'),
215
+ };
216
+ };
@@ -0,0 +1,6 @@
1
+ // This file is auto-generated by @hey-api/openapi-ts
2
+ export { formDataBodySerializer, jsonBodySerializer, urlSearchParamsBodySerializer, } from '../core/bodySerializer.gen.js';
3
+ export { buildClientParams } from '../core/params.gen.js';
4
+ export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen.js';
5
+ export { createClient } from './client.gen.js';
6
+ export { createConfig, mergeHeaders } from './utils.gen.js';
@@ -0,0 +1,2 @@
1
+ // This file is auto-generated by @hey-api/openapi-ts
2
+ export {};