@notionhq/custom-blocks-dev-shell 0.0.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 ADDED
@@ -0,0 +1,30 @@
1
+ # Notion custom blocks dev shell
2
+
3
+ A local preview shell for Notion custom block workers. It builds your worker,
4
+ reads the manifest declared by your `worker.customBlock(...)` calls, serves each
5
+ block with your project's own Vite, and renders them in a mock Notion host with
6
+ sample data sources you can bind against.
7
+
8
+ ## Usage
9
+
10
+ From anywhere inside your worker project:
11
+
12
+ ```bash
13
+ npx @notionhq/custom-blocks-dev-shell
14
+ ```
15
+
16
+ Then open http://localhost:9873.
17
+
18
+ Options:
19
+
20
+ - `--worker <dir>` — point at a worker directory explicitly instead of
21
+ detecting one from the current directory.
22
+ - `--port <port>` — serve the shell UI somewhere other than 9873.
23
+ - `--block-base-port <port>` — first port handed to the per-block dev servers
24
+ (default 9876; blocks count up from there).
25
+
26
+ ## Requirements
27
+
28
+ - Node.js >= 20.19 (per Vite's own minimum for dev servers).
29
+ - The worker's dependencies installed (`npm install`), including `vite` and a
30
+ build script that emits `dist/index.js`.
package/bin/cli.js ADDED
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process"
3
+ import { createRequire } from "node:module"
4
+ import { dirname, resolve } from "node:path"
5
+ import { fileURLToPath } from "node:url"
6
+
7
+ const require = createRequire(import.meta.url)
8
+ let tsxBin
9
+ try {
10
+ const tsxPkg = require.resolve("tsx/package.json")
11
+ tsxBin = resolve(dirname(tsxPkg), "dist/cli.mjs")
12
+ } catch {
13
+ process.stderr.write(
14
+ "✗ Could not locate the bundled tsx runtime. Reinstall the dev shell package.\n",
15
+ )
16
+ process.exit(1)
17
+ }
18
+
19
+ const entryPath = resolve(
20
+ dirname(fileURLToPath(import.meta.url)),
21
+ "..",
22
+ "cli",
23
+ "main.ts",
24
+ )
25
+ const result = spawnSync(
26
+ process.execPath,
27
+ [tsxBin, entryPath, ...process.argv.slice(2)],
28
+ {
29
+ stdio: "inherit",
30
+ env: process.env,
31
+ },
32
+ )
33
+
34
+ if (result.error) {
35
+ process.stderr.write(`✗ Failed to start the dev shell: ${result.error}\n`)
36
+ }
37
+ process.exit(result.status ?? 1)
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Per-block Vite server plumbing shared by the repo dev script
3
+ * (`scripts/dev.ts`) and the published dev-shell CLI (`cli/main.ts`).
4
+ */
5
+
6
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs"
7
+ import { relative, resolve } from "node:path"
8
+ import type { WorkerBlockCapability } from "./worker-manifest"
9
+
10
+ /** First port handed to per-block Vite servers; blocks count up from here. */
11
+ export const BLOCK_BASE_PORT = 9876
12
+
13
+ /** Port the dev-shell-2 UI is served on. */
14
+ export const SHELL_2_PORT = 9873
15
+
16
+ /** One block entry handed to the dev-shell-2 UI. */
17
+ export type BlockRegistryEntry = {
18
+ key: string
19
+ name: string
20
+ url: string
21
+ }
22
+
23
+ /** The registry for a worker's blocks, assuming sequential port assignment. */
24
+ export function buildBlockRegistry(
25
+ blocks: readonly WorkerBlockCapability[],
26
+ basePort: number = BLOCK_BASE_PORT,
27
+ ): BlockRegistryEntry[] {
28
+ return blocks.map((capability, index) => ({
29
+ key: capability.key,
30
+ name: capability.key,
31
+ url: `http://localhost:${basePort + index}/`,
32
+ }))
33
+ }
34
+
35
+ /** Import specifier from `fromDir` to `toPath`, POSIX-style for ESM. */
36
+ function relativeImport(fromDir: string, toPath: string): string {
37
+ const rel = relative(fromDir, toPath).split(/[\\/]/).join("/")
38
+ return rel.startsWith(".") ? rel : `./${rel}`
39
+ }
40
+
41
+ /**
42
+ * Write a per-block Vite config wrapper into the worker's `.dev-shell/` dir. It
43
+ * re-exports the block's own config (keeping its plugins) but pins `root` to the
44
+ * block and `cacheDir` to a block-specific dir, so concurrently-run block
45
+ * servers don't collide in the worker's shared `node_modules/.vite`. It also
46
+ * serves `/custom_blocks.json` from the block's slice of the worker manifest —
47
+ * worker-backed blocks carry no manifest file of their own, so the wrapper
48
+ * plays the part production infra does and answers the SDK's manifest fetch
49
+ * from the worker's declaration. Returns the wrapper path for `vite --config`.
50
+ */
51
+ export function writeBlockViteConfig(
52
+ workerDir: string,
53
+ blockDir: string,
54
+ capability: WorkerBlockCapability,
55
+ ): string {
56
+ const key = capability.key
57
+ const dir = resolve(workerDir, ".dev-shell")
58
+ mkdirSync(dir, { recursive: true })
59
+ const blockConfig = resolve(blockDir, "vite.config.ts")
60
+ const toRoot = relativeImport(dir, blockDir)
61
+ const toCache = relativeImport(
62
+ dir,
63
+ resolve(workerDir, "node_modules/.vite", key),
64
+ )
65
+ const baseImport = existsSync(blockConfig)
66
+ ? `import base from "${relativeImport(dir, blockConfig)}"`
67
+ : `const base = {}`
68
+ const file = resolve(dir, `${key}.mjs`)
69
+ writeFileSync(
70
+ file,
71
+ `import { fileURLToPath } from "node:url"
72
+ ${baseImport}
73
+
74
+ const here = p => fileURLToPath(new URL(p, import.meta.url))
75
+ const resolved =
76
+ typeof base === "function"
77
+ ? await base({ command: "serve", mode: "development" })
78
+ : base
79
+
80
+ const manifest = ${JSON.stringify(JSON.stringify(capability.config.manifest))}
81
+
82
+ // enforce: "pre" registers this middleware ahead of the SDK vite plugin's,
83
+ // which would otherwise 404 the path (no manifest file exists in the block).
84
+ const serveWorkerManifest = {
85
+ name: "dev-shell:worker-block-manifest",
86
+ enforce: "pre",
87
+ configureServer(server) {
88
+ server.middlewares.use((req, res, next) => {
89
+ if (req.url?.split("?", 1)[0] !== "/custom_blocks.json") {
90
+ next()
91
+ return
92
+ }
93
+ res.setHeader("Content-Type", "application/json")
94
+ res.end(manifest)
95
+ })
96
+ },
97
+ }
98
+
99
+ export default {
100
+ ...resolved,
101
+ root: here("${toRoot}"),
102
+ cacheDir: here("${toCache}"),
103
+ plugins: [serveWorkerManifest, ...(resolved.plugins ?? [])],
104
+ }
105
+ `,
106
+ )
107
+ return file
108
+ }
package/cli/main.ts ADDED
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Entry point for the published dev-shell CLI (`npx`-run from a worker
3
+ * project). Mirrors the worker mode of the repo's `scripts/dev.ts`: build the
4
+ * worker, extract its manifest, serve one Vite dev server per custom block
5
+ * (using the worker's own Vite install), and serve the prebuilt dev-shell-2 UI
6
+ * with the block registry injected at runtime.
7
+ */
8
+
9
+ import { spawn } from "node:child_process"
10
+ import { existsSync, readFileSync } from "node:fs"
11
+ import { createRequire } from "node:module"
12
+ import { basename, dirname, join, resolve } from "node:path"
13
+ import { fileURLToPath } from "node:url"
14
+ import {
15
+ BLOCK_BASE_PORT,
16
+ buildBlockRegistry,
17
+ SHELL_2_PORT,
18
+ writeBlockViteConfig,
19
+ } from "./block-server"
20
+ import { serveUi } from "./serve-ui"
21
+ import {
22
+ blockCapabilities,
23
+ findWorkerDir,
24
+ generateWorkerManifest,
25
+ } from "./worker-manifest"
26
+
27
+ const __dirname = dirname(fileURLToPath(import.meta.url))
28
+
29
+ const dim = "\x1b[2m"
30
+ const bold = "\x1b[1m"
31
+ const cyan = "\x1b[36m"
32
+ const reset = "\x1b[0m"
33
+ const label = (name: string) => `${cyan}[${name}]${reset}`
34
+
35
+ type CliArgs = {
36
+ worker: string | undefined
37
+ shellPort: number
38
+ blockBasePort: number
39
+ }
40
+
41
+ function parseCliArgs(argv: readonly string[]): CliArgs {
42
+ const args: CliArgs = {
43
+ worker: undefined,
44
+ shellPort: SHELL_2_PORT,
45
+ blockBasePort: BLOCK_BASE_PORT,
46
+ }
47
+ const takeValue = (name: string, index: number): string => {
48
+ const value = argv[index]
49
+ if (value === undefined || value.startsWith("--")) {
50
+ throw new Error(`${name} requires a value.`)
51
+ }
52
+ return value
53
+ }
54
+ const takePort = (name: string, raw: string): number => {
55
+ const port = Number(raw)
56
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
57
+ throw new Error(`${name} requires a port number, got "${raw}".`)
58
+ }
59
+ return port
60
+ }
61
+ for (let index = 0; index < argv.length; index++) {
62
+ const arg = argv[index]
63
+ if (arg === "--worker") {
64
+ args.worker = takeValue("--worker", ++index)
65
+ } else if (arg.startsWith("--worker=")) {
66
+ args.worker = arg.slice("--worker=".length)
67
+ if (args.worker.length === 0) {
68
+ throw new Error("--worker requires a path to a worker directory.")
69
+ }
70
+ } else if (arg === "--port") {
71
+ args.shellPort = takePort("--port", takeValue("--port", ++index))
72
+ } else if (arg.startsWith("--port=")) {
73
+ args.shellPort = takePort("--port", arg.slice("--port=".length))
74
+ } else if (arg === "--block-base-port") {
75
+ args.blockBasePort = takePort(
76
+ "--block-base-port",
77
+ takeValue("--block-base-port", ++index),
78
+ )
79
+ } else if (arg.startsWith("--block-base-port=")) {
80
+ args.blockBasePort = takePort(
81
+ "--block-base-port",
82
+ arg.slice("--block-base-port=".length),
83
+ )
84
+ }
85
+ }
86
+ return args
87
+ }
88
+
89
+ function resolveWorkerDir(workerArg: string | undefined): string {
90
+ if (workerArg !== undefined) {
91
+ return resolve(process.cwd(), workerArg)
92
+ }
93
+ const detected = findWorkerDir(process.cwd())
94
+ if (detected !== undefined) {
95
+ console.log(`Detected a worker at ${detected}.`)
96
+ return detected
97
+ }
98
+ throw new Error(
99
+ "No worker found: run from inside a worker directory, or pass --worker <dir>.",
100
+ )
101
+ }
102
+
103
+ /**
104
+ * The worker's own Vite binary. Blocks are served with the worker's Vite (and
105
+ * plugins) rather than anything bundled here, matching how the block builds in
106
+ * production.
107
+ */
108
+ function resolveViteBin(workerDir: string): string {
109
+ let vitePkgPath: string
110
+ try {
111
+ const workerRequire = createRequire(join(workerDir, "package.json"))
112
+ vitePkgPath = workerRequire.resolve("vite/package.json")
113
+ } catch {
114
+ throw new Error(
115
+ `Could not resolve "vite" from ${workerDir}. Add vite to the worker's ` +
116
+ `devDependencies and reinstall.`,
117
+ )
118
+ }
119
+ const vitePkg = JSON.parse(readFileSync(vitePkgPath, "utf-8")) as {
120
+ bin?: string | Record<string, string>
121
+ }
122
+ const bin = typeof vitePkg.bin === "string" ? vitePkg.bin : vitePkg.bin?.vite
123
+ if (bin === undefined) {
124
+ throw new Error(`The vite package at ${vitePkgPath} exposes no bin.`)
125
+ }
126
+ return resolve(dirname(vitePkgPath), bin)
127
+ }
128
+
129
+ const procs: ReturnType<typeof spawn>[] = []
130
+
131
+ async function main() {
132
+ const cliArgs = parseCliArgs(process.argv.slice(2))
133
+ const workerDir = resolveWorkerDir(cliArgs.worker)
134
+ if (!existsSync(resolve(workerDir, "node_modules"))) {
135
+ throw new Error(
136
+ `No node_modules in ${workerDir}. Install the worker's dependencies first ` +
137
+ `(e.g. \`npm install\`), then rerun.`,
138
+ )
139
+ }
140
+
141
+ console.log(`${label(basename(workerDir))} Extracting worker manifest...`)
142
+ const { manifest, manifestPath } = await generateWorkerManifest(workerDir)
143
+ console.log(`${label(basename(workerDir))} Wrote ${manifestPath}`)
144
+ const blocks = blockCapabilities(manifest)
145
+ if (blocks.length === 0) {
146
+ // Not an error — start the shell anyway; it shows "None" under Blocks.
147
+ console.log(
148
+ `${label(basename(workerDir))} Worker declares no custom blocks.`,
149
+ )
150
+ }
151
+
152
+ if (
153
+ blocks.length > 0 &&
154
+ cliArgs.shellPort >= cliArgs.blockBasePort &&
155
+ cliArgs.shellPort < cliArgs.blockBasePort + blocks.length
156
+ ) {
157
+ throw new Error(
158
+ `--port ${cliArgs.shellPort} collides with the block server ports ` +
159
+ `(${cliArgs.blockBasePort}–${cliArgs.blockBasePort + blocks.length - 1}); ` +
160
+ `pick a port outside that range or move --block-base-port.`,
161
+ )
162
+ }
163
+
164
+ const viteBin = blocks.length > 0 ? resolveViteBin(workerDir) : undefined
165
+ const registry = buildBlockRegistry(blocks, cliArgs.blockBasePort)
166
+
167
+ for (const [index, capability] of blocks.entries()) {
168
+ const blockDir = resolve(workerDir, capability.config.source.path)
169
+ const configFile = writeBlockViteConfig(workerDir, blockDir, capability)
170
+ const port = cliArgs.blockBasePort + index
171
+ const proc = spawn(
172
+ process.execPath,
173
+ [
174
+ viteBin as string,
175
+ "--config",
176
+ configFile,
177
+ "--port",
178
+ String(port),
179
+ "--strictPort",
180
+ ],
181
+ {
182
+ cwd: workerDir,
183
+ stdio: ["ignore", "ignore", "inherit"],
184
+ // Process groups (and negative-PID kills) are POSIX-only; on
185
+ // Windows children are killed individually in shutdown().
186
+ detached: process.platform !== "win32",
187
+ },
188
+ )
189
+ proc.on("exit", code => {
190
+ if (shuttingDown || code === 0 || code === null) {
191
+ return
192
+ }
193
+ console.error(
194
+ `${label(capability.key)} dev server exited with code ${code}`,
195
+ )
196
+ process.exitCode = code
197
+ shutdown("SIGTERM")
198
+ })
199
+ procs.push(proc)
200
+ }
201
+
202
+ // The published layout is dist/ next to cli/; index.html must be prebuilt.
203
+ const distDir = resolve(__dirname, "..", "dist")
204
+ if (!existsSync(join(distDir, "index.html"))) {
205
+ throw new Error(
206
+ `No prebuilt UI found at ${distDir}. This package was not assembled ` +
207
+ `correctly; reinstall it.`,
208
+ )
209
+ }
210
+ try {
211
+ await serveUi(distDir, cliArgs.shellPort, {
212
+ mode: "worker",
213
+ blocks: registry,
214
+ })
215
+ } catch (error) {
216
+ const code = (error as NodeJS.ErrnoException).code
217
+ if (code === "EADDRINUSE") {
218
+ throw new Error(
219
+ `Port ${cliArgs.shellPort} is already in use. Stop whatever holds it ` +
220
+ `or rerun with --port <port> (and --block-base-port <port> for the ` +
221
+ `block servers).`,
222
+ )
223
+ }
224
+ throw error
225
+ }
226
+
227
+ console.log("")
228
+ console.log(`${bold}Dev shell${reset}`)
229
+ console.log(
230
+ ` ${label("dev-shell")} ${dim}http://localhost:${cliArgs.shellPort}${reset}`,
231
+ )
232
+ if (blocks.length > 0) {
233
+ console.log("")
234
+ console.log(`${bold}Blocks${reset}`)
235
+ for (const [index, entry] of registry.entries()) {
236
+ console.log(
237
+ ` ${label(entry.key)} ${dim}http://localhost:${cliArgs.blockBasePort + index}${reset}`,
238
+ )
239
+ }
240
+ }
241
+ console.log("")
242
+ }
243
+
244
+ function killProc(p: ReturnType<typeof spawn>, signal: NodeJS.Signals) {
245
+ if (p.pid === undefined || p.killed) {
246
+ return
247
+ }
248
+ try {
249
+ if (process.platform === "win32") {
250
+ p.kill(signal)
251
+ } else {
252
+ process.kill(-p.pid, signal)
253
+ }
254
+ } catch {}
255
+ }
256
+
257
+ let shuttingDown = false
258
+ function shutdown(signal: NodeJS.Signals | "exit") {
259
+ if (shuttingDown) {
260
+ return
261
+ }
262
+ shuttingDown = true
263
+ if (signal !== "exit") {
264
+ console.log(`\n${dim}Shutting down...${reset}`)
265
+ }
266
+ for (const p of procs) {
267
+ killProc(p, "SIGTERM")
268
+ }
269
+ setTimeout(() => {
270
+ for (const p of procs) {
271
+ killProc(p, "SIGKILL")
272
+ }
273
+ // Preserve a failure exit code set before shutdown (startup errors,
274
+ // crashed block servers); plain signal shutdowns still exit 0.
275
+ process.exit(process.exitCode ?? 0)
276
+ }, 1500).unref()
277
+ }
278
+
279
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
280
+ process.on(signal, () => shutdown(signal))
281
+ }
282
+ process.on("exit", () => shutdown("exit"))
283
+ process.on("uncaughtException", err => {
284
+ console.error(err)
285
+ process.exitCode = 1
286
+ shutdown("SIGTERM")
287
+ })
288
+
289
+ main().catch(err => {
290
+ console.error(err instanceof Error ? err.message : err)
291
+ process.exitCode = 1
292
+ shutdown("SIGTERM")
293
+ })
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Dependency-free static server for the prebuilt dev-shell-2 UI (`dist/`).
3
+ *
4
+ * The published UI is a plain Vite build, so the block registry can't ride in
5
+ * through build-time `define` globals the way it does under `scripts/dev.ts`.
6
+ * Instead, this server injects a `window.__DEV_SHELL_2_CONFIG__` script into
7
+ * `index.html` as it is served; `src/helpers/templates.ts` prefers that global
8
+ * over the baked-in defines.
9
+ */
10
+
11
+ import { readFileSync } from "node:fs"
12
+ import { createServer, type Server } from "node:http"
13
+ import { extname, join, normalize, resolve, sep } from "node:path"
14
+ import type { BlockRegistryEntry } from "./block-server"
15
+
16
+ export type DevShellRuntimeConfig = {
17
+ mode: "worker"
18
+ blocks: BlockRegistryEntry[]
19
+ }
20
+
21
+ const CONTENT_TYPES: Record<string, string> = {
22
+ ".html": "text/html; charset=utf-8",
23
+ ".js": "text/javascript; charset=utf-8",
24
+ ".mjs": "text/javascript; charset=utf-8",
25
+ ".css": "text/css; charset=utf-8",
26
+ ".json": "application/json; charset=utf-8",
27
+ ".svg": "image/svg+xml",
28
+ ".png": "image/png",
29
+ ".ico": "image/x-icon",
30
+ ".woff": "font/woff",
31
+ ".woff2": "font/woff2",
32
+ ".map": "application/json; charset=utf-8",
33
+ }
34
+
35
+ /** `index.html` with the runtime config injected ahead of the bundle script. */
36
+ function injectConfig(html: string, config: DevShellRuntimeConfig): string {
37
+ const tag = `<script>window.__DEV_SHELL_2_CONFIG__ = ${JSON.stringify(config)}</script>`
38
+ if (html.includes("<head>")) {
39
+ return html.replace("<head>", `<head>\n\t\t${tag}`)
40
+ }
41
+ return `${tag}\n${html}`
42
+ }
43
+
44
+ /**
45
+ * Serve `distDir` on `port`. Unknown extensionless paths fall back to
46
+ * `index.html`. Rejects on listen errors (e.g. the port is taken).
47
+ */
48
+ export function serveUi(
49
+ distDir: string,
50
+ port: number,
51
+ config: DevShellRuntimeConfig,
52
+ ): Promise<Server> {
53
+ const dist = resolve(distDir)
54
+ const indexHtml = injectConfig(
55
+ readFileSync(join(dist, "index.html"), "utf-8"),
56
+ config,
57
+ )
58
+
59
+ const server = createServer((req, res) => {
60
+ let path: string
61
+ try {
62
+ path = normalize(decodeURIComponent(req.url?.split("?", 1)[0] ?? "/"))
63
+ } catch {
64
+ res.writeHead(400)
65
+ res.end()
66
+ return
67
+ }
68
+ const filePath = join(dist, path)
69
+ if (!filePath.startsWith(dist + sep) && filePath !== dist) {
70
+ res.writeHead(403)
71
+ res.end()
72
+ return
73
+ }
74
+
75
+ const ext = extname(filePath)
76
+ if (path === "/" || path === `${sep}index.html` || ext === "") {
77
+ res.writeHead(200, { "Content-Type": CONTENT_TYPES[".html"] })
78
+ res.end(indexHtml)
79
+ return
80
+ }
81
+
82
+ try {
83
+ const body = readFileSync(filePath)
84
+ res.writeHead(200, {
85
+ "Content-Type": CONTENT_TYPES[ext] ?? "application/octet-stream",
86
+ })
87
+ res.end(body)
88
+ } catch {
89
+ res.writeHead(404)
90
+ res.end()
91
+ }
92
+ })
93
+
94
+ return new Promise((resolvePromise, reject) => {
95
+ server.once("error", reject)
96
+ // Loopback only: every response embeds the worker's block registry, and
97
+ // the per-block Vite servers are localhost-only too.
98
+ server.listen(port, "127.0.0.1", () => resolvePromise(server))
99
+ })
100
+ }