Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
dfc0345
fix(rest-api-client): migrate to fetch
tasshi-me Mar 31, 2026
7fcbe24
chore: bump minimum Node.js version to v22
tasshi-me Mar 31, 2026
55628ba
fix(rest-api-client): remove unused Dispatcher type import
tasshi-me Jul 27, 2026
8cee958
fix(rest-api-client): update undici from ^7.0.0 to ^8.0.0
tasshi-me Jul 27, 2026
89ab5a4
fix(rest-api-client): fix form-data compatibility with native fetch
tasshi-me Jul 27, 2026
17941ba
fix(rest-api-client): cache dispatcher in constructor for connection …
tasshi-me Jul 27, 2026
2e5b9f3
fix(rest-api-client): add request-level timeout via AbortSignal.timeo…
tasshi-me Jul 27, 2026
bd0eb71
fix(rest-api-client): support Stream fields in form-data body for fet…
yokotaso Jul 30, 2026
7749263
Merge commit 'aa246d261a0699a4eb3a2c7d68471a40716cbe6c' into fix/migr…
tasshi-me Jul 30, 2026
04e74f3
fix: regenerate pnpm-lock.yaml based on main to fix vitest/vite peer …
tasshi-me Jul 30, 2026
09188f9
ci: drop Node.js 20.x from test matrix
tasshi-me Jul 30, 2026
c190e39
Merge branch 'main' into fix/migrate-axios-to-fetch
tasshi-me Aug 4, 2026
471d709
fix(rest-api-client): fetch移行を行うために、準備したテストと結合 (#2)
yokotaso Aug 7, 2026
8e74a2a
fix(rest-api-client): fix dispatcher/fetch undici version mismatch an…
yokotaso Aug 8, 2026
0309c87
fix(rest-api-client): honor httpsAgent when it has no TLS options, dr…
yokotaso Aug 13, 2026
69d2874
feat(rest-api-client): add `dispatcher` option, drop dead `RequestCon…
yokotaso Aug 23, 2026
4dca9a9
fix(rest-api-client): run prettier on README.md
yokotaso Aug 23, 2026
3938f0c
test(rest-api-client): verify `proxy` + `clientCertAuth` as the migra…
yokotaso Aug 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:

strategy:
matrix:
node-version: [20.x, 22.x, 24.x]
node-version: [22.x, 24.x]
os: [ubuntu-latest, windows-latest, macos-latest]

steps:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:

strategy:
matrix:
node-version: [20.x, 22.x, 24.x]
node-version: [22.x, 24.x]
os: [ubuntu-latest, windows-latest, macos-latest]

steps:
Expand Down
47 changes: 24 additions & 23 deletions packages/rest-api-client/README.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions packages/rest-api-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,11 @@
"test:ci": "vitest run"
},
"dependencies": {
"axios": "1.18.1",
"form-data": "4.0.6",
"js-base64": "3.8.1",
"mime": "3.0.0",
"qs": "6.15.3"
"qs": "6.15.3",
"undici": "8.9.0"
},
"devDependencies": {
"@rollup/plugin-babel": "6.1.0",
Expand All @@ -91,7 +91,7 @@
"webpack-cli": "7.2.2"
},
"engines": {
"node": ">=20"
"node": ">=22"
},
Comment on lines 93 to 95
"publishConfig": {
"access": "public"
Expand Down
75 changes: 42 additions & 33 deletions packages/rest-api-client/src/KintoneRequestConfigBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,10 @@ import type {
import type { BasicAuth, DiscriminatedAuth } from "./types/auth";
import { platformDeps } from "./platform/";
import type { Agent as HttpsAgent } from "https";
import type { Dispatcher } from "undici";

type Data = Params | FormData;

const DEFAULT_PROXY_PROTOCOL = "http";

type KintoneAuthHeader =
| {
"X-Cybozu-Authorization": string;
Expand Down Expand Up @@ -49,6 +48,10 @@ type Options = {
pfxFilePath: string;
password: string;
};
// Escape hatch for connection strategies `proxy`/`httpsAgent`/`clientCertAuth`
// can't express (e.g. a SOCKS proxy): a caller-supplied undici Dispatcher,
// used as-is instead of one built from the other options.
dispatcher?: Dispatcher;
userAgent?: string;
socketTimeout?: number;
};
Expand All @@ -71,6 +74,7 @@ export class KintoneRequestConfigBuilder implements RequestConfigBuilder {
};
private readonly proxy?: ProxyConfig;
private readonly socketTimeout?: number;
private readonly dispatcher: unknown;
private requestToken: string | null;

constructor(options: Options) {
Expand All @@ -80,18 +84,47 @@ export class KintoneRequestConfigBuilder implements RequestConfigBuilder {
basicAuth: options.basicAuth,
userAgent: options.userAgent,
});
if ("httpsAgent" in options) {
if ("clientCertAuth" in options) {
// `!== undefined` rather than `"httpsAgent" in options`/`"clientCertAuth"
// in options`: the `in` form treats `{ httpsAgent: undefined,
// clientCertAuth: {...} }` (e.g. from spreading a partially-optional
// config object) as "both specified" and throws on two falsy values.
if (options.httpsAgent !== undefined) {
if (options.clientCertAuth !== undefined) {
throw new Error("Cannot specify clientCertAuth along with httpsAgent.");
}
this.httpsAgent = options.httpsAgent;
} else if ("clientCertAuth" in options) {
} else if (options.clientCertAuth !== undefined) {
this.clientCertAuth = options.clientCertAuth;
}

this.proxy = options.proxy;
this.requestToken = null;
this.socketTimeout = options.socketTimeout;

// A caller-supplied dispatcher is an escape hatch for connection
// strategies (e.g. a SOCKS proxy) that `proxy`/`httpsAgent`/
// `clientCertAuth` can't express. Combining it with any of them would
// mean two independent ways of deciding how to connect, so reject the
// combination instead of silently picking one.
if (options.dispatcher !== undefined) {
if (options.proxy !== undefined) {
throw new Error("Cannot specify proxy along with dispatcher.");
}
if (this.httpsAgent !== undefined) {
throw new Error("Cannot specify httpsAgent along with dispatcher.");
}
if (this.clientCertAuth !== undefined) {
throw new Error("Cannot specify clientCertAuth along with dispatcher.");
}
this.dispatcher = options.dispatcher;
} else {
this.dispatcher = platformDeps.buildFetchDispatcher({
httpsAgent: this.httpsAgent,
clientCertAuth: this.clientCertAuth,
proxy: this.proxy,
socketTimeout: this.socketTimeout,
});
}
}

