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