@ic-reactor/codegen 0.12.0 → 0.13.0
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/README.md +14 -4
- package/dist/index.cjs +415 -699
- package/dist/index.d.cts +119 -28
- package/dist/index.d.ts +119 -28
- package/dist/index.js +404 -98
- package/package.json +9 -15
- package/src/generators/declarations.ts +164 -21
- package/src/generators/reactor.ts +20 -7
- package/src/index.ts +19 -5
- package/src/pipeline.ts +190 -18
- package/src/validate.ts +407 -0
- package/dist/chunk-VBCR5IVT.js +0 -609
- package/dist/renderer.cjs +0 -639
- package/dist/renderer.d.cts +0 -33
- package/dist/renderer.d.ts +0 -33
- package/dist/renderer.js +0 -14
- package/src/__snapshots__/bindgen.test.ts.snap +0 -18
- package/src/__snapshots__/reactor.test.ts.snap +0 -127
- package/src/bindgen.test.ts +0 -85
- package/src/metadata-rules.json +0 -144
- package/src/metadata.ts +0 -157
- package/src/naming.test.ts +0 -45
- package/src/parser.ts +0 -79
- package/src/pipeline.test.ts +0 -355
- package/src/reactor.test.ts +0 -84
- package/src/renderer.test.ts +0 -573
- package/src/renderer.ts +0 -515
package/src/validate.ts
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config validation for the codegen pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Every value validated here arrives from a project's `ic-reactor.json` or from
|
|
5
|
+
* `@ic-reactor/vite-plugin` options — that is, from a file in a repository the
|
|
6
|
+
* user may have merely cloned. The pipeline turns those values into filesystem
|
|
7
|
+
* paths it recursively deletes and into source text it writes into the user's
|
|
8
|
+
* bundle, so they are treated as untrusted input rather than as configuration.
|
|
9
|
+
*
|
|
10
|
+
* Validation lives here, at the pipeline's own entry point, rather than in the
|
|
11
|
+
* CLI's interactive prompt: the prompt only covers one of the three entry paths
|
|
12
|
+
* (hand-edited config and plugin options bypass it entirely).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from "node:fs"
|
|
16
|
+
import path from "node:path"
|
|
17
|
+
import type { CodegenTarget, ReactorClassName } from "./types.js"
|
|
18
|
+
import {
|
|
19
|
+
getHookPrefix,
|
|
20
|
+
getReactorName,
|
|
21
|
+
getServiceTypeName,
|
|
22
|
+
toPascalCase,
|
|
23
|
+
} from "./naming.js"
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Thrown when a canister config would produce an unsafe path or unsafe
|
|
27
|
+
* generated source. Callers convert this into a `PipelineResult` error.
|
|
28
|
+
*/
|
|
29
|
+
export class CodegenConfigError extends Error {
|
|
30
|
+
override readonly name = "CodegenConfigError"
|
|
31
|
+
|
|
32
|
+
constructor(message: string) {
|
|
33
|
+
super(message)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Characters allowed in a canister name.
|
|
39
|
+
*
|
|
40
|
+
* A canister name becomes a directory segment, so this excludes path
|
|
41
|
+
* separators, quotes, whitespace and control characters. It deliberately does
|
|
42
|
+
* NOT require a leading letter: `dfx` places no restriction on canister names,
|
|
43
|
+
* and real projects use `_private` and `my.canister`, both of which derive
|
|
44
|
+
* perfectly good identifiers. Identifier validity is enforced separately, on
|
|
45
|
+
* the *derived* names, by {@link assertSafeCanisterName}.
|
|
46
|
+
*/
|
|
47
|
+
export const CANISTER_NAME_PATTERN = /^[A-Za-z0-9_.-]+$/
|
|
48
|
+
|
|
49
|
+
/** A valid JavaScript/TypeScript identifier. */
|
|
50
|
+
const IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
|
51
|
+
|
|
52
|
+
const MAX_CANISTER_NAME_LENGTH = 64
|
|
53
|
+
|
|
54
|
+
/** Matches a URI scheme (`https:`, `data:`, `file:`) or a protocol-relative prefix. */
|
|
55
|
+
const HAS_URI_SCHEME = /^(?:[A-Za-z][A-Za-z0-9+.-]*:|\/\/)/
|
|
56
|
+
|
|
57
|
+
/** Characters that would terminate the string literal or import specifier we emit into. */
|
|
58
|
+
// eslint-disable-next-line no-control-regex
|
|
59
|
+
const BREAKS_OUT_OF_LITERAL = /["'`\\\u0000-\u001f\u2028\u2029]/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Assert that a canister name is safe to use as a path segment and as the stem
|
|
63
|
+
* of a generated identifier.
|
|
64
|
+
*
|
|
65
|
+
* Rejecting a leading digit here is what stops `2048_game` from reaching the
|
|
66
|
+
* generator, where it would become `export const 2048GameReactor` — invalid
|
|
67
|
+
* TypeScript emitted with a success status.
|
|
68
|
+
*/
|
|
69
|
+
export function assertSafeCanisterName(name: unknown): asserts name is string {
|
|
70
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
71
|
+
throw new CodegenConfigError(
|
|
72
|
+
`Invalid canister name: expected a non-empty string, received ${
|
|
73
|
+
name === undefined ? "undefined" : JSON.stringify(name)
|
|
74
|
+
}. Check the "canisters" entries in your ic-reactor.json (or the plugin's "canisters" option).`
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (name.length > MAX_CANISTER_NAME_LENGTH) {
|
|
79
|
+
throw new CodegenConfigError(
|
|
80
|
+
`Invalid canister name ${JSON.stringify(name)}: must be at most ${MAX_CANISTER_NAME_LENGTH} characters.`
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!CANISTER_NAME_PATTERN.test(name)) {
|
|
85
|
+
throw new CodegenConfigError(
|
|
86
|
+
`Invalid canister name ${JSON.stringify(name)}: may contain only letters, digits, ` +
|
|
87
|
+
`"_", "." and "-" (${CANISTER_NAME_PATTERN.source}). Canister names become directory ` +
|
|
88
|
+
`names, so path separators, quotes and whitespace are not allowed.`
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// "." and ".." pass the character rule but are path traversal as segments.
|
|
93
|
+
if (name === "." || name === "..") {
|
|
94
|
+
throw new CodegenConfigError(
|
|
95
|
+
`Invalid canister name ${JSON.stringify(name)}: refers to a directory, not a canister.`
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The name is also the stem of every generated identifier. Check the derived
|
|
100
|
+
// names rather than the raw one, so dfx-legal names like "_private" and
|
|
101
|
+
// "my.canister" keep working while "2048_game" — which would emit
|
|
102
|
+
// `export const 2048GameReactor` — is caught here instead of by the compiler.
|
|
103
|
+
if (toPascalCase(name).length === 0) {
|
|
104
|
+
throw new CodegenConfigError(
|
|
105
|
+
`Invalid canister name ${JSON.stringify(name)}: contains no letters or digits, so it ` +
|
|
106
|
+
`collapses to an empty identifier in generated code.`
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const derived: Array<[string, string]> = [
|
|
111
|
+
["reactor constant", getReactorName(name)],
|
|
112
|
+
["service type", getServiceTypeName(name)],
|
|
113
|
+
["hook name", `use${getHookPrefix(name)}Query`],
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
for (const [what, identifier] of derived) {
|
|
117
|
+
if (!IDENTIFIER_PATTERN.test(identifier)) {
|
|
118
|
+
throw new CodegenConfigError(
|
|
119
|
+
`Invalid canister name ${JSON.stringify(name)}: it derives the ${what} ` +
|
|
120
|
+
`${JSON.stringify(identifier)}, which is not a valid TypeScript identifier. ` +
|
|
121
|
+
`Rename the canister so it does not begin with a digit.`
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Assert that a module specifier we interpolate into an `import` statement is a
|
|
129
|
+
* relative path or a bare package name — never a URL.
|
|
130
|
+
*
|
|
131
|
+
* A `https://…` specifier here would make the generated module pull code from a
|
|
132
|
+
* remote host at import time, inside the user's own bundle.
|
|
133
|
+
*/
|
|
134
|
+
export function assertSafeModuleSpecifier(
|
|
135
|
+
label: string,
|
|
136
|
+
specifier: unknown
|
|
137
|
+
): asserts specifier is string {
|
|
138
|
+
if (typeof specifier !== "string" || specifier.length === 0) {
|
|
139
|
+
throw new CodegenConfigError(
|
|
140
|
+
`Invalid ${label}: expected a non-empty string, received ${
|
|
141
|
+
specifier === undefined ? "undefined" : JSON.stringify(specifier)
|
|
142
|
+
}.`
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (BREAKS_OUT_OF_LITERAL.test(specifier)) {
|
|
147
|
+
throw new CodegenConfigError(
|
|
148
|
+
`Invalid ${label} ${JSON.stringify(specifier)}: must not contain quotes, backslashes or control characters.`
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Surrounding whitespace is stripped when a specifier is parsed as a URL, so
|
|
153
|
+
// " https://evil.example/x.js" would slip past an anchored scheme check and
|
|
154
|
+
// then load as a remote URL anyway. Reject it outright and match the scheme
|
|
155
|
+
// against the trimmed value.
|
|
156
|
+
if (/^\s|\s$/.test(specifier)) {
|
|
157
|
+
throw new CodegenConfigError(
|
|
158
|
+
`Invalid ${label} ${JSON.stringify(specifier)}: must not begin or end with whitespace.`
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (HAS_URI_SCHEME.test(specifier.trim())) {
|
|
163
|
+
throw new CodegenConfigError(
|
|
164
|
+
`Invalid ${label} ${JSON.stringify(specifier)}: must be a relative path ("./…", "../…") or a ` +
|
|
165
|
+
`bare package name. URLs are not allowed — the generated file imports from this specifier.`
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Derive the declarations file stem from a `.did` path and assert it is usable
|
|
172
|
+
* both as a file name and as the tail of the import specifier we emit.
|
|
173
|
+
*
|
|
174
|
+
* `path.basename` is happy to hand back `"."` or `".."`: a `didFile` of
|
|
175
|
+
* `"canisters/.."` makes the generated reactor `import … from
|
|
176
|
+
* "./declarations/.."`, a *directory* reference — reported as a successful
|
|
177
|
+
* generation, rejected by the consumer's bundler. Validating the derived
|
|
178
|
+
* specifier here is the same treatment `clientManagerPath` gets, for the same
|
|
179
|
+
* reason: it is config-supplied text that ends up inside an `import`.
|
|
180
|
+
*/
|
|
181
|
+
export function resolveDeclarationsBaseName(didFile: unknown): string {
|
|
182
|
+
if (typeof didFile !== "string" || didFile.length === 0) {
|
|
183
|
+
throw new CodegenConfigError(
|
|
184
|
+
`Invalid didFile: expected a non-empty string, received ${
|
|
185
|
+
didFile === undefined ? "undefined" : JSON.stringify(didFile)
|
|
186
|
+
}.`
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const baseName = path.basename(didFile, ".did")
|
|
191
|
+
|
|
192
|
+
if (baseName === "" || baseName === "." || baseName === "..") {
|
|
193
|
+
throw new CodegenConfigError(
|
|
194
|
+
`Invalid didFile ${JSON.stringify(didFile)}: its file name ${JSON.stringify(baseName)} ` +
|
|
195
|
+
`refers to a directory, not a Candid file. Generated declarations are named after the ` +
|
|
196
|
+
`.did file, so this would emit an import of a directory.`
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
assertSafeModuleSpecifier(
|
|
201
|
+
`declarations import derived from didFile ${JSON.stringify(didFile)}`,
|
|
202
|
+
`./declarations/${baseName}`
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
return baseName
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Reactor classes the generator knows how to emit an import for. */
|
|
209
|
+
export const REACTOR_CLASS_NAMES: readonly ReactorClassName[] = [
|
|
210
|
+
"Reactor",
|
|
211
|
+
"DisplayReactor",
|
|
212
|
+
"CandidReactor",
|
|
213
|
+
"CandidDisplayReactor",
|
|
214
|
+
"MetadataDisplayReactor",
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
/** Runtime targets the generator knows how to emit. */
|
|
218
|
+
export const CODEGEN_TARGETS: readonly CodegenTarget[] = ["react", "core"]
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Assert a config value is one of a closed set.
|
|
222
|
+
*
|
|
223
|
+
* `mode` and `target` are interpolated into emitted source as bare identifiers
|
|
224
|
+
* and module specifiers, so an unrecognized value is not merely unsupported —
|
|
225
|
+
* it is injected.
|
|
226
|
+
*/
|
|
227
|
+
export function assertOneOf<T extends string>(
|
|
228
|
+
label: string,
|
|
229
|
+
value: unknown,
|
|
230
|
+
allowed: readonly T[]
|
|
231
|
+
): asserts value is T {
|
|
232
|
+
if (typeof value !== "string" || !allowed.includes(value as T)) {
|
|
233
|
+
throw new CodegenConfigError(
|
|
234
|
+
`Invalid ${label} ${JSON.stringify(value)}: must be one of ${allowed
|
|
235
|
+
.map((a) => JSON.stringify(a))
|
|
236
|
+
.join(", ")}.`
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Resolve a path to its real location on disk, following symlinks.
|
|
243
|
+
*
|
|
244
|
+
* The target usually does not exist yet (it is about to be generated), so this
|
|
245
|
+
* resolves the deepest ancestor that *does* exist and re-appends the remainder.
|
|
246
|
+
* Without this, containment is purely lexical and a symlink placed inside the
|
|
247
|
+
* project root — which `path.resolve` does not follow — escapes it.
|
|
248
|
+
*/
|
|
249
|
+
function realpathAllowingMissing(target: string): string {
|
|
250
|
+
let current = path.resolve(target)
|
|
251
|
+
const missing: string[] = []
|
|
252
|
+
|
|
253
|
+
for (;;) {
|
|
254
|
+
try {
|
|
255
|
+
return path.join(fs.realpathSync(current), ...missing)
|
|
256
|
+
} catch {
|
|
257
|
+
const parent = path.dirname(current)
|
|
258
|
+
// Reached the filesystem root without finding anything that exists.
|
|
259
|
+
if (parent === current) return path.resolve(target)
|
|
260
|
+
missing.unshift(path.basename(current))
|
|
261
|
+
current = parent
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Assert that an already-resolved absolute path lies inside `projectRoot`.
|
|
268
|
+
*
|
|
269
|
+
* Containment is decided on the real on-disk locations: `path.resolve` does not
|
|
270
|
+
* follow symlinks, so a purely lexical comparison can be walked out of via a
|
|
271
|
+
* symlink placed inside the project root.
|
|
272
|
+
*
|
|
273
|
+
* @param original - the path as the user wrote it, for the error message
|
|
274
|
+
*/
|
|
275
|
+
export function assertContainedPath(
|
|
276
|
+
label: string,
|
|
277
|
+
resolved: string,
|
|
278
|
+
projectRoot: string,
|
|
279
|
+
original: string = resolved
|
|
280
|
+
): void {
|
|
281
|
+
const relative = path.relative(
|
|
282
|
+
realpathAllowingMissing(projectRoot),
|
|
283
|
+
realpathAllowingMissing(resolved)
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
// Test for a real ".." *segment*, not a ".." prefix: a directory legitimately
|
|
287
|
+
// named "..generated" relativizes to "..generated", which is inside the root.
|
|
288
|
+
const [firstSegment] = relative.split(/[\\/]/)
|
|
289
|
+
|
|
290
|
+
if (firstSegment === ".." || path.isAbsolute(relative)) {
|
|
291
|
+
throw new CodegenConfigError(
|
|
292
|
+
`Invalid ${label} ${JSON.stringify(original)}: resolves to ${JSON.stringify(resolved)}, ` +
|
|
293
|
+
`which is outside the project root ${JSON.stringify(path.resolve(projectRoot))}. ` +
|
|
294
|
+
`Generated output must stay inside the project — generated directories are deleted and ` +
|
|
295
|
+
`rewritten on every run.`
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Resolve `outDir` against `projectRoot` and assert the result stays inside it.
|
|
302
|
+
*
|
|
303
|
+
* The pipeline recursively deletes `<outDir>/declarations` before every
|
|
304
|
+
* generation, so an `outDir` that escapes the project root turns a config file
|
|
305
|
+
* into an arbitrary-directory delete.
|
|
306
|
+
*/
|
|
307
|
+
export function resolveContainedOutDir(
|
|
308
|
+
label: string,
|
|
309
|
+
outDir: unknown,
|
|
310
|
+
projectRoot: string
|
|
311
|
+
): string {
|
|
312
|
+
if (typeof outDir !== "string" || outDir.length === 0) {
|
|
313
|
+
throw new CodegenConfigError(
|
|
314
|
+
`Invalid ${label}: expected a non-empty string, received ${
|
|
315
|
+
outDir === undefined ? "undefined" : JSON.stringify(outDir)
|
|
316
|
+
}.`
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// The path we hand back is the lexically resolved one, so generated file
|
|
321
|
+
// paths look the way the user wrote them; only the containment decision uses
|
|
322
|
+
// the real on-disk location.
|
|
323
|
+
const resolved = path.isAbsolute(outDir)
|
|
324
|
+
? path.resolve(outDir)
|
|
325
|
+
: path.resolve(projectRoot, outDir)
|
|
326
|
+
|
|
327
|
+
assertContainedPath(label, resolved, projectRoot, outDir)
|
|
328
|
+
|
|
329
|
+
return resolved
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export interface ValidatedCanisterPaths {
|
|
333
|
+
/** The validated canister name. */
|
|
334
|
+
name: string
|
|
335
|
+
/** Absolute output directory, guaranteed to be inside `projectRoot`. */
|
|
336
|
+
outDir: string
|
|
337
|
+
/** Validated module specifier for the client manager import. */
|
|
338
|
+
clientManagerPath: string
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export interface ValidateCanisterConfigOptions {
|
|
342
|
+
name: unknown
|
|
343
|
+
/** Per-canister `outDir`, if the config sets one. */
|
|
344
|
+
canisterOutDir?: unknown
|
|
345
|
+
/** Global `outDir`; the canister name is appended to it when used. */
|
|
346
|
+
globalOutDir: unknown
|
|
347
|
+
clientManagerPath: unknown
|
|
348
|
+
projectRoot: string
|
|
349
|
+
/** Resolved reactor class (`canisterConfig.mode`), if the config sets one. */
|
|
350
|
+
mode?: unknown
|
|
351
|
+
/** Resolved runtime target (`canisterConfig.target` / global `target`). */
|
|
352
|
+
target?: unknown
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Validate one canister's config and return the resolved, contained paths the
|
|
357
|
+
* pipeline should use.
|
|
358
|
+
*
|
|
359
|
+
* Call this before any filesystem work: it is the single choke point that all
|
|
360
|
+
* three entry paths (CLI, vite plugin, direct API) share.
|
|
361
|
+
*/
|
|
362
|
+
export function assertSafeCanisterConfig(
|
|
363
|
+
options: ValidateCanisterConfigOptions
|
|
364
|
+
): ValidatedCanisterPaths {
|
|
365
|
+
const {
|
|
366
|
+
name,
|
|
367
|
+
canisterOutDir,
|
|
368
|
+
globalOutDir,
|
|
369
|
+
clientManagerPath,
|
|
370
|
+
projectRoot,
|
|
371
|
+
mode,
|
|
372
|
+
target,
|
|
373
|
+
} = options
|
|
374
|
+
|
|
375
|
+
assertSafeCanisterName(name)
|
|
376
|
+
assertSafeModuleSpecifier("clientManagerPath", clientManagerPath)
|
|
377
|
+
|
|
378
|
+
// `mode` and `target` reach the generator as bare identifiers and as the
|
|
379
|
+
// module specifier they are imported from, so they must be known values.
|
|
380
|
+
if (mode != null) assertOneOf("mode", mode, REACTOR_CLASS_NAMES)
|
|
381
|
+
if (target != null) assertOneOf("target", target, CODEGEN_TARGETS)
|
|
382
|
+
|
|
383
|
+
const outDir =
|
|
384
|
+
canisterOutDir != null
|
|
385
|
+
? resolveContainedOutDir(
|
|
386
|
+
`outDir for canister ${JSON.stringify(name)}`,
|
|
387
|
+
canisterOutDir,
|
|
388
|
+
projectRoot
|
|
389
|
+
)
|
|
390
|
+
: path.join(
|
|
391
|
+
resolveContainedOutDir("outDir", globalOutDir, projectRoot),
|
|
392
|
+
name
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
// Re-assert on the FINAL path. In the global-outDir branch the canister name
|
|
396
|
+
// is appended after that directory was checked, and the canister directory can
|
|
397
|
+
// itself be a symlink pointing out of the project — checking only the parent
|
|
398
|
+
// leaves the whole traversal open. Whatever this function returns has been
|
|
399
|
+
// containment-checked as a complete path.
|
|
400
|
+
assertContainedPath(
|
|
401
|
+
`output directory for canister ${JSON.stringify(name)}`,
|
|
402
|
+
outDir,
|
|
403
|
+
projectRoot
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
return { name, outDir, clientManagerPath }
|
|
407
|
+
}
|