@langchain/langgraph-cli 0.0.23 → 0.0.25

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.
@@ -0,0 +1,172 @@
1
+ import * as os from "node:os";
2
+ import * as path from "node:path";
3
+ import * as fs from "node:fs/promises";
4
+ import { Readable, Transform, Writable } from "node:stream";
5
+ import { ChildProcess, spawn } from "node:child_process";
6
+ import { extract as tarExtract } from "tar";
7
+ import * as nodeStream from "node:stream/web";
8
+ import { fileURLToPath } from "node:url";
9
+ import { logger } from "../utils/logging.mjs";
10
+ import { BytesLineDecoder } from "./utils/stream.mjs";
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ const CLOUDFLARED_VERSION = "2025.2.1";
13
+ const CLOUDFLARED_CACHE_DIR = path.join(__dirname, ".cloudflare", CLOUDFLARED_VERSION);
14
+ const writeFile = async (path, stream) => {
15
+ if (stream == null)
16
+ throw new Error("Stream is null");
17
+ return await fs.writeFile(path, Readable.fromWeb(stream));
18
+ };
19
+ class CloudflareLoggerStream extends WritableStream {
20
+ constructor() {
21
+ const decoder = new TextDecoder();
22
+ super({
23
+ write(chunk) {
24
+ const text = decoder.decode(chunk);
25
+ const [_timestamp, level, ...rest] = text.split(" ");
26
+ const message = rest.join(" ");
27
+ if (level === "INF") {
28
+ logger.debug(message);
29
+ }
30
+ else if (level === "ERR") {
31
+ logger.error(message);
32
+ }
33
+ else {
34
+ logger.info(message);
35
+ }
36
+ },
37
+ });
38
+ }
39
+ fromWeb() {
40
+ return Writable.fromWeb(this);
41
+ }
42
+ }
43
+ class CloudflareUrlStream extends TransformStream {
44
+ constructor() {
45
+ const decoder = new TextDecoder();
46
+ super({
47
+ transform(chunk, controller) {
48
+ const str = decoder.decode(chunk);
49
+ const urlMatch = str.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/)?.[0];
50
+ if (urlMatch)
51
+ controller.enqueue(urlMatch);
52
+ },
53
+ });
54
+ }
55
+ fromWeb() {
56
+ // @ts-expect-error
57
+ return Transform.fromWeb(this, { objectMode: true });
58
+ }
59
+ }
60
+ export async function startCloudflareTunnel(port) {
61
+ const targetBinaryPath = await ensureCloudflared();
62
+ logger.info("Starting tunnel");
63
+ const child = spawn(targetBinaryPath, ["tunnel", "--url", `http://localhost:${port}`], { stdio: ["inherit", "pipe", "pipe"] });
64
+ child.stdout
65
+ .pipe(new BytesLineDecoder().fromWeb())
66
+ .pipe(new CloudflareLoggerStream().fromWeb());
67
+ child.stderr
68
+ .pipe(new BytesLineDecoder().fromWeb())
69
+ .pipe(new CloudflareLoggerStream().fromWeb());
70
+ const tunnelUrl = new Promise((resolve) => {
71
+ child.stderr
72
+ .pipe(new CloudflareUrlStream().fromWeb())
73
+ .once("data", (data) => {
74
+ logger.info(`Tunnel URL: "${data}"`);
75
+ resolve(data);
76
+ });
77
+ });
78
+ return { child, tunnelUrl };
79
+ }
80
+ function getFiles() {
81
+ const platform = getPlatform();
82
+ const arch = getArchitecture();
83
+ if (platform === "windows") {
84
+ if (arch !== "386" && arch !== "amd64") {
85
+ throw new Error(`Unsupported architecture: ${arch}`);
86
+ }
87
+ return { binary: `cloudflared-${platform}-${arch}.exe` };
88
+ }
89
+ if (platform === "darwin") {
90
+ if (arch !== "arm64" && arch !== "amd64") {
91
+ throw new Error(`Unsupported architecture: ${arch}`);
92
+ }
93
+ return {
94
+ archive: `cloudflared-${platform}-${arch}.tgz`,
95
+ binary: "cloudflared",
96
+ };
97
+ }
98
+ if (platform === "linux") {
99
+ if (arch !== "arm64" && arch !== "amd64" && arch !== "386") {
100
+ throw new Error(`Unsupported architecture: ${arch}`);
101
+ }
102
+ return { binary: `cloudflared-${platform}-${arch}` };
103
+ }
104
+ throw new Error(`Unsupported platform: ${platform}`);
105
+ }
106
+ async function downloadCloudflared() {
107
+ await fs.mkdir(CLOUDFLARED_CACHE_DIR, { recursive: true });
108
+ logger.info("Requesting download of `cloudflared`");
109
+ const { binary, archive } = getFiles();
110
+ const downloadFile = archive ?? binary;
111
+ const tempDirPath = await fs.mkdtemp(path.join(os.tmpdir(), "cloudflared-"));
112
+ const tempFilePath = path.join(tempDirPath, downloadFile);
113
+ const url = `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/${downloadFile}`;
114
+ logger.debug("Downloading `${archive}`", { url, target: tempDirPath });
115
+ const response = await fetch(url);
116
+ if (!response.ok || !response.body) {
117
+ throw new Error(`Failed to download cloudflared: ${response.statusText}`);
118
+ }
119
+ await writeFile(tempFilePath, response.body);
120
+ if (archive != null) {
121
+ if (path.extname(archive) !== ".tgz") {
122
+ throw new Error(`Invalid archive type: "${path.extname(archive)}"`);
123
+ }
124
+ logger.debug("Extracting `cloudflared`");
125
+ await tarExtract({ file: tempFilePath, cwd: tempDirPath });
126
+ }
127
+ const sourceBinaryPath = path.resolve(tempDirPath, binary);
128
+ const targetBinaryPath = path.resolve(CLOUDFLARED_CACHE_DIR, binary);
129
+ logger.debug("Moving `cloudflared` to target directory", {
130
+ targetBinaryPath,
131
+ });
132
+ await fs.rename(sourceBinaryPath, targetBinaryPath);
133
+ await fs.chmod(targetBinaryPath, 0o755);
134
+ }
135
+ async function ensureCloudflared() {
136
+ const { binary } = getFiles();
137
+ const targetBinaryPath = path.resolve(CLOUDFLARED_CACHE_DIR, binary);
138
+ try {
139
+ await fs.access(targetBinaryPath);
140
+ }
141
+ catch {
142
+ await downloadCloudflared();
143
+ }
144
+ return targetBinaryPath;
145
+ }
146
+ function getArchitecture() {
147
+ const arch = os.arch();
148
+ switch (arch) {
149
+ case "x64":
150
+ return "amd64";
151
+ case "arm64":
152
+ return "arm64";
153
+ case "ia32":
154
+ case "x86":
155
+ return "386";
156
+ default:
157
+ throw new Error(`Unsupported architecture: ${arch}`);
158
+ }
159
+ }
160
+ function getPlatform() {
161
+ const platform = os.platform();
162
+ switch (platform) {
163
+ case "darwin":
164
+ return "darwin";
165
+ case "linux":
166
+ return "linux";
167
+ case "win32":
168
+ return "windows";
169
+ default:
170
+ throw new Error(`Unsupported platform: ${platform}`);
171
+ }
172
+ }
package/dist/cli/dev.mjs CHANGED
@@ -4,6 +4,7 @@ import { parse, populate } from "dotenv";
4
4
  import { watch } from "chokidar";
