@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 +30 -0
- package/bin/cli.js +37 -0
- package/cli/block-server.ts +108 -0
- package/cli/main.ts +293 -0
- package/cli/serve-ui.ts +100 -0
- package/cli/worker-manifest.ts +196 -0
- package/dist/assets/index-CEUqyStq.js +9 -0
- package/dist/assets/index-DvRqSoqY.css +2 -0
- package/dist/index.html +13 -0
- package/package.json +47 -0
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
|
+
})
|
package/cli/serve-ui.ts
ADDED
|
@@ -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
|
+
}
|