@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
|
@@ -14,6 +14,7 @@ import { didToJs, didToTs } from "@ic-reactor/parser"
|
|
|
14
14
|
import path from "node:path"
|
|
15
15
|
import fs from "node:fs"
|
|
16
16
|
import type { GeneratorResult } from "../types.js"
|
|
17
|
+
import { CodegenConfigError, resolveDeclarationsBaseName } from "../validate.js"
|
|
17
18
|
|
|
18
19
|
export interface DeclarationsGeneratorOptions {
|
|
19
20
|
/** Absolute path to the .did file */
|
|
@@ -31,18 +32,95 @@ export interface DeclarationsGeneratorResult {
|
|
|
31
32
|
error?: string
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Move `from` onto `to`, replacing whatever is already there.
|
|
37
|
+
*
|
|
38
|
+
* `rename(2)` refuses to replace a non-empty directory, so the previous output
|
|
39
|
+
* is moved aside first and only removed once the new directory is in place. If
|
|
40
|
+
* the second rename fails the old directory is put back, so the observable
|
|
41
|
+
* states are "old output" and "new output" — never "no output".
|
|
42
|
+
*/
|
|
43
|
+
function replaceDirectory(from: string, to: string): void {
|
|
44
|
+
if (!fs.existsSync(to)) {
|
|
45
|
+
fs.renameSync(from, to)
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Same parent as `to`, so this is a rename within one filesystem. Dot-prefixed
|
|
50
|
+
// because a crash between the two renames below strands this directory next
|
|
51
|
+
// to the real one: `declarations.old-XYZ` sits inside the project's source
|
|
52
|
+
// tree and TypeScript would compile it, producing duplicate declarations on
|
|
53
|
+
// top of whatever the crash already caused.
|
|
54
|
+
const displaced = fs.mkdtempSync(
|
|
55
|
+
path.join(path.dirname(to), `.${path.basename(to)}.old-`)
|
|
56
|
+
)
|
|
57
|
+
// mkdtemp created the directory; rename needs the name to be free.
|
|
58
|
+
fs.rmdirSync(displaced)
|
|
59
|
+
fs.renameSync(to, displaced)
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
fs.renameSync(from, to)
|
|
63
|
+
} catch (error) {
|
|
64
|
+
fs.renameSync(displaced, to)
|
|
65
|
+
throw error
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
fs.rmSync(displaced, { recursive: true, force: true })
|
|
69
|
+
}
|
|
70
|
+
|
|
34
71
|
/**
|
|
35
72
|
* Generate TypeScript declarations from a Candid file.
|
|
36
73
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
74
|
+
* Generation happens in a staging directory that is swapped over the existing
|
|
75
|
+
* `declarations/` only after every file has been written. The parser runs on
|
|
76
|
+
* user-authored Candid and throws on a syntax error — under the vite plugin
|
|
77
|
+
* that is a *normal* watch-mode event, one keystroke in a .did file — so
|
|
78
|
+
* deleting the previous output before parsing turned every typo into a broken
|
|
79
|
+
* build with no declarations at all. A failure now leaves the previous
|
|
80
|
+
* declarations byte-identical.
|
|
81
|
+
*/
|
|
82
|
+
/**
|
|
83
|
+
* The export the generated reactor imports. Its presence in `didToJs` output is
|
|
84
|
+
* the authoritative "this .did describes a service" test — see the note in
|
|
85
|
+
* generateDeclarations.
|
|
86
|
+
*/
|
|
87
|
+
const HAS_IDL_FACTORY = /\bexport\s+const\s+idlFactory\b/
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Marker naming the canister an output directory belongs to. Dot-prefixed so
|
|
91
|
+
* it stays out of the generated surface consumers import.
|
|
39
92
|
*/
|
|
93
|
+
export const OWNER_FILE = ".ic-reactor-owner"
|
|
94
|
+
|
|
40
95
|
export async function generateDeclarations(
|
|
41
96
|
options: DeclarationsGeneratorOptions
|
|
42
97
|
): Promise<DeclarationsGeneratorResult> {
|
|
43
98
|
const { didFile, outDir, canisterName } = options
|
|
44
99
|
|
|
45
|
-
|
|
100
|
+
const declarationsDir = path.join(outDir, "declarations")
|
|
101
|
+
|
|
102
|
+
// Checked before the file itself: an absolute `didFile` ending in ".." is not
|
|
103
|
+
// normalized by the caller and does exist, so without this the diagnostic
|
|
104
|
+
// would be about the file type rather than about the path being a directory.
|
|
105
|
+
let baseName: string
|
|
106
|
+
try {
|
|
107
|
+
baseName = resolveDeclarationsBaseName(didFile)
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (error instanceof CodegenConfigError) {
|
|
110
|
+
return {
|
|
111
|
+
success: false,
|
|
112
|
+
declarationsDir: "",
|
|
113
|
+
files: [],
|
|
114
|
+
error: `[${canisterName}] ${error.message}`,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
throw error
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let didStat: fs.Stats
|
|
121
|
+
try {
|
|
122
|
+
didStat = fs.statSync(didFile)
|
|
123
|
+
} catch {
|
|
46
124
|
return {
|
|
47
125
|
success: false,
|
|
48
126
|
declarationsDir: "",
|
|
@@ -51,34 +129,72 @@ export async function generateDeclarations(
|
|
|
51
129
|
}
|
|
52
130
|
}
|
|
53
131
|
|
|
54
|
-
|
|
55
|
-
|
|
132
|
+
// A fifo or character device reads forever. Refusing anything that is not a
|
|
133
|
+
// regular file turns an indefinite hang into a diagnosable error.
|
|
134
|
+
if (!didStat.isFile()) {
|
|
135
|
+
return {
|
|
136
|
+
success: false,
|
|
137
|
+
declarationsDir: "",
|
|
138
|
+
files: [],
|
|
139
|
+
error: `[${canisterName}] DID path is not a regular file: ${didFile}`,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let staging: string | undefined
|
|
56
144
|
|
|
57
145
|
try {
|
|
58
146
|
// Read the DID content before any directory manipulation
|
|
59
147
|
const didContent = fs.readFileSync(didFile, "utf-8")
|
|
60
148
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
fs.mkdirSync(outDir, { recursive: true })
|
|
64
|
-
}
|
|
149
|
+
const jsContent = didToJs(didContent)
|
|
150
|
+
const tsContent = didToTs(didContent)
|
|
65
151
|
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
152
|
+
// A .did that parses but declares no service is a normal thing to have — a
|
|
153
|
+
// shared types file, say — but it compiles to a module with no `idlFactory`
|
|
154
|
+
// and no `_SERVICE`, which is exactly what the generated reactor imports.
|
|
155
|
+
// Caught here the caller gets the .did path; not caught, the consumer's
|
|
156
|
+
// build fails on a file we just reported as generated.
|
|
157
|
+
//
|
|
158
|
+
// The test is on the GENERATED OUTPUT, not on `parseDid(...).service`. That
|
|
159
|
+
// AST field is only populated for an inline body; it is null for every
|
|
160
|
+
// alias form — `service : S;`, `service : (args) -> S;`, `service name : S;`
|
|
161
|
+
// — which the parser nonetheless compiles to a complete idlFactory. Gating
|
|
162
|
+
// on the AST field rejected those valid files outright.
|
|
163
|
+
if (!HAS_IDL_FACTORY.test(jsContent)) {
|
|
164
|
+
return {
|
|
165
|
+
success: false,
|
|
166
|
+
declarationsDir,
|
|
167
|
+
files: [],
|
|
168
|
+
error:
|
|
169
|
+
`[${canisterName}] ${didFile} produces no idlFactory, so there is no service ` +
|
|
170
|
+
`to generate against. Point this canister at the .did file that declares its ` +
|
|
171
|
+
`service.`,
|
|
172
|
+
}
|
|
69
173
|
}
|
|
70
|
-
fs.mkdirSync(declarationsDir, { recursive: true })
|
|
71
174
|
|
|
72
|
-
|
|
73
|
-
|
|
175
|
+
// Ensure output dir exists — it is also the staging directory's parent, so
|
|
176
|
+
// the swap below stays a same-filesystem rename.
|
|
177
|
+
fs.mkdirSync(outDir, { recursive: true })
|
|
178
|
+
|
|
179
|
+
// Record which canister owns this directory. The pipeline reads it to stop
|
|
180
|
+
// a second canister generating into the same outDir and wiping the first
|
|
181
|
+
// one's declarations. It lives here rather than in index.generated.ts
|
|
182
|
+
// because `--bindgen-only` never writes that file, and the declarations
|
|
183
|
+
// replacement it skips past is exactly the destructive step.
|
|
184
|
+
fs.writeFileSync(path.join(outDir, OWNER_FILE), `${canisterName}\n`)
|
|
185
|
+
|
|
186
|
+
staging = fs.mkdtempSync(path.join(outDir, ".declarations.tmp-"))
|
|
74
187
|
|
|
75
188
|
const jsPath = path.join(declarationsDir, `${baseName}.js`)
|
|
76
189
|
const dtsPath = path.join(declarationsDir, `${baseName}.d.ts`)
|
|
77
190
|
const didCopyPath = path.join(declarationsDir, `${baseName}.did`)
|
|
78
191
|
|
|
79
|
-
fs.writeFileSync(
|
|
80
|
-
fs.writeFileSync(
|
|
81
|
-
fs.writeFileSync(
|
|
192
|
+
fs.writeFileSync(path.join(staging, `${baseName}.js`), jsContent)
|
|
193
|
+
fs.writeFileSync(path.join(staging, `${baseName}.d.ts`), tsContent)
|
|
194
|
+
fs.writeFileSync(path.join(staging, `${baseName}.did`), didContent)
|
|
195
|
+
|
|
196
|
+
replaceDirectory(staging, declarationsDir)
|
|
197
|
+
staging = undefined
|
|
82
198
|
|
|
83
199
|
return {
|
|
84
200
|
success: true,
|
|
@@ -97,16 +213,43 @@ export async function generateDeclarations(
|
|
|
97
213
|
files: [],
|
|
98
214
|
error: `[${canisterName}] Failed to generate declarations: ${message}`,
|
|
99
215
|
}
|
|
216
|
+
} finally {
|
|
217
|
+
// Reached on the failure path and on an interrupted swap; a completed swap
|
|
218
|
+
// has already cleared `staging`.
|
|
219
|
+
if (staging) fs.rmSync(staging, { recursive: true, force: true })
|
|
100
220
|
}
|
|
101
221
|
}
|
|
102
222
|
|
|
103
223
|
/**
|
|
104
224
|
* Check if declarations already exist for a canister.
|
|
225
|
+
*
|
|
226
|
+
* Declarations are written under the *.did basename*, which need not equal the
|
|
227
|
+
* canister name — `{ name: "backend", didFile: "service.did" }` writes
|
|
228
|
+
* `declarations/service.d.ts`. Pass `didFile` for an exact answer; without it
|
|
229
|
+
* this falls back to any `.d.ts` in the directory, because a bare
|
|
230
|
+
* `<canisterName>.d.ts` check reports "missing" for perfectly good output.
|
|
105
231
|
*/
|
|
106
232
|
export function declarationsExist(
|
|
107
233
|
outDir: string,
|
|
108
|
-
canisterName: string
|
|
234
|
+
canisterName: string,
|
|
235
|
+
didFile?: string
|
|
109
236
|
): boolean {
|
|
110
|
-
const
|
|
111
|
-
|
|
237
|
+
const declarationsDir = path.join(outDir, "declarations")
|
|
238
|
+
|
|
239
|
+
if (didFile !== undefined) {
|
|
240
|
+
const baseName = path.basename(didFile, ".did")
|
|
241
|
+
return fs.existsSync(path.join(declarationsDir, `${baseName}.d.ts`))
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (fs.existsSync(path.join(declarationsDir, `${canisterName}.d.ts`))) {
|
|
245
|
+
return true
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
return fs
|
|
250
|
+
.readdirSync(declarationsDir)
|
|
251
|
+
.some((entry) => entry.endsWith(".d.ts"))
|
|
252
|
+
} catch {
|
|
253
|
+
return false
|
|
254
|
+
}
|
|
112
255
|
}
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
* } = createActorHooks(backendReactor)
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import path from "node:path"
|
|
24
23
|
import { toPascalCase, getReactorName, getServiceTypeName } from "../naming.js"
|
|
25
24
|
import type { CodegenTarget, ReactorClassName } from "../types.js"
|
|
25
|
+
import { resolveDeclarationsBaseName } from "../validate.js"
|
|
26
26
|
|
|
27
27
|
export interface ReactorGeneratorOptions {
|
|
28
28
|
/** Canister name (e.g. "backend") */
|
|
@@ -60,6 +60,14 @@ function getReactorClassImportSource(
|
|
|
60
60
|
case "CandidDisplayReactor":
|
|
61
61
|
case "MetadataDisplayReactor":
|
|
62
62
|
return "@ic-reactor/candid"
|
|
63
|
+
default:
|
|
64
|
+
// The pipeline validates `mode` against a closed set before we are
|
|
65
|
+
// reached. Failing closed here means a caller that skips validation gets
|
|
66
|
+
// an error rather than an unknown class name interpolated into source.
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Unknown reactor class ${JSON.stringify(reactorClass)}. Expected one of: ` +
|
|
69
|
+
`Reactor, DisplayReactor, CandidReactor, CandidDisplayReactor, MetadataDisplayReactor.`
|
|
70
|
+
)
|
|
63
71
|
}
|
|
64
72
|
}
|
|
65
73
|
|
|
@@ -80,8 +88,10 @@ export function generateReactorFile(options: ReactorGeneratorOptions): string {
|
|
|
80
88
|
const reactorName = getReactorName(canisterName)
|
|
81
89
|
const serviceName = getServiceTypeName(canisterName)
|
|
82
90
|
|
|
83
|
-
// Derive the declarations import path from the .did filename
|
|
84
|
-
|
|
91
|
+
// Derive the declarations import path from the .did filename. The helper
|
|
92
|
+
// rejects stems like "." and ".." rather than emitting an import of a
|
|
93
|
+
// directory — this runs even for callers that bypass the pipeline.
|
|
94
|
+
const baseName = resolveDeclarationsBaseName(didFile)
|
|
85
95
|
const declarationsPath = `./declarations/${baseName}`
|
|
86
96
|
const reactorImportSource = getReactorClassImportSource(
|
|
87
97
|
reactorClass,
|
|
@@ -105,9 +115,12 @@ export const {
|
|
|
105
115
|
`
|
|
106
116
|
: ""
|
|
107
117
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
118
|
+
// Every interpolation into emitted source is JSON.stringify'd. The pipeline
|
|
119
|
+
// validates these values before we are called; quoting them here as well
|
|
120
|
+
// means a future caller that skips validation cannot inject source text.
|
|
121
|
+
return `${runtimeTarget === "react" ? 'import { createActorHooks } from "@ic-reactor/react"\n' : ""}import { ${reactorClass} } from ${JSON.stringify(reactorImportSource)}
|
|
122
|
+
import { clientManager } from ${JSON.stringify(clientManagerPath)}
|
|
123
|
+
import { idlFactory, type _SERVICE } from ${JSON.stringify(declarationsPath)}
|
|
111
124
|
|
|
112
125
|
export type ${serviceName} = _SERVICE
|
|
113
126
|
|
|
@@ -123,7 +136,7 @@ export type ${serviceName} = _SERVICE
|
|
|
123
136
|
export const ${reactorName} = new ${reactorClass}<${serviceName}>({
|
|
124
137
|
clientManager,
|
|
125
138
|
idlFactory,
|
|
126
|
-
${canisterIdLine} name:
|
|
139
|
+
${canisterIdLine} name: ${JSON.stringify(canisterName)},
|
|
127
140
|
})${hookExports || "\n"}`
|
|
128
141
|
}
|
|
129
142
|
|
package/src/index.ts
CHANGED
|
@@ -21,11 +21,25 @@ export type { PipelineOptions, PipelineResult } from "./pipeline.js"
|
|
|
21
21
|
// Utilities
|
|
22
22
|
export { toPascalCase, getReactorName, getServiceTypeName } from "./naming.js"
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
// Config validation (run by the pipeline; exported for callers that want to
|
|
25
|
+
// validate a config before invoking generation)
|
|
26
|
+
export {
|
|
27
|
+
assertSafeCanisterConfig,
|
|
28
|
+
assertSafeCanisterName,
|
|
29
|
+
assertSafeModuleSpecifier,
|
|
30
|
+
assertContainedPath,
|
|
31
|
+
assertOneOf,
|
|
32
|
+
resolveContainedOutDir,
|
|
33
|
+
resolveDeclarationsBaseName,
|
|
34
|
+
CodegenConfigError,
|
|
35
|
+
CANISTER_NAME_PATTERN,
|
|
36
|
+
REACTOR_CLASS_NAMES,
|
|
37
|
+
CODEGEN_TARGETS,
|
|
38
|
+
} from "./validate.js"
|
|
39
|
+
export type {
|
|
40
|
+
ValidatedCanisterPaths,
|
|
41
|
+
ValidateCanisterConfigOptions,
|
|
42
|
+
} from "./validate.js"
|
|
26
43
|
|
|
27
44
|
// Individual Generators (Advanced Usage)
|
|
28
45
|
export * from "./generators/index.js"
|
|
29
|
-
|
|
30
|
-
// Renderer (Candid Codec)
|
|
31
|
-
export { generateCodecDeclarations } from "./renderer.js"
|
package/src/pipeline.ts
CHANGED
|
@@ -20,11 +20,17 @@ import type {
|
|
|
20
20
|
GeneratorResult,
|
|
21
21
|
ReactorClassName,
|
|
22
22
|
} from "./types.js"
|
|
23
|
-
import { generateDeclarations } from "./generators/declarations.js"
|
|
23
|
+
import { generateDeclarations, OWNER_FILE } from "./generators/declarations.js"
|
|
24
24
|
import {
|
|
25
25
|
generateReactorEntryFile,
|
|
26
26
|
generateReactorFile,
|
|
27
27
|
} from "./generators/reactor.js"
|
|
28
|
+
import {
|
|
29
|
+
assertSafeCanisterConfig,
|
|
30
|
+
CodegenConfigError,
|
|
31
|
+
resolveDeclarationsBaseName,
|
|
32
|
+
} from "./validate.js"
|
|
33
|
+
import { getReactorName, getServiceTypeName } from "./naming.js"
|
|
28
34
|
|
|
29
35
|
export interface PipelineOptions {
|
|
30
36
|
/** Canister name and config */
|
|
@@ -60,13 +66,114 @@ function normalizeFileContent(content: string): string {
|
|
|
60
66
|
return content.replace(/\r\n/g, "\n").trim()
|
|
61
67
|
}
|
|
62
68
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
69
|
+
/** Header line every version of this generator has written into its output. */
|
|
70
|
+
const GENERATED_MARKER = "Auto-generated by @ic-reactor/codegen"
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The reactor's `name:` property as this generator emits it. Canister names are
|
|
74
|
+
* restricted to `CANISTER_NAME_PATTERN`, so the emitted line is exactly this.
|
|
75
|
+
*/
|
|
76
|
+
const GENERATED_CANISTER_NAME = /^ {2}name: "([A-Za-z0-9_.-]+)",$/m
|
|
77
|
+
|
|
78
|
+
/** Every `export …` statement, one per line, as the generator writes them. */
|
|
79
|
+
const EXPORT_STATEMENT = /^export\b.*$/gm
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Decide whether an existing `index.ts` is still the pre-split generated
|
|
83
|
+
* reactor file, which this pipeline migrates to the thin wrapper.
|
|
84
|
+
*
|
|
85
|
+
* The old test — the header marker plus the substring `createActorHooks(` —
|
|
86
|
+
* matched any file that had *ever* been generated, including one the user has
|
|
87
|
+
* since filled with their own factories and hooks. Migration would overwrite it
|
|
88
|
+
* with six lines of boilerplate and report the write as an ordinary success.
|
|
89
|
+
*
|
|
90
|
+
* So the shape is checked, not just the header: the file must still declare the
|
|
91
|
+
* reactor and hooks for *this* canister and must export nothing the old
|
|
92
|
+
* generator did not write. Anything the user added — another `export const`, an
|
|
93
|
+
* `export * from`, a re-export — takes the file out of scope and it is
|
|
94
|
+
* preserved. Edits that add no export are still possible, which is why the
|
|
95
|
+
* caller keeps a backup before writing.
|
|
96
|
+
*/
|
|
97
|
+
function isLegacyGeneratedIndexFile(
|
|
98
|
+
content: string,
|
|
99
|
+
canisterName: string
|
|
100
|
+
): boolean {
|
|
101
|
+
if (!content.includes(GENERATED_MARKER)) return false
|
|
102
|
+
|
|
103
|
+
const reactorName = getReactorName(canisterName)
|
|
104
|
+
const serviceName = getServiceTypeName(canisterName)
|
|
105
|
+
|
|
106
|
+
// The two lines that make this the generated reactor for this canister.
|
|
107
|
+
if (!content.includes(`export const ${reactorName} = new `)) return false
|
|
108
|
+
if (!content.includes(`= createActorHooks(${reactorName})`)) return false
|
|
109
|
+
|
|
110
|
+
const allowed = [
|
|
111
|
+
`export type ${serviceName} = _SERVICE`,
|
|
112
|
+
`export const ${reactorName} = new `,
|
|
113
|
+
// Opens the destructured hook block; its members are not export statements.
|
|
114
|
+
"export const {",
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
const statements = normalizeFileContent(content).match(EXPORT_STATEMENT) ?? []
|
|
118
|
+
|
|
119
|
+
return statements.every((statement) =>
|
|
120
|
+
allowed.some((prefix) => statement.startsWith(prefix))
|
|
67
121
|
)
|
|
68
122
|
}
|
|
69
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Copy `filePath` next to itself before it is overwritten, and return the
|
|
126
|
+
* backup path.
|
|
127
|
+
*
|
|
128
|
+
* Migration decides from the file's contents that it is safe to replace. That
|
|
129
|
+
* inference can be wrong for an edit that adds no export, and the file being
|
|
130
|
+
* replaced may be the only copy of work the user has not committed, so the
|
|
131
|
+
* original is kept rather than deleted. An existing backup is never clobbered.
|
|
132
|
+
*/
|
|
133
|
+
function backUpFile(filePath: string): string {
|
|
134
|
+
let backupPath = `${filePath}.bak`
|
|
135
|
+
for (let n = 2; fs.existsSync(backupPath); n += 1) {
|
|
136
|
+
backupPath = `${filePath}.bak.${n}`
|
|
137
|
+
}
|
|
138
|
+
fs.copyFileSync(filePath, backupPath)
|
|
139
|
+
return backupPath
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Read the canister name out of an existing `index.generated.ts`, if the file
|
|
144
|
+
* exists and this package wrote it.
|
|
145
|
+
*
|
|
146
|
+
* Two canisters pointed at the same `outDir` overwrite each other's
|
|
147
|
+
* `index.generated.ts` and — since the declarations directory is replaced
|
|
148
|
+
* wholesale — each other's declarations too, with both runs reporting success.
|
|
149
|
+
* The generated file records which canister owns the directory, so the second
|
|
150
|
+
* canister can be stopped before it destroys the first one's output.
|
|
151
|
+
*/
|
|
152
|
+
function findOutDirOwner(outDir: string): string | undefined {
|
|
153
|
+
// Written by generateDeclarations, so it is present for `--bindgen-only`
|
|
154
|
+
// runs too — those skip index.generated.ts but still replace declarations/,
|
|
155
|
+
// which is the step that destroys the other canister's output.
|
|
156
|
+
try {
|
|
157
|
+
const owner = fs.readFileSync(path.join(outDir, OWNER_FILE), "utf-8").trim()
|
|
158
|
+
if (owner) return owner
|
|
159
|
+
} catch {
|
|
160
|
+
// fall through to the legacy marker
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Directories generated before the marker existed only carry the canister
|
|
164
|
+
// name inside index.generated.ts.
|
|
165
|
+
let content: string
|
|
166
|
+
try {
|
|
167
|
+
content = fs.readFileSync(path.join(outDir, "index.generated.ts"), "utf-8")
|
|
168
|
+
} catch {
|
|
169
|
+
return undefined
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!content.includes(GENERATED_MARKER)) return undefined
|
|
173
|
+
|
|
174
|
+
return GENERATED_CANISTER_NAME.exec(content)?.[1]
|
|
175
|
+
}
|
|
176
|
+
|
|
70
177
|
function isManagedEntryWrapper(
|
|
71
178
|
content: string,
|
|
72
179
|
expectedEntryContent: string
|
|
@@ -102,6 +209,37 @@ export async function runCanisterPipeline(
|
|
|
102
209
|
|
|
103
210
|
const files: GeneratorResult[] = []
|
|
104
211
|
|
|
212
|
+
// ── Validate config ────────────────────────────────────────────────────────
|
|
213
|
+
//
|
|
214
|
+
// Runs before any filesystem work. `name`, `outDir` and `clientManagerPath`
|
|
215
|
+
// come from a checked-in config file, and downstream they become a directory
|
|
216
|
+
// this pipeline recursively deletes and source text it writes into the user's
|
|
217
|
+
// bundle — so they are validated here, at the choke point every entry path
|
|
218
|
+
// (CLI, vite plugin, direct API) goes through.
|
|
219
|
+
let validated: ReturnType<typeof assertSafeCanisterConfig>
|
|
220
|
+
try {
|
|
221
|
+
validated = assertSafeCanisterConfig({
|
|
222
|
+
name,
|
|
223
|
+
canisterOutDir: canisterConfig.outDir,
|
|
224
|
+
globalOutDir: globalConfig.outDir,
|
|
225
|
+
clientManagerPath:
|
|
226
|
+
clientManagerPath ?? globalConfig.clientManagerPath ?? "../../clients",
|
|
227
|
+
projectRoot,
|
|
228
|
+
mode: canisterConfig.mode,
|
|
229
|
+
target: canisterConfig.target ?? globalConfig.target,
|
|
230
|
+
})
|
|
231
|
+
} catch (err) {
|
|
232
|
+
if (err instanceof CodegenConfigError) {
|
|
233
|
+
return {
|
|
234
|
+
canisterName: typeof name === "string" ? name : String(name),
|
|
235
|
+
success: false,
|
|
236
|
+
files,
|
|
237
|
+
error: err.message,
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
throw err
|
|
241
|
+
}
|
|
242
|
+
|
|
105
243
|
// ── Resolve paths ──────────────────────────────────────────────────────────
|
|
106
244
|
|
|
107
245
|
const resolvedDidFile = path.isAbsolute(didFile)
|
|
@@ -117,17 +255,47 @@ export async function runCanisterPipeline(
|
|
|
117
255
|
}
|
|
118
256
|
}
|
|
119
257
|
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
258
|
+
// The .did basename names the generated declaration files and is the tail of
|
|
259
|
+
// the import specifier the reactor emits, so it is validated here, before any
|
|
260
|
+
// filesystem work, rather than surfacing as a broken import later.
|
|
261
|
+
try {
|
|
262
|
+
resolveDeclarationsBaseName(resolvedDidFile)
|
|
263
|
+
} catch (err) {
|
|
264
|
+
if (err instanceof CodegenConfigError) {
|
|
265
|
+
return {
|
|
266
|
+
canisterName: name,
|
|
267
|
+
success: false,
|
|
268
|
+
files,
|
|
269
|
+
error: `[${name}] ${err.message}`,
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
throw err
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Per-canister outDir overrides global outDir; both are resolved and
|
|
276
|
+
// containment-checked by assertSafeCanisterConfig above.
|
|
277
|
+
const canisterOutDir = validated.outDir
|
|
278
|
+
|
|
279
|
+
// Refuse to generate into a directory another canister already owns. This has
|
|
280
|
+
// to happen before the declarations step, which replaces `declarations/`
|
|
281
|
+
// wholesale and would otherwise take the other canister's output with it.
|
|
282
|
+
const owner = findOutDirOwner(canisterOutDir)
|
|
283
|
+
if (owner !== undefined && owner !== name) {
|
|
284
|
+
return {
|
|
285
|
+
canisterName: name,
|
|
286
|
+
success: false,
|
|
287
|
+
files,
|
|
288
|
+
error:
|
|
289
|
+
`[${name}] Output directory ${canisterOutDir} was generated for canister ` +
|
|
290
|
+
`"${owner}". Two canisters cannot share an outDir — each run replaces the ` +
|
|
291
|
+
`declarations directory and index.generated.ts, so they would overwrite each ` +
|
|
292
|
+
`other. Give each canister its own "outDir". If you renamed "${owner}" to ` +
|
|
293
|
+
`"${name}", delete that directory and regenerate.`,
|
|
294
|
+
}
|
|
295
|
+
}
|
|
127
296
|
|
|
128
297
|
// clientManagerPath falls back to global, then a safe default
|
|
129
|
-
const resolvedClientManagerPath =
|
|
130
|
-
clientManagerPath ?? globalConfig.clientManagerPath ?? "../../clients"
|
|
298
|
+
const resolvedClientManagerPath = validated.clientManagerPath
|
|
131
299
|
|
|
132
300
|
// ── Step 1: Declarations ───────────────────────────────────────────────────
|
|
133
301
|
|
|
@@ -193,11 +361,15 @@ export async function runCanisterPipeline(
|
|
|
193
361
|
} else {
|
|
194
362
|
const existingEntryContent = fs.readFileSync(entryPath, "utf-8")
|
|
195
363
|
|
|
196
|
-
if (
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
) {
|
|
364
|
+
if (isManagedEntryWrapper(existingEntryContent, entryContent)) {
|
|
365
|
+
fs.writeFileSync(entryPath, entryContent)
|
|
366
|
+
files.push({ success: true, filePath: entryPath })
|
|
367
|
+
} else if (isLegacyGeneratedIndexFile(existingEntryContent, name)) {
|
|
368
|
+
// Migration replaces a file the user owns, on the strength of a
|
|
369
|
+
// content match. Keep the original so a wrong match is recoverable.
|
|
370
|
+
const backupPath = backUpFile(entryPath)
|
|
200
371
|
fs.writeFileSync(entryPath, entryContent)
|
|
372
|
+
files.push({ success: true, filePath: backupPath })
|
|
201
373
|
files.push({ success: true, filePath: entryPath })
|
|
202
374
|
} else {
|
|
203
375
|
files.push({ success: true, filePath: entryPath, skipped: true })
|