@scalar/workspace-store 0.57.0 → 0.58.1

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/entities/auth/schema.d.ts +25 -0
  3. package/dist/entities/auth/schema.d.ts.map +1 -1
  4. package/dist/helpers/for-each-path-item-operation.d.ts +10 -0
  5. package/dist/helpers/for-each-path-item-operation.d.ts.map +1 -1
  6. package/dist/helpers/for-each-path-item-operation.js +67 -10
  7. package/dist/helpers/get-resolved-ref.d.ts.map +1 -1
  8. package/dist/helpers/get-resolved-ref.js +9 -0
  9. package/dist/mutators/operation/parameters.d.ts.map +1 -1
  10. package/dist/mutators/operation/parameters.js +6 -1
  11. package/dist/navigation/get-navigation-options.d.ts.map +1 -1
  12. package/dist/navigation/get-navigation-options.js +6 -3
  13. package/dist/navigation/helpers/traverse-tags.d.ts +3 -2
  14. package/dist/navigation/helpers/traverse-tags.d.ts.map +1 -1
  15. package/dist/navigation/helpers/traverse-tags.js +140 -18
  16. package/dist/request-example/builder/body/get-request-body-example.d.ts +12 -0
  17. package/dist/request-example/builder/body/get-request-body-example.d.ts.map +1 -1
  18. package/dist/request-example/builder/body/get-request-body-example.js +25 -12
  19. package/dist/request-example/builder/index.d.ts +1 -1
  20. package/dist/request-example/builder/index.d.ts.map +1 -1
  21. package/dist/request-example/builder/index.js +1 -1
  22. package/dist/request-example/index.d.ts +1 -1
  23. package/dist/request-example/index.d.ts.map +1 -1
  24. package/dist/request-example/index.js +1 -1
  25. package/dist/schemas/navigation.d.ts +16 -2
  26. package/dist/schemas/navigation.d.ts.map +1 -1
  27. package/dist/schemas/navigation.js +1 -0
  28. package/dist/schemas/reference-config/index.d.ts +6 -1
  29. package/dist/schemas/reference-config/index.d.ts.map +1 -1
  30. package/dist/schemas/reference-config/settings.d.ts +5 -0
  31. package/dist/schemas/reference-config/settings.d.ts.map +1 -1
  32. package/dist/schemas/v3.1/openapi/index.d.ts +3 -0
  33. package/dist/schemas/v3.1/openapi/index.d.ts.map +1 -1
  34. package/dist/schemas/v3.1/openapi/index.js +7 -0
  35. package/dist/schemas/v3.1/strict/openapi-document.d.ts +175 -0
  36. package/dist/schemas/v3.1/strict/openapi-document.d.ts.map +1 -1
  37. package/dist/schemas/v3.1/strict/tag.d.ts +12 -0
  38. package/dist/schemas/v3.1/strict/tag.d.ts.map +1 -1
  39. package/dist/schemas/v3.1/strict/tag.js +6 -0
  40. package/dist/schemas/v3.2/strict/openapi-document.d.ts +70 -0
  41. package/dist/schemas/v3.2/strict/openapi-document.d.ts.map +1 -1
  42. package/dist/schemas/workspace-specification/index.d.ts +1 -1
  43. package/dist/schemas/workspace.d.ts +1 -1
  44. package/dist/server.d.ts +3 -3
  45. package/dist/server.d.ts.map +1 -1
  46. package/dist/server.js +177 -16
  47. package/package.json +14 -14
package/dist/server.js CHANGED
@@ -1,20 +1,81 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import { cwd } from 'node:process';
3
+ import { upgrade as upgradeAsyncApi } from '@scalar/asyncapi-upgrader';
3
4
  import { parseJsonPointerSegments } from '@scalar/helpers/json/parse-json-pointer-segments';
4
5
  import { getValueAtPath } from '@scalar/helpers/object/get-value-at-path';
6
+ import { preventPollution } from '@scalar/helpers/object/prevent-pollution';
7
+ import { extensions as bundleExtensions } from '@scalar/json-magic/bundle';
5
8
  import { fetchUrls, readFiles } from '@scalar/json-magic/bundle/plugins/node';
6
9
  import { escapeJsonPointer } from '@scalar/json-magic/helpers/escape-json-pointer';
10
+ import { createMagicProxy, getRaw } from '@scalar/json-magic/magic-proxy';
7
11
  import { upgrade } from '@scalar/openapi-upgrader';
