@durable-streams/cli 0.1.7 → 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/dist/index.cjs +284 -110
- package/dist/index.d.cts +40 -4
- package/dist/index.d.ts +40 -4
- package/dist/index.js +279 -110
- package/package.json +3 -3
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,43 +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
179
|
const STREAM_AUTH = process.env.STREAM_AUTH;
|
|
96
180
|
/**
|
|
97
|
-
* Parse global options (
|
|
98
|
-
* Falls back to STREAM_AUTH env
|
|
99
|
-
* Returns the parsed options and
|
|
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.
|
|
100
184
|
*/
|
|
101
185
|
function parseGlobalOptions(args) {
|
|
102
186
|
const options = {};
|
|
103
187
|
const remainingArgs = [];
|
|
188
|
+
const warnings = [];
|
|
104
189
|
for (let i = 0; i < args.length; i++) {
|
|
105
190
|
const arg = args[i];
|
|
106
|
-
if (arg === `--
|
|
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`) {
|
|
107
199
|
const value = args[i + 1];
|
|
108
|
-
if (!value || value.startsWith(`--`)) throw new Error(`--auth requires a value
|
|
109
|
-
|
|
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);
|
|
110
204
|
options.auth = value;
|
|
111
205
|
i++;
|
|
112
206
|
} else remainingArgs.push(arg);
|
|
113
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
|
+
}
|
|
114
213
|
if (!options.auth && STREAM_AUTH) {
|
|
115
|
-
|
|
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);
|
|
116
217
|
options.auth = STREAM_AUTH;
|
|
117
218
|
}
|
|
118
219
|
return {
|
|
119
220
|
options,
|
|
120
|
-
remainingArgs
|
|
221
|
+
remainingArgs,
|
|
222
|
+
warnings
|
|
121
223
|
};
|
|
122
224
|
}
|
|
225
|
+
function getErrorMessage(error) {
|
|
226
|
+
return error instanceof Error ? error.message : String(error);
|
|
227
|
+
}
|
|
123
228
|
function buildHeaders(options) {
|
|
124
|
-
|
|
125
|
-
return {};
|
|
229
|
+
return options.auth ? { Authorization: options.auth } : {};
|
|
126
230
|
}
|
|
127
|
-
function
|
|
128
|
-
|
|
231
|
+
function getUsageText() {
|
|
232
|
+
return `
|
|
129
233
|
Usage:
|
|
130
234
|
durable-stream create <stream_id> Create a new stream
|
|
131
235
|
durable-stream write <stream_id> <content> Write content to a stream
|
|
@@ -134,7 +238,9 @@ Usage:
|
|
|
134
238
|
durable-stream delete <stream_id> Delete a stream
|
|
135
239
|
|
|
136
240
|
Global Options:
|
|
241
|
+
--url <url> Stream server URL (overrides STREAM_URL env var)
|
|
137
242
|
--auth <value> Authorization header value (e.g., "Bearer my-token")
|
|
243
|
+
--help, -h Show this help message
|
|
138
244
|
|
|
139
245
|
Write Options:
|
|
140
246
|
--content-type <type> Content-Type for the message (default: application/octet-stream)
|
|
@@ -144,191 +250,255 @@ Write Options:
|
|
|
144
250
|
Environment Variables:
|
|
145
251
|
STREAM_URL Base URL of the stream server (default: http://localhost:4437)
|
|
146
252
|
STREAM_AUTH Authorization header value (overridden by --auth flag)
|
|
147
|
-
|
|
253
|
+
`;
|
|
148
254
|
}
|
|
149
|
-
|
|
150
|
-
const
|
|
255
|
+
function printUsage({ to = `stderr` } = {}) {
|
|
256
|
+
const out = to === `stderr` ? node_process.stderr : node_process.stdout;
|
|
257
|
+
out.write(getUsageText());
|
|
258
|
+
}
|
|
259
|
+
async function createStream(baseUrl, streamId, headers) {
|
|
260
|
+
const url = `${baseUrl}/v1/stream/${streamId}`;
|
|
151
261
|
try {
|
|
152
262
|
await __durable_streams_client.DurableStream.create({
|
|
153
263
|
url,
|
|
154
264
|
headers,
|
|
155
265
|
contentType: `application/octet-stream`
|
|
156
266
|
});
|
|
157
|
-
console.log(`
|
|
267
|
+
console.log(`Stream created successfully: "${streamId}"`);
|
|
268
|
+
console.log(` URL: ${url}`);
|
|
158
269
|
} catch (error) {
|
|
159
|
-
|
|
160
|
-
node_process.stderr.write(`
|
|
270
|
+
node_process.stderr.write(`Failed to create stream "${streamId}"\n`);
|
|
271
|
+
node_process.stderr.write(` ${formatErrorMessage(getErrorMessage(error))}\n`);
|
|
161
272
|
process.exit(1);
|
|
162
273
|
}
|
|
163
274
|
}
|
|
164
275
|
/**
|
|
165
|
-
*
|
|
276
|
+
* Format error messages from the server/client for better readability.
|
|
166
277
|
*/
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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})`);
|
|
172
285
|
}
|
|
173
|
-
return
|
|
286
|
+
return message;
|
|
287
|
+
}
|
|
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);
|
|
174
317
|
}
|
|
175
|
-
|
|
176
|
-
|
|
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}`;
|
|
177
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
|
+
}
|
|
178
340
|
try {
|
|
179
341
|
const stream = new __durable_streams_client.DurableStream({
|
|
180
342
|
url,
|
|
181
343
|
headers,
|
|
182
344
|
contentType
|
|
183
345
|
});
|
|
184
|
-
if (
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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`);
|
|
191
359
|
} else {
|
|
192
|
-
|
|
193
|
-
|
|
360
|
+
node_process.stderr.write(`Failed to parse JSON from stdin\n`);
|
|
361
|
+
node_process.stderr.write(` ${parseMessage}\n`);
|
|
194
362
|
}
|
|
195
|
-
|
|
196
|
-
await stream.append(processedContent);
|
|
197
|
-
console.log(`Wrote ${processedContent.length} bytes to ${streamId}`);
|
|
363
|
+
process.exit(1);
|
|
198
364
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
chunks.push(chunk);
|
|
203
|
-
});
|
|
204
|
-
await new Promise((resolve, reject) => {
|
|
205
|
-
node_process.stdin.on(`end`, resolve);
|
|
206
|
-
node_process.stdin.on(`error`, reject);
|
|
207
|
-
});
|
|
208
|
-
const data = Buffer.concat(chunks);
|
|
209
|
-
if (isJson) {
|
|
210
|
-
const parsed = JSON.parse(data.toString(`utf8`));
|
|
211
|
-
if (batchJson) {
|
|
212
|
-
const count = await appendJson(stream, parsed);
|
|
213
|
-
console.log(`Wrote ${count} message(s) to ${streamId}`);
|
|
214
|
-
} else {
|
|
215
|
-
await stream.append(parsed);
|
|
216
|
-
console.log(`Wrote 1 message to ${streamId}`);
|
|
217
|
-
}
|
|
365
|
+
if (batchJson) {
|
|
366
|
+
const count = await appendJsonBatch(stream, parsed);
|
|
367
|
+
console.log(`Wrote ${count} message${count !== 1 ? `s` : ``} to stream "${streamId}"`);
|
|
218
368
|
} else {
|
|
219
|
-
await stream.append(
|
|
220
|
-
console.log(`Wrote
|
|
369
|
+
await stream.append(JSON.stringify(parsed));
|
|
370
|
+
console.log(`Wrote 1 JSON message to stream "${streamId}"`);
|
|
221
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}"`);
|
|
222
376
|
}
|
|
223
377
|
} catch (error) {
|
|
224
|
-
|
|
225
|
-
node_process.stderr.write(`
|
|
378
|
+
node_process.stderr.write(`Failed to write to stream "${streamId}"\n`);
|
|
379
|
+
node_process.stderr.write(` ${formatErrorMessage(getErrorMessage(error))}\n`);
|
|
226
380
|
process.exit(1);
|
|
227
381
|
}
|
|
228
382
|
}
|
|
229
|
-
|
|
230
|
-
|
|
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}`;
|
|
231
391
|
try {
|
|
232
392
|
const stream = new __durable_streams_client.DurableStream({
|
|
233
393
|
url,
|
|
234
394
|
headers
|
|
235
395
|
});
|
|
236
|
-
const res = await stream.stream({ live:
|
|
396
|
+
const res = await stream.stream({ live: true });
|
|
237
397
|
for await (const chunk of res.bodyStream()) if (chunk.length > 0) node_process.stdout.write(chunk);
|
|
238
398
|
} catch (error) {
|
|
239
|
-
|
|
240
|
-
node_process.stderr.write(`
|
|
399
|
+
node_process.stderr.write(`Failed to read stream "${streamId}"\n`);
|
|
400
|
+
node_process.stderr.write(` ${formatErrorMessage(getErrorMessage(error))}\n`);
|
|
241
401
|
process.exit(1);
|
|
242
402
|
}
|
|
243
403
|
}
|
|
244
|
-
async function deleteStream(streamId, headers) {
|
|
245
|
-
const url = `${
|
|
404
|
+
async function deleteStream(baseUrl, streamId, headers) {
|
|
405
|
+
const url = `${baseUrl}/v1/stream/${streamId}`;
|
|
246
406
|
try {
|
|
247
407
|
const stream = new __durable_streams_client.DurableStream({
|
|
248
408
|
url,
|
|
249
409
|
headers
|
|
250
410
|
});
|
|
251
411
|
await stream.delete();
|
|
252
|
-
console.log(`
|
|
412
|
+
console.log(`Stream deleted successfully: "${streamId}"`);
|
|
253
413
|
} catch (error) {
|
|
254
|
-
|
|
255
|
-
node_process.stderr.write(`
|
|
414
|
+
node_process.stderr.write(`Failed to delete stream "${streamId}"\n`);
|
|
415
|
+
node_process.stderr.write(` ${formatErrorMessage(getErrorMessage(error))}\n`);
|
|
256
416
|
process.exit(1);
|
|
257
417
|
}
|
|
258
418
|
}
|
|
259
419
|
async function main() {
|
|
420
|
+
const rawArgs = process.argv.slice(2);
|
|
421
|
+
if (rawArgs.includes(`--help`) || rawArgs.includes(`-h`)) {
|
|
422
|
+
printUsage({ to: `stdout` });
|
|
423
|
+
process.exit(0);
|
|
424
|
+
}
|
|
260
425
|
let options;
|
|
261
426
|
let args;
|
|
427
|
+
let warnings;
|
|
262
428
|
try {
|
|
263
|
-
const parsed = parseGlobalOptions(
|
|
429
|
+
const parsed = parseGlobalOptions(rawArgs);
|
|
264
430
|
options = parsed.options;
|
|
265
431
|
args = parsed.remainingArgs;
|
|
432
|
+
warnings = parsed.warnings;
|
|
266
433
|
} catch (error) {
|
|
267
|
-
|
|
268
|
-
node_process.stderr.write(`Error: ${message}\n`);
|
|
434
|
+
node_process.stderr.write(`Error: ${getErrorMessage(error)}\n`);
|
|
269
435
|
process.exit(1);
|
|
270
436
|
}
|
|
437
|
+
for (const warning of warnings) node_process.stderr.write(`${warning}\n`);
|
|
271
438
|
const headers = buildHeaders(options);
|
|
272
439
|
if (args.length < 1) {
|
|
440
|
+
node_process.stderr.write(`Error: No command specified\n`);
|
|
273
441
|
printUsage();
|
|
274
442
|
process.exit(1);
|
|
275
443
|
}
|
|
276
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
|
+
}
|
|
277
459
|
switch (command) {
|
|
278
460
|
case `create`: {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
printUsage();
|
|
282
|
-
process.exit(1);
|
|
283
|
-
}
|
|
284
|
-
await createStream(args[1], headers);
|
|
461
|
+
const streamId = getStreamId();
|
|
462
|
+
await createStream(options.url, streamId, headers);
|
|
285
463
|
break;
|
|
286
464
|
}
|
|
287
465
|
case `write`: {
|
|
288
|
-
|
|
289
|
-
node_process.stderr.write(`Error: stream_id required\n`);
|
|
290
|
-
printUsage();
|
|
291
|
-
process.exit(1);
|
|
292
|
-
}
|
|
293
|
-
const streamId = args[1];
|
|
466
|
+
const streamId = getStreamId();
|
|
294
467
|
let parsed;
|
|
295
468
|
try {
|
|
296
469
|
parsed = parseWriteArgs(args.slice(2));
|
|
297
470
|
} catch (error) {
|
|
298
|
-
|
|
299
|
-
node_process.stderr.write(`Error: ${message}\n`);
|
|
471
|
+
node_process.stderr.write(`Error: ${getErrorMessage(error)}\n`);
|
|
300
472
|
process.exit(1);
|
|
301
473
|
}
|
|
302
|
-
|
|
303
|
-
|
|
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);
|
|
304
476
|
else {
|
|
305
|
-
node_process.stderr.write(`Error: content
|
|
306
|
-
|
|
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`);
|
|
307
481
|
process.exit(1);
|
|
308
482
|
}
|
|
309
483
|
break;
|
|
310
484
|
}
|
|
311
485
|
case `read`: {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
printUsage();
|
|
315
|
-
process.exit(1);
|
|
316
|
-
}
|
|
317
|
-
await readStream(args[1], headers);
|
|
486
|
+
const streamId = getStreamId();
|
|
487
|
+
await readStream(options.url, streamId, headers);
|
|
318
488
|
break;
|
|
319
489
|
}
|
|
320
490
|
case `delete`: {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
printUsage();
|
|
324
|
-
process.exit(1);
|
|
325
|
-
}
|
|
326
|
-
await deleteStream(args[1], headers);
|
|
491
|
+
const streamId = getStreamId();
|
|
492
|
+
await deleteStream(options.url, streamId, headers);
|
|
327
493
|
break;
|
|
328
494
|
}
|
|
329
495
|
default:
|
|
330
|
-
node_process.stderr.write(`Error:
|
|
331
|
-
|
|
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`);
|
|
332
502
|
process.exit(1);
|
|
333
503
|
}
|
|
334
504
|
}
|
|
@@ -339,14 +509,18 @@ function isMainModule() {
|
|
|
339
509
|
return scriptPath === modulePath;
|
|
340
510
|
}
|
|
341
511
|
if (isMainModule()) main().catch((error) => {
|
|
342
|
-
|
|
343
|
-
node_process.stderr.write(`Fatal error: ${message}\n`);
|
|
512
|
+
node_process.stderr.write(`Fatal error: ${getErrorMessage(error)}\n`);
|
|
344
513
|
process.exit(1);
|
|
345
514
|
});
|
|
346
515
|
|
|
347
516
|
//#endregion
|
|
348
517
|
exports.buildHeaders = buildHeaders
|
|
349
518
|
exports.flattenJsonForAppend = flattenJsonForAppend
|
|
519
|
+
exports.getUsageText = getUsageText
|
|
350
520
|
exports.isJsonContentType = isJsonContentType
|
|
521
|
+
exports.normalizeBaseUrl = normalizeBaseUrl
|
|
351
522
|
exports.parseGlobalOptions = parseGlobalOptions
|
|
352
|
-
exports.parseWriteArgs = parseWriteArgs
|
|
523
|
+
exports.parseWriteArgs = parseWriteArgs
|
|
524
|
+
exports.validateAuth = validateAuth
|
|
525
|
+
exports.validateStreamId = validateStreamId
|
|
526
|
+
exports.validateUrl = validateUrl
|
package/dist/index.d.cts
CHANGED
|
@@ -29,21 +29,57 @@ interface ParsedWriteArgs {
|
|
|
29
29
|
*/
|
|
30
30
|
declare function parseWriteArgs(args: Array<string>): ParsedWriteArgs;
|
|
31
31
|
|
|
32
|
+
//#endregion
|
|
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
|
+
|
|
32
65
|
//#endregion
|
|
33
66
|
//#region src/index.d.ts
|
|
34
67
|
interface GlobalOptions {
|
|
68
|
+
url?: string;
|
|
35
69
|
auth?: string;
|
|
36
70
|
}
|
|
37
71
|
/**
|
|
38
|
-
* Parse global options (
|
|
39
|
-
* Falls back to STREAM_AUTH env
|
|
40
|
-
* Returns the parsed options and
|
|
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.
|
|
41
75
|
*/
|
|
42
76
|
declare function parseGlobalOptions(args: Array<string>): {
|
|
43
77
|
options: GlobalOptions;
|
|
44
78
|
remainingArgs: Array<string>;
|
|
79
|
+
warnings: Array<string>;
|
|
45
80
|
};
|
|
46
81
|
declare function buildHeaders(options: GlobalOptions): Record<string, string>;
|
|
82
|
+
declare function getUsageText(): string;
|
|
47
83
|
|
|
48
84
|
//#endregion
|
|
49
|
-
export { GlobalOptions, ParsedWriteArgs, buildHeaders, flattenJsonForAppend, isJsonContentType, parseGlobalOptions, parseWriteArgs };
|
|
85
|
+
export { GlobalOptions, ParsedWriteArgs, buildHeaders, flattenJsonForAppend, getUsageText, isJsonContentType, normalizeBaseUrl, parseGlobalOptions, parseWriteArgs, validateAuth, validateStreamId, validateUrl };
|
package/dist/index.d.ts
CHANGED
|
@@ -29,21 +29,57 @@ interface ParsedWriteArgs {
|
|
|
29
29
|
*/
|
|
30
30
|
declare function parseWriteArgs(args: Array<string>): ParsedWriteArgs;
|
|
31
31
|
|
|
32
|
+
//#endregion
|
|
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
|
+
|
|
32
65
|
//#endregion
|
|
33
66
|
//#region src/index.d.ts
|
|
34
67
|
interface GlobalOptions {
|
|
68
|
+
url?: string;
|
|
35
69
|
auth?: string;
|
|
36
70
|
}
|
|
37
71
|
/**
|
|
38
|
-
* Parse global options (
|
|
39
|
-
* Falls back to STREAM_AUTH env
|
|
40
|
-
* Returns the parsed options and
|
|
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.
|
|
41
75
|
*/
|
|
42
76
|
declare function parseGlobalOptions(args: Array<string>): {
|
|
43
77
|
options: GlobalOptions;
|
|
44
78
|
remainingArgs: Array<string>;
|
|
79
|
+
warnings: Array<string>;
|
|
45
80
|
};
|
|
46
81
|
declare function buildHeaders(options: GlobalOptions): Record<string, string>;
|
|
82
|
+
declare function getUsageText(): string;
|
|
47
83
|
|
|
48
84
|
//#endregion
|
|
49
|
-
export { GlobalOptions, ParsedWriteArgs, buildHeaders, flattenJsonForAppend, isJsonContentType, parseGlobalOptions, parseWriteArgs };
|
|
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,43 +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
155
|
const STREAM_AUTH = process.env.STREAM_AUTH;
|
|
72
156
|
/**
|
|
73
|
-
* Parse global options (
|
|
74
|
-
* Falls back to STREAM_AUTH env
|
|
75
|
-
* Returns the parsed options and
|
|
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.
|
|
76
160
|
*/
|
|
77
161
|
function parseGlobalOptions(args) {
|
|
78
162
|
const options = {};
|
|
79
163
|
const remainingArgs = [];
|
|
164
|
+
const warnings = [];
|
|
80
165
|
for (let i = 0; i < args.length; i++) {
|
|
81
166
|
const arg = args[i];
|
|
82
|
-
if (arg === `--
|
|
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`) {
|
|
83
175
|
const value = args[i + 1];
|
|
84
|
-
if (!value || value.startsWith(`--`)) throw new Error(`--auth requires a value
|
|
85
|
-
|
|
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);
|
|
86
180
|
options.auth = value;
|
|
87
181
|
i++;
|
|
88
182
|
} else remainingArgs.push(arg);
|
|
89
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
|
+
}
|
|
90
189
|
if (!options.auth && STREAM_AUTH) {
|
|
91
|
-
|
|
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);
|
|
92
193
|
options.auth = STREAM_AUTH;
|
|
93
194
|
}
|
|
94
195
|
return {
|
|
95
196
|
options,
|
|
96
|
-
remainingArgs
|
|
197
|
+
remainingArgs,
|
|
198
|
+
warnings
|
|
97
199
|
};
|
|
98
200
|
}
|
|
201
|
+
function getErrorMessage(error) {
|
|
202
|
+
return error instanceof Error ? error.message : String(error);
|
|
203
|
+
}
|
|
99
204
|
function buildHeaders(options) {
|
|
100
|
-
|
|
101
|
-
return {};
|
|
205
|
+
return options.auth ? { Authorization: options.auth } : {};
|
|
102
206
|
}
|
|
103
|
-
function
|
|
104
|
-
|
|
207
|
+
function getUsageText() {
|
|
208
|
+
return `
|
|
105
209
|
Usage:
|
|
106
210
|
durable-stream create <stream_id> Create a new stream
|
|
107
211
|
durable-stream write <stream_id> <content> Write content to a stream
|
|
@@ -110,7 +214,9 @@ Usage:
|
|
|
110
214
|
durable-stream delete <stream_id> Delete a stream
|
|
111
215
|
|
|
112
216
|
Global Options:
|
|
217
|
+
--url <url> Stream server URL (overrides STREAM_URL env var)
|
|
113
218
|
--auth <value> Authorization header value (e.g., "Bearer my-token")
|
|
219
|
+
--help, -h Show this help message
|
|
114
220
|
|
|
115
221
|
Write Options:
|
|
116
222
|
--content-type <type> Content-Type for the message (default: application/octet-stream)
|
|
@@ -120,191 +226,255 @@ Write Options:
|
|
|
120
226
|
Environment Variables:
|
|
121
227
|
STREAM_URL Base URL of the stream server (default: http://localhost:4437)
|
|
122
228
|
STREAM_AUTH Authorization header value (overridden by --auth flag)
|
|
123
|
-
|
|
229
|
+
`;
|
|
124
230
|
}
|
|
125
|
-
|
|
126
|
-
const
|
|
231
|
+
function printUsage({ to = `stderr` } = {}) {
|
|
232
|
+
const out = to === `stderr` ? stderr : stdout;
|
|
233
|
+
out.write(getUsageText());
|
|
234
|
+
}
|
|
235
|
+
async function createStream(baseUrl, streamId, headers) {
|
|
236
|
+
const url = `${baseUrl}/v1/stream/${streamId}`;
|
|
127
237
|
try {
|
|
128
238
|
await DurableStream.create({
|
|
129
239
|
url,
|
|
130
240
|
headers,
|
|
131
241
|
contentType: `application/octet-stream`
|
|
132
242
|
});
|
|
133
|
-
console.log(`
|
|
243
|
+
console.log(`Stream created successfully: "${streamId}"`);
|
|
244
|
+
console.log(` URL: ${url}`);
|
|
134
245
|
} catch (error) {
|
|
135
|
-
|
|
136
|
-
stderr.write(`
|
|
246
|
+
stderr.write(`Failed to create stream "${streamId}"\n`);
|
|
247
|
+
stderr.write(` ${formatErrorMessage(getErrorMessage(error))}\n`);
|
|
137
248
|
process.exit(1);
|
|
138
249
|
}
|
|
139
250
|
}
|
|
140
251
|
/**
|
|
141
|
-
*
|
|
252
|
+
* Format error messages from the server/client for better readability.
|
|
142
253
|
*/
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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})`);
|
|
148
261
|
}
|
|
149
|
-
return
|
|
262
|
+
return message;
|
|
263
|
+
}
|
|
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);
|
|
150
293
|
}
|
|
151
|
-
|
|
152
|
-
|
|
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}`;
|
|
153
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
|
+
}
|
|
154
316
|
try {
|
|
155
317
|
const stream = new DurableStream({
|
|
156
318
|
url,
|
|
157
319
|
headers,
|
|
158
320
|
contentType
|
|
159
321
|
});
|
|
160
|
-
if (
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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`);
|
|
167
335
|
} else {
|
|
168
|
-
|
|
169
|
-
|
|
336
|
+
stderr.write(`Failed to parse JSON from stdin\n`);
|
|
337
|
+
stderr.write(` ${parseMessage}\n`);
|
|
170
338
|
}
|
|
171
|
-
|
|
172
|
-
await stream.append(processedContent);
|
|
173
|
-
console.log(`Wrote ${processedContent.length} bytes to ${streamId}`);
|
|
339
|
+
process.exit(1);
|
|
174
340
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
chunks.push(chunk);
|
|
179
|
-
});
|
|
180
|
-
await new Promise((resolve$1, reject) => {
|
|
181
|
-
stdin.on(`end`, resolve$1);
|
|
182
|
-
stdin.on(`error`, reject);
|
|
183
|
-
});
|
|
184
|
-
const data = Buffer.concat(chunks);
|
|
185
|
-
if (isJson) {
|
|
186
|
-
const parsed = JSON.parse(data.toString(`utf8`));
|
|
187
|
-
if (batchJson) {
|
|
188
|
-
const count = await appendJson(stream, parsed);
|
|
189
|
-
console.log(`Wrote ${count} message(s) to ${streamId}`);
|
|
190
|
-
} else {
|
|
191
|
-
await stream.append(parsed);
|
|
192
|
-
console.log(`Wrote 1 message to ${streamId}`);
|
|
193
|
-
}
|
|
341
|
+
if (batchJson) {
|
|
342
|
+
const count = await appendJsonBatch(stream, parsed);
|
|
343
|
+
console.log(`Wrote ${count} message${count !== 1 ? `s` : ``} to stream "${streamId}"`);
|
|
194
344
|
} else {
|
|
195
|
-
await stream.append(
|
|
196
|
-
console.log(`Wrote
|
|
345
|
+
await stream.append(JSON.stringify(parsed));
|
|
346
|
+
console.log(`Wrote 1 JSON message to stream "${streamId}"`);
|
|
197
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}"`);
|
|
198
352
|
}
|
|
199
353
|
} catch (error) {
|
|
200
|
-
|
|
201
|
-
stderr.write(`
|
|
354
|
+
stderr.write(`Failed to write to stream "${streamId}"\n`);
|
|
355
|
+
stderr.write(` ${formatErrorMessage(getErrorMessage(error))}\n`);
|
|
202
356
|
process.exit(1);
|
|
203
357
|
}
|
|
204
358
|
}
|
|
205
|
-
|
|
206
|
-
|
|
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}`;
|
|
207
367
|
try {
|
|
208
368
|
const stream = new DurableStream({
|
|
209
369
|
url,
|
|
210
370
|
headers
|
|
211
371
|
});
|
|
212
|
-
const res = await stream.stream({ live:
|
|
372
|
+
const res = await stream.stream({ live: true });
|
|
213
373
|
for await (const chunk of res.bodyStream()) if (chunk.length > 0) stdout.write(chunk);
|
|
214
374
|
} catch (error) {
|
|
215
|
-
|
|
216
|
-
stderr.write(`
|
|
375
|
+
stderr.write(`Failed to read stream "${streamId}"\n`);
|
|
376
|
+
stderr.write(` ${formatErrorMessage(getErrorMessage(error))}\n`);
|
|
217
377
|
process.exit(1);
|
|
218
378
|
}
|
|
219
379
|
}
|
|
220
|
-
async function deleteStream(streamId, headers) {
|
|
221
|
-
const url = `${
|
|
380
|
+
async function deleteStream(baseUrl, streamId, headers) {
|
|
381
|
+
const url = `${baseUrl}/v1/stream/${streamId}`;
|
|
222
382
|
try {
|
|
223
383
|
const stream = new DurableStream({
|
|
224
384
|
url,
|
|
225
385
|
headers
|
|
226
386
|
});
|
|
227
387
|
await stream.delete();
|
|
228
|
-
console.log(`
|
|
388
|
+
console.log(`Stream deleted successfully: "${streamId}"`);
|
|
229
389
|
} catch (error) {
|
|
230
|
-
|
|
231
|
-
stderr.write(`
|
|
390
|
+
stderr.write(`Failed to delete stream "${streamId}"\n`);
|
|
391
|
+
stderr.write(` ${formatErrorMessage(getErrorMessage(error))}\n`);
|
|
232
392
|
process.exit(1);
|
|
233
393
|
}
|
|
234
394
|
}
|
|
235
395
|
async function main() {
|
|
396
|
+
const rawArgs = process.argv.slice(2);
|
|
397
|
+
if (rawArgs.includes(`--help`) || rawArgs.includes(`-h`)) {
|
|
398
|
+
printUsage({ to: `stdout` });
|
|
399
|
+
process.exit(0);
|
|
400
|
+
}
|
|
236
401
|
let options;
|
|
237
402
|
let args;
|
|
403
|
+
let warnings;
|
|
238
404
|
try {
|
|
239
|
-
const parsed = parseGlobalOptions(
|
|
405
|
+
const parsed = parseGlobalOptions(rawArgs);
|
|
240
406
|
options = parsed.options;
|
|
241
407
|
args = parsed.remainingArgs;
|
|
408
|
+
warnings = parsed.warnings;
|
|
242
409
|
} catch (error) {
|
|
243
|
-
|
|
244
|
-
stderr.write(`Error: ${message}\n`);
|
|
410
|
+
stderr.write(`Error: ${getErrorMessage(error)}\n`);
|
|
245
411
|
process.exit(1);
|
|
246
412
|
}
|
|
413
|
+
for (const warning of warnings) stderr.write(`${warning}\n`);
|
|
247
414
|
const headers = buildHeaders(options);
|
|
248
415
|
if (args.length < 1) {
|
|
416
|
+
stderr.write(`Error: No command specified\n`);
|
|
249
417
|
printUsage();
|
|
250
418
|
process.exit(1);
|
|
251
419
|
}
|
|
252
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
|
+
}
|
|
253
435
|
switch (command) {
|
|
254
436
|
case `create`: {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
printUsage();
|
|
258
|
-
process.exit(1);
|
|
259
|
-
}
|
|
260
|
-
await createStream(args[1], headers);
|
|
437
|
+
const streamId = getStreamId();
|
|
438
|
+
await createStream(options.url, streamId, headers);
|
|
261
439
|
break;
|
|
262
440
|
}
|
|
263
441
|
case `write`: {
|
|
264
|
-
|
|
265
|
-
stderr.write(`Error: stream_id required\n`);
|
|
266
|
-
printUsage();
|
|
267
|
-
process.exit(1);
|
|
268
|
-
}
|
|
269
|
-
const streamId = args[1];
|
|
442
|
+
const streamId = getStreamId();
|
|
270
443
|
let parsed;
|
|
271
444
|
try {
|
|
272
445
|
parsed = parseWriteArgs(args.slice(2));
|
|
273
446
|
} catch (error) {
|
|
274
|
-
|
|
275
|
-
stderr.write(`Error: ${message}\n`);
|
|
447
|
+
stderr.write(`Error: ${getErrorMessage(error)}\n`);
|
|
276
448
|
process.exit(1);
|
|
277
449
|
}
|
|
278
|
-
|
|
279
|
-
|
|
450
|
+
const hasContent = parsed.content || !stdin.isTTY;
|
|
451
|
+
if (hasContent) await writeStream(options.url, streamId, parsed.contentType, parsed.batchJson, headers, parsed.content || void 0);
|
|
280
452
|
else {
|
|
281
|
-
stderr.write(`Error: content
|
|
282
|
-
|
|
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`);
|
|
283
457
|
process.exit(1);
|
|
284
458
|
}
|
|
285
459
|
break;
|
|
286
460
|
}
|
|
287
461
|
case `read`: {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
printUsage();
|
|
291
|
-
process.exit(1);
|
|
292
|
-
}
|
|
293
|
-
await readStream(args[1], headers);
|
|
462
|
+
const streamId = getStreamId();
|
|
463
|
+
await readStream(options.url, streamId, headers);
|
|
294
464
|
break;
|
|
295
465
|
}
|
|
296
466
|
case `delete`: {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
printUsage();
|
|
300
|
-
process.exit(1);
|
|
301
|
-
}
|
|
302
|
-
await deleteStream(args[1], headers);
|
|
467
|
+
const streamId = getStreamId();
|
|
468
|
+
await deleteStream(options.url, streamId, headers);
|
|
303
469
|
break;
|
|
304
470
|
}
|
|
305
471
|
default:
|
|
306
|
-
stderr.write(`Error:
|
|
307
|
-
|
|
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`);
|
|
308
478
|
process.exit(1);
|
|
309
479
|
}
|
|
310
480
|
}
|
|
@@ -315,10 +485,9 @@ function isMainModule() {
|
|
|
315
485
|
return scriptPath === modulePath;
|
|
316
486
|
}
|
|
317
487
|
if (isMainModule()) main().catch((error) => {
|
|
318
|
-
|
|
319
|
-
stderr.write(`Fatal error: ${message}\n`);
|
|
488
|
+
stderr.write(`Fatal error: ${getErrorMessage(error)}\n`);
|
|
320
489
|
process.exit(1);
|
|
321
490
|
});
|
|
322
491
|
|
|
323
492
|
//#endregion
|
|
324
|
-
export { buildHeaders, flattenJsonForAppend, isJsonContentType, parseGlobalOptions, 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"
|