5
5
  import { z } from "zod";
6
6
  import open from "open";
7
+ import { startCloudflareTunnel } from "./cloudflare.mjs";
7
8
  import { createIpcServer } from "./utils/ipc/server.mjs";
8
9
  import { getProjectPath } from "./utils/project.mjs";
9
10
  import { getConfig } from "../utils/config.mjs";
@@ -19,6 +20,7 @@ builder
19
20
  .option("--no-browser", "disable auto-opening the browser")
20
21
  .option("-n, --n-jobs-per-worker <number>", "number of workers to run", "10")
21
22
  .option("-c, --config <path>", "path to configuration file", process.cwd())
23
+ .option("--tunnel", "use Cloudflare Tunnel to expose the server to the internet")
22
24
  .allowExcessArguments()
23
25
  .allowUnknownOption()
24
26
  .exitOverride((error) => gracefulExit(error.exitCode))
@@ -27,6 +29,7 @@ builder
27
29
  port: command.opts().port !== "2024",
28
30
  host: command.opts().host !== "localhost",
29
31
  n_jobs_per_worker: command.opts().nJobsPerWorker !== "10",
32
+ tunnel: Boolean(command.opts().tunnel),
30
33
  })))
31
34
  .action(async (options, { args }) => {
32
35
  try {
@@ -39,12 +42,20 @@ builder
39
42
  });
40
43
  let hasOpenedFlag = false;
41
44
  let child = undefined;
45
+ let tunnel = undefined;
42
46
  let hostUrl = "https://smith.langchain.com";
43
- server.on("data", (data) => {
47
+ server.on("data", async (data) => {
44
48
  const response = z.object({ queryParams: z.string() }).parse(data);
45
49
  if (options.browser && !hasOpenedFlag) {
46
50
  hasOpenedFlag = true;
47
- open(`${hostUrl}/studio${response.queryParams}`);
51
+ const queryParams = new URLSearchParams(response.queryParams);
52
+ const tunnelUrl = await tunnel?.tunnelUrl;
53
+ if (tunnelUrl)
54
+ queryParams.set("baseUrl", tunnelUrl);
55
+ let queryParamsStr = queryParams.toString();
56
+ if (queryParamsStr)
57
+ queryParamsStr = `?${queryParams.toString()}`;
58
+ open(`${hostUrl}/studio${queryParamsStr}`);
48
59
  }
49
60
  });
50
61
  // check if .gitignore already contains .langgraph-api
@@ -97,6 +108,10 @@ builder
97
108
  const { config, env, hostUrl } = await prepareContext();
98
109
  if (child != null)
99
110
  child.kill();
111
+ if (tunnel != null)
112
+ tunnel.child.kill();
113
+ if (options.tunnel)
114
+ tunnel = await startCloudflareTunnel(options.port);
100
115
  if ("python_version" in config) {
101
116
  logger.warn("Launching Python server from @langchain/langgraph-cli is experimental. Please use the `langgraph-cli` package from PyPi instead.");
102
117
  const { spawnPythonServer } = await import("./dev.python.mjs");
@@ -0,0 +1,90 @@
1
+ import { Transform } from "node:stream";
2
+ const CR = "\r".charCodeAt(0);
3
+ const LF = "\n".charCodeAt(0);
4
+ const TRAILING_NEWLINE = [CR, LF];
5
+ function joinArrays(data) {
6
+ const totalLength = data.reduce((acc, curr) => acc + curr.length, 0);
7
+ let merged = new Uint8Array(totalLength);
8
+ let offset = 0;
9
+ for (const c of data) {
10
+ merged.set(c, offset);
11
+ offset += c.length;
12
+ }
13
+ return merged;
14
+ }
15
+ export class BytesLineDecoder extends TransformStream {
16
+ constructor() {
17
+ let buffer = [];
18
+ let trailingCr = false;
19
+ super({
20
+ start() {
21
+ buffer = [];
22
+ trailingCr = false;
23
+ },
24
+ transform(chunk, controller) {
25
+ // See https://docs.python.org/3/glossary.html#term-universal-newlines
26
+ let text = chunk;
27
+ // Handle trailing CR from previous chunk
28
+ if (trailingCr) {
29
+ text = joinArrays([[CR], text]);
30
+ trailingCr = false;
31
+ }
32
+ // Check for trailing CR in current chunk
33
+ if (text.length > 0 && text.at(-1) === CR) {
34
+ trailingCr = true;
35
+ text = text.subarray(0, -1);
36
+ }
37
+ if (!text.length)
38
+ return;
39
+ const trailingNewline = TRAILING_NEWLINE.includes(text.at(-1));
40
+ const lastIdx = text.length - 1;
41
+ const { lines } = text.reduce((acc, cur, idx) => {
42
+ if (acc.from > idx)
43
+ return acc;
44
+ if (cur === CR || cur === LF) {
45
+ acc.lines.push(text.subarray(acc.from, idx));
46
+ if (cur === CR && text[idx + 1] === LF) {
47
+ acc.from = idx + 2;
48
+ }
49
+ else {
50
+ acc.from = idx + 1;
51
+ }
52
+ }
53
+ if (idx === lastIdx && acc.from <= lastIdx) {
54
+ acc.lines.push(text.subarray(acc.from));
55
+ }
56
+ return acc;
57
+ }, { lines: [], from: 0 });
58
+ if (lines.length === 1 && !trailingNewline) {
59
+ buffer.push(lines[0]);
60
+ return;
61
+ }
62
+ if (buffer.length) {
63
+ // Include existing buffer in first line
64
+ buffer.push(lines[0]);
65
+ lines[0] = joinArrays(buffer);
66
+ buffer = [];
67
+ }
68
+ if (!trailingNewline) {
69
+ // If the last segment is not newline terminated,
70
+ // buffer it for the next chunk
71
+ if (lines.length)
72
+ buffer = [lines.pop()];
73
+ }
74
+ // Enqueue complete lines
75
+ for (const line of lines) {
76
+ controller.enqueue(line);
77
+ }
78
+ },
79
+ flush(controller) {
80
+ if (buffer.length) {
81
+ controller.enqueue(joinArrays(buffer));
82
+ }
83
+ },
84
+ });
85
+ }
86
+ fromWeb() {
87
+ // @ts-expect-error invalid types for response.body
88
+ return Transform.fromWeb(this);
89
+ }
90
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@langchain/langgraph-cli",
3
- "version": "0.0.23",
3
+ "version": "0.0.25",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": "^18.19.0 || >=20.16.0"
@@ -36,7 +36,7 @@
36
36
  "yaml": "^2.7.0",
37
37
  "zod": "^3.23.8",
38
38
  "@babel/code-frame": "^7.26.2",
39
- "@langchain/langgraph-api": "0.0.23"
39
+ "@langchain/langgraph-api": "0.0.25"
40
40
  },
41
41
  "devDependencies": {
42
42
  "tsx": "^4.19.3",