@ic-reactor/vite-plugin 0.14.0 → 4.0.0-beta.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/README.md +173 -130
- package/dist/index.cjs +782 -218
- package/dist/index.d.cts +99 -29
- package/dist/index.d.ts +99 -29
- package/dist/index.js +783 -221
- package/package.json +6 -8
- package/src/dev-environment.ts +268 -0
- package/src/env.ts +54 -20
- package/src/generate.ts +594 -0
- package/src/index.ts +665 -396
- package/llms.txt +0 -55
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ic-reactor/vite-plugin",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Vite plugin
|
|
3
|
+
"version": "4.0.0-beta.1",
|
|
4
|
+
"description": "Vite plugin that generates candid-core modules from .did files with candid-core-cli and injects the local IC environment (ic_env cookie and /api proxy) in development",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
7
7
|
"module": "./dist/index.js",
|
|
@@ -24,8 +24,7 @@
|
|
|
24
24
|
"src",
|
|
25
25
|
"!src/**/*.test.ts",
|
|
26
26
|
"!src/**/__snapshots__",
|
|
27
|
-
"README.md"
|
|
28
|
-
"llms.txt"
|
|
27
|
+
"README.md"
|
|
29
28
|
],
|
|
30
29
|
"keywords": [
|
|
31
30
|
"vite",
|
|
@@ -48,14 +47,13 @@
|
|
|
48
47
|
"bugs": {
|
|
49
48
|
"url": "https://github.com/B3Pay/ic-reactor/issues"
|
|
50
49
|
},
|
|
51
|
-
"homepage": "https://ic-reactor.b3pay.net/
|
|
52
|
-
"dependencies": {
|
|
53
|
-
"@ic-reactor/codegen": "0.14.0"
|
|
54
|
-
},
|
|
50
|
+
"homepage": "https://ic-reactor.b3pay.net/v4/",
|
|
55
51
|
"peerDependencies": {
|
|
52
|
+
"@candid-core/cli": "0.2.0-beta.1",
|
|
56
53
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
|
57
54
|
},
|
|
58
55
|
"devDependencies": {
|
|
56
|
+
"@candid-core/cli": "0.2.0-beta.1",
|
|
59
57
|
"@types/node": "^26.5.1",
|
|
60
58
|
"tsup": "^8.5.1",
|
|
61
59
|
"typescript": "^6.0.3",
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local IC environment that `vite dev` and `vite preview` inject, kept up
|
|
3
|
+
* to date while detection is incomplete.
|
|
4
|
+
*
|
|
5
|
+
* The dev server usually starts before the canisters are deployed, and
|
|
6
|
+
* sometimes before the local network is up. Detection used to run once, in the
|
|
7
|
+
* `config` hook, and its answer was fixed into `server.headers` and
|
|
8
|
+
* `server.proxy` for the server's lifetime. A canister deployed afterwards
|
|
9
|
+
* never reached the `ic_env` cookie, and a network started afterwards never
|
|
10
|
+
* received `/api`, until the dev server was restarted.
|
|
11
|
+
*
|
|
12
|
+
* Detection now runs again for each page load while it is incomplete, that is
|
|
13
|
+
* while `icp` reports no network or a configured canister has no ID. Once
|
|
14
|
+
* every configured canister has an ID on a detected network it stops, and page
|
|
15
|
+
* loads run no `icp` command. A redeploy into a fresh network, with new IDs and
|
|
16
|
+
* a new root key, still needs a restart.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { IncomingMessage, ServerResponse } from "node:http"
|
|
20
|
+
import {
|
|
21
|
+
buildIcEnvCookie,
|
|
22
|
+
getIcEnvironmentInfo,
|
|
23
|
+
type IcEnvironment,
|
|
24
|
+
} from "./env.js"
|
|
25
|
+
|
|
26
|
+
/** Where `/api` goes while `icp` reports no network. */
|
|
27
|
+
export const DEFAULT_LOCAL_REPLICA = "http://127.0.0.1:4943"
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The Internet Identity provider the cookie names when no canister is
|
|
31
|
+
* configured and `icp` reports no network: icp-cli's built-in one, on its
|
|
32
|
+
* default port.
|
|
33
|
+
*/
|
|
34
|
+
const DEFAULT_INTERNET_IDENTITY_PROVIDER =
|
|
35
|
+
"http://id.ai.localhost:8000/authorize"
|
|
36
|
+
|
|
37
|
+
/** What the dev server injects, from the latest detection. */
|
|
38
|
+
export interface LocalEnvironmentState {
|
|
39
|
+
/** What `icp` reported, or `null` while it has reported no network. */
|
|
40
|
+
environment: IcEnvironment | null
|
|
41
|
+
/** The `ic_env` cookie's value, or `undefined` when no cookie is set. */
|
|
42
|
+
cookie: string | undefined
|
|
43
|
+
/** Where the plugin's `/api` proxy sends requests. */
|
|
44
|
+
proxyTarget: string
|
|
45
|
+
/** Configured canisters with neither a detected nor a configured ID. */
|
|
46
|
+
missingCanisterIds: string[]
|
|
47
|
+
/**
|
|
48
|
+
* `icp` reported a network and every configured canister has an ID, so
|
|
49
|
+
* detection does not run again.
|
|
50
|
+
*/
|
|
51
|
+
complete: boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface LocalEnvironmentOptions {
|
|
55
|
+
/** The configured canisters' names. */
|
|
56
|
+
canisterNames: string[]
|
|
57
|
+
/** IDs set in the plugin config. They win over detected ones. */
|
|
58
|
+
configuredCanisterIds: Record<string, string>
|
|
59
|
+
/** The directory `icp` runs in. */
|
|
60
|
+
projectRoot: string
|
|
61
|
+
/** Receives each line that explains why an `icp` command failed. */
|
|
62
|
+
onDiagnostic: (message: string) => void
|
|
63
|
+
/**
|
|
64
|
+
* Called after each detection with the state before it, `undefined` for the
|
|
65
|
+
* first, and the state after it.
|
|
66
|
+
*/
|
|
67
|
+
onUpdate?: (
|
|
68
|
+
previous: LocalEnvironmentState | undefined,
|
|
69
|
+
next: LocalEnvironmentState
|
|
70
|
+
) => void
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface LocalEnvironment {
|
|
74
|
+
/** The latest detection's result, once one has finished. */
|
|
75
|
+
readonly state: LocalEnvironmentState | undefined
|
|
76
|
+
/**
|
|
77
|
+
* Ask `icp` again and resolve with the new state. A call while a detection
|
|
78
|
+
* runs shares it. Never rejects.
|
|
79
|
+
*/
|
|
80
|
+
detect(): Promise<LocalEnvironmentState>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function createLocalEnvironment(
|
|
84
|
+
options: LocalEnvironmentOptions
|
|
85
|
+
): LocalEnvironment {
|
|
86
|
+
const {
|
|
87
|
+
canisterNames,
|
|
88
|
+
configuredCanisterIds,
|
|
89
|
+
projectRoot,
|
|
90
|
+
onDiagnostic,
|
|
91
|
+
onUpdate,
|
|
92
|
+
} = options
|
|
93
|
+
|
|
94
|
+
// Two entries can share a name. internet_identity is looked up as well, but
|
|
95
|
+
// it is routinely not deployed, so it is not needed for detection to be
|
|
96
|
+
// complete.
|
|
97
|
+
const requiredNames = [...new Set(canisterNames)]
|
|
98
|
+
const lookupNames = requiredNames.includes("internet_identity")
|
|
99
|
+
? requiredNames
|
|
100
|
+
: [...requiredNames, "internet_identity"]
|
|
101
|
+
|
|
102
|
+
let state: LocalEnvironmentState | undefined
|
|
103
|
+
let running: Promise<LocalEnvironmentState> | undefined
|
|
104
|
+
|
|
105
|
+
const toState = (
|
|
106
|
+
environment: IcEnvironment | null
|
|
107
|
+
): LocalEnvironmentState => {
|
|
108
|
+
const canisterIds = {
|
|
109
|
+
...environment?.canisterIds,
|
|
110
|
+
...configuredCanisterIds,
|
|
111
|
+
}
|
|
112
|
+
const missingCanisterIds = requiredNames.filter(
|
|
113
|
+
(name) => !canisterIds[name]
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
let cookie: string | undefined
|
|
117
|
+
if (environment) {
|
|
118
|
+
cookie = buildIcEnvCookie(
|
|
119
|
+
canisterIds,
|
|
120
|
+
environment.rootKey,
|
|
121
|
+
environment.internetIdentityProvider
|
|
122
|
+
)
|
|
123
|
+
} else if (requiredNames.length === 0) {
|
|
124
|
+
// Env-only mode: there are no canister IDs to carry, but the app can
|
|
125
|
+
// still sign in with icp-cli's built-in Internet Identity.
|
|
126
|
+
cookie = buildIcEnvCookie(
|
|
127
|
+
{},
|
|
128
|
+
undefined,
|
|
129
|
+
DEFAULT_INTERNET_IDENTITY_PROVIDER
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
environment,
|
|
135
|
+
cookie,
|
|
136
|
+
proxyTarget: environment?.proxyTarget ?? DEFAULT_LOCAL_REPLICA,
|
|
137
|
+
missingCanisterIds,
|
|
138
|
+
complete: environment !== null && missingCanisterIds.length === 0,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const run = async (): Promise<LocalEnvironmentState> => {
|
|
143
|
+
const previous = state
|
|
144
|
+
try {
|
|
145
|
+
const { environment, diagnostics } = await getIcEnvironmentInfo(
|
|
146
|
+
lookupNames,
|
|
147
|
+
projectRoot
|
|
148
|
+
)
|
|
149
|
+
diagnostics.forEach(onDiagnostic)
|
|
150
|
+
state = toState(
|
|
151
|
+
mergeDetections(previous?.environment ?? null, environment)
|
|
152
|
+
)
|
|
153
|
+
} catch (error) {
|
|
154
|
+
// getIcEnvironmentInfo reports failures in its result. This is for
|
|
155
|
+
// anything else, which must not fail the page request that waits here.
|
|
156
|
+
onDiagnostic(
|
|
157
|
+
`Detecting the local IC environment failed: ${
|
|
158
|
+
error instanceof Error ? error.message : String(error)
|
|
159
|
+
}`
|
|
160
|
+
)
|
|
161
|
+
state = previous ?? toState(null)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
onUpdate?.(previous, state)
|
|
166
|
+
} catch (error) {
|
|
167
|
+
onDiagnostic(
|
|
168
|
+
`Applying the detected IC environment failed: ${
|
|
169
|
+
error instanceof Error ? error.message : String(error)
|
|
170
|
+
}`
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
return state
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
get state() {
|
|
178
|
+
return state
|
|
179
|
+
},
|
|
180
|
+
detect() {
|
|
181
|
+
running ??= run().finally(() => {
|
|
182
|
+
running = undefined
|
|
183
|
+
})
|
|
184
|
+
return running
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Combine a detection with the one before it.
|
|
191
|
+
*
|
|
192
|
+
* `icp` can fail for a moment, during a deploy say. A detection that finds no
|
|
193
|
+
* network keeps the last one that did, and on the same network, which the
|
|
194
|
+
* root key identifies, a canister keeps the ID it had. A cookie that works is
|
|
195
|
+
* not taken away by a command that failed once.
|
|
196
|
+
*/
|
|
197
|
+
function mergeDetections(
|
|
198
|
+
previous: IcEnvironment | null,
|
|
199
|
+
next: IcEnvironment | null
|
|
200
|
+
): IcEnvironment | null {
|
|
201
|
+
if (!next) return previous
|
|
202
|
+
if (!previous || previous.rootKey !== next.rootKey) return next
|
|
203
|
+
const canisterIds = { ...previous.canisterIds, ...next.canisterIds }
|
|
204
|
+
return {
|
|
205
|
+
...next,
|
|
206
|
+
canisterIds,
|
|
207
|
+
// The built-in provider stands in only for a project that has no
|
|
208
|
+
// internet_identity canister, as getIcEnvironmentInfo decides it.
|
|
209
|
+
internetIdentityProvider: canisterIds.internet_identity
|
|
210
|
+
? undefined
|
|
211
|
+
: next.internetIdentityProvider,
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Whether `req` loads a page, which is when the app reads the `ic_env` cookie.
|
|
217
|
+
*
|
|
218
|
+
* Browsers put `text/html` in the Accept header of a navigation, and not in
|
|
219
|
+
* that of a script, a stylesheet, a `fetch` call or an HMR request.
|
|
220
|
+
*/
|
|
221
|
+
export function isDocumentRequest(req: IncomingMessage): boolean {
|
|
222
|
+
if (req.method !== "GET" && req.method !== "HEAD") return false
|
|
223
|
+
const { accept } = req.headers
|
|
224
|
+
return typeof accept === "string" && accept.includes("text/html")
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Add the `ic_env` cookie to `res`, keeping any other cookie set on it. */
|
|
228
|
+
function setIcEnvCookie(res: ServerResponse, value: string): void {
|
|
229
|
+
const existing = res.getHeader("Set-Cookie")
|
|
230
|
+
const others = (
|
|
231
|
+
existing === undefined
|
|
232
|
+
? []
|
|
233
|
+
: Array.isArray(existing)
|
|
234
|
+
? existing
|
|
235
|
+
: [String(existing)]
|
|
236
|
+
).filter((cookie) => !cookie.startsWith("ic_env="))
|
|
237
|
+
const cookie = `ic_env=${value}; Path=/; SameSite=Lax;`
|
|
238
|
+
res.setHeader("Set-Cookie", others.length > 0 ? [...others, cookie] : cookie)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Connect middleware that sets the `ic_env` cookie on each response.
|
|
243
|
+
*
|
|
244
|
+
* For a page load while detection is incomplete, it first asks `icp` again and
|
|
245
|
+
* waits for the answer, so the page that loads after a deploy already carries
|
|
246
|
+
* the new ID. Other requests, and every request once detection is complete,
|
|
247
|
+
* get the cookie from the latest detection without running `icp`.
|
|
248
|
+
*/
|
|
249
|
+
export function icEnvMiddleware(environment: LocalEnvironment) {
|
|
250
|
+
return (
|
|
251
|
+
req: IncomingMessage,
|
|
252
|
+
res: ServerResponse,
|
|
253
|
+
next: (error?: unknown) => void
|
|
254
|
+
): void => {
|
|
255
|
+
const respond = (state: LocalEnvironmentState | undefined) => {
|
|
256
|
+
if (state?.cookie !== undefined) setIcEnvCookie(res, state.cookie)
|
|
257
|
+
next()
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const { state } = environment
|
|
261
|
+
if (state?.complete || !isDocumentRequest(req)) {
|
|
262
|
+
respond(state)
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
void environment.detect().then(respond)
|
|
267
|
+
}
|
|
268
|
+
}
|
package/src/env.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* and building the `ic_env` cookie for the browser.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { execFile } from "child_process"
|
|
9
9
|
|
|
10
10
|
export interface IcEnvironment {
|
|
11
11
|
environment: string
|
|
@@ -27,6 +27,45 @@ export interface IcEnvironmentDetection {
|
|
|
27
27
|
diagnostics: string[]
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* How long one `icp` command may run. The dev server runs detection while a
|
|
32
|
+
* page request waits for it, so a command that never exits must not hold the
|
|
33
|
+
* page forever.
|
|
34
|
+
*/
|
|
35
|
+
const ICP_TIMEOUT_MS = 10_000
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Run `icp` with `args` in `cwd` and resolve with its stdout.
|
|
39
|
+
*
|
|
40
|
+
* Asynchronous, so a page request that waits for detection does not block the
|
|
41
|
+
* dev server's other requests. stderr is captured rather than shown, so a
|
|
42
|
+
* failure can explain itself: it only reaches the terminal if the caller prints
|
|
43
|
+
* the diagnostics. stdin is closed, so a command that asks a question fails
|
|
44
|
+
* instead of waiting for an answer.
|
|
45
|
+
*/
|
|
46
|
+
function runIcp(args: string[], cwd: string): Promise<string> {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const child = execFile(
|
|
49
|
+
"icp",
|
|
50
|
+
args,
|
|
51
|
+
{
|
|
52
|
+
cwd,
|
|
53
|
+
encoding: "utf-8",
|
|
54
|
+
timeout: ICP_TIMEOUT_MS,
|
|
55
|
+
windowsHide: true,
|
|
56
|
+
},
|
|
57
|
+
(error, stdout, stderr) => {
|
|
58
|
+
if (error) {
|
|
59
|
+
reject(Object.assign(error, { stderr }))
|
|
60
|
+
} else {
|
|
61
|
+
resolve(stdout)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
child.stdin?.end()
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
30
69
|
/**
|
|
31
70
|
* Detect the IC environment using the `icp` CLI.
|
|
32
71
|
*
|
|
@@ -34,23 +73,19 @@ export interface IcEnvironmentDetection {
|
|
|
34
73
|
* looking for `icp.yaml` there and in each parent directory, so this has to be
|
|
35
74
|
* the app's root and not wherever the process happened to start.
|
|
36
75
|
*/
|
|
37
|
-
export function getIcEnvironmentInfo(
|
|
76
|
+
export async function getIcEnvironmentInfo(
|
|
38
77
|
canisterNames: string[],
|
|
39
78
|
projectRoot: string = process.cwd()
|
|
40
|
-
): IcEnvironmentDetection {
|
|
79
|
+
): Promise<IcEnvironmentDetection> {
|
|
41
80
|
const networkName = process.env.ICP_ENVIRONMENT || "local"
|
|
42
81
|
const diagnostics: string[] = []
|
|
43
82
|
|
|
44
83
|
try {
|
|
45
84
|
const networkStatus = JSON.parse(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
// Piping still keeps it off the terminal — it only reaches the user if
|
|
51
|
-
// the caller decides to print the diagnostics we collect below.
|
|
52
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
53
|
-
})
|
|
85
|
+
await runIcp(
|
|
86
|
+
["network", "status", "-e", networkName, "--json"],
|
|
87
|
+
projectRoot
|
|
88
|
+
)
|
|
54
89
|
)
|
|
55
90
|
|
|
56
91
|
const rootKey = networkStatus.root_key
|
|
@@ -70,16 +105,15 @@ export function getIcEnvironmentInfo(
|
|
|
70
105
|
|
|
71
106
|
const canisterIds: Record<string, string> = {}
|
|
72
107
|
|
|
108
|
+
// One at a time, as icp is run from a terminal. Each command reads the
|
|
109
|
+
// project's state, and these are not known to be safe to run at once.
|
|
73
110
|
for (const name of canisterNames) {
|
|
74
111
|
try {
|
|
75
|
-
const canisterId =
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
encoding: "utf-8",
|
|
81
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
82
|
-
}
|
|
112
|
+
const canisterId = (
|
|
113
|
+
await runIcp(
|
|
114
|
+
["canister", "status", name, "-e", networkName, "-i"],
|
|
115
|
+
projectRoot
|
|
116
|
+
)
|
|
83
117
|
).trim()
|
|
84
118
|
|
|
85
119
|
if (canisterId) {
|
|
@@ -143,7 +177,7 @@ export function buildIcEnvCookie(
|
|
|
143
177
|
}
|
|
144
178
|
|
|
145
179
|
/**
|
|
146
|
-
* Turn whatever `
|
|
180
|
+
* Turn whatever `icp` failed with into one readable line.
|
|
147
181
|
*
|
|
148
182
|
* The interesting part is almost always the captured stderr — the thrown
|
|
149
183
|
* Error's own message is just "Command failed: icp ..." — but stderr is absent
|