Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "microsoft-events",
"description": "Connect your project to Microsoft Build and Ignite sessions — discover relevant talks, explore what's new for your stack, and plan next steps from your development environment.",
"version": "1.0.2",
"version": "1.0.3",
"author": {
"name": "Microsoft"
},
Expand Down
2 changes: 1 addition & 1 deletion .github/plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "microsoft-events",
"description": "Connect your project to Microsoft Build and Ignite sessions — discover relevant talks, explore what's new for your stack, and plan next steps from your development environment.",
"version": "1.0.2",
"version": "1.0.3",
"author": {
"name": "Microsoft",
"url": "https://www.microsoft.com"
Expand Down
8 changes: 8 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,14 @@ Use `--event <id>` to filter to a single event. Without it, commands search acro
- **Disambiguation**: if a session code exists in multiple events, the CLI shows options.
- **Results**: 10 by default, `--limit` to override.

## Environment variables

| Variable | Default | Purpose |
|----------|---------|---------|
| `MSEVENTS_CACHE_DIR` | per-OS cache path | Override the local cache directory. |
| `MSEVENTS_FETCH_TIMEOUT_MS` | `30000` | Abort catalog requests after this many milliseconds. |
| `MSEVENTS_MAX_RESPONSE_BYTES` | `52428800` (50 MiB) | Reject catalog responses larger than this. |

## Development

To build and test from source:
Expand Down
4 changes: 2 additions & 2 deletions cli/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@microsoft/events-cli",
"version": "0.2.0",
"version": "0.3.0",
"description": "CLI for searching Microsoft flagship event sessions (Build, Ignite).",
"type": "module",
"bin": {
Expand Down
19 changes: 19 additions & 0 deletions cli/src/commands/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,25 @@ export function validateEventId(eventId: string): boolean {
return false;
}

const MAX_LIMIT = 200;

export function validateLimit(raw: string): number | null {
const trimmed = raw.trim();
if (!/^[1-9]\d*$/.test(trimmed)) {
console.error(`--limit must be a positive integer (got: "${raw}")`);
process.exitCode = 1;
return null;
}

const parsed = Number.parseInt(trimmed, 10);
if (parsed > MAX_LIMIT) {
process.stderr.write(`--limit ${parsed} exceeds maximum (${MAX_LIMIT}); clamping.\n`);
return MAX_LIMIT;
}

return parsed;
}

export async function ensureCache(eventFilter?: string): Promise<Session[]> {
let missingCacheHeaderPrinted = false;
const availableSessions: Session[] = [];
Expand Down
54 changes: 36 additions & 18 deletions cli/src/data/cache.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
import { readFile, writeFile, mkdir, rename, rm, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { existsSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import envPaths from 'env-paths';
import type { Session, CacheMeta, EventConfig, CacheCheckStatus } from '../contracts.js';
import { KNOWN_EVENTS } from '../config.js';
import { FetchError } from '../errors.js';
import { normalizeCatalog } from './normalize.js';
import { safeFetchJson, type SafeFetchResult } from './http.js';

const paths = envPaths('msevents', { suffix: '' });
const MINUTE_MS = 60 * 1000;
Expand Down Expand Up @@ -55,8 +57,8 @@ function formatSessionCount(count: number): string {
return `${count} session${count === 1 ? '' : 's'}`;
}

function formatResponseStatus(response: Response): string {
return [response.status, response.statusText].filter(Boolean).join(' ');
function formatStatusLine(status: number, statusText: string): string {
return [status, statusText].filter(Boolean).join(' ');
}

function intervalForStableCatalog(meta: CacheMeta, now: Date): number {
Expand Down Expand Up @@ -100,9 +102,20 @@ export function isCacheCheckDue(meta: CacheMeta | null, now: Date = new Date()):
return now.getTime() - lastCheck >= ACTIVE_REVALIDATION_INTERVAL_MS;
}

async function writeAtomic(path: string, data: string): Promise<void> {
const tmp = `${path}.tmp.${process.pid}.${randomUUID()}`;
try {
await writeFile(tmp, data);
await rename(tmp, path);
} catch (err) {
await rm(tmp, { force: true }).catch(() => {});
Comment thread
TianqiZhang marked this conversation as resolved.
throw err;
}
}

async function writeMeta(eventId: string, meta: CacheMeta): Promise<void> {
await ensureCacheDir();
await writeFile(metaPath(eventId), JSON.stringify(meta, null, 2));
await writeAtomic(metaPath(eventId), JSON.stringify(meta, null, 2));
}

async function cachedSessionsTimestamp(eventId: string, fallback: Date): Promise<string> {
Expand Down Expand Up @@ -182,23 +195,24 @@ export async function fetchAndCache(
log?.(' Remote check: GET.\n');
}

let response: Response;
let result: SafeFetchResult;
try {
response = await fetch(event.endpoint, { headers });
result = await safeFetchJson(event.endpoint, { headers });
} catch (err) {
await recordFetchFailure(event.id);
if (err instanceof FetchError) throw err;
throw new FetchError(
`Failed to reach ${event.endpoint}: ${err instanceof Error ? err.message : String(err)}`,
);
}

// 304 Not Modified — cache is still fresh
if (response.status === 304) {
if (result.status === 304) {
if (!canRevalidate || existingMeta === null) {
await recordFetchFailure(event.id);
throw new FetchError(
`${event.endpoint} returned 304 without a usable local cache`,
response.status,
result.status,
);
}

Expand All @@ -207,7 +221,7 @@ export async function fetchAndCache(
await recordFetchFailure(event.id);
throw new FetchError(
`${event.endpoint} returned 304 without a usable local cache`,
response.status,
result.status,
);
}

Expand All @@ -226,21 +240,21 @@ export async function fetchAndCache(
return existingSessions;
}

if (!response.ok) {
log?.(` Remote catalog: failed (${formatResponseStatus(response)}).\n`);
if (result.status < 200 || result.status >= 300) {
log?.(` Remote catalog: failed (${formatStatusLine(result.status, result.statusText)}).\n`);
await recordFetchFailure(event.id);
throw new FetchError(
`${event.endpoint} returned ${response.status}`,
response.status,
`${event.endpoint} returned ${result.status}`,
result.status,
);
}

log?.(` Remote catalog: downloaded (${formatResponseStatus(response)}).\n`);
log?.(` Remote catalog: downloaded (${formatStatusLine(result.status, result.statusText)}).\n`);
log?.(' JSON download: yes.\n');

let raw: unknown;
try {
raw = await response.json();
raw = JSON.parse(result.body ?? '');
} catch (err) {
await recordFetchFailure(event.id);
throw new FetchError(
Expand All @@ -254,15 +268,19 @@ export async function fetchAndCache(
}

const sessions = normalizeCatalog(raw, event.id);
if (sessions.length === 0) {
await recordFetchFailure(event.id);
throw new FetchError(`${event.endpoint} returned a catalog with no valid sessions`);
}
const now = new Date();

const metaBase: CacheMeta = {
eventId: event.id,
fetchedAt: now.toISOString(),
checkedAt: now.toISOString(),
sessionCount: sessions.length,
etag: response.headers.get('etag') ?? undefined,
lastModified: response.headers.get('last-modified') ?? undefined,
etag: result.headers.get('etag') ?? undefined,
lastModified: result.headers.get('last-modified') ?? undefined,
lastCheckStatus: 'updated',
consecutiveFailures: 0,
};
Expand All @@ -271,7 +289,7 @@ export async function fetchAndCache(
nextCheckAt: nextCheckAt(metaBase, 'updated', now),
};

await writeFile(sessionsPath(event.id), JSON.stringify(sessions));
await writeAtomic(sessionsPath(event.id), JSON.stringify(sessions));
await writeMeta(event.id, meta);
log?.(` Local cache: ${hasExistingSessions ? 'updated' : 'created'} with ${formatSessionCount(sessions.length)}.\n`);

Expand Down
125 changes: 125 additions & 0 deletions cli/src/data/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { FetchError } from '../errors.js';

export interface SafeFetchOptions {
timeoutMs?: number;
maxBytes?: number;
headers?: Record<string, string>;
}

export interface SafeFetchResult {
status: number;
statusText: string;
headers: Headers;
body: string | null;
finalUrl: string;
}

const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_BYTES = 50 * 1024 * 1024;

function envInt(name: string, fallback: number): number {
const raw = process.env[name];
if (!raw) return fallback;
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}

async function resultWithoutBody(response: Response): Promise<SafeFetchResult> {
await response.body?.cancel();
return {
status: response.status,
statusText: response.statusText,
headers: response.headers,
body: null,
finalUrl: response.url,
};
}

export async function safeFetchJson(
url: string,
options: SafeFetchOptions = {},
): Promise<SafeFetchResult> {
const timeoutMs = options.timeoutMs
?? envInt('MSEVENTS_FETCH_TIMEOUT_MS', DEFAULT_TIMEOUT_MS);
const maxBytes = options.maxBytes
?? envInt('MSEVENTS_MAX_RESPONSE_BYTES', DEFAULT_MAX_BYTES);

let response: Response;
try {
response = await fetch(url, {
headers: options.headers,
redirect: 'follow',
signal: AbortSignal.timeout(timeoutMs),
});
} catch (err) {
const name = err instanceof Error ? err.name : '';
if (name === 'TimeoutError' || name === 'AbortError') {
throw new FetchError(`Request to ${url} timed out after ${timeoutMs}ms`);
}
throw new FetchError(
`Failed to reach ${url}: ${err instanceof Error ? err.message : String(err)}`,
);
}

if (response.status === 304) {
return resultWithoutBody(response);
}

if (!response.ok) {
return resultWithoutBody(response);
}
Comment thread
TianqiZhang marked this conversation as resolved.

const contentLength = response.headers.get('content-length');
if (contentLength) {
const parsedLength = Number.parseInt(contentLength, 10);
if (Number.isFinite(parsedLength) && parsedLength > maxBytes) {
throw new FetchError(
`Response from ${url} declares ${parsedLength} bytes (> ${maxBytes})`,
);
}
}

const contentType = response.headers.get('content-type') ?? '';
if (!contentType.toLowerCase().includes('application/json')) {
throw new FetchError(
`Unexpected Content-Type from ${url}: ${contentType || '<none>'}`,
);
}

if (!response.body) {
return {
status: response.status,
statusText: response.statusText,
headers: response.headers,
body: '',
finalUrl: response.url,
};
}

const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;

try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > maxBytes) {
await reader.cancel();
throw new FetchError(`Response from ${url} exceeded ${maxBytes} bytes`);
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}

return {
status: response.status,
statusText: response.statusText,
headers: response.headers,
body: Buffer.concat(chunks).toString('utf-8'),
finalUrl: response.url,
};
}
6 changes: 4 additions & 2 deletions cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { refresh } from './commands/refresh.js';
import { sessions } from './commands/sessions.js';
import { session } from './commands/session.js';
import { status } from './commands/status.js';
import { validateEventId } from './commands/common.js';
import { validateEventId, validateLimit } from './commands/common.js';
import { KNOWN_EVENTS } from './config.js';

const knownIds = KNOWN_EVENTS.map((e) => e.id).join(', ');
Expand Down Expand Up @@ -86,7 +86,9 @@ Examples:
return;
}
if (opts.event && !validateEventId(opts.event)) return;
await sessions({ ...opts, limit: parseInt(opts.limit, 10) });
const limit = validateLimit(opts.limit);
if (limit === null) return;
await sessions({ ...opts, limit });
});

program
Expand Down
Loading
Loading