Skip to content
Open
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
10 changes: 10 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,14 @@ module.exports = {
'no-use-before-define': ['off'],
'max-classes-per-file': ['off'],
},
overrides: [
{
// A test may assert through a local `expectSomething` helper rather than
// calling `expect` directly; count those as assertions.
files: ['test/**/*.js'],
rules: {
'jest/expect-expect': ['warn', { assertFunctionNames: ['expect', 'expect*'] }],
},
},
],
};
15 changes: 14 additions & 1 deletion errors/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ You can add an optional Markdown body beneath the frontmatter for detail-page co

See [`guidelines.md`](./guidelines.md) for the full rules on title, summary, body, tone, and terminology, and run `npm run validate:errors` to check your entry (CI runs both, and fails if `errors.json` is out of date).

## Generating SDK constants

The registry also drives the error-code constants used by the JavaScript SDKs, so that every SDK refers to a code by the same name and adding a code is a single step: register it here, then use it wherever you're working. `identifier` is the canonical basis for each generated name — `room_is_in_an_invalid_state` becomes `RoomIsInAnInvalidState` — which is why it's a frozen contract rather than something to churn.

```sh
npm run generate:errorcodes-ts -- --format=type --out path/to/errorcodes.ts # union of numeric literals
npm run generate:errorcodes-ts -- --format=const --out path/to/errorcodes.ts # one export const per code
```

Use `--format=type` where you only want compile-time checking (the type erases, so it costs no bundle size) and `--format=const` where you need the values at runtime. Omit `--out` to write to stdout. The output is deterministic and has no dependencies beyond Node's standard library, so a consuming repository can generate from its vendored submodule without running `npm install` inside it.

