@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ic-reactor/vite-plugin",
3
- "version": "0.14.0",
4
- "description": "Vite plugin for zero-config IC reactor generation from Candid files",
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/v3/packages/vite-plugin",
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 { execFileSync } from "child_process"
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
- execFileSync("icp", ["network", "status", "-e", networkName, "--json"], {
47
- cwd: projectRoot,
48
- encoding: "utf-8",
49
- // stderr is piped rather than ignored so a failure can explain itself.
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 = execFileSync(
76
- "icp",
77
- ["canister", "status", name, "-e", networkName, "-i"],
78
- {
79
- cwd: projectRoot,
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 `execFileSync` threw into one readable line.
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