12
+ import { asyncApiObjectSchema } from '@scalar/schemas/asyncapi/3.1';
13
+ import { coerce } from '@scalar/validation';
14
+ import { deepClone } from './helpers/deep-clone.js';
8
15
  import { forEachPathItemOperation, getResolvedPathItem } from './helpers/for-each-path-item-operation.js';
9
16
  import { keyOf } from './helpers/general.js';
10
17
  import { getResolvedRef } from './helpers/get-resolved-ref.js';
11
- import { createNavigation } from './navigation/index.js';
18
+ import { mergeObjects } from './helpers/merge-object.js';
19
+ import { createNavigation, traverseAsyncApiDocument } from './navigation/index.js';
12
20
  import { extensions } from './schemas/extensions.js';
21
+ import { isAsyncApiDocument } from './schemas/type-guards.js';
13
22
  import { coerceValue } from './schemas/typebox-coerce.js';
14
23
  import { OpenAPIDocumentSchema, } from './schemas/v3.1/strict/openapi-document.js';
15
24
  const DEFAULT_ASSETS_FOLDER = 'assets';
16
25
  export const WORKSPACE_FILE_NAME = 'scalar-workspace.json';
17
26
  const httpMethods = new Set(['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']);
27
+ /**
28
+ * Wraps a document so local `$ref`s resolve while the store inspects it.
29
+ *
30
+ * Navigation building and externalization both read through `getResolvedRef`, which needs the
31
+ * `$ref-value` the magic proxy supplies. Without it a `$ref`'d path item reads as a bare `{ $ref }`,
32
+ * so its operations reach neither the sidebar nor the generated chunks and disappear from the
33
+ * rendered document with no error. This is the same treatment the client store gives every document.
34
+ *
35
+ * Resolution is lazy and local, so this stays synchronous and never fetches. The proxy is only ever
36
+ * read through: everything the store keeps is unwrapped with `getRaw` first, because `$ref-value` is
37
+ * enumerable on a proxy and serializing one would inline every referenced value beside its `$ref`.
38
+ */
39
+ const resolveLocalReferences = (document) => createMagicProxy(document);
40
+ /**
41
+ * The keys `@scalar/json-magic` bundling parks external documents under.
42
+ *
43
+ * Sourced straight from the bundler's own `extensions` defaults so the two cannot drift: if bundling
44
+ * ever renames a bucket, this follows without a silent break.
45
+ *
46
+ * Both are needed. `x-ext` holds the bundled documents that rewritten references resolve against,
47
+ * and `x-ext-urls` maps each bucket key back to the URL it came from — `restoreOriginalRefs` reads it
48
+ * to turn the local pointers back into the references the author wrote.
49
+ *
50
+ * Note that both are served: they stay on the stored document and ship in the workspace payload,
51
+ * because the generated chunks keep their `#/x-ext/…` pointers and the client resolves those against
52
+ * the document root. Only the client's export path (`purgeInternalDocumentKeys`) strips them.
53
+ */
54
+ const BUNDLED_EXTERNAL_KEYS = [bundleExtensions.externalDocuments, bundleExtensions.externalDocumentsMappings];
55
+ /**
56
+ * Copies the buckets bundling parks external documents in onto the coerced document.
57
+ *
58
+ * Bundling does not inline an external reference — it moves the target under `x-ext` and rewrites the
59
+ * `$ref` to a local pointer into that bucket. The OpenAPI schema does not model those keys, so
60
+ * coercion drops them and leaves every rewritten reference dangling, which is how a split-file
61
+ * document loses the operations it keeps in its other files.
62
+ *
63
+ * Copied by reference, deliberately. `upgrade` hands back the very object it was given when the
64
+ * document is already 3.1, so the served workspace ends up sharing these buckets with the caller's
65
+ * document — but that is what the store already does with every other field coercion passes through
66
+ * untouched (`info` among them), so cloning only these two would buy consistency nowhere. It would
67
+ * also be the worst place to pay for it: `x-ext` holds every external document that was bundled in,
68
+ * so cloning it roughly doubles peak memory at ingest, and `deepClone` recurses per level and throws
69
+ * on input nested a few thousand deep. Cloning the caller's document as a whole is the fix, and it
70
+ * belongs with the aliasing the store already has rather than here.
71
+ */
72
+ const preserveBundledExternals = (source, target) => {
73
+ for (const key of BUNDLED_EXTERNAL_KEYS) {
74
+ if (source[key] !== undefined) {
75
+ target[key] = source[key];
76
+ }
77
+ }
78
+ };
18
79
  /**
19
80
  * Filters an OpenAPI PathsObject to only include standard HTTP methods.
20
81
  * Removes any vendor extensions or other non-HTTP properties.
@@ -44,7 +105,12 @@ export function filterHttpMethodsOnly(paths) {
44
105
  const filteredMethods = {};
45
106
  forEachPathItemOperation(pathItemRef, (method, operation) => {
46
107
  if (httpMethods.has(method.toLowerCase())) {
47
- filteredMethods[method] = getResolvedRef(operation) ?? operation;
108
+ // Unwrapped because the caller hands us a resolved document. A magic proxy enumerates a
109
+ // virtual `$ref-value`, so storing one would inline every referenced component beside its
110
+ // `$ref` when the chunk is written — and a self-referential schema would never finish
111
+ // serializing. Unwrapping one level is enough: the proxy wraps lazily, so a raw target's
112
+ // children are already raw.
113
+ filteredMethods[method] = getRaw(getResolvedRef(operation) ?? operation);
48
114
  }
49
115
  });
50
116
  if (Object.keys(filteredMethods).length > 0) {
@@ -116,11 +182,14 @@ export function externalizePathReferences(document, meta) {
116
182
  : `./chunks/${meta.name}/operations/${escapedPath}/${type}.json#`;
117
183
  result[path][type] = { '$ref': ref, $global: true };
118
184
  }
119
- else if (type !== '$ref') {
120
- // Skip the path-item `$ref` merged in by getResolvedPathItem: the referenced component is
121
- // externalized on its own and the operations are externalized above, so keeping it would
122
- // emit a hybrid entry with both a component `$ref` and inlined operation references.
123
- result[path][type] = pathItemRecord[type];
185
+ else if (type !== '$ref' && type !== '$ref-value') {
186
+ // Skip the reference plumbing merged in by getResolvedPathItem. The referenced path item is
187
+ // externalized on its own and its operations are externalized above, so keeping the `$ref`
188
+ // would emit a hybrid entry carrying both a component reference and inlined operation
189
+ // references. `$ref-value` is meant to be virtual and never belongs in a stored document.
190
+ //
191
+ // Unwrapped for the same reason as in filterHttpMethodsOnly: what is kept here is stored.
192
+ result[path][type] = getRaw(pathItemRecord[type]);
124
193
  }
125
194
  });
126
195
  });
@@ -184,6 +253,55 @@ export async function createServerWorkspaceStore(workspaceProps) {
184
253
  * for that document.
185
254
  */