public async build(
Expand All @@ -105,12 +138,10 @@ export class KintoneRequestConfigBuilder implements RequestConfigBuilder {
headers: this.headers,
url: `${this.baseUrl}${path}`,
...(options ? options : {}),
...platformDeps.buildPlatformDependentConfig({
httpsAgent: this.httpsAgent,
clientCertAuth: this.clientCertAuth,
socketTimeout: this.socketTimeout,
}),
proxy: this.buildProxyConfig(this.proxy),
...(this.dispatcher !== undefined ? { dispatcher: this.dispatcher } : {}),
...(this.socketTimeout !== undefined
? { timeout: this.socketTimeout }
: {}),
};

switch (method) {
Expand Down Expand Up @@ -169,28 +200,6 @@ export class KintoneRequestConfigBuilder implements RequestConfigBuilder {
}
}

private buildProxyConfig(proxyConfig?: ProxyConfig): ProxyConfig | undefined {
if (proxyConfig === undefined) {
return undefined;
}

if (proxyConfig === false) {
return false;
}

const proxy = proxyConfig;
if (
proxy.auth &&
(proxy.auth.username.length === 0 || proxy.auth.password.length === 0)
) {
proxy.auth = undefined;
}

proxy.protocol = proxy.protocol ?? DEFAULT_PROXY_PROTOCOL;

return proxy;
}

private buildRequestUrl(path: string, params: Data): string {
return `${this.baseUrl}${path}?${qs.stringify(params)}`;
}
Expand Down
7 changes: 7 additions & 0 deletions packages/rest-api-client/src/KintoneRestAPIClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { KintoneResponseHandler } from "./KintoneResponseHandler";
import { platformDeps } from "./platform";
import { UnsupportedPlatformError } from "./platform/UnsupportedPlatformError";
import type { Agent as HttpsAgent } from "https";
import type { Dispatcher } from "undici";

type OmitTypePropertyFromUnion<T> = T extends unknown ? Omit<T, "type"> : never;
type Auth = OmitTypePropertyFromUnion<DiscriminatedAuth>;
Expand All @@ -35,6 +36,12 @@ type Options = {
pfxFilePath: string;
password: string;
};
// Escape hatch for connection strategies `proxy`/`httpsAgent`/
// `clientCertAuth` can't express (e.g. a SOCKS proxy): a caller-supplied
// undici Dispatcher, used as-is instead of one built from the other
// options. Available only in Node.js environment, and mutually exclusive
// with `proxy`/`httpsAgent`/`clientCertAuth`.
dispatcher?: Dispatcher;
featureFlags?: {
enableAbortSearchError: boolean;
};
Expand Down
88 changes: 83 additions & 5 deletions packages/rest-api-client/src/__tests__/ClientCertAuth.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,36 @@
import fs from "node:fs";
import https from "node:https";
import path from "node:path";
import type { AddressInfo } from "node:net";
import type { TLSSocket } from "node:tls";
import { KintoneRestAPIClient } from "../KintoneRestAPIClient";

const FIXTURES_DIR = path.join(__dirname, "fixtures/clientCertAuth");

// A throwaway self-signed cert, not tied to any real service. Regenerate with:
// openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 3650 \
// -nodes -subj "/CN=kintone-js-sdk-test-client"
// openssl pkcs12 -export -out dummy-client-cert.pfx -inkey key.pem -in cert.pem \
// -passout pass:correct-passphrase
// (key.pem/cert.pem are discarded; only the PFX is committed.)
const PFX_PATH = path.join(
__dirname,
"fixtures/clientCertAuth/dummy-client-cert.pfx",
);
// (key.pem is discarded; the PFX and the extracted-from-it public cert below
// are committed.)
const PFX_PATH = path.join(FIXTURES_DIR, "dummy-client-cert.pfx");
const CORRECT_PASSPHRASE = "correct-passphrase";

// The same cert bundled in the PFX above, in PEM form so a test server can
// list it as a trusted CA (a self-signed cert can verify a peer presenting
// that exact cert). Re-extract after regenerating the PFX with:
// openssl pkcs12 -in dummy-client-cert.pfx -clcerts -nokeys \
// -passin pass:correct-passphrase | openssl x509 > dummy-client-cert.pem
const CLIENT_CERT_PEM_PATH = path.join(FIXTURES_DIR, "dummy-client-cert.pem");

// A throwaway self-signed server identity for the mTLS test below, unrelated
// to the client cert above. Regenerate with:
// openssl req -x509 -newkey rsa:2048 -keyout dummy-mtls-server-key.pem \
// -out dummy-mtls-server-cert.pem -days 3650 -nodes -subj "/CN=localhost"
const SERVER_CERT_PATH = path.join(FIXTURES_DIR, "dummy-mtls-server-cert.pem");
const SERVER_KEY_PATH = path.join(FIXTURES_DIR, "dummy-mtls-server-key.pem");

describe("clientCertAuth", () => {
// No mock server involved: decrypting the PFX with the wrong passphrase
// fails inside Node's TLS/crypto layer before any socket is opened, so this
Expand Down Expand Up @@ -49,4 +66,65 @@ describe("clientCertAuth", () => {
"invalid clientCertAuth setting",
);
});

// Unlike the two tests above (which only check whether a passphrase-related
// error is thrown), this spins up a real server that requires and verifies
// a client certificate, so it proves the SDK actually presents a usable
// client cert during the TLS handshake -- not just that the connection
// fails for some other reason (e.g. ECONNREFUSED) before ever reaching one.
it("completes a real mTLS handshake and lets the server verify the client cert", async () => {
let socketAuthorized: boolean | undefined;
let peerCommonName: string | undefined;

const server = https.createServer(
{
cert: fs.readFileSync(SERVER_CERT_PATH),
key: fs.readFileSync(SERVER_KEY_PATH),
requestCert: true,
rejectUnauthorized: true,
ca: [fs.readFileSync(CLIENT_CERT_PEM_PATH)],
},
(req, res) => {
const socket = req.socket as TLSSocket;
socketAuthorized = socket.authorized;
peerCommonName = socket.getPeerCertificate()?.subject?.CN;
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({}));
},
);
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve),
);
const { port } = server.address() as AddressInfo;

// clientCertAuth has no way to pass a custom CA for the *server's* own
// certificate (only the separate httpsAgent option supports that, via
// buildTlsOptions), so this is the only way to get the client past this
// throwaway self-signed test server. It's orthogonal to what's under
// test: it only affects whether the client trusts the server's
// certificate, not whether the server verifies the client's.
const originalRejectUnauthorized = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";

try {
const client = new KintoneRestAPIClient({
baseUrl: `https://127.0.0.1:${port}`,
auth: { apiToken: "dummy-token" },
clientCertAuth: {
pfx: fs.readFileSync(PFX_PATH),
password: CORRECT_PASSPHRASE,
},
});

await client.app.getApp({ id: 1 });

expect(socketAuthorized).toBe(true);
expect(peerCommonName).toBe("kintone-js-sdk-test-client");
} finally {
// Restoring a saved snapshot, not racing a concurrent read of the same env var.
// eslint-disable-next-line require-atomic-updates
process.env.NODE_TLS_REJECT_UNAUTHORIZED = originalRejectUnauthorized;
server.close();
}
});
});
Loading
Loading