@redpanda-data/docs-extensions-and-macros 5.35.1 → 5.37.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.
- package/bin/doc-tools.js +262 -0
- package/cli-utils/kapa-credentials.js +109 -0
- package/docs-data/kapa-source-groups.json +119 -0
- package/docs-data/kapa-source-groups.schema.json +73 -0
- package/extensions/kapa-source-groups.js +180 -0
- package/extensions/util/scan-content-urls.js +48 -3
- package/package.json +7 -3
- package/tools/generate-cli-docs.js +19 -4
- package/tools/kapa-source-groups/fetch-with-deadline.js +33 -0
- package/tools/kapa-source-groups/generate-kapa-source-groups.js +319 -0
- package/tools/kapa-source-groups/kapa-source-versions.js +83 -0
- package/tools/kapa-source-groups/published-segments.js +98 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/* Publishes the Kapa source-group mapping to the UI as an AsciiDoc attribute.
|
|
2
|
+
*
|
|
3
|
+
* Example use in the playbook:
|
|
4
|
+
* antora:
|
|
5
|
+
* extensions:
|
|
6
|
+
* - require: '@redpanda-data/docs-extensions-and-macros/extensions/kapa-source-groups'
|
|
7
|
+
*
|
|
8
|
+
* WHY THIS EXISTS
|
|
9
|
+
* ---------------
|
|
10
|
+
* docs-ui's get-kapa-source-groups helper needs to turn the page's version
|
|
11
|
+
* segment into a Kapa source group id, so Ask AI retrieval is scoped to the docs
|
|
12
|
+
* version the reader is actually on (DOC-1807, DOC-2450). docs-ui does not depend
|
|
13
|
+
* on this package and must not carry a second copy of the mapping, which would
|
|
14
|
+
* drift silently. So the mapping travels as an attribute: generated here by
|
|
15
|
+
* `doc-tools generate kapa-source-groups`, committed to docs-data, and merged
|
|
16
|
+
* onto every component version at build time.
|
|
17
|
+
*
|
|
18
|
+
* Set on every component version rather than only the versioned ones, because the
|
|
19
|
+
* helper needs the mapping on unversioned pages too. Those resolve to the
|
|
20
|
+
* mapping's default_segment, and sending no filter there is exactly what caused
|
|
21
|
+
* DOC-2450: the reporter was on a page with no version of its own.
|
|
22
|
+
*
|
|
23
|
+
* DEGRADATION
|
|
24
|
+
* -----------
|
|
25
|
+
* Every failure sets nothing and warns. The helper treats a missing attribute as
|
|
26
|
+
* "send no source group", which is the pre-DOC-2450 behaviour of searching every
|
|
27
|
+
* version. That is the right way to fail: a wrong group is worse than no group,
|
|
28
|
+
* because scoping to a group that does not hold the reader's version returns only
|
|
29
|
+
* Kapa's global sources, silently and with no error.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const fs = require('fs')
|
|
33
|
+
const path = require('path')
|
|
34
|
+
const { raiseListenerLimit } = require('./util/raise-listener-limit')
|
|
35
|
+
|
|
36
|
+
const ATTRIBUTE_NAME = 'kapa-source-groups'
|
|
37
|
+
// Sits beside assets/widgets/, which proxy-api-docs.js already fetches from the
|
|
38
|
+
// site origin, so the /api/ path needs no new hostname or auth.
|
|
39
|
+
const ASSET_DIR = 'assets/data'
|
|
40
|
+
const ASSET_FILENAME = 'kapa-source-groups.json'
|
|
41
|
+
const DEFAULT_MAPPING_PATH = path.join(__dirname, '..', 'docs-data', 'kapa-source-groups.json')
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Validate the shape the helper depends on, so a malformed mapping is caught at
|
|
45
|
+
* build time with a named reason rather than producing pages that quietly lose
|
|
46
|
+
* version scoping.
|
|
47
|
+
*
|
|
48
|
+
* @param {*} mapping
|
|
49
|
+
* @returns {string|null} Reason it is unusable, or null when it is fine
|
|
50
|
+
*/
|
|
51
|
+
function validateMapping (mapping) {
|
|
52
|
+
if (!mapping || typeof mapping !== 'object') return 'not an object'
|
|
53
|
+
if (!mapping.segments || typeof mapping.segments !== 'object') return 'no segments object'
|
|
54
|
+
const segments = Object.keys(mapping.segments)
|
|
55
|
+
if (segments.length === 0) return 'segments is empty'
|
|
56
|
+
if (!mapping.default_segment) return 'no default_segment'
|
|
57
|
+
if (!mapping.segments[mapping.default_segment]) {
|
|
58
|
+
// The helper falls back to default_segment for every unversioned page, so a
|
|
59
|
+
// dangling default silently disables scoping across most of the site.
|
|
60
|
+
return `default_segment "${mapping.default_segment}" is not one of the segments (${segments.join(', ')})`
|
|
61
|
+
}
|
|
62
|
+
const missing = segments.filter((s) => !mapping.segments[s] || !mapping.segments[s].group_id)
|
|
63
|
+
if (missing.length) return `segments missing group_id: ${missing.join(', ')}`
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports.register = function ({ config = {} } = {}) {
|
|
68
|
+
const logger = this.getLogger('kapa-source-groups-extension')
|
|
69
|
+
|
|
70
|
+
// Three listeners on a generator context that already carries 30+ extensions,
|
|
71
|
+
// which trips Node's default max-listeners warning. Raised before subscribing,
|
|
72
|
+
// per __tests__/extensions/raise-listener-limit.test.js.
|
|
73
|
+
raiseListenerLimit(this)
|
|
74
|
+
const mappingPath = config.mapping_file ? path.resolve(config.mapping_file) : DEFAULT_MAPPING_PATH
|
|
75
|
+
|
|
76
|
+
// Loaded on first use and reused by all three listeners, so a missing or
|
|
77
|
+
// broken mapping warns once per build rather than once per event.
|
|
78
|
+
let loaded = false
|
|
79
|
+
let cached = null
|
|
80
|
+
function load () {
|
|
81
|
+
if (loaded) return cached
|
|
82
|
+
loaded = true
|
|
83
|
+
cached = readMapping()
|
|
84
|
+
return cached
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Read + validate, or return null having warned with the consequence. */
|
|
88
|
+
function readMapping () {
|
|
89
|
+
let mapping
|
|
90
|
+
try {
|
|
91
|
+
if (!fs.existsSync(mappingPath)) {
|
|
92
|
+
logger.warn(
|
|
93
|
+
`Kapa source-group mapping not found at ${mappingPath}. Ask AI will search every docs version. ` +
|
|
94
|
+
'Generate it with: doc-tools generate kapa-source-groups'
|
|
95
|
+
)
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
mapping = JSON.parse(fs.readFileSync(mappingPath, 'utf8'))
|
|
99
|
+
} catch (err) {
|
|
100
|
+
logger.warn(`Could not read the Kapa source-group mapping at ${mappingPath}: ${err.message}. Ask AI will search every docs version.`)
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
const problem = validateMapping(mapping)
|
|
104
|
+
if (problem) {
|
|
105
|
+
logger.warn(`Kapa source-group mapping at ${mappingPath} is unusable (${problem}). Ask AI will search every docs version.`)
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
return mapping
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// site.keys reaches EVERY page, including ones with no component: the 404 page
|
|
112
|
+
// renders the Ask AI panel but has no page.component or page.componentVersion,
|
|
113
|
+
// so a component-version attribute alone leaves it unscoped. Its layout pulls
|
|
114
|
+
// in head and header, both of which already read site.keys, so this is the one
|
|
115
|
+
// channel that covers it. Set in playbookBuilt because the site UI model is
|
|
116
|
+
// derived from the playbook.
|
|
117
|
+
this.on('playbookBuilt', ({ playbook }) => {
|
|
118
|
+
const mapping = load()
|
|
119
|
+
if (!mapping) return
|
|
120
|
+
playbook.site = playbook.site || {}
|
|
121
|
+
playbook.site.keys = playbook.site.keys || {}
|
|
122
|
+
if (playbook.site.keys[ATTRIBUTE_NAME] === undefined) {
|
|
123
|
+
playbook.site.keys[ATTRIBUTE_NAME] = JSON.stringify(mapping)
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
// The /api/ reference is Bump.sh HTML assembled by docs-site's proxy-api-docs
|
|
128
|
+
// edge function, which has no Antora page context and cannot import from
|
|
129
|
+
// node_modules. Publishing the mapping as a site asset lets that function read
|
|
130
|
+
// the default group at request time, in parallel with the widget fetches it
|
|
131
|
+
// already does. The alternative -- a UUID pasted into docs-ui's static widget
|
|
132
|
+
// context or a Netlify env var -- is a second copy of the mapping that drifts
|
|
133
|
+
// silently the first time a group is recreated.
|
|
134
|
+
this.on('beforePublish', ({ siteCatalog }) => {
|
|
135
|
+
const mapping = load()
|
|
136
|
+
if (!mapping) return
|
|
137
|
+
siteCatalog.addFile({
|
|
138
|
+
contents: Buffer.from(JSON.stringify(mapping, null, 2) + '\n', 'utf8'),
|
|
139
|
+
out: { path: path.join(ASSET_DIR, ASSET_FILENAME) },
|
|
140
|
+
})
|
|
141
|
+
logger.info(`Kapa source groups: published ${ASSET_DIR}/${ASSET_FILENAME} for the /api/ proxy.`)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
this.on('contentClassified', async ({ contentCatalog }) => {
|
|
145
|
+
const mapping = load()
|
|
146
|
+
if (!mapping) return
|
|
147
|
+
|
|
148
|
+
// Serialised once. The helper accepts a string or an object, but a string is
|
|
149
|
+
// what survives Antora's attribute handling unchanged, and it keeps the
|
|
150
|
+
// attribute value comparable between builds.
|
|
151
|
+
const serialised = JSON.stringify(mapping)
|
|
152
|
+
|
|
153
|
+
let versions = 0
|
|
154
|
+
const components = await contentCatalog.getComponents()
|
|
155
|
+
for (const component of components) {
|
|
156
|
+
component.versions.forEach(({ asciidoc }) => {
|
|
157
|
+
if (!asciidoc) return
|
|
158
|
+
asciidoc.attributes = asciidoc.attributes || {}
|
|
159
|
+
// Do not clobber an explicit override: a playbook or antora.yml that
|
|
160
|
+
// already sets this wins, matching how add-global-attributes.js lets
|
|
161
|
+
// component attributes beat the shared file.
|
|
162
|
+
if (asciidoc.attributes[ATTRIBUTE_NAME] === undefined) {
|
|
163
|
+
asciidoc.attributes[ATTRIBUTE_NAME] = serialised
|
|
164
|
+
}
|
|
165
|
+
versions += 1
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
logger.info(
|
|
170
|
+
`Kapa source groups: set ${ATTRIBUTE_NAME} on ${versions} component version(s); ` +
|
|
171
|
+
`${Object.keys(mapping.segments).length} version segments, default "${mapping.default_segment}".`
|
|
172
|
+
)
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports.validateMapping = validateMapping
|
|
177
|
+
module.exports.ATTRIBUTE_NAME = ATTRIBUTE_NAME
|
|
178
|
+
module.exports.DEFAULT_MAPPING_PATH = DEFAULT_MAPPING_PATH
|
|
179
|
+
module.exports.ASSET_DIR = ASSET_DIR
|
|
180
|
+
module.exports.ASSET_FILENAME = ASSET_FILENAME
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
*
|
|
14
14
|
* A block closes only on a delimiter of the same length as the one that opened
|
|
15
15
|
* it; a longer run, such as the row of dashes in a rendered table, is content.
|
|
16
|
+
* - indented literal paragraphs: an indented line that opens a block is
|
|
17
|
+
* rendered verbatim, so a URL on it is text and not a link
|
|
16
18
|
* - line comments (//), which Asciidoctor drops entirely
|
|
17
19
|
* - inline code spans (`...`)
|
|
18
20
|
*
|
|
@@ -60,6 +62,23 @@ const ATTRIBUTE_REFERENCE_RX = /\{[a-zA-Z0-9_][a-zA-Z0-9_-]*\}/
|
|
|
60
62
|
// prose cannot swallow the rest of the document.
|
|
61
63
|
const MAX_WRAPPED_LABEL_LENGTH = 500
|
|
62
64
|
|
|
65
|
+
// An indented line that opens a block is a literal paragraph: Asciidoctor
|
|
66
|
+
// renders it verbatim, so any URL on it is text rather than a link. This is
|
|
67
|
+
// where connector descriptions put their shell examples, which is how the
|
|
68
|
+
// three comma-joined gcloud scope strings in google_drive_list_labels were
|
|
69
|
+
// read as one URL and reported broken.
|
|
70
|
+
const INDENTED_RX = /^[ \t]+\S/
|
|
71
|
+
// Indentation does not make a literal paragraph out of a list item, a table
|
|
72
|
+
// cell or a description list: Asciidoctor still parses those, so their links
|
|
73
|
+
// are real. Verified against @asciidoctor/core, marker by marker (-, *, **,
|
|
74
|
+
// ., .., 1., a., |, term::).
|
|
75
|
+
const INDENTED_PARSED_RX = /^[ \t]+(?:[*.-]+[ \t]|\d+\.[ \t]|[a-zA-Z]\.[ \t]|\||\S.*::(?:[ \t]|$))/
|
|
76
|
+
// Lines a block can start after, so an indented line that follows one opens a
|
|
77
|
+
// literal paragraph: a list continuation (+), a block attribute list
|
|
78
|
+
// ([source,yaml]) or a block title (.Title). A blank line and the close of a
|
|
79
|
+
// delimited block do the same and are handled in the scan loop.
|
|
80
|
+
const BLOCK_START_RX = /^(?:\+|\[.*\]|\.\S.*)$/
|
|
81
|
+
|
|
63
82
|
// A delimiter run, plus the info string a fence may carry. Comment blocks
|
|
64
83
|
// (////) come first in the alternation so a //// line is not mistaken for the
|
|
65
84
|
// start of a line comment.
|
|
@@ -108,22 +127,48 @@ function closesBlock (delimiter, open) {
|
|
|
108
127
|
return delimiter.char === open.char && delimiter.length === open.length && !delimiter.info
|
|
109
128
|
}
|
|
110
129
|
|
|
130
|
+
/**
|
|
131
|
+
* True when an indented line at the start of a block opens a literal
|
|
132
|
+
* paragraph, rather than continuing a construct Asciidoctor still parses.
|
|
133
|
+
*/
|
|
134
|
+
function opensLiteralParagraph (line) {
|
|
135
|
+
return INDENTED_RX.test(line) && !INDENTED_PARSED_RX.test(line)
|
|
136
|
+
}
|
|
137
|
+
|
|
111
138
|
function scanContentUrls (content) {
|
|
112
139
|
const matches = []
|
|
113
140
|
const lines = content.split('\n')
|
|
114
141
|
let offset = 0
|
|
115
142
|
let openDelimiter = null
|
|
143
|
+
// A literal paragraph runs from the indented line that opened it to the next
|
|
144
|
+
// blank line, and takes any unindented lines in between with it.
|
|
145
|
+
let inLiteralParagraph = false
|
|
146
|
+
// A block can start at the top of the file, and again after every line that
|
|
147
|
+
// ended the last one.
|
|
148
|
+
let atBlockStart = true
|
|
116
149
|
for (const rawLine of lines) {
|
|
117
150
|
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine
|
|
118
151
|
const delimiter = blockDelimiter(line)
|
|
119
152
|
if (openDelimiter) {
|
|
120
153
|
// Inside a block, only its own matching delimiter gets out. Anything
|
|
121
154
|
// else on this line is content and is never scanned.
|
|
122
|
-
if (delimiter && closesBlock(delimiter, openDelimiter))
|
|
155
|
+
if (delimiter && closesBlock(delimiter, openDelimiter)) {
|
|
156
|
+
openDelimiter = null
|
|
157
|
+
atBlockStart = true
|
|
158
|
+
}
|
|
123
159
|
} else if (delimiter) {
|
|
124
160
|
openDelimiter = delimiter
|
|
125
|
-
|
|
126
|
-
|
|
161
|
+
inLiteralParagraph = false
|
|
162
|
+
} else {
|
|
163
|
+
const blank = !line.trim()
|
|
164
|
+
if (inLiteralParagraph) {
|
|
165
|
+
if (blank) inLiteralParagraph = false
|
|
166
|
+
} else if (atBlockStart && opensLiteralParagraph(line)) {
|
|
167
|
+
inLiteralParagraph = true
|
|
168
|
+
} else if (!LINE_COMMENT_RX.test(line)) {
|
|
169
|
+
scanLine(line, offset, matches, content)
|
|
170
|
+
}
|
|
171
|
+
atBlockStart = blank || BLOCK_START_RX.test(line)
|
|
127
172
|
}
|
|
128
173
|
offset += rawLine.length + 1
|
|
129
174
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@redpanda-data/docs-extensions-and-macros",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.37.1",
|
|
4
4
|
"description": "Antora extensions and macros developed for Redpanda documentation.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"antora",
|
|
@@ -92,7 +92,9 @@
|
|
|
92
92
|
"./macros/enterprise": "./macros/enterprise.js",
|
|
93
93
|
"./macros/prop": "./macros/prop.js",
|
|
94
94
|
"./extensions/property-page-index": "./extensions/property-page-index.js",
|
|
95
|
-
"./extensions/render-property-descriptions": "./extensions/render-property-descriptions.js"
|
|
95
|
+
"./extensions/render-property-descriptions": "./extensions/render-property-descriptions.js",
|
|
96
|
+
"./extensions/kapa-source-groups": "./extensions/kapa-source-groups.js",
|
|
97
|
+
"./docs-data/kapa-source-groups.json": "./docs-data/kapa-source-groups.json"
|
|
96
98
|
},
|
|
97
99
|
"files": [
|
|
98
100
|
"extensions",
|
|
@@ -104,7 +106,9 @@
|
|
|
104
106
|
"tools",
|
|
105
107
|
"docker-compose",
|
|
106
108
|
"mcp",
|
|
107
|
-
"docs-data/rpk-overrides.schema.json"
|
|
109
|
+
"docs-data/rpk-overrides.schema.json",
|
|
110
|
+
"docs-data/kapa-source-groups.json",
|
|
111
|
+
"docs-data/kapa-source-groups.schema.json"
|
|
108
112
|
],
|
|
109
113
|
"license": "ISC",
|
|
110
114
|
"engines": {
|
|
@@ -138,11 +138,19 @@ function parseJSDocComments(sourceFile) {
|
|
|
138
138
|
// was documented with the first command's prose. That is exactly what
|
|
139
139
|
// happened to `generate migrate-rpcn-descriptions`, which shipped a
|
|
140
140
|
// regenerated section describing migrate-rpcn-metadata.
|
|
141
|
-
|
|
141
|
+
//
|
|
142
|
+
// The group is captured too, because a command NAME is not unique: the same
|
|
143
|
+
// name can be registered under both `automation` (generate) and `validation`
|
|
144
|
+
// (validate). `kapa-source-groups` is, and keying on the bare name alone made
|
|
145
|
+
// the later registration silently overwrite the earlier, so the
|
|
146
|
+
// `generate kapa-source-groups` section shipped describing the validate
|
|
147
|
+
// command, complete with validate examples under a generate usage line.
|
|
148
|
+
// Same failure as the migrate-rpcn one above, one level up.
|
|
149
|
+
const pattern = /\/\*\*\s*((?:(?!\*\/)[\s\S])*?)\s*\*\/\s*(programCli|automation|validation)\s*\.command\(['"]([^'"]+)['"]\)/g;
|
|
142
150
|
|
|
143
151
|
let match;
|
|
144
152
|
while ((match = pattern.exec(content)) !== null) {
|
|
145
|
-
const [, commentText, commandName] = match;
|
|
153
|
+
const [, commentText, group, commandName] = match;
|
|
146
154
|
|
|
147
155
|
// Parse the comment into sections
|
|
148
156
|
const parsed = {
|
|
@@ -189,7 +197,12 @@ function parseJSDocComments(sourceFile) {
|
|
|
189
197
|
.trim();
|
|
190
198
|
}
|
|
191
199
|
|
|
192
|
-
|
|
200
|
+
// Keyed by the CLI path a reader would type, so `generate x` and
|
|
201
|
+
// `validate x` cannot collide. The bare name is kept as well, for the
|
|
202
|
+
// top-level commands registered straight onto programCli.
|
|
203
|
+
const groupPrefix = { automation: 'generate ', validation: 'validate ', programCli: '' }[group]
|
|
204
|
+
comments[`${groupPrefix}${commandName}`] = parsed;
|
|
205
|
+
if (comments[commandName] === undefined) comments[commandName] = parsed;
|
|
193
206
|
}
|
|
194
207
|
|
|
195
208
|
return comments;
|
|
@@ -290,7 +303,9 @@ IMPORTANT: This documentation is auto-generated. Do not edit manually. Run \`npm
|
|
|
290
303
|
const label = commandPath.join(' ');
|
|
291
304
|
console.log(`${' '.repeat(level - 1)}Generating docs for: ${label}`);
|
|
292
305
|
const data = parseHelp(getHelpOutput(label));
|
|
293
|
-
|
|
306
|
+
// Prefer the full command path, falling back to the bare name so that
|
|
307
|
+
// commands registered directly on programCli still resolve.
|
|
308
|
+
const jsdoc = jsdocs[label] || jsdocs[commandPath[commandPath.length - 1]];
|
|
294
309
|
doc += generateCommandDoc(label, data, jsdoc, level);
|
|
295
310
|
for (const child of data.commands) {
|
|
296
311
|
// Commander lists `help [command]` in every group; it is not a command
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fetch() with a deadline that covers the WHOLE exchange, body included.
|
|
3
|
+
*
|
|
4
|
+
* The obvious shape, `try { res = await fetch(url, { signal }) } finally
|
|
5
|
+
* { clearTimeout(timer) }` and then `await res.text()`, only bounds the time to
|
|
6
|
+
* headers. fetch resolves as soon as headers arrive, the timer is cleared, and a
|
|
7
|
+
* server that then stalls mid-body holds the caller forever. For a scheduled
|
|
8
|
+
* check that is a job that never finishes rather than one that exits 2, so
|
|
9
|
+
* nobody is told and the next run queues behind it.
|
|
10
|
+
*
|
|
11
|
+
* @param {typeof fetch} fetchImpl
|
|
12
|
+
* @param {string} url
|
|
13
|
+
* @param {RequestInit} init - Merged with the abort signal
|
|
14
|
+
* @param {number} timeoutMs
|
|
15
|
+
* @param {(res: Response, signal: AbortSignal) => Promise<*>} read
|
|
16
|
+
* Consumes the body while the timer is still armed. Return null for a
|
|
17
|
+
* response you do not want to read (a non-OK status, say) so the caller can
|
|
18
|
+
* inspect res.status first.
|
|
19
|
+
* @returns {Promise<{res: Response, body: *}>}
|
|
20
|
+
*/
|
|
21
|
+
async function fetchWithDeadline (fetchImpl, url, init, timeoutMs, read) {
|
|
22
|
+
const controller = new AbortController()
|
|
23
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetchImpl(url, { ...init, signal: controller.signal })
|
|
26
|
+
const body = await read(res, controller.signal)
|
|
27
|
+
return { res, body }
|
|
28
|
+
} finally {
|
|
29
|
+
clearTimeout(timer)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { fetchWithDeadline }
|