186
255
  const assets = {};
256
+ /**
257
+ * Adds an AsyncAPI document to the workspace.
258
+ *
259
+ * AsyncAPI keeps its content under `channels` and `operations` instead of `paths`, so none of the
260
+ * OpenAPI externalization applies: there are no path operations to split into chunks, and the
261
+ * consumers read channels and operations straight off the stored document. The document is
262
+ * therefore kept whole, and only the AsyncAPI upgrader runs so 1.x/2.x documents reach the 3.x
263
+ * shape the traversal and renderer expect.
264
+ *
265
+ * @param document - The AsyncAPI document to process and add
266
+ * @param meta - The document name plus any metadata to merge onto the stored document
267
+ */
268
+ const addAsyncApiDocumentSync = (document, { name, documentMeta }, navigationOptions) => {
269
+ // Capture the original version before the upgrader bumps `asyncapi` to the latest.
270
+ const originalAasVersion = document.asyncapi;
271
+ // Clone first: the upgrader and the traversal both write to the document they are handed, and
272
+ // the caller may keep using the object it passed in.
273
+ // The upgrader is typed against the loose `UnknownObject` shape; the result is a valid 3.x
274
+ // AsyncAPI document, so cast it back.
275
+ const asyncApiDocument = upgradeAsyncApi(deepClone(document));
276
+ // Coerced against the AsyncAPI schema for the same reason the OpenAPI path coerces against its
277
+ // own: the traversal and every consumer downstream expect a normalized document. Skipping it
278
+ // leaves `info` missing on a partial document (which the traversal reads unguarded) and passes
279
+ // shapes like `channels: null` straight through to the browser. Merged rather than assigned, so
280
+ // nothing the schema does not model is dropped.
281
+ mergeObjects(asyncApiDocument, coerce(asyncApiObjectSchema, deepClone(asyncApiDocument)));
282
+ // Nothing is externalized, so the document owns no chunks. The empty entry keeps `get()` and
283
+ // chunk generation well defined for the document name.
284
+ assets[name] = {};
285
+ // Traversed before the spread below: its last act is a top-level `x-scalar-order` write on the
286
+ // document, and a snapshot taken first would both miss it and preserve whatever stale order the
287
+ // input arrived with.
288
+ const navigation = traverseAsyncApiDocument(name,
289
+ // Resolved so the traversal can follow references the same way the client store does. A
290
+ // channel names its messages by `$ref` into `components.messages`, and an unresolved
291
+ // traversal falls back to the map key — so the sidebar reads `planetCreated` where the
292
+ // rendered page reads "Planet Created".
293
+ resolveLocalReferences(asyncApiDocument), navigationOptions ?? workspaceProps.navigationOptions);
294
+ workspace.documents[name] = {
295
+ ...documentMeta,
296
+ ...asyncApiDocument,
297
+ 'x-original-aas-version': originalAasVersion,
298
+ [extensions.document.navigation]: navigation,
299
+ };
300
+ // A document carrying both discriminators is ingested as AsyncAPI here, but `getDocumentType`
301
+ // checks OpenAPI first — so leaving `openapi` in place would hand an OpenAPI renderer a
302
+ // navigation tree of channel entries. The document is stored as the type it was read as.
303
+ delete workspace.documents[name]['openapi'];
304
+ };
187
305
  /**
188
306
  * Adds a new document to the workspace.
189
307
  *
@@ -201,19 +319,37 @@ export async function createServerWorkspaceStore(workspaceProps) {
201
319
  */
