@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/CHANGELOG.md +11 -0
- package/README.md +2 -2
- package/dist/diagnostics.mjs +110 -1
- package/dist/index.d.mts +20 -3
- package/dist/index.mjs +86 -1
- package/dist/vite.d.mts +9 -12
- package/dist/vite.mjs +185 -4
- package/package.json +16 -9
- package/src/contracts.d.ts +127 -0
- package/src/contracts.ts +132 -0
- package/src/diagnostics.d.ts +19 -0
- package/src/diagnostics.test.ts +106 -0
- package/src/diagnostics.ts +182 -0
- package/src/index.d.ts +249 -0
- package/src/index.test.ts +35 -0
- package/src/index.ts +299 -0
- package/src/vite.test.ts +62 -0
- package/src/vite.ts +289 -0
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
|
+
}
|