@durable-streams/cli 0.1.6 → 0.1.8
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 +22 -0
- package/dist/index.cjs +331 -94
- package/dist/index.d.cts +53 -1
- package/dist/index.d.ts +53 -1
- package/dist/index.js +324 -94
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -69,6 +69,11 @@ durable-stream-dev read my-stream
|
|
|
69
69
|
### Environment Variables
|
|
70
70
|
|
|
71
71
|
- `STREAM_URL` - Base URL of the stream server (default: `http://localhost:4437`)
|
|
72
|
+
- `STREAM_AUTH` - Authorization header value (e.g., `Bearer my-token`)
|
|
73
|
+
|
|
74
|
+
### Global Options
|
|
75
|
+
|
|
76
|
+
- `--auth <value>` - Authorization header value (overrides `STREAM_AUTH` env var)
|
|
72
77
|
|
|
73
78
|
### Write Options
|
|
74
79
|
|
|
@@ -125,6 +130,23 @@ durable-stream-dev read <stream_id>
|
|
|
125
130
|
durable-stream-dev delete <stream_id>
|
|
126
131
|
```
|
|
127
132
|
|
|
133
|
+
### Authentication
|
|
134
|
+
|
|
135
|
+
Use the `--auth` flag or `STREAM_AUTH` environment variable to authenticate:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
# Using environment variable
|
|
139
|
+
export STREAM_AUTH="Bearer my-token"
|
|
140
|
+
durable-stream-dev read my-stream
|
|
141
|
+
|
|
142
|
+
# Using --auth flag (overrides env var)
|
|
143
|
+
durable-stream-dev --auth "Bearer my-token" read my-stream
|
|
144
|
+
|
|
145
|
+
# Works with any auth scheme
|
|
146
|
+
durable-stream-dev --auth "Basic dXNlcjpwYXNz" read my-stream
|
|
147
|
+
durable-stream-dev --auth "ApiKey abc123" read my-stream
|
|
148
|
+
```
|
|
149
|
+
|
|
128
150
|
## Complete Example Workflow
|
|
129
151
|
|
|
130
152
|
```bash
|
package/dist/index.cjs
CHANGED
|
@@ -23,6 +23,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
23
23
|
}) : target, mod));
|
|
24
24
|
|
|
25
25
|
//#endregion
|
|
26
|
+
const node_http = __toESM(require("node:http"));
|
|
26
27
|
const node_path = __toESM(require("node:path"));
|
|
27
28
|
const node_process = __toESM(require("node:process"));
|
|
28
29
|
const node_url = __toESM(require("node:url"));
|
|
@@ -89,11 +90,146 @@ function parseWriteArgs(args) {
|
|
|
89
90
|
};
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
//#endregion
|
|
94
|
+
//#region src/validation.ts
|
|
95
|
+
/**
|
|
96
|
+
* Validate a URL string.
|
|
97
|
+
* Must be a valid HTTP or HTTPS URL.
|
|
98
|
+
*/
|
|
99
|
+
function validateUrl(url) {
|
|
100
|
+
if (!url || !url.trim()) return {
|
|
101
|
+
valid: false,
|
|
102
|
+
error: `URL cannot be empty`
|
|
103
|
+
};
|
|
104
|
+
let parsed;
|
|
105
|
+
try {
|
|
106
|
+
parsed = new URL(url);
|
|
107
|
+
} catch {
|
|
108
|
+
return {
|
|
109
|
+
valid: false,
|
|
110
|
+
error: `Invalid URL format: "${url}"\n Expected format: http://host:port or https://host:port`
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (parsed.protocol !== `http:` && parsed.protocol !== `https:`) return {
|
|
114
|
+
valid: false,
|
|
115
|
+
error: `Invalid URL protocol: "${parsed.protocol}"\n Only http:// and https:// are supported`
|
|
116
|
+
};
|
|
117
|
+
if (!parsed.hostname) return {
|
|
118
|
+
valid: false,
|
|
119
|
+
error: `URL must include a hostname: "${url}"`
|
|
120
|
+
};
|
|
121
|
+
return { valid: true };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Normalize a base URL by removing trailing slashes.
|
|
125
|
+
* This prevents double-slashes when appending paths (e.g., "http://host/" + "/v1/...").
|
|
126
|
+
*/
|
|
127
|
+
function normalizeBaseUrl(url) {
|
|
128
|
+
return url.replace(/\/+$/, ``);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Validate an authorization header value.
|
|
132
|
+
* Returns valid=true for any non-empty value, but includes a warning
|
|
133
|
+
* if the value doesn't match common auth schemes (Bearer, Basic, ApiKey, Token).
|
|
134
|
+
*/
|
|
135
|
+
function validateAuth(auth) {
|
|
136
|
+
if (!auth || !auth.trim()) return {
|
|
137
|
+
valid: false,
|
|
138
|
+
error: `Authorization value cannot be empty`
|
|
139
|
+
};
|
|
140
|
+
const trimmed = auth.trim();
|
|
141
|
+
const lowerTrimmed = trimmed.toLowerCase();
|
|
142
|
+
const hasScheme = [
|
|
143
|
+
`bearer `,
|
|
144
|
+
`basic `,
|
|
145
|
+
`apikey `,
|
|
146
|
+
`token `
|
|
147
|
+
].some((scheme) => lowerTrimmed.startsWith(scheme));
|
|
148
|
+
if (!hasScheme && !trimmed.includes(` `)) return {
|
|
149
|
+
valid: true,
|
|
150
|
+
warning: `Warning: Authorization value doesn't match common formats.\n Expected: "Bearer <token>", "Basic <credentials>", or "ApiKey <key>"`
|
|
151
|
+
};
|
|
152
|
+
return { valid: true };
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Validate a stream ID.
|
|
156
|
+
* Must be 1-256 characters containing only: letters, numbers, underscores,
|
|
157
|
+
* hyphens, dots, and colons (URL-safe characters).
|
|
158
|
+
*/
|
|
159
|
+
function validateStreamId(streamId) {
|
|
160
|
+
if (!streamId || !streamId.trim()) return {
|
|
161
|
+
valid: false,
|
|
162
|
+
error: `Stream ID cannot be empty`
|
|
163
|
+
};
|
|
164
|
+
const validPattern = /^[a-zA-Z0-9_\-.:]+$/;
|
|
165
|
+
if (!validPattern.test(streamId)) return {
|
|
166
|
+
valid: false,
|
|
167
|
+
error: `Invalid stream ID: "${streamId}"\n Stream IDs can only contain letters, numbers, underscores, hyphens, dots, and colons`
|
|
168
|
+
};
|
|
169
|
+
if (streamId.length > 256) return {
|
|
170
|
+
valid: false,
|
|
171
|
+
error: `Stream ID too long (${streamId.length} chars)\n Maximum length is 256 characters`
|
|
172
|
+
};
|
|
173
|
+
return { valid: true };
|
|
174
|
+
}
|
|
175
|
+
|
|
92
176
|
//#endregion
|
|
93
177
|
//#region src/index.ts
|
|
94
178
|
const STREAM_URL = process.env.STREAM_URL || `http://localhost:4437`;
|
|
95
|
-
|
|
96
|
-
|
|
179
|
+
const STREAM_AUTH = process.env.STREAM_AUTH;
|
|
180
|
+
/**
|
|
181
|
+
* Parse global options (--url, --auth) from args.
|
|
182
|
+
* Falls back to STREAM_URL/STREAM_AUTH env vars when flags not provided.
|
|
183
|
+
* Returns the parsed options, remaining args, and any warnings.
|
|
184
|
+
*/
|
|
185
|
+
function parseGlobalOptions(args) {
|
|
186
|
+
const options = {};
|
|
187
|
+
const remainingArgs = [];
|
|
188
|
+
const warnings = [];
|
|
189
|
+
for (let i = 0; i < args.length; i++) {
|
|
190
|
+
const arg = args[i];
|
|
191
|
+
if (arg === `--url`) {
|
|
192
|
+
const value = args[i + 1];
|
|
193
|
+
if (!value || value.startsWith(`--`)) throw new Error(`--url requires a value\n Example: --url "http://localhost:4437"`);
|
|
194
|
+
const urlValidation = validateUrl(value);
|
|
195
|
+
if (!urlValidation.valid) throw new Error(urlValidation.error);
|
|
196
|
+
options.url = normalizeBaseUrl(value);
|
|
197
|
+
i++;
|
|
198
|
+
} else if (arg === `--auth`) {
|
|
199
|
+
const value = args[i + 1];
|
|
200
|
+
if (!value || value.startsWith(`--`)) throw new Error(`--auth requires a value\n Example: --auth "Bearer my-token"`);
|
|
201
|
+
const authValidation = validateAuth(value);
|
|
202
|
+
if (!authValidation.valid) throw new Error(authValidation.error);
|
|
203
|
+
if (authValidation.warning) warnings.push(authValidation.warning);
|
|
204
|
+
options.auth = value;
|
|
205
|
+
i++;
|
|
206
|
+
} else remainingArgs.push(arg);
|
|
207
|
+
}
|
|
208
|
+
if (!options.url) {
|
|
209
|
+
const urlValidation = validateUrl(STREAM_URL);
|
|
210
|
+
if (!urlValidation.valid) throw new Error(`Invalid STREAM_URL environment variable: ${urlValidation.error}`);
|
|
211
|
+
options.url = normalizeBaseUrl(STREAM_URL);
|
|
212
|
+
}
|
|
213
|
+
if (!options.auth && STREAM_AUTH) {
|
|
214
|
+
const authValidation = validateAuth(STREAM_AUTH);
|
|
215
|
+
if (!authValidation.valid) throw new Error(`Invalid STREAM_AUTH environment variable: ${authValidation.error}`);
|
|
216
|
+
if (authValidation.warning) warnings.push(authValidation.warning);
|
|
217
|
+
options.auth = STREAM_AUTH;
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
options,
|
|
221
|
+
remainingArgs,
|
|
222
|
+
warnings
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
function getErrorMessage(error) {
|
|
226
|
+
return error instanceof Error ? error.message : String(error);
|
|
227
|
+
}
|
|
228
|
+
function buildHeaders(options) {
|
|
229
|
+
return options.auth ? { Authorization: options.auth } : {};
|
|
230
|
+
}
|
|
231
|
+
function getUsageText() {
|
|
232
|
+
return `
|
|
97
233
|
Usage:
|
|
98
234
|
durable-stream create <stream_id> Create a new stream
|
|
99
235
|
durable-stream write <stream_id> <content> Write content to a stream
|
|
@@ -101,6 +237,11 @@ Usage:
|
|
|
101
237
|
durable-stream read <stream_id> Follow a stream and write to stdout
|
|
102
238
|
durable-stream delete <stream_id> Delete a stream
|
|
103
239
|
|
|
240
|
+
Global Options:
|
|
241
|
+
--url <url> Stream server URL (overrides STREAM_URL env var)
|
|
242
|
+
--auth <value> Authorization header value (e.g., "Bearer my-token")
|
|
243
|
+
--help, -h Show this help message
|
|
244
|
+
|
|
104
245
|
Write Options:
|
|
105
246
|
--content-type <type> Content-Type for the message (default: application/octet-stream)
|
|
106
247
|
--json Write as JSON (input stored as single message)
|
|
@@ -108,167 +249,256 @@ Write Options:
|
|
|
108
249
|
|
|
109
250
|
Environment Variables:
|
|
110
251
|
STREAM_URL Base URL of the stream server (default: http://localhost:4437)
|
|
111
|
-
|
|
252
|
+
STREAM_AUTH Authorization header value (overridden by --auth flag)
|
|
253
|
+
`;
|
|
254
|
+
}
|
|
255
|
+
function printUsage({ to = `stderr` } = {}) {
|
|
256
|
+
const out = to === `stderr` ? node_process.stderr : node_process.stdout;
|
|
257
|
+
out.write(getUsageText());
|
|
112
258
|
}
|
|
113
|
-
async function createStream(streamId) {
|
|
114
|
-
const url = `${
|
|
259
|
+
async function createStream(baseUrl, streamId, headers) {
|
|
260
|
+
const url = `${baseUrl}/v1/stream/${streamId}`;
|
|
115
261
|
try {
|
|
116
262
|
await __durable_streams_client.DurableStream.create({
|
|
117
263
|
url,
|
|
264
|
+
headers,
|
|
118
265
|
contentType: `application/octet-stream`
|
|
119
266
|
});
|
|
120
|
-
console.log(`
|
|
267
|
+
console.log(`Stream created successfully: "${streamId}"`);
|
|
268
|
+
console.log(` URL: ${url}`);
|
|
121
269
|
} catch (error) {
|
|
122
|
-
|
|
270
|
+
node_process.stderr.write(`Failed to create stream "${streamId}"\n`);
|
|
271
|
+
node_process.stderr.write(` ${formatErrorMessage(getErrorMessage(error))}\n`);
|
|
123
272
|
process.exit(1);
|
|
124
273
|
}
|
|
125
274
|
}
|
|
126
275
|
/**
|
|
127
|
-
*
|
|
276
|
+
* Format error messages from the server/client for better readability.
|
|
128
277
|
*/
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
278
|
+
function formatErrorMessage(message) {
|
|
279
|
+
const httpMatch = message.match(/HTTP Error (\d+)/);
|
|
280
|
+
const statusCode = httpMatch?.[1];
|
|
281
|
+
if (statusCode) {
|
|
282
|
+
const status = parseInt(statusCode, 10);
|
|
283
|
+
const statusText = getHttpStatusText(status);
|
|
284
|
+
return message.replace(/HTTP Error \d+/, `${statusText} (${status})`);
|
|
134
285
|
}
|
|
135
|
-
return
|
|
286
|
+
return message;
|
|
136
287
|
}
|
|
137
|
-
|
|
138
|
-
|
|
288
|
+
function getHttpStatusText(status) {
|
|
289
|
+
return node_http.STATUS_CODES[status] ?? `HTTP Error`;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Append JSON data to a stream using batch semantics.
|
|
293
|
+
* Arrays are flattened one level (each element becomes a separate message).
|
|
294
|
+
* Non-array values are written as a single message.
|
|
295
|
+
* Returns the number of messages written.
|
|
296
|
+
*/
|
|
297
|
+
async function appendJsonBatch(stream, parsed) {
|
|
298
|
+
const items = [...flattenJsonForAppend(parsed)];
|
|
299
|
+
for (const item of items) await stream.append(JSON.stringify(item));
|
|
300
|
+
return items.length;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Read all data from stdin into a Buffer.
|
|
304
|
+
*/
|
|
305
|
+
async function readStdin() {
|
|
306
|
+
const chunks = [];
|
|
307
|
+
node_process.stdin.on(`data`, (chunk) => {
|
|
308
|
+
chunks.push(chunk);
|
|
309
|
+
});
|
|
310
|
+
await new Promise((resolve, reject) => {
|
|
311
|
+
node_process.stdin.on(`end`, resolve);
|
|
312
|
+
node_process.stdin.on(`error`, (err) => {
|
|
313
|
+
reject(new Error(`Failed to read from stdin: ${err.message}`));
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
return Buffer.concat(chunks);
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Process escape sequences in content string.
|
|
320
|
+
*/
|
|
321
|
+
function processEscapeSequences(content) {
|
|
322
|
+
return content.replace(/\\n/g, `\n`).replace(/\\t/g, `\t`).replace(/\\r/g, `\r`).replace(/\\\\/g, `\\`);
|
|
323
|
+
}
|
|
324
|
+
async function writeStream(baseUrl, streamId, contentType, batchJson, headers, content) {
|
|
325
|
+
const url = `${baseUrl}/v1/stream/${streamId}`;
|
|
139
326
|
const isJson = isJsonContentType(contentType);
|
|
327
|
+
let data;
|
|
328
|
+
let source;
|
|
329
|
+
if (content) {
|
|
330
|
+
data = processEscapeSequences(content);
|
|
331
|
+
source = `argument`;
|
|
332
|
+
} else {
|
|
333
|
+
data = await readStdin();
|
|
334
|
+
source = `stdin`;
|
|
335
|
+
if (data.length === 0) {
|
|
336
|
+
node_process.stderr.write(`No data received from stdin\n`);
|
|
337
|
+
process.exit(1);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
140
340
|
try {
|
|
141
341
|
const stream = new __durable_streams_client.DurableStream({
|
|
142
342
|
url,
|
|
343
|
+
headers,
|
|
143
344
|
contentType
|
|
144
345
|
});
|
|
145
|
-
if (
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
346
|
+
if (isJson) {
|
|
347
|
+
const jsonString = typeof data === `string` ? data : data.toString(`utf8`);
|
|
348
|
+
let parsed;
|
|
349
|
+
try {
|
|
350
|
+
parsed = JSON.parse(jsonString);
|
|
351
|
+
} catch (parseError) {
|
|
352
|
+
const parseMessage = parseError instanceof SyntaxError ? parseError.message : `Unknown parsing error`;
|
|
353
|
+
if (source === `argument`) {
|
|
354
|
+
const preview = jsonString.slice(0, 100);
|
|
355
|
+
const ellipsis = jsonString.length > 100 ? `...` : ``;
|
|
356
|
+
node_process.stderr.write(`Failed to parse JSON content\n`);
|
|
357
|
+
node_process.stderr.write(` ${parseMessage}\n`);
|
|
358
|
+
node_process.stderr.write(` Input: ${preview}${ellipsis}\n`);
|
|
152
359
|
} else {
|
|
153
|
-
|
|
154
|
-
|
|
360
|
+
node_process.stderr.write(`Failed to parse JSON from stdin\n`);
|
|
361
|
+
node_process.stderr.write(` ${parseMessage}\n`);
|
|
155
362
|
}
|
|
156
|
-
|
|
157
|
-
await stream.append(processedContent);
|
|
158
|
-
console.log(`Wrote ${processedContent.length} bytes to ${streamId}`);
|
|
363
|
+
process.exit(1);
|
|
159
364
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
chunks.push(chunk);
|
|
164
|
-
});
|
|
165
|
-
await new Promise((resolve, reject) => {
|
|
166
|
-
node_process.stdin.on(`end`, resolve);
|
|
167
|
-
node_process.stdin.on(`error`, reject);
|
|
168
|
-
});
|
|
169
|
-
const data = Buffer.concat(chunks);
|
|
170
|
-
if (isJson) {
|
|
171
|
-
const parsed = JSON.parse(data.toString(`utf8`));
|
|
172
|
-
if (batchJson) {
|
|
173
|
-
const count = await appendJson(stream, parsed);
|
|
174
|
-
console.log(`Wrote ${count} message(s) to ${streamId}`);
|
|
175
|
-
} else {
|
|
176
|
-
await stream.append(parsed);
|
|
177
|
-
console.log(`Wrote 1 message to ${streamId}`);
|
|
178
|
-
}
|
|
365
|
+
if (batchJson) {
|
|
366
|
+
const count = await appendJsonBatch(stream, parsed);
|
|
367
|
+
console.log(`Wrote ${count} message${count !== 1 ? `s` : ``} to stream "${streamId}"`);
|
|
179
368
|
} else {
|
|
180
|
-
await stream.append(
|
|
181
|
-
console.log(`Wrote
|
|
369
|
+
await stream.append(JSON.stringify(parsed));
|
|
370
|
+
console.log(`Wrote 1 JSON message to stream "${streamId}"`);
|
|
182
371
|
}
|
|
372
|
+
} else {
|
|
373
|
+
await stream.append(data);
|
|
374
|
+
const byteCount = typeof data === `string` ? Buffer.byteLength(data, `utf8`) : data.length;
|
|
375
|
+
console.log(`Wrote ${formatBytes(byteCount)} to stream "${streamId}"`);
|
|
183
376
|
}
|
|
184
377
|
} catch (error) {
|
|
185
|
-
|
|
378
|
+
node_process.stderr.write(`Failed to write to stream "${streamId}"\n`);
|
|
379
|
+
node_process.stderr.write(` ${formatErrorMessage(getErrorMessage(error))}\n`);
|
|
186
380
|
process.exit(1);
|
|
187
381
|
}
|
|
188
382
|
}
|
|
189
|
-
|
|
190
|
-
|
|
383
|
+
function formatBytes(bytes) {
|
|
384
|
+
if (bytes === 1) return `1 byte`;
|
|
385
|
+
if (bytes < 1024) return `${bytes} bytes`;
|
|
386
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
387
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
388
|
+
}
|
|
389
|
+
async function readStream(baseUrl, streamId, headers) {
|
|
390
|
+
const url = `${baseUrl}/v1/stream/${streamId}`;
|
|
191
391
|
try {
|
|
192
|
-
const stream = new __durable_streams_client.DurableStream({
|
|
193
|
-
|
|
392
|
+
const stream = new __durable_streams_client.DurableStream({
|
|
393
|
+
url,
|
|
394
|
+
headers
|
|
395
|
+
});
|
|
396
|
+
const res = await stream.stream({ live: true });
|
|
194
397
|
for await (const chunk of res.bodyStream()) if (chunk.length > 0) node_process.stdout.write(chunk);
|
|
195
398
|
} catch (error) {
|
|
196
|
-
|
|
399
|
+
node_process.stderr.write(`Failed to read stream "${streamId}"\n`);
|
|
400
|
+
node_process.stderr.write(` ${formatErrorMessage(getErrorMessage(error))}\n`);
|
|
197
401
|
process.exit(1);
|
|
198
402
|
}
|
|
199
403
|
}
|
|
200
|
-
async function deleteStream(streamId) {
|
|
201
|
-
const url = `${
|
|
404
|
+
async function deleteStream(baseUrl, streamId, headers) {
|
|
405
|
+
const url = `${baseUrl}/v1/stream/${streamId}`;
|
|
202
406
|
try {
|
|
203
|
-
const stream = new __durable_streams_client.DurableStream({
|
|
407
|
+
const stream = new __durable_streams_client.DurableStream({
|
|
408
|
+
url,
|
|
409
|
+
headers
|
|
410
|
+
});
|
|
204
411
|
await stream.delete();
|
|
205
|
-
console.log(`
|
|
412
|
+
console.log(`Stream deleted successfully: "${streamId}"`);
|
|
206
413
|
} catch (error) {
|
|
207
|
-
|
|
414
|
+
node_process.stderr.write(`Failed to delete stream "${streamId}"\n`);
|
|
415
|
+
node_process.stderr.write(` ${formatErrorMessage(getErrorMessage(error))}\n`);
|
|
208
416
|
process.exit(1);
|
|
209
417
|
}
|
|
210
418
|
}
|
|
211
419
|
async function main() {
|
|
212
|
-
const
|
|
420
|
+
const rawArgs = process.argv.slice(2);
|
|
421
|
+
if (rawArgs.includes(`--help`) || rawArgs.includes(`-h`)) {
|
|
422
|
+
printUsage({ to: `stdout` });
|
|
423
|
+
process.exit(0);
|
|
424
|
+
}
|
|
425
|
+
let options;
|
|
426
|
+
let args;
|
|
427
|
+
let warnings;
|
|
428
|
+
try {
|
|
429
|
+
const parsed = parseGlobalOptions(rawArgs);
|
|
430
|
+
options = parsed.options;
|
|
431
|
+
args = parsed.remainingArgs;
|
|
432
|
+
warnings = parsed.warnings;
|
|
433
|
+
} catch (error) {
|
|
434
|
+
node_process.stderr.write(`Error: ${getErrorMessage(error)}\n`);
|
|
435
|
+
process.exit(1);
|
|
436
|
+
}
|
|
437
|
+
for (const warning of warnings) node_process.stderr.write(`${warning}\n`);
|
|
438
|
+
const headers = buildHeaders(options);
|
|
213
439
|
if (args.length < 1) {
|
|
440
|
+
node_process.stderr.write(`Error: No command specified\n`);
|
|
214
441
|
printUsage();
|
|
215
442
|
process.exit(1);
|
|
216
443
|
}
|
|
217
444
|
const command = args[0];
|
|
445
|
+
function getStreamId() {
|
|
446
|
+
if (args.length < 2) {
|
|
447
|
+
node_process.stderr.write(`Error: Missing stream_id\n`);
|
|
448
|
+
node_process.stderr.write(` Usage: durable-stream ${command} <stream_id>\n`);
|
|
449
|
+
process.exit(1);
|
|
450
|
+
}
|
|
451
|
+
const streamId = args[1];
|
|
452
|
+
const validation = validateStreamId(streamId);
|
|
453
|
+
if (!validation.valid) {
|
|
454
|
+
node_process.stderr.write(`Error: ${validation.error}\n`);
|
|
455
|
+
process.exit(1);
|
|
456
|
+
}
|
|
457
|
+
return streamId;
|
|
458
|
+
}
|
|
218
459
|
switch (command) {
|
|
219
460
|
case `create`: {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
printUsage();
|
|
223
|
-
process.exit(1);
|
|
224
|
-
}
|
|
225
|
-
await createStream(args[1]);
|
|
461
|
+
const streamId = getStreamId();
|
|
462
|
+
await createStream(options.url, streamId, headers);
|
|
226
463
|
break;
|
|
227
464
|
}
|
|
228
465
|
case `write`: {
|
|
229
|
-
|
|
230
|
-
node_process.stderr.write(`Error: stream_id required\n`);
|
|
231
|
-
printUsage();
|
|
232
|
-
process.exit(1);
|
|
233
|
-
}
|
|
234
|
-
const streamId = args[1];
|
|
466
|
+
const streamId = getStreamId();
|
|
235
467
|
let parsed;
|
|
236
468
|
try {
|
|
237
469
|
parsed = parseWriteArgs(args.slice(2));
|
|
238
470
|
} catch (error) {
|
|
239
|
-
|
|
471
|
+
node_process.stderr.write(`Error: ${getErrorMessage(error)}\n`);
|
|
240
472
|
process.exit(1);
|
|
241
473
|
}
|
|
242
|
-
|
|
243
|
-
|
|
474
|
+
const hasContent = parsed.content || !node_process.stdin.isTTY;
|
|
475
|
+
if (hasContent) await writeStream(options.url, streamId, parsed.contentType, parsed.batchJson, headers, parsed.content || void 0);
|
|
244
476
|
else {
|
|
245
|
-
node_process.stderr.write(`Error: content
|
|
246
|
-
|
|
477
|
+
node_process.stderr.write(`Error: No content provided\n`);
|
|
478
|
+
node_process.stderr.write(` Provide content as an argument or pipe from stdin:\n`);
|
|
479
|
+
node_process.stderr.write(` durable-stream write ${streamId} "your content here"\n`);
|
|
480
|
+
node_process.stderr.write(` echo "content" | durable-stream write ${streamId}\n`);
|
|
247
481
|
process.exit(1);
|
|
248
482
|
}
|
|
249
483
|
break;
|
|
250
484
|
}
|
|
251
485
|
case `read`: {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
printUsage();
|
|
255
|
-
process.exit(1);
|
|
256
|
-
}
|
|
257
|
-
await readStream(args[1]);
|
|
486
|
+
const streamId = getStreamId();
|
|
487
|
+
await readStream(options.url, streamId, headers);
|
|
258
488
|
break;
|
|
259
489
|
}
|
|
260
490
|
case `delete`: {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
printUsage();
|
|
264
|
-
process.exit(1);
|
|
265
|
-
}
|
|
266
|
-
await deleteStream(args[1]);
|
|
491
|
+
const streamId = getStreamId();
|
|
492
|
+
await deleteStream(options.url, streamId, headers);
|
|
267
493
|
break;
|
|
268
494
|
}
|
|
269
495
|
default:
|
|
270
|
-
node_process.stderr.write(`Error:
|
|
271
|
-
|
|
496
|
+
if (command?.startsWith(`-`)) node_process.stderr.write(`Error: Unknown option "${command}"\n`);
|
|
497
|
+
else {
|
|
498
|
+
node_process.stderr.write(`Error: Unknown command "${command}"\n`);
|
|
499
|
+
node_process.stderr.write(` Available commands: create, write, read, delete\n`);
|
|
500
|
+
}
|
|
501
|
+
node_process.stderr.write(` Run "durable-stream --help" for usage information\n`);
|
|
272
502
|
process.exit(1);
|
|
273
503
|
}
|
|
274
504
|
}
|
|
@@ -279,11 +509,18 @@ function isMainModule() {
|
|
|
279
509
|
return scriptPath === modulePath;
|
|
280
510
|
}
|
|
281
511
|
if (isMainModule()) main().catch((error) => {
|
|
282
|
-
node_process.stderr.write(`Fatal error: ${error
|
|
512
|
+
node_process.stderr.write(`Fatal error: ${getErrorMessage(error)}\n`);
|
|
283
513
|
process.exit(1);
|
|
284
514
|
});
|
|
285
515
|
|
|
286
516
|
//#endregion
|
|
517
|
+
exports.buildHeaders = buildHeaders
|
|
287
518
|
exports.flattenJsonForAppend = flattenJsonForAppend
|
|
519
|
+
exports.getUsageText = getUsageText
|
|
288
520
|
exports.isJsonContentType = isJsonContentType
|
|
289
|
-
exports.
|
|
521
|
+
exports.normalizeBaseUrl = normalizeBaseUrl
|
|
522
|
+
exports.parseGlobalOptions = parseGlobalOptions
|
|
523
|
+
exports.parseWriteArgs = parseWriteArgs
|
|
524
|
+
exports.validateAuth = validateAuth
|
|
525
|
+
exports.validateStreamId = validateStreamId
|
|
526
|
+
exports.validateUrl = validateUrl
|
package/dist/index.d.cts
CHANGED
|
@@ -30,4 +30,56 @@ interface ParsedWriteArgs {
|
|
|
30
30
|
declare function parseWriteArgs(args: Array<string>): ParsedWriteArgs;
|
|
31
31
|
|
|
32
32
|
//#endregion
|
|
33
|
-
|
|
33
|
+
//#region src/validation.d.ts
|
|
34
|
+
/**
|
|
35
|
+
* Validation utilities for CLI options with helpful error messages.
|
|
36
|
+
*/
|
|
37
|
+
interface ValidationResult {
|
|
38
|
+
valid: boolean;
|
|
39
|
+
error?: string;
|
|
40
|
+
warning?: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Validate a URL string.
|
|
44
|
+
* Must be a valid HTTP or HTTPS URL.
|
|
45
|
+
*/
|
|
46
|
+
declare function validateUrl(url: string): ValidationResult;
|
|
47
|
+
/**
|
|
48
|
+
* Normalize a base URL by removing trailing slashes.
|
|
49
|
+
* This prevents double-slashes when appending paths (e.g., "http://host/" + "/v1/...").
|
|
50
|
+
*/
|
|
51
|
+
declare function normalizeBaseUrl(url: string): string;
|
|
52
|
+
/**
|
|
53
|
+
* Validate an authorization header value.
|
|
54
|
+
* Returns valid=true for any non-empty value, but includes a warning
|
|
55
|
+
* if the value doesn't match common auth schemes (Bearer, Basic, ApiKey, Token).
|
|
56
|
+
*/
|
|
57
|
+
declare function validateAuth(auth: string): ValidationResult;
|
|
58
|
+
/**
|
|
59
|
+
* Validate a stream ID.
|
|
60
|
+
* Must be 1-256 characters containing only: letters, numbers, underscores,
|
|
61
|
+
* hyphens, dots, and colons (URL-safe characters).
|
|
62
|
+
*/
|
|
63
|
+
declare function validateStreamId(streamId: string): ValidationResult;
|
|
64
|
+
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region src/index.d.ts
|
|
67
|
+
interface GlobalOptions {
|
|
68
|
+
url?: string;
|
|
69
|
+
auth?: string;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Parse global options (--url, --auth) from args.
|
|
73
|
+
* Falls back to STREAM_URL/STREAM_AUTH env vars when flags not provided.
|
|
74
|
+
* Returns the parsed options, remaining args, and any warnings.
|
|
75
|
+
*/
|
|
76
|
+
declare function parseGlobalOptions(args: Array<string>): {
|
|
77
|
+
options: GlobalOptions;
|
|
78
|
+
remainingArgs: Array<string>;
|
|
79
|
+
warnings: Array<string>;
|
|
80
|
+
};
|
|
81
|
+
declare function buildHeaders(options: GlobalOptions): Record<string, string>;
|
|
82
|
+
declare function getUsageText(): string;
|
|
83
|
+
|
|
84
|
+
//#endregion
|
|
85
|
+
export { GlobalOptions, ParsedWriteArgs, buildHeaders, flattenJsonForAppend, getUsageText, isJsonContentType, normalizeBaseUrl, parseGlobalOptions, parseWriteArgs, validateAuth, validateStreamId, validateUrl };
|
package/dist/index.d.ts
CHANGED
|
@@ -30,4 +30,56 @@ interface ParsedWriteArgs {
|
|
|
30
30
|
declare function parseWriteArgs(args: Array<string>): ParsedWriteArgs;
|
|
31
31
|
|
|
32
32
|
//#endregion
|
|
33
|
-
|
|
33
|
+
//#region src/validation.d.ts
|
|
34
|
+
/**
|
|
35
|
+
* Validation utilities for CLI options with helpful error messages.
|
|
36
|
+
*/
|
|
37
|
+
interface ValidationResult {
|
|
38
|
+
valid: boolean;
|
|
39
|
+
error?: string;
|
|
40
|
+
warning?: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Validate a URL string.
|
|
44
|
+
* Must be a valid HTTP or HTTPS URL.
|
|
45
|
+
*/
|
|
46
|
+
declare function validateUrl(url: string): ValidationResult;
|
|
47
|
+
/**
|
|
48
|
+
* Normalize a base URL by removing trailing slashes.
|
|
49
|
+
* This prevents double-slashes when appending paths (e.g., "http://host/" + "/v1/...").
|
|
50
|
+
*/
|
|
51
|
+
declare function normalizeBaseUrl(url: string): string;
|
|
52
|
+
/**
|
|
53
|
+
* Validate an authorization header value.
|
|
54
|
+
* Returns valid=true for any non-empty value, but includes a warning
|
|
55
|
+
* if the value doesn't match common auth schemes (Bearer, Basic, ApiKey, Token).
|
|
56
|
+
*/
|
|
57
|
+
declare function validateAuth(auth: string): ValidationResult;
|
|
58
|
+
/**
|
|
59
|
+
* Validate a stream ID.
|
|
60
|
+
* Must be 1-256 characters containing only: letters, numbers, underscores,
|
|
61
|
+
* hyphens, dots, and colons (URL-safe characters).
|
|
62
|
+
*/
|
|
63
|
+
declare function validateStreamId(streamId: string): ValidationResult;
|
|
64
|
+
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region src/index.d.ts
|
|
67
|
+
interface GlobalOptions {
|
|
68
|
+
url?: string;
|
|
69
|
+
auth?: string;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Parse global options (--url, --auth) from args.
|
|
73
|
+
* Falls back to STREAM_URL/STREAM_AUTH env vars when flags not provided.
|
|
74
|
+
* Returns the parsed options, remaining args, and any warnings.
|
|
75
|
+
*/
|
|
76
|
+
declare function parseGlobalOptions(args: Array<string>): {
|
|
77
|
+
options: GlobalOptions;
|
|
78
|
+
remainingArgs: Array<string>;
|
|
79
|
+
warnings: Array<string>;
|
|
80
|
+
};
|
|
81
|
+
declare function buildHeaders(options: GlobalOptions): Record<string, string>;
|
|
82
|
+
declare function getUsageText(): string;
|
|
83
|
+
|
|
84
|
+
//#endregion
|
|
85
|
+
export { GlobalOptions, ParsedWriteArgs, buildHeaders, flattenJsonForAppend, getUsageText, isJsonContentType, normalizeBaseUrl, parseGlobalOptions, parseWriteArgs, validateAuth, validateStreamId, validateUrl };
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { STATUS_CODES } from "node:http";
|
|
2
3
|
import { resolve } from "node:path";
|
|
3
4
|
import { stderr, stdin, stdout } from "node:process";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -65,11 +66,146 @@ function parseWriteArgs(args) {
|
|
|
65
66
|
};
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
//#endregion
|
|
70
|
+
//#region src/validation.ts
|
|
71
|
+
/**
|
|
72
|
+
* Validate a URL string.
|
|
73
|
+
* Must be a valid HTTP or HTTPS URL.
|
|
74
|
+
*/
|
|
75
|
+
function validateUrl(url) {
|
|
76
|
+
if (!url || !url.trim()) return {
|
|
77
|
+
valid: false,
|
|
78
|
+
error: `URL cannot be empty`
|
|
79
|
+
};
|
|
80
|
+
let parsed;
|
|
81
|
+
try {
|
|
82
|
+
parsed = new URL(url);
|
|
83
|
+
} catch {
|
|
84
|
+
return {
|
|
85
|
+
valid: false,
|
|
86
|
+
error: `Invalid URL format: "${url}"\n Expected format: http://host:port or https://host:port`
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (parsed.protocol !== `http:` && parsed.protocol !== `https:`) return {
|
|
90
|
+
valid: false,
|
|
91
|
+
error: `Invalid URL protocol: "${parsed.protocol}"\n Only http:// and https:// are supported`
|
|
92
|
+
};
|
|
93
|
+
if (!parsed.hostname) return {
|
|
94
|
+
valid: false,
|
|
95
|
+
error: `URL must include a hostname: "${url}"`
|
|
96
|
+
};
|
|
97
|
+
return { valid: true };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Normalize a base URL by removing trailing slashes.
|
|
101
|
+
* This prevents double-slashes when appending paths (e.g., "http://host/" + "/v1/...").
|
|
102
|
+
*/
|
|
103
|
+
function normalizeBaseUrl(url) {
|
|
104
|
+
return url.replace(/\/+$/, ``);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Validate an authorization header value.
|
|
108
|
+
* Returns valid=true for any non-empty value, but includes a warning
|
|
109
|
+
* if the value doesn't match common auth schemes (Bearer, Basic, ApiKey, Token).
|
|
110
|
+
*/
|
|
111
|
+
function validateAuth(auth) {
|
|
112
|
+
if (!auth || !auth.trim()) return {
|
|
113
|
+
valid: false,
|
|
114
|
+
error: `Authorization value cannot be empty`
|
|
115
|
+
};
|
|
116
|
+
const trimmed = auth.trim();
|
|
117
|
+
const lowerTrimmed = trimmed.toLowerCase();
|
|
118
|
+
const hasScheme = [
|
|
119
|
+
`bearer `,
|
|
120
|
+
`basic `,
|
|
121
|
+
`apikey `,
|
|
122
|
+
`token `
|
|
123
|
+
].some((scheme) => lowerTrimmed.startsWith(scheme));
|
|
124
|
+
if (!hasScheme && !trimmed.includes(` `)) return {
|
|
125
|
+
valid: true,
|
|
126
|
+
warning: `Warning: Authorization value doesn't match common formats.\n Expected: "Bearer <token>", "Basic <credentials>", or "ApiKey <key>"`
|
|
127
|
+
};
|
|
128
|
+
return { valid: true };
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Validate a stream ID.
|
|
132
|
+
* Must be 1-256 characters containing only: letters, numbers, underscores,
|
|
133
|
+
* hyphens, dots, and colons (URL-safe characters).
|
|
134
|
+
*/
|
|
135
|
+
function validateStreamId(streamId) {
|
|
136
|
+
if (!streamId || !streamId.trim()) return {
|
|
137
|
+
valid: false,
|
|
138
|
+
error: `Stream ID cannot be empty`
|
|
139
|
+
};
|
|
140
|
+
const validPattern = /^[a-zA-Z0-9_\-.:]+$/;
|
|
141
|
+
if (!validPattern.test(streamId)) return {
|
|
142
|
+
valid: false,
|
|
143
|
+
error: `Invalid stream ID: "${streamId}"\n Stream IDs can only contain letters, numbers, underscores, hyphens, dots, and colons`
|
|
144
|
+
};
|
|
145
|
+
if (streamId.length > 256) return {
|
|
146
|
+
valid: false,
|
|
147
|
+
error: `Stream ID too long (${streamId.length} chars)\n Maximum length is 256 characters`
|
|
148
|
+
};
|
|
149
|
+
return { valid: true };
|
|
150
|
+
}
|
|
151
|
+
|
|
68
152
|
//#endregion
|
|
69
153
|
//#region src/index.ts
|
|
70
154
|
const STREAM_URL = process.env.STREAM_URL || `http://localhost:4437`;
|
|
71
|
-
|
|
72
|
-
|
|
155
|
+
const STREAM_AUTH = process.env.STREAM_AUTH;
|
|
156
|
+
/**
|
|
157
|
+
* Parse global options (--url, --auth) from args.
|
|
158
|
+
* Falls back to STREAM_URL/STREAM_AUTH env vars when flags not provided.
|
|
159
|
+
* Returns the parsed options, remaining args, and any warnings.
|
|
160
|
+
*/
|
|
161
|
+
function parseGlobalOptions(args) {
|
|
162
|
+
const options = {};
|
|
163
|
+
const remainingArgs = [];
|
|
164
|
+
const warnings = [];
|
|
165
|
+
for (let i = 0; i < args.length; i++) {
|
|
166
|
+
const arg = args[i];
|
|
167
|
+
if (arg === `--url`) {
|
|
168
|
+
const value = args[i + 1];
|
|
169
|
+
if (!value || value.startsWith(`--`)) throw new Error(`--url requires a value\n Example: --url "http://localhost:4437"`);
|
|
170
|
+
const urlValidation = validateUrl(value);
|
|
171
|
+
if (!urlValidation.valid) throw new Error(urlValidation.error);
|
|
172
|
+
options.url = normalizeBaseUrl(value);
|
|
173
|
+
i++;
|
|
174
|
+
} else if (arg === `--auth`) {
|
|
175
|
+
const value = args[i + 1];
|
|
176
|
+
if (!value || value.startsWith(`--`)) throw new Error(`--auth requires a value\n Example: --auth "Bearer my-token"`);
|
|
177
|
+
const authValidation = validateAuth(value);
|
|
178
|
+
if (!authValidation.valid) throw new Error(authValidation.error);
|
|
179
|
+
if (authValidation.warning) warnings.push(authValidation.warning);
|
|
180
|
+
options.auth = value;
|
|
181
|
+
i++;
|
|
182
|
+
} else remainingArgs.push(arg);
|
|
183
|
+
}
|
|
184
|
+
if (!options.url) {
|
|
185
|
+
const urlValidation = validateUrl(STREAM_URL);
|
|
186
|
+
if (!urlValidation.valid) throw new Error(`Invalid STREAM_URL environment variable: ${urlValidation.error}`);
|
|
187
|
+
options.url = normalizeBaseUrl(STREAM_URL);
|
|
188
|
+
}
|
|
189
|
+
if (!options.auth && STREAM_AUTH) {
|
|
190
|
+
const authValidation = validateAuth(STREAM_AUTH);
|
|
191
|
+
if (!authValidation.valid) throw new Error(`Invalid STREAM_AUTH environment variable: ${authValidation.error}`);
|
|
192
|
+
if (authValidation.warning) warnings.push(authValidation.warning);
|
|
193
|
+
options.auth = STREAM_AUTH;
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
options,
|
|
197
|
+
remainingArgs,
|
|
198
|
+
warnings
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
function getErrorMessage(error) {
|
|
202
|
+
return error instanceof Error ? error.message : String(error);
|
|
203
|
+
}
|
|
204
|
+
function buildHeaders(options) {
|
|
205
|
+
return options.auth ? { Authorization: options.auth } : {};
|
|
206
|
+
}
|
|
207
|
+
function getUsageText() {
|
|
208
|
+
return `
|
|
73
209
|
Usage:
|
|
74
210
|
durable-stream create <stream_id> Create a new stream
|
|
75
211
|
durable-stream write <stream_id> <content> Write content to a stream
|
|
@@ -77,6 +213,11 @@ Usage:
|
|
|
77
213
|
durable-stream read <stream_id> Follow a stream and write to stdout
|
|
78
214
|
durable-stream delete <stream_id> Delete a stream
|
|
79
215
|
|
|
216
|
+
Global Options:
|
|
217
|
+
--url <url> Stream server URL (overrides STREAM_URL env var)
|
|
218
|
+
--auth <value> Authorization header value (e.g., "Bearer my-token")
|
|
219
|
+
--help, -h Show this help message
|
|
220
|
+
|
|
80
221
|
Write Options:
|
|
81
222
|
--content-type <type> Content-Type for the message (default: application/octet-stream)
|
|
82
223
|
--json Write as JSON (input stored as single message)
|
|
@@ -84,167 +225,256 @@ Write Options:
|
|
|
84
225
|
|
|
85
226
|
Environment Variables:
|
|
86
227
|
STREAM_URL Base URL of the stream server (default: http://localhost:4437)
|
|
87
|
-
|
|
228
|
+
STREAM_AUTH Authorization header value (overridden by --auth flag)
|
|
229
|
+
`;
|
|
230
|
+
}
|
|
231
|
+
function printUsage({ to = `stderr` } = {}) {
|
|
232
|
+
const out = to === `stderr` ? stderr : stdout;
|
|
233
|
+
out.write(getUsageText());
|
|
88
234
|
}
|
|
89
|
-
async function createStream(streamId) {
|
|
90
|
-
const url = `${
|
|
235
|
+
async function createStream(baseUrl, streamId, headers) {
|
|
236
|
+
const url = `${baseUrl}/v1/stream/${streamId}`;
|
|
91
237
|
try {
|
|
92
238
|
await DurableStream.create({
|
|
93
239
|
url,
|
|
240
|
+
headers,
|
|
94
241
|
contentType: `application/octet-stream`
|
|
95
242
|
});
|
|
96
|
-
console.log(`
|
|
243
|
+
console.log(`Stream created successfully: "${streamId}"`);
|
|
244
|
+
console.log(` URL: ${url}`);
|
|
97
245
|
} catch (error) {
|
|
98
|
-
|
|
246
|
+
stderr.write(`Failed to create stream "${streamId}"\n`);
|
|
247
|
+
stderr.write(` ${formatErrorMessage(getErrorMessage(error))}\n`);
|
|
99
248
|
process.exit(1);
|
|
100
249
|
}
|
|
101
250
|
}
|
|
102
251
|
/**
|
|
103
|
-
*
|
|
252
|
+
* Format error messages from the server/client for better readability.
|
|
104
253
|
*/
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
254
|
+
function formatErrorMessage(message) {
|
|
255
|
+
const httpMatch = message.match(/HTTP Error (\d+)/);
|
|
256
|
+
const statusCode = httpMatch?.[1];
|
|
257
|
+
if (statusCode) {
|
|
258
|
+
const status = parseInt(statusCode, 10);
|
|
259
|
+
const statusText = getHttpStatusText(status);
|
|
260
|
+
return message.replace(/HTTP Error \d+/, `${statusText} (${status})`);
|
|
110
261
|
}
|
|
111
|
-
return
|
|
262
|
+
return message;
|
|
112
263
|
}
|
|
113
|
-
|
|
114
|
-
|
|
264
|
+
function getHttpStatusText(status) {
|
|
265
|
+
return STATUS_CODES[status] ?? `HTTP Error`;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Append JSON data to a stream using batch semantics.
|
|
269
|
+
* Arrays are flattened one level (each element becomes a separate message).
|
|
270
|
+
* Non-array values are written as a single message.
|
|
271
|
+
* Returns the number of messages written.
|
|
272
|
+
*/
|
|
273
|
+
async function appendJsonBatch(stream, parsed) {
|
|
274
|
+
const items = [...flattenJsonForAppend(parsed)];
|
|
275
|
+
for (const item of items) await stream.append(JSON.stringify(item));
|
|
276
|
+
return items.length;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Read all data from stdin into a Buffer.
|
|
280
|
+
*/
|
|
281
|
+
async function readStdin() {
|
|
282
|
+
const chunks = [];
|
|
283
|
+
stdin.on(`data`, (chunk) => {
|
|
284
|
+
chunks.push(chunk);
|
|
285
|
+
});
|
|
286
|
+
await new Promise((resolve$1, reject) => {
|
|
287
|
+
stdin.on(`end`, resolve$1);
|
|
288
|
+
stdin.on(`error`, (err) => {
|
|
289
|
+
reject(new Error(`Failed to read from stdin: ${err.message}`));
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
return Buffer.concat(chunks);
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Process escape sequences in content string.
|
|
296
|
+
*/
|
|
297
|
+
function processEscapeSequences(content) {
|
|
298
|
+
return content.replace(/\\n/g, `\n`).replace(/\\t/g, `\t`).replace(/\\r/g, `\r`).replace(/\\\\/g, `\\`);
|
|
299
|
+
}
|
|
300
|
+
async function writeStream(baseUrl, streamId, contentType, batchJson, headers, content) {
|
|
301
|
+
const url = `${baseUrl}/v1/stream/${streamId}`;
|
|
115
302
|
const isJson = isJsonContentType(contentType);
|
|
303
|
+
let data;
|
|
304
|
+
let source;
|
|
305
|
+
if (content) {
|
|
306
|
+
data = processEscapeSequences(content);
|
|
307
|
+
source = `argument`;
|
|
308
|
+
} else {
|
|
309
|
+
data = await readStdin();
|
|
310
|
+
source = `stdin`;
|
|
311
|
+
if (data.length === 0) {
|
|
312
|
+
stderr.write(`No data received from stdin\n`);
|
|
313
|
+
process.exit(1);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
116
316
|
try {
|
|
117
317
|
const stream = new DurableStream({
|
|
118
318
|
url,
|
|
319
|
+
headers,
|
|
119
320
|
contentType
|
|
120
321
|
});
|
|
121
|
-
if (
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
322
|
+
if (isJson) {
|
|
323
|
+
const jsonString = typeof data === `string` ? data : data.toString(`utf8`);
|
|
324
|
+
let parsed;
|
|
325
|
+
try {
|
|
326
|
+
parsed = JSON.parse(jsonString);
|
|
327
|
+
} catch (parseError) {
|
|
328
|
+
const parseMessage = parseError instanceof SyntaxError ? parseError.message : `Unknown parsing error`;
|
|
329
|
+
if (source === `argument`) {
|
|
330
|
+
const preview = jsonString.slice(0, 100);
|
|
331
|
+
const ellipsis = jsonString.length > 100 ? `...` : ``;
|
|
332
|
+
stderr.write(`Failed to parse JSON content\n`);
|
|
333
|
+
stderr.write(` ${parseMessage}\n`);
|
|
334
|
+
stderr.write(` Input: ${preview}${ellipsis}\n`);
|
|
128
335
|
} else {
|
|
129
|
-
|
|
130
|
-
|
|
336
|
+
stderr.write(`Failed to parse JSON from stdin\n`);
|
|
337
|
+
stderr.write(` ${parseMessage}\n`);
|
|
131
338
|
}
|
|
132
|
-
|
|
133
|
-
await stream.append(processedContent);
|
|
134
|
-
console.log(`Wrote ${processedContent.length} bytes to ${streamId}`);
|
|
339
|
+
process.exit(1);
|
|
135
340
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
chunks.push(chunk);
|
|
140
|
-
});
|
|
141
|
-
await new Promise((resolve$1, reject) => {
|
|
142
|
-
stdin.on(`end`, resolve$1);
|
|
143
|
-
stdin.on(`error`, reject);
|
|
144
|
-
});
|
|
145
|
-
const data = Buffer.concat(chunks);
|
|
146
|
-
if (isJson) {
|
|
147
|
-
const parsed = JSON.parse(data.toString(`utf8`));
|
|
148
|
-
if (batchJson) {
|
|
149
|
-
const count = await appendJson(stream, parsed);
|
|
150
|
-
console.log(`Wrote ${count} message(s) to ${streamId}`);
|
|
151
|
-
} else {
|
|
152
|
-
await stream.append(parsed);
|
|
153
|
-
console.log(`Wrote 1 message to ${streamId}`);
|
|
154
|
-
}
|
|
341
|
+
if (batchJson) {
|
|
342
|
+
const count = await appendJsonBatch(stream, parsed);
|
|
343
|
+
console.log(`Wrote ${count} message${count !== 1 ? `s` : ``} to stream "${streamId}"`);
|
|
155
344
|
} else {
|
|
156
|
-
await stream.append(
|
|
157
|
-
console.log(`Wrote
|
|
345
|
+
await stream.append(JSON.stringify(parsed));
|
|
346
|
+
console.log(`Wrote 1 JSON message to stream "${streamId}"`);
|
|
158
347
|
}
|
|
348
|
+
} else {
|
|
349
|
+
await stream.append(data);
|
|
350
|
+
const byteCount = typeof data === `string` ? Buffer.byteLength(data, `utf8`) : data.length;
|
|
351
|
+
console.log(`Wrote ${formatBytes(byteCount)} to stream "${streamId}"`);
|
|
159
352
|
}
|
|
160
353
|
} catch (error) {
|
|
161
|
-
|
|
354
|
+
stderr.write(`Failed to write to stream "${streamId}"\n`);
|
|
355
|
+
stderr.write(` ${formatErrorMessage(getErrorMessage(error))}\n`);
|
|
162
356
|
process.exit(1);
|
|
163
357
|
}
|
|
164
358
|
}
|
|
165
|
-
|
|
166
|
-
|
|
359
|
+
function formatBytes(bytes) {
|
|
360
|
+
if (bytes === 1) return `1 byte`;
|
|
361
|
+
if (bytes < 1024) return `${bytes} bytes`;
|
|
362
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
363
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
364
|
+
}
|
|
365
|
+
async function readStream(baseUrl, streamId, headers) {
|
|
366
|
+
const url = `${baseUrl}/v1/stream/${streamId}`;
|
|
167
367
|
try {
|
|
168
|
-
const stream = new DurableStream({
|
|
169
|
-
|
|
368
|
+
const stream = new DurableStream({
|
|
369
|
+
url,
|
|
370
|
+
headers
|
|
371
|
+
});
|
|
372
|
+
const res = await stream.stream({ live: true });
|
|
170
373
|
for await (const chunk of res.bodyStream()) if (chunk.length > 0) stdout.write(chunk);
|
|
171
374
|
} catch (error) {
|
|
172
|
-
|
|
375
|
+
stderr.write(`Failed to read stream "${streamId}"\n`);
|
|
376
|
+
stderr.write(` ${formatErrorMessage(getErrorMessage(error))}\n`);
|
|
173
377
|
process.exit(1);
|
|
174
378
|
}
|
|
175
379
|
}
|
|
176
|
-
async function deleteStream(streamId) {
|
|
177
|
-
const url = `${
|
|
380
|
+
async function deleteStream(baseUrl, streamId, headers) {
|
|
381
|
+
const url = `${baseUrl}/v1/stream/${streamId}`;
|
|
178
382
|
try {
|
|
179
|
-
const stream = new DurableStream({
|
|
383
|
+
const stream = new DurableStream({
|
|
384
|
+
url,
|
|
385
|
+
headers
|
|
386
|
+
});
|
|
180
387
|
await stream.delete();
|
|
181
|
-
console.log(`
|
|
388
|
+
console.log(`Stream deleted successfully: "${streamId}"`);
|
|
182
389
|
} catch (error) {
|
|
183
|
-
|
|
390
|
+
stderr.write(`Failed to delete stream "${streamId}"\n`);
|
|
391
|
+
stderr.write(` ${formatErrorMessage(getErrorMessage(error))}\n`);
|
|
184
392
|
process.exit(1);
|
|
185
393
|
}
|
|
186
394
|
}
|
|
187
395
|
async function main() {
|
|
188
|
-
const
|
|
396
|
+
const rawArgs = process.argv.slice(2);
|
|
397
|
+
if (rawArgs.includes(`--help`) || rawArgs.includes(`-h`)) {
|
|
398
|
+
printUsage({ to: `stdout` });
|
|
399
|
+
process.exit(0);
|
|
400
|
+
}
|
|
401
|
+
let options;
|
|
402
|
+
let args;
|
|
403
|
+
let warnings;
|
|
404
|
+
try {
|
|
405
|
+
const parsed = parseGlobalOptions(rawArgs);
|
|
406
|
+
options = parsed.options;
|
|
407
|
+
args = parsed.remainingArgs;
|
|
408
|
+
warnings = parsed.warnings;
|
|
409
|
+
} catch (error) {
|
|
410
|
+
stderr.write(`Error: ${getErrorMessage(error)}\n`);
|
|
411
|
+
process.exit(1);
|
|
412
|
+
}
|
|
413
|
+
for (const warning of warnings) stderr.write(`${warning}\n`);
|
|
414
|
+
const headers = buildHeaders(options);
|
|
189
415
|
if (args.length < 1) {
|
|
416
|
+
stderr.write(`Error: No command specified\n`);
|
|
190
417
|
printUsage();
|
|
191
418
|
process.exit(1);
|
|
192
419
|
}
|
|
193
420
|
const command = args[0];
|
|
421
|
+
function getStreamId() {
|
|
422
|
+
if (args.length < 2) {
|
|
423
|
+
stderr.write(`Error: Missing stream_id\n`);
|
|
424
|
+
stderr.write(` Usage: durable-stream ${command} <stream_id>\n`);
|
|
425
|
+
process.exit(1);
|
|
426
|
+
}
|
|
427
|
+
const streamId = args[1];
|
|
428
|
+
const validation = validateStreamId(streamId);
|
|
429
|
+
if (!validation.valid) {
|
|
430
|
+
stderr.write(`Error: ${validation.error}\n`);
|
|
431
|
+
process.exit(1);
|
|
432
|
+
}
|
|
433
|
+
return streamId;
|
|
434
|
+
}
|
|
194
435
|
switch (command) {
|
|
195
436
|
case `create`: {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
printUsage();
|
|
199
|
-
process.exit(1);
|
|
200
|
-
}
|
|
201
|
-
await createStream(args[1]);
|
|
437
|
+
const streamId = getStreamId();
|
|
438
|
+
await createStream(options.url, streamId, headers);
|
|
202
439
|
break;
|
|
203
440
|
}
|
|
204
441
|
case `write`: {
|
|
205
|
-
|
|
206
|
-
stderr.write(`Error: stream_id required\n`);
|
|
207
|
-
printUsage();
|
|
208
|
-
process.exit(1);
|
|
209
|
-
}
|
|
210
|
-
const streamId = args[1];
|
|
442
|
+
const streamId = getStreamId();
|
|
211
443
|
let parsed;
|
|
212
444
|
try {
|
|
213
445
|
parsed = parseWriteArgs(args.slice(2));
|
|
214
446
|
} catch (error) {
|
|
215
|
-
|
|
447
|
+
stderr.write(`Error: ${getErrorMessage(error)}\n`);
|
|
216
448
|
process.exit(1);
|
|
217
449
|
}
|
|
218
|
-
|
|
219
|
-
|
|
450
|
+
const hasContent = parsed.content || !stdin.isTTY;
|
|
451
|
+
if (hasContent) await writeStream(options.url, streamId, parsed.contentType, parsed.batchJson, headers, parsed.content || void 0);
|
|
220
452
|
else {
|
|
221
|
-
stderr.write(`Error: content
|
|
222
|
-
|
|
453
|
+
stderr.write(`Error: No content provided\n`);
|
|
454
|
+
stderr.write(` Provide content as an argument or pipe from stdin:\n`);
|
|
455
|
+
stderr.write(` durable-stream write ${streamId} "your content here"\n`);
|
|
456
|
+
stderr.write(` echo "content" | durable-stream write ${streamId}\n`);
|
|
223
457
|
process.exit(1);
|
|
224
458
|
}
|
|
225
459
|
break;
|
|
226
460
|
}
|
|
227
461
|
case `read`: {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
printUsage();
|
|
231
|
-
process.exit(1);
|
|
232
|
-
}
|
|
233
|
-
await readStream(args[1]);
|
|
462
|
+
const streamId = getStreamId();
|
|
463
|
+
await readStream(options.url, streamId, headers);
|
|
234
464
|
break;
|
|
235
465
|
}
|
|
236
466
|
case `delete`: {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
printUsage();
|
|
240
|
-
process.exit(1);
|
|
241
|
-
}
|
|
242
|
-
await deleteStream(args[1]);
|
|
467
|
+
const streamId = getStreamId();
|
|
468
|
+
await deleteStream(options.url, streamId, headers);
|
|
243
469
|
break;
|
|
244
470
|
}
|
|
245
471
|
default:
|
|
246
|
-
stderr.write(`Error:
|
|
247
|
-
|
|
472
|
+
if (command?.startsWith(`-`)) stderr.write(`Error: Unknown option "${command}"\n`);
|
|
473
|
+
else {
|
|
474
|
+
stderr.write(`Error: Unknown command "${command}"\n`);
|
|
475
|
+
stderr.write(` Available commands: create, write, read, delete\n`);
|
|
476
|
+
}
|
|
477
|
+
stderr.write(` Run "durable-stream --help" for usage information\n`);
|
|
248
478
|
process.exit(1);
|
|
249
479
|
}
|
|
250
480
|
}
|
|
@@ -255,9 +485,9 @@ function isMainModule() {
|
|
|
255
485
|
return scriptPath === modulePath;
|
|
256
486
|
}
|
|
257
487
|
if (isMainModule()) main().catch((error) => {
|
|
258
|
-
stderr.write(`Fatal error: ${error
|
|
488
|
+
stderr.write(`Fatal error: ${getErrorMessage(error)}\n`);
|
|
259
489
|
process.exit(1);
|
|
260
490
|
});
|
|
261
491
|
|
|
262
492
|
//#endregion
|
|
263
|
-
export { flattenJsonForAppend, isJsonContentType, parseWriteArgs };
|
|
493
|
+
export { buildHeaders, flattenJsonForAppend, getUsageText, isJsonContentType, normalizeBaseUrl, parseGlobalOptions, parseWriteArgs, validateAuth, validateStreamId, validateUrl };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@durable-streams/cli",
|
|
3
3
|
"description": "CLI tool for working with Durable Streams",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.8",
|
|
5
5
|
"author": "Durable Stream contributors",
|
|
6
6
|
"bin": {
|
|
7
7
|
"cli": "./dist/index.js",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"url": "https://github.com/durable-streams/durable-streams/issues"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@durable-streams/client": "0.
|
|
15
|
+
"@durable-streams/client": "0.2.0"
|
|
16
16
|
},
|
|
17
17
|
"devDependencies": {
|
|
18
18
|
"@types/node": "^22.15.21",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"tsx": "^4.19.2",
|
|
21
21
|
"typescript": "^5.5.2",
|
|
22
22
|
"vitest": "^3.1.3",
|
|
23
|
-
"@durable-streams/server": "0.1.
|
|
23
|
+
"@durable-streams/server": "0.1.7"
|
|
24
24
|
},
|
|
25
25
|
"engines": {
|
|
26
26
|
"node": ">=18.0.0"
|