202
320
  const addDocumentSync = (document, meta, navigationOptions) => {
203
321
  const { name, ...documentMeta } = meta;
204
- const documentV3 = coerceValue(OpenAPIDocumentSchema, upgrade(document, '3.1'));
322
+ // The name is caller-supplied and used as a computed key on both `workspace.documents` and
323
+ // `assets`, so a name like `__proto__` would write straight onto Object.prototype. Rejected
324
+ // here rather than filtered, and `addDocument` turns the throw into a skipped document.
325
+ preventPollution(name, 'server workspace document name');
326
+ // AsyncAPI documents get their own ingestion path, mirroring the client store. The OpenAPI
327
+ // upgrade and coerce steps would strip `channels` and `operations`, inject an empty
328
+ // `openapi: ''` that breaks the type discriminator, and add an empty `paths` object.
329
+ if (isAsyncApiDocument(document)) {
330
+ addAsyncApiDocumentSync(document, { name, documentMeta }, navigationOptions);
331
+ return;
332
+ }
333
+ const upgradedDocument = upgrade(document, '3.1');
334
+ const documentV3 = coerceValue(OpenAPIDocumentSchema, upgradedDocument);
335
+ preserveBundledExternals(upgradedDocument, documentV3);
336
+ // Everything that inspects the document reads through this; everything that stores a piece of it
337
+ // stores the raw `documentV3` or a `getRaw` of the piece.
338
+ const resolvedDocument = resolveLocalReferences(documentV3);
205
339
  // add the assets
206
340
  assets[meta.name] = {
341
+ // Components need no resolution: they are externalized as authored, and the client resolves the
342
+ // references inside them the same way it resolves the ones this store leaves behind.
207
343
  components: documentV3.components,
208
- operations: documentV3.paths && escapePaths(filterHttpMethodsOnly(documentV3.paths)),
344
+ operations: resolvedDocument.paths && escapePaths(filterHttpMethodsOnly(resolvedDocument.paths)),
209
345
  };
210
346
  const options = workspaceProps.mode === 'ssr'
211
347
  ? { mode: workspaceProps.mode, name, baseUrl: workspaceProps.baseUrl }
212
348
  : { mode: workspaceProps.mode, name, directory: workspaceProps.directory ?? DEFAULT_ASSETS_FOLDER };
213
349
  const components = externalizeComponentReferences(documentV3, options);
214
- const paths = externalizePathReferences(documentV3, options);
350
+ const paths = externalizePathReferences(resolvedDocument, options);
215
351
  // Build the sidebar entries
216
- const navigation = createNavigation(name, documentV3, navigationOptions ?? workspaceProps.navigationOptions);
352
+ const navigation = createNavigation(name, resolvedDocument, navigationOptions ?? workspaceProps.navigationOptions);
217
353
  // The document is now a minimal version with externalized references to components and operations.
218
354
  // These references will be resolved asynchronously when needed through the workspace's get() method.
219
355
  workspace.documents[meta.name] = {
@@ -235,12 +371,37 @@ export async function createServerWorkspaceStore(workspaceProps) {
235
371
  * @param input - The document input containing the document source and metadata
236
372
  */
237
373
  const addDocument = async (input, navigationOptions) => {
238
- const document = await loadDocument(input);
239
- if (!document.ok) {
240
- console.warn(`Failed to load document "${input.name}`);
241
- return;
374
+ // Captured so a failed add restores exactly what was there. The assets are written partway
375
+ // through, and several steps throw before that point, so clearing the key outright would strip
376
+ // a working document's chunks whenever a name is reused — leaving the workspace pointing at
377
+ // references that resolve to nothing.
378
+ //
379
+ // The snapshot is taken before the first await, so two adds racing under the same name would
380
+ // both capture the pre-state and the failing one could put back what the other just replaced.
381
+ // Adds are not serialized per name: concurrent adds sharing a name are already last-write-wins,
382
+ // and callers are expected to await one before starting another.
383
+ const assetsBeforeAdd = assets[input.name];
384
+ try {
385
+ const document = await loadDocument(input);
386
+ if (!document.ok) {
387
+ console.warn(`Failed to load document "${input.name}"`);
388
+ return;
389
+ }
390
+ addDocumentSync(document.data, { name: input.name, ...input.meta }, navigationOptions);
391
+ }
392
+ catch (error) {
393
+ // Honours the contract above: a document that cannot be processed is skipped rather than
394
+ // taking the workspace with it, since the initial documents are ingested together and one
395
+ // malformed description should not fail an entire documentation build. Loading is inside the
396
+ // try too, so a document that cannot even be serialized is skipped the same way.
397
+ if (assetsBeforeAdd === undefined) {
398
+ delete assets[input.name];
399
+ }
400
+ else {
401
+ assets[input.name] = assetsBeforeAdd;
402
+ }
403
+ console.warn(`Failed to process document "${input.name}"`, error);
242
404
  }
243
- addDocumentSync(document.data, { name: input.name, ...input.meta }, navigationOptions);
244
405
  };
245
406
  // Load and process all initial documents in parallel
246
407
  await Promise.all(workspaceProps.documents.map((document) => addDocument(document)));
package/package.json CHANGED
@@ -16,7 +16,7 @@
16
16
  "openapi",
17
17
  "scalar"
18
18
  ],
19
- "version": "0.57.0",
19
+ "version": "0.58.1",
20
20
  "engines": {
21
21
  "node": ">=22"
22
22
  },
@@ -144,23 +144,23 @@
144
144
  ],
145
145
  "dependencies": {
146
146
  "@scalar/typebox": "0.1.3",
147
- "js-base64": "^3.7.8",
148
- "type-fest": "^5.3.1",
147
+ "js-base64": "^3.9.2",
148
+ "type-fest": "^5.8.0",
149
149
  "vue": "^3.5.40",
150
150
  "yaml": "^2.9.0",
151
- "@scalar/asyncapi-upgrader": "0.1.5",
152
- "@scalar/json-magic": "0.12.20",
153
- "@scalar/openapi-upgrader": "0.2.13",
154
- "@scalar/helpers": "0.10.0",
155
- "@scalar/schemas": "0.8.1",
156
- "@scalar/snippetz": "0.9.26",
157
- "@scalar/types": "0.18.0",
158
- "@scalar/validation": "0.6.2"
151
+ "@scalar/asyncapi-upgrader": "0.1.7",
152
+ "@scalar/helpers": "0.11.1",
153
+ "@scalar/json-magic": "0.13.2",
154
+ "@scalar/openapi-upgrader": "0.2.15",
155
+ "@scalar/schemas": "0.8.3",
156
+ "@scalar/snippetz": "0.9.28",
157
+ "@scalar/types": "0.18.2",
158
+ "@scalar/validation": "0.6.3"
159
159
  },
160
160
  "devDependencies": {
161
- "@google-cloud/storage": "7.16.0",
162
- "fake-indexeddb": "6.2.3",
163
- "fastify": "^5.8.1",
161
+ "@google-cloud/storage": "7.21.0",
162
+ "fake-indexeddb": "6.2.5",
163
+ "fastify": "^5.11.2",
164
164
  "vite": "8.1.5",
165
165
  "vitest": "4.1.10"
166
166
  },