@jchat/addon-sdk 0.0.1 → 1.0.0-alpha.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/src/vite.ts ADDED
@@ -0,0 +1,289 @@
1
+ import type { Plugin } from "vite"
2
+ import { execFileSync } from "node:child_process"
3
+ import crypto from "node:crypto"
4
+ import fs from "node:fs"
5
+ import path from "node:path"
6
+ import type { AddonManifest } from "./contracts"
7
+
8
+ /**
9
+ * Vite plugin to configure Jchat addon manifest generation, bundle backend scripts, copy
10
+ * migrations, declarative seed data, and zip production build artifacts.
11
+ */
12
+ export function jchatAddon(options: JchatAddonPluginOptions): Plugin {
13
+ const { manifest: manifestData } = options
14
+ let shouldZip = options.zip
15
+ let developmentBuild = false
16
+
17
+ return {
18
+ name: "jchat-addon",
19
+ apply: "build",
20
+ config(_, env) {
21
+ return {
22
+ define: {
23
+ "process.env.NODE_ENV": JSON.stringify(env.mode || "production")
24
+ }
25
+ }
26
+ },
27
+ configResolved(config) {
28
+ developmentBuild = config.mode === "development"
29
+ if (shouldZip === undefined) {
30
+ shouldZip = config.mode === "production"
31
+ }
32
+ },
33
+ async writeBundle() {
34
+ const rootDir = process.cwd()
35
+ const pkgPath = path.resolve(rootDir, "package.json")
36
+ if (!fs.existsSync(pkgPath)) return
37
+
38
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"))
39
+ const buildDir = path.resolve(rootDir, "build")
40
+ if (!fs.existsSync(buildDir)) return
41
+
42
+ const files = fs.readdirSync(buildDir)
43
+ const bundleFile = files.find((f) => f.includes("ui") && f.endsWith(".js"))
44
+
45
+ if (!bundleFile) return
46
+
47
+ if (options.css !== false) {
48
+ embedCssIntoBundle(buildDir, manifestData.id)
49
+ }
50
+
51
+ let manifest: AddonManifest = {
52
+ ...manifestData,
53
+ version: pkg.version,
54
+ frontend: {
55
+ ...manifestData.frontend,
56
+ bundle: `./${bundleFile}`,
57
+ mountPoints: manifestData.frontend?.mountPoints ?? []
58
+ }
59
+ }
60
+
61
+ const serverSrcPath = path.resolve(rootDir, "src/api.ts")
62
+ const hasServer = fs.existsSync(serverSrcPath)
63
+ if (hasServer) {
64
+ execFileSync(
65
+ "bun",
66
+ createAddonServerBuildArguments(
67
+ serverSrcPath,
68
+ path.resolve(buildDir, "api.js"),
69
+ developmentBuild
70
+ ),
71
+ { stdio: "inherit" }
72
+ )
73
+ manifest = {
74
+ ...manifest,
75
+ server: {
76
+ ...manifest.server,
77
+ bundle: "./api.js"
78
+ }
79
+ }
80
+ }
81
+
82
+ const databaseFiles = [
83
+ ...(manifest.server?.migrations ?? []),
84
+ ...(manifest.server?.seeds ?? [])
85
+ ]
86
+ for (const directory of ["migrations", "seeds"]) {
87
+ fs.rmSync(path.resolve(buildDir, directory), {
88
+ recursive: true,
89
+ force: true
90
+ })
91
+ }
92
+ for (const relativePath of databaseFiles) {
93
+ if (
94
+ path.isAbsolute(relativePath) ||
95
+ relativePath.includes("\\") ||
96
+ relativePath.split("/").includes("..")
97
+ ) {
98
+ throw new Error(`Unsafe addon database file path: ${relativePath}`)
99
+ }
100
+ const source = path.resolve(rootDir, relativePath)
101
+ if (!fs.existsSync(source) || !fs.statSync(source).isFile()) {
102
+ throw new Error(`Missing addon database file: ${relativePath}`)
103
+ }
104
+ const destination = path.resolve(buildDir, relativePath)
105
+ fs.mkdirSync(path.dirname(destination), { recursive: true })
106
+ fs.copyFileSync(source, destination)
107
+ }
108
+
109
+ if (manifest.server?.migrations?.length || manifest.server?.seeds?.length) {
110
+ const signingPrivateKey = requireAddonSigningKey(
111
+ options.signingPrivateKey ?? process.env.JCHAT_ADDON_SIGNING_PRIVATE_KEY,
112
+ developmentBuild
113
+ )
114
+ if (signingPrivateKey) {
115
+ const archivePaths = [
116
+ "manifest.json",
117
+ bundleFile,
118
+ ...(manifest.server?.migrations ?? []),
119
+ ...(manifest.server?.seeds ?? [])
120
+ ]
121
+ if (hasServer) archivePaths.push("api.js")
122
+ manifest = signAddonManifest(
123
+ manifest,
124
+ archivePaths.map((file) => ({
125
+ path: file,
126
+ contents:
127
+ file === "manifest.json"
128
+ ? Buffer.from("")
129
+ : fs.readFileSync(path.resolve(buildDir, file))
130
+ })),
131
+ signingPrivateKey
132
+ )
133
+ }
134
+ }
135
+
136
+ // Save manifest.json
137
+ const manifestJson = JSON.stringify(manifest, null, 2)
138
+ fs.writeFileSync(path.resolve(buildDir, "manifest.json"), manifestJson)
139
+
140
+ if (shouldZip) {
141
+ const zipName = `${manifest.id}-${pkg.version}.zip`
142
+ const filesToZip = ["manifest.json", bundleFile]
143
+
144
+ if (fs.existsSync(path.resolve(buildDir, "api.js"))) {
145
+ filesToZip.push("api.js")
146
+ }
147
+
148
+ const migrationsDest = path.resolve(buildDir, "migrations")
149
+ if (fs.existsSync(migrationsDest)) {
150
+ filesToZip.push("migrations")
151
+ }
152
+ const seedsDest = path.resolve(buildDir, "seeds")
153
+ if (fs.existsSync(seedsDest)) {
154
+ filesToZip.push("seeds")
155
+ }
156
+
157
+ execFileSync("tar", ["-a", "-cf", zipName, ...filesToZip], {
158
+ cwd: buildDir,
159
+ stdio: "inherit"
160
+ })
161
+ console.log(`Build Complete: ${manifest.id} v${pkg.version} → ${zipName}`)
162
+ }
163
+ }
164
+ }
165
+ }
166
+
167
+ export interface JchatAddonPluginOptions {
168
+ manifest: AddonManifest
169
+ zip?: boolean
170
+ signingPrivateKey?: string
171
+ /**
172
+ * Embed Tailwind CSS output into the JS bundle and scope it to the addon's DOM container.
173
+ * Defaults to `true`. Set to `false` only if the addon has no CSS.
174
+ */
175
+ css?: boolean
176
+ }
177
+
178
+ export function createAddonServerBuildArguments(
179
+ entryPath: string,
180
+ outputPath: string,
181
+ developmentBuild: boolean
182
+ ): string[] {
183
+ return [
184
+ "build",
185
+ entryPath,
186
+ "--target=bun",
187
+ "--format=esm",
188
+ `--outfile=${outputPath}`,
189
+ developmentBuild ? "--sourcemap=external" : "--minify"
190
+ ]
191
+ }
192
+
193
+ export function signAddonManifest(
194
+ manifest: AddonManifest,
195
+ files: readonly { path: string; contents: Buffer }[],
196
+ privateKey: string
197
+ ): AddonManifest {
198
+ if (!manifest.publisher?.id) throw new Error("Signed addons require manifest.publisher.id")
199
+ const unsigned: AddonManifest = {
200
+ ...manifest,
201
+ signature: { algorithm: "rsa-sha256", digest: "", value: "" }
202
+ }
203
+ const hash = crypto.createHash("sha256")
204
+ for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
205
+ hash.update(file.path)
206
+ hash.update("\0")
207
+ hash.update(file.path === "manifest.json" ? stableStringify(unsigned) : file.contents)
208
+ hash.update("\0")
209
+ }
210
+ const digest = hash.digest("hex")
211
+ return {
212
+ ...unsigned,
213
+ signature: {
214
+ algorithm: "rsa-sha256",
215
+ digest,
216
+ value: crypto.sign("sha256", Buffer.from(digest), privateKey).toString("base64url")
217
+ }
218
+ }
219
+ }
220
+
221
+ export function requireAddonSigningKey(
222
+ signingPrivateKey: string | undefined,
223
+ developmentBuild: boolean
224
+ ): string | undefined {
225
+ if (signingPrivateKey) return signingPrivateKey
226
+ if (developmentBuild) return undefined
227
+ throw new Error("Database-bearing addon builds require JCHAT_ADDON_SIGNING_PRIVATE_KEY")
228
+ }
229
+
230
+ function embedCssIntoBundle(buildDir: string, addonId: string): void {
231
+ const files = fs.readdirSync(buildDir)
232
+ const cssFile = files.find((f) => f.endsWith(".css"))
233
+ const jsFile = files.find((f) => f.includes("ui") && f.endsWith(".js"))
234
+
235
+ if (!cssFile || !jsFile) return
236
+
237
+ const cssPath = path.join(buildDir, cssFile)
238
+ const jsPath = path.join(buildDir, jsFile)
239
+ let css = fs.readFileSync(cssPath, "utf-8")
240
+ const js = fs.readFileSync(jsPath, "utf-8")
241
+
242
+ if (css.length < 10) {
243
+ css = ""
244
+ } else {
245
+ // Strip out the host theme root and dark definitions to allow the addon to inherit them
246
+ css = css.replace(/:root\s*\{[^}]+\}/g, "")
247
+ css = css.replace(/\.dark\s*\{[^}]+\}/g, "")
248
+ css = css.replace(/\[data-theme=[^\]]+\]\s*\{[^}]+\}/g, "")
249
+ css = css.replace(/\.dark\s*\[data-theme=[^\]]+\]\s*\{[^}]+\}/g, "")
250
+
251
+ // Inject a base reset so addons don't default to currentColor (white in dark mode)
252
+ const resetCss = `*, ::before, ::after { border-color: var(--border); }`
253
+ css = `@scope (#addon-${addonId}) {\n${resetCss}\n${css}\n}`
254
+ }
255
+
256
+ // Inject the CSS into the head. Since addon styles are scoped,
257
+ // we don't need to force them to the top of the head.
258
+ const injection = `
259
+ (function() {
260
+ if (typeof document !== 'undefined' && ${JSON.stringify(css)}.length > 0) {
261
+ const style = document.createElement('style');
262
+ style.type = 'text/css';
263
+ style.textContent = ${JSON.stringify(css)};
264
+ document.head.appendChild(style);
265
+ }
266
+ })();
267
+ `
268
+ fs.writeFileSync(jsPath, injection + js)
269
+ try {
270
+ fs.unlinkSync(cssPath)
271
+ } catch {
272
+ // ignore ENOENT if another plugin or build process already removed it
273
+ }
274
+ }
275
+
276
+ function stableStringify(value: unknown): string {
277
+ const sortValue = (child: unknown): unknown => {
278
+ if (Array.isArray(child)) return child.map(sortValue)
279
+ if (child && typeof child === "object") {
280
+ return Object.fromEntries(
281
+ Object.entries(child)
282
+ .sort(([left], [right]) => left.localeCompare(right))
283
+ .map(([key, nested]) => [key, sortValue(nested)])
284
+ )
285
+ }
286
+ return child
287
+ }
288
+ return JSON.stringify(sortValue(value))
289
+ }