Skip to content

Commit 3fb459d

Browse files
authored
Merge pull request #16 from solana-foundation/feat/server-helpers-subpath
Expose server helpers via @solana/client/server
2 parents a92c3e2 + 68532c2 commit 3fb459d

File tree

10 files changed

+296
-19
lines changed

10 files changed

+296
-19
lines changed

packages/build-scripts/tsup.config.package.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,15 @@ if (packageDirName === 'react-hooks') {
99
external.push('@solana/client');
1010
}
1111

12-
const entry = ['src/index.ts'];
12+
const baseEntry = ['src/index.ts'];
13+
const nodeEntry = packageDirName === 'client' ? [...baseEntry, 'src/server/index.ts'] : baseEntry;
14+
const browserEntry = baseEntry;
15+
const neutralEntry = baseEntry;
16+
1317
const common = {
1418
clean: true,
1519
dts: false,
16-
entry,
20+
entry: nodeEntry,
1721
keepNames: true,
1822
minify: false,
1923
shims: false,
@@ -29,6 +33,7 @@ const common = {
2933
export default defineConfig([
3034
{
3135
...common,
36+
entry: nodeEntry,
3237
format: ['esm', 'cjs'],
3338
outDir: 'dist',
3439
outExtension({ format }) {
@@ -40,6 +45,7 @@ export default defineConfig([
4045
},
4146
{
4247
...common,
48+
entry: browserEntry,
4349
format: ['esm', 'cjs'],
4450
outDir: 'dist',
4551
outExtension({ format }) {
@@ -51,6 +57,7 @@ export default defineConfig([
5157
},
5258
{
5359
...common,
60+
entry: neutralEntry,
5461
format: ['esm'],
5562
outDir: 'dist',
5663
outExtension() {

packages/client/package.json

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,23 @@
33
"version": "0.1.1",
44
"description": "Framework-agnostic Solana client orchestration layer powering higher-level experiences",
55
"exports": {
6-
"edge-light": {
6+
".": {
7+
"types": "./dist/types/index.d.ts",
8+
"browser": "./dist/index.browser.mjs",
9+
"edge-light": "./dist/index.node.mjs",
10+
"workerd": "./dist/index.node.mjs",
11+
"node": "./dist/index.node.mjs",
12+
"react-native": "./dist/index.native.mjs",
713
"import": "./dist/index.node.mjs",
8-
"require": "./dist/index.node.cjs"
14+
"require": "./dist/index.node.cjs",
15+
"default": "./dist/index.node.mjs"
916
},
10-
"workerd": {
11-
"import": "./dist/index.node.mjs",
12-
"require": "./dist/index.node.cjs"
13-
},
14-
"browser": {
15-
"import": "./dist/index.browser.mjs",
16-
"require": "./dist/index.browser.cjs"
17-
},
18-
"node": {
19-
"import": "./dist/index.node.mjs",
20-
"require": "./dist/index.node.cjs"
21-
},
22-
"react-native": "./dist/index.native.mjs",
23-
"types": "./dist/types/index.d.ts"
17+
"./server": {
18+
"types": "./dist/types/server/index.d.ts",
19+
"import": "./dist/server/index.node.mjs",
20+
"require": "./dist/server/index.node.cjs",
21+
"default": "./dist/server/index.node.mjs"
22+
}
2423
},
2524
"browser": {
2625
"./dist/index.node.cjs": "./dist/index.browser.cjs",
@@ -30,6 +29,13 @@
3029
"module": "./dist/index.node.mjs",
3130
"react-native": "./dist/index.native.mjs",
3231
"types": "./dist/types/index.d.ts",
32+
"typesVersions": {
33+
"*": {
34+
"server": [
35+
"./dist/types/server/index.d.ts"
36+
]
37+
}
38+
},
3339
"type": "commonjs",
3440
"files": [
3541
"./dist/"
@@ -70,6 +76,7 @@
7076
"@wallet-standard/errors": "^0.1.1",
7177
"@wallet-standard/features": "^1.0.3",
7278
"@solana/wallet-standard-features": "^1.3.0",
79+
"bs58": "^6.0.0",
7380
"zustand": "^5.0.0"
7481
},
7582
"devDependencies": {

packages/client/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ export type {
116116
WalletStatus,
117117
} from './types';
118118
export { type AddressLike, toAddress, toAddressString } from './utils/addressLike';
119+
export { type ClusterMoniker, resolveCluster } from './utils/cluster';
119120
export { stableStringify } from './utils/stableStringify';
120121
export { createWalletRegistry } from './wallet/registry';
121122
export {
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from '../utils/cluster';
2+
export * from './signers';
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { mkdtemp, rm } from 'node:fs/promises';
2+
import { tmpdir } from 'node:os';
3+
import { join } from 'node:path';
4+
import bs58 from 'bs58';
5+
import { describe, expect, it } from 'vitest';
6+
7+
import { loadKeypairFromBase58, loadKeypairFromBytes, loadKeypairFromFile, saveKeypairToFile } from './signers';
8+
9+
describe('server signers', () => {
10+
it('derives signer and expanded secret key from base58 input', async () => {
11+
const seed = Uint8Array.from({ length: 32 }, (_value, index) => index);
12+
const base58 = bs58.encode(seed);
13+
14+
const keypair = await loadKeypairFromBase58(base58);
15+
16+
expect(keypair.secretKey).toHaveLength(64);
17+
expect(keypair.base58SecretKey).toBe(bs58.encode(keypair.secretKey));
18+
expect(typeof keypair.signer.address.toString()).toBe('string');
19+
});
20+
21+
it('saves and reloads keypairs in JSON format', async () => {
22+
const seed = Uint8Array.from({ length: 32 }, (_value, index) => index + 5);
23+
const keypair = await loadKeypairFromBytes(seed);
24+
25+
const dir = await mkdtemp(join(tmpdir(), 'server-signers-'));
26+
const filePath = join(dir, 'kp.json');
27+
28+
await saveKeypairToFile(filePath, { keypair });
29+
const reloaded = await loadKeypairFromFile(filePath);
30+
31+
expect(reloaded.base58SecretKey).toBe(keypair.base58SecretKey);
32+
await rm(dir, { recursive: true, force: true });
33+
});
34+
});
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { promises as fs } from 'node:fs';
2+
import { dirname } from 'node:path';
3+
import { createKeyPairSignerFromBytes, createKeyPairSignerFromPrivateKeyBytes, type KeyPairSigner } from '@solana/kit';
4+
import bs58 from 'bs58';
5+
6+
type PersistableKeypair = Readonly<{
7+
base58SecretKey: string;
8+
publicKey: Uint8Array;
9+
secretKey: Uint8Array;
10+
signer: KeyPairSigner;
11+
}>;
12+
13+
type KeypairLoadOptions = Readonly<{
14+
extractable?: boolean;
15+
}>;
16+
17+
function concatBytes(first: Uint8Array, second: Uint8Array): Uint8Array {
18+
const result = new Uint8Array(first.length + second.length);
19+
result.set(first, 0);
20+
result.set(second, first.length);
21+
return result;
22+
}
23+
24+
async function exportPublicKeyBytes(signer: KeyPairSigner): Promise<Uint8Array> {
25+
const raw = await crypto.subtle.exportKey('raw', signer.keyPair.publicKey);
26+
return new Uint8Array(raw);
27+
}
28+
29+
async function loadSignerFromBytes(bytes: Uint8Array, extractable: boolean): Promise<PersistableKeypair> {
30+
const normalized = new Uint8Array(bytes);
31+
if (normalized.length !== 32 && normalized.length !== 64) {
32+
throw new Error('Expected 32-byte private key or 64-byte secret key.');
33+
}
34+
const signer =
35+
normalized.length === 32
36+
? await createKeyPairSignerFromPrivateKeyBytes(normalized, extractable)
37+
: await createKeyPairSignerFromBytes(normalized, extractable);
38+
const publicKey = await exportPublicKeyBytes(signer);
39+
const secretKey = normalized.length === 64 ? normalized : concatBytes(normalized, publicKey);
40+
return {
41+
base58SecretKey: bs58.encode(secretKey),
42+
publicKey,
43+
secretKey,
44+
signer,
45+
};
46+
}
47+
48+
function parseKeyMaterial(raw: string): Uint8Array {
49+
const trimmed = raw.trim();
50+
try {
51+
const parsed = JSON.parse(trimmed);
52+
if (Array.isArray(parsed)) {
53+
return new Uint8Array(parsed);
54+
}
55+
} catch {
56+
// fall through to base58 parsing below
57+
}
58+
try {
59+
return bs58.decode(trimmed);
60+
} catch {
61+
throw new Error('Could not parse key material. Expected a base58 string or JSON array.');
62+
}
63+
}
64+
65+
async function ensureParentDir(path: string): Promise<void> {
66+
await fs.mkdir(dirname(path), { recursive: true });
67+
}
68+
69+
export async function generateKeypair(options: KeypairLoadOptions = {}): Promise<PersistableKeypair> {
70+
const byteCount = 32;
71+
const seed = new Uint8Array(byteCount);
72+
crypto.getRandomValues(seed);
73+
return loadSignerFromBytes(seed, options.extractable ?? true);
74+
}
75+
76+
export async function loadKeypairFromBytes(
77+
bytes: Uint8Array,
78+
options: KeypairLoadOptions = {},
79+
): Promise<PersistableKeypair> {
80+
return loadSignerFromBytes(bytes, options.extractable ?? true);
81+
}
82+
83+
export async function loadKeypairFromBase58(
84+
secret: string,
85+
options: KeypairLoadOptions = {},
86+
): Promise<PersistableKeypair> {
87+
return loadSignerFromBytes(bs58.decode(secret), options.extractable ?? true);
88+
}
89+
90+
export async function loadKeypairFromEnv(
91+
variableName: string,
92+
options: KeypairLoadOptions = {},
93+
): Promise<PersistableKeypair> {
94+
const value = process.env[variableName];
95+
if (!value) {
96+
throw new Error(`Environment variable ${variableName} is not set.`);
97+
}
98+
return loadSignerFromBytes(parseKeyMaterial(value), options.extractable ?? true);
99+
}
100+
101+
export async function loadKeypairFromFile(
102+
filePath: string,
103+
options: KeypairLoadOptions = {},
104+
): Promise<PersistableKeypair> {
105+
const contents = await fs.readFile(filePath, 'utf8');
106+
return loadSignerFromBytes(parseKeyMaterial(contents), options.extractable ?? true);
107+
}
108+
109+
export type SaveFormat = 'json' | 'base58';
110+
111+
export type SaveKeypairInput = Readonly<{
112+
format?: SaveFormat;
113+
keypair: Pick<PersistableKeypair, 'secretKey'> | PersistableKeypair;
114+
}>;
115+
116+
export async function saveKeypairToFile(filePath: string, input: SaveKeypairInput): Promise<void> {
117+
const secretKey = new Uint8Array(input.keypair.secretKey);
118+
const format = input.format ?? 'json';
119+
const encoded = format === 'base58' ? bs58.encode(secretKey) : JSON.stringify(Array.from(secretKey));
120+
await ensureParentDir(filePath);
121+
await fs.writeFile(filePath, `${encoded}\n`, { encoding: 'utf8', mode: 0o600 });
122+
}
123+
124+
export async function saveKeypairToEnvFile(
125+
envPath: string,
126+
variableName: string,
127+
input: SaveKeypairInput,
128+
): Promise<void> {
129+
const secretKey = new Uint8Array(input.keypair.secretKey);
130+
const format = input.format ?? 'base58';
131+
const encoded = format === 'base58' ? bs58.encode(secretKey) : JSON.stringify(Array.from(secretKey));
132+
await ensureParentDir(envPath);
133+
await fs.appendFile(envPath, `${variableName}=${encoded}\n`, { encoding: 'utf8', mode: 0o600 });
134+
}
135+
136+
export type ServerKeypair = PersistableKeypair;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { resolveCluster } from './cluster';
4+
5+
describe('resolveCluster', () => {
6+
it('defaults to devnet when no endpoint is provided', () => {
7+
const resolved = resolveCluster({});
8+
expect(resolved.moniker).toBe('devnet');
9+
expect(resolved.endpoint).toBe('https://api.devnet.solana.com');
10+
expect(resolved.websocketEndpoint).toBe('wss://api.devnet.solana.com');
11+
});
12+
13+
it('infers websocket endpoint from http endpoint', () => {
14+
const resolved = resolveCluster({ endpoint: 'http://127.0.0.1:8899' });
15+
expect(resolved.moniker).toBe('custom');
16+
expect(resolved.endpoint).toBe('http://127.0.0.1:8899');
17+
expect(resolved.websocketEndpoint).toBe('ws://127.0.0.1:8899');
18+
});
19+
20+
it('maps monikers to known endpoints', () => {
21+
const resolved = resolveCluster({ moniker: 'mainnet' });
22+
expect(resolved.endpoint).toBe('https://api.mainnet-beta.solana.com');
23+
expect(resolved.websocketEndpoint).toBe('wss://api.mainnet-beta.solana.com');
24+
});
25+
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type { ClusterUrl } from '@solana/kit';
2+
3+
export type ClusterMoniker = 'mainnet' | 'mainnet-beta' | 'testnet' | 'devnet' | 'localnet' | 'localhost';
4+
5+
type ResolvedCluster = Readonly<{
6+
endpoint: ClusterUrl;
7+
moniker: ClusterMoniker | 'custom';
8+
websocketEndpoint: ClusterUrl;
9+
}>;
10+
11+
const MONIKER_ENDPOINTS: Record<ClusterMoniker, Readonly<{ endpoint: ClusterUrl; websocketEndpoint: ClusterUrl }>> = {
12+
devnet: {
13+
endpoint: 'https://api.devnet.solana.com',
14+
websocketEndpoint: 'wss://api.devnet.solana.com',
15+
},
16+
localhost: {
17+
endpoint: 'http://127.0.0.1:8899',
18+
websocketEndpoint: 'ws://127.0.0.1:8900',
19+
},
20+
localnet: {
21+
endpoint: 'http://127.0.0.1:8899',
22+
websocketEndpoint: 'ws://127.0.0.1:8900',
23+
},
24+
'mainnet-beta': {
25+
endpoint: 'https://api.mainnet-beta.solana.com',
26+
websocketEndpoint: 'wss://api.mainnet-beta.solana.com',
27+
},
28+
mainnet: {
29+
endpoint: 'https://api.mainnet-beta.solana.com',
30+
websocketEndpoint: 'wss://api.mainnet-beta.solana.com',
31+
},
32+
testnet: {
33+
endpoint: 'https://api.testnet.solana.com',
34+
websocketEndpoint: 'wss://api.testnet.solana.com',
35+
},
36+
};
37+
38+
function inferWebsocketEndpoint(endpoint: ClusterUrl): ClusterUrl {
39+
if (endpoint.startsWith('https://')) {
40+
return endpoint.replace('https://', 'wss://') as ClusterUrl;
41+
}
42+
if (endpoint.startsWith('http://')) {
43+
return endpoint.replace('http://', 'ws://') as ClusterUrl;
44+
}
45+
return endpoint;
46+
}
47+
48+
export function resolveCluster(
49+
config: Readonly<{ endpoint?: ClusterUrl; moniker?: ClusterMoniker; websocketEndpoint?: ClusterUrl }>,
50+
): ResolvedCluster {
51+
const moniker = config.moniker ?? (config.endpoint ? 'custom' : 'devnet');
52+
const mapped = moniker === 'custom' ? undefined : MONIKER_ENDPOINTS[moniker];
53+
const endpoint = (config.endpoint ?? mapped?.endpoint) as ClusterUrl;
54+
const websocketEndpoint = (config.websocketEndpoint ??
55+
mapped?.websocketEndpoint ??
56+
inferWebsocketEndpoint(endpoint)) as ClusterUrl;
57+
return {
58+
endpoint,
59+
moniker,
60+
websocketEndpoint,
61+
};
62+
}

packages/client/tsconfig.declarations.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@
66
"outDir": "./dist/types"
77
},
88
"extends": "./tsconfig.json",
9-
"include": ["../build-scripts/build-time-constants.d.ts", "src/index.ts"]
9+
"include": ["../build-scripts/build-time-constants.d.ts", "src/index.ts", "src/server/index.ts"]
1010
}

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)