Generated output is not committed here. Each consuming repository generates it, commits the result into its own `src/`, and has a CI step that regenerates at the pinned submodule commit and fails on a diff — the same arrangement as [publishing to the docs site](#publishing-to-the-docs-site) below. Because the check runs at the *pinned* commit, an SDK can't merge a reference to a code that hasn't been merged here first.

## Publishing to the docs site

Changes here don't reach [ably.com/docs](https://ably.com/docs/platform/errors/codes) automatically. The docs site vendors this registry as a git submodule and generates its public error pages from it, so once your change is merged to `main` a follow-up PR against [`ably/docs`](https://github.com/ably/docs) is needed to publish it:
Expand All @@ -39,6 +52,6 @@ CI in `ably/docs` (`check-error-docs`) regenerates and diffs, so a PR whose comm
- [`codes/`](./codes) — the registry: one `<CODE>.md` per valid code.
- [`guidelines.md`](./guidelines.md) — how to write entries: rules on title, summary, body, tone, and terminology.
- [`CLAUDE.md`](./CLAUDE.md) — guidance for agents adding, editing, or reviewing entries.
- [`scripts/`](./scripts) — the validator run in CI.
- [`scripts/`](./scripts) — the validator run in CI, and the generators for `protocol/errors.json` and the SDK TypeScript constants.

`protocol/errors.json` is generated from this registry — a machine-readable map of each code to its `identifier`, `title`, and `summary`. It must not be edited by hand; run `npm run generate:errors` to regenerate it, and CI fails if the committed file is out of date.
319 changes: 319 additions & 0 deletions errors/scripts/generate-ts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,319 @@
#!/usr/bin/env node

/*
* Generates TypeScript error-code declarations from the registry in
* `errors/codes/`.
*
* The generator lives here; each SDK runs it against its vendored copy of this
* repository, commits the output into its own `src/`, and its CI regenerates at
* the pinned submodule commit and fails on a diff. Two output shapes:
*
* --format=type a bare `ErrorCode` union of numeric literals, for consumers
* that want compile-time checking at zero runtime cost.
* --format=const one `export const` per code plus an `ErrorCode` union, for
* consumers that need the values at runtime. Individual
* consts rather than an object or a TS `enum` so that unused
* codes tree-shake out of browser bundles.
*
* Output is deterministic — sorted by numeric code, byte-identical for a given
* registry state — because the consumers' drift check diffs it.
*
* No dependencies beyond `fs`, `path`, and the local `frontmatter.js`, so this
* runs from a superproject without an `npm install` inside the submodule.
*/

const fs = require('fs');
const path = require('path');
const { parseFrontmatter } = require('./frontmatter');

const CODES_DIR = path.resolve(__dirname, '..', 'codes');

const REQUIRED = ['code', 'identifier', 'title', 'summary'];
const FORMATS = ['type', 'const'];

const HEADER = [
'// GENERATED FROM ably-common/errors/codes — DO NOT EDIT.',
'// Regenerate with: npm run generate:errorcodes-ts',
];

const USAGE = 'Usage: node errors/scripts/generate-ts.js --format=type|const [--out <path>]';

/** Width available for JSDoc prose, after the leading ` * `. */
const DOC_WIDTH = 76;

/**
* A failure whose message is meant for whoever ran the generator: a bad
* argument, or a registry that can't be turned into valid TypeScript.
*
* `main` prints these as a plain message and exits 1. Anything else keeps its
* stack, because it's a bug in the generator rather than a problem with the
* input.
*/
class GeneratorError extends Error {}

/**
* Convert a registry `identifier` to the PascalCase name used for its constant.
*
* @param {string} identifier - A `snake_case` registry identifier.
* @returns {string} The PascalCase equivalent.
*/
function pascalCase(identifier) {
return identifier
.split('_')
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('');
}

/**
* Read every `<code>.md` in the registry, in ascending numeric code order.
*
* @param {string} [dir] - The directory to read; defaults to `errors/codes`.
* @returns {Array<object>} One `{ code, identifier, title, summary }` per entry.
*/
function loadEntries(dir = CODES_DIR) {
if (!fs.existsSync(dir)) {
throw new GeneratorError(`no error registry at ${dir}: if this is a vendored copy of ably-common, the submodule may be uninitialised or pinned to a commit predating errors/codes/`);
}
return fs.readdirSync(dir)
.filter((f) => f.endsWith('.md'))
.map((f) => f.replace(/\.md$/, ''))
.sort((a, b) => Number(a) - Number(b))
.map((code) => {
let content;
try {
content = fs.readFileSync(path.join(dir, `${code}.md`), 'utf8');
} catch (err) {
throw new GeneratorError(`could not read ${code}.md: ${err.message}`);
}
const parsed = parseFrontmatter(content);
if (parsed.error) {
throw new GeneratorError(`${code}.md: ${parsed.error}`);
}
const { fields } = parsed;
const missing = REQUIRED.filter((k) => !fields[k]);
if (missing.length) {
throw new GeneratorError(`${code}.md: missing frontmatter field(s): ${missing.join(', ')}`);
}
return {
code: Number(fields.code),
identifier: fields.identifier,
title: fields.title,
summary: fields.summary,
};
});
}

/**
* Attach the generated constant name to each entry.
*
* Fails rather than emitting a duplicate or unusable declaration on: a
* duplicate `identifier`, two identifiers colliding on one PascalCase name, or
* a name that isn't a valid JavaScript identifier. None of the three occurs in
* the registry today; the assertions are here to keep it that way.
*
* @param {Array<object>} entries - Entries from `loadEntries`.
* @returns {Array<object>} The same entries, each with a `name` property.
*/
function nameEntries(entries) {
const byIdentifier = new Map();
const byName = new Map();

return entries.map((entry) => {
const { code, identifier } = entry;

if (byIdentifier.has(identifier)) {
throw new GeneratorError(`duplicate identifier "${identifier}": used by both ${byIdentifier.get(identifier)} and ${code}`);
}
byIdentifier.set(identifier, code);

const name = pascalCase(identifier);
if (!/^[A-Z][A-Za-z0-9]*$/.test(name)) {
throw new GeneratorError(`identifier "${identifier}" (${code}) generates "${name}", which is not a valid JavaScript identifier`);
}
if (byName.has(name)) {
const other = byName.get(name);
throw new GeneratorError(`identifier "${identifier}" (${code}) collides with "${other.identifier}" (${other.code}): both generate "${name}"`);
}
byName.set(name, entry);

return { ...entry, name };
});
}

/**
* Hard-wrap prose to the JSDoc content width.
*
* @param {string} text - The text to wrap.
* @returns {Array<string>} One string per output line.
*/
function wrap(text) {
const lines = [];
let line = '';
text.split(/\s+/).filter(Boolean).forEach((word) => {
if (line === '') {
line = word;
} else if (`${line} ${word}`.length <= DOC_WIDTH) {
line += ` ${word}`;
} else {
lines.push(line);
line = word;
}
});
if (line !== '') lines.push(line);
return lines;
}

/**
* Neutralise anything in registry prose that would close a JSDoc comment.
*
* @param {string} text - The text to escape.
* @returns {string} The text, safe to embed in a block comment.
*/
function escapeDoc(text) {
return text.replace(/\*\//g, '*\\/');
}

/**
* Render the JSDoc block documenting one code.
*
* @param {object} entry - A named entry.
* @returns {string} The comment block, without a trailing newline.
*/
function docBlock(entry) {
const title = escapeDoc(entry.title).replace(/\.$/, '');
return [
'/**',
` * ${title}.`,
' *',
...wrap(escapeDoc(entry.summary)).map((l) => ` * ${l}`),
` * @see https://help.ably.io/error/${entry.code}`,
' */',
].join('\n');
}

/**
* Render the `--format=type` output: the union of numeric literals alone.
*
* @param {Array<object>} entries - Named entries in output order.
* @returns {string} The file contents.
*/
function renderType(entries) {
return [
...HEADER,
'',
'/** A registered Ably error code. */',
'export type ErrorCode =',
...entries.map((e, i) => ` | ${e.code}${i === entries.length - 1 ? ';' : ''}`),
'',
].join('\n');
}

/**
* Render the `--format=const` output: one const per code, then the union.
*
* @param {Array<object>} entries - Named entries in output order.
* @returns {string} The file contents.
*/
function renderConst(entries) {
return [
...HEADER,
'',
...entries.flatMap((e) => [docBlock(e), `export const ${e.name} = ${e.code};`, '']),
'/** A registered Ably error code. */',
'export type ErrorCode =',
...entries.map((e, i) => ` | typeof ${e.name}${i === entries.length - 1 ? ';' : ''}`),
'',
].join('\n');
}

/**
* Generate the TypeScript source for a set of registry entries.
*
* @param {string} format - Either `type` or `const`.
* @param {Array<object>} [entries] - Entries to render; defaults to the registry.
* @returns {string} The file contents.
*/
function generate(format, entries = loadEntries()) {
if (!FORMATS.includes(format)) {
throw new GeneratorError(`unknown --format "${format}" (expected ${FORMATS.join(' or ')})`);
}
const named = nameEntries(entries);
if (named.length === 0) {
throw new GeneratorError('the registry is empty: nothing to generate');
}
return format === 'type' ? renderType(named) : renderConst(named);
}

/**
* Parse `--format` and `--out` from the command line.
*
* @param {Array<string>} argv - Arguments after the script name.
* @returns {{ format: string, out: string | null }} The parsed options; `out`
* is null when the output goes to stdout.
*/
function parseArgs(argv) {
let format = null;
let out = null;

for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
const flag = arg.replace(/=.*$/, '');
let value = arg.includes('=') ? arg.slice(arg.indexOf('=') + 1) : null;
if (value === null && (flag === '--format' || flag === '--out')) {
i += 1;
value = i < argv.length ? argv[i] : null;
if (value === null) throw new GeneratorError(`${flag} requires a value\n${USAGE}`);
}
if (flag === '--format') {
format = value;
} else if (flag === '--out') {
out = value;
} else {
throw new GeneratorError(`unexpected argument "${arg}"\n${USAGE}`);
}
}

if (!format) throw new GeneratorError(`--format is required\n${USAGE}`);
return { format, out: out === '-' ? null : out };
}

/**
* Run as a CLI: generate and write to `--out`, or to stdout if it is omitted.
*
* @returns {void}
*/
function main() {
try {
const opts = parseArgs(process.argv.slice(2));
const source = generate(opts.format);
if (opts.out === null) {
process.stdout.write(source);
return;
}
const target = path.resolve(process.cwd(), opts.out);
try {
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, source);
} catch (err) {
throw new GeneratorError(`could not write ${target}: ${err.message}`);
}
const shown = path.relative(process.cwd(), target);
console.error(`Wrote ${shown.startsWith('..') ? target : shown}`);
} catch (err) {
// A bad argument or an unusable registry is the caller's problem, so report
// it as a message. A stack here would only ever be noise. Anything else is
// a bug in the generator, and rethrowing keeps the stack that locates it.
if (!(err instanceof GeneratorError)) throw err;
console.error(err.message);
process.exit(1);
}
}

if (require.main === module) {
main();
}

module.exports = {
pascalCase, loadEntries, nameEntries, generate,
};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"validate:errors": "node errors/scripts/validate-errors.js",
"validate:errors-json": "node scripts/validate-json-schema.js protocol/errors.json",
"generate:errors": "node errors/scripts/generate-errors-json.js && prettier --write protocol/errors.json",
"generate:errorcodes-ts": "node errors/scripts/generate-ts.js",
"validate:json-schema": "node scripts/validate-json-schema.js",
"fetch:agent-releases": "node scripts/fetch-agent-releases.js",
"export:agents": "node scripts/export-agents-csv.js",
Expand Down
Loading
Loading