@pikku/deploy-standalone 0.12.12 → 0.12.17
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 +177 -0
- package/dist/adapter.d.ts +45 -0
- package/dist/adapter.js +448 -6
- package/dist/runtime/cli.d.ts +75 -0
- package/dist/runtime/cli.js +193 -0
- package/dist/runtime/index.d.ts +11 -0
- package/dist/runtime/index.js +9 -0
- package/dist/runtime/parent-watch.d.ts +45 -0
- package/dist/runtime/parent-watch.js +87 -0
- package/dist/tauri/generate.d.ts +45 -0
- package/dist/tauri/generate.js +230 -0
- package/dist/tauri/icon.d.ts +1 -0
- package/dist/tauri/icon.js +54 -0
- package/dist/tauri/main-rs.d.ts +31 -0
- package/dist/tauri/main-rs.js +213 -0
- package/dist/tauri/next-steps.d.ts +15 -0
- package/dist/tauri/next-steps.js +16 -0
- package/dist/tauri/target-triple.d.ts +29 -0
- package/dist/tauri/target-triple.js +42 -0
- package/knowledge/decisions/a-pikku-server-serves-a-static-frontend.md +36 -0
- package/knowledge/decisions/a-remote-desktop-shell-bundles-nothing.md +38 -0
- package/knowledge/decisions/deploy-consumes-a-built-frontend.md +33 -0
- package/knowledge/decisions/desktop-builds-are-unsigned-and-never-update-themselves.md +34 -0
- package/knowledge/decisions/index.md +19 -0
- package/knowledge/decisions/standalone-assets-are-embedded-in-the-bun-binary.md +39 -0
- package/knowledge/decisions/the-desktop-shell-runs-the-server-as-a-sidecar.md +51 -0
- package/knowledge/decisions/the-sidecar-reports-its-port-the-shell-never-picks-one.md +44 -0
- package/knowledge/index.md +22 -0
- package/package.json +7 -4
- package/src/adapter.test.ts +725 -0
- package/src/adapter.ts +508 -6
- package/src/desktop-deploy.test.ts +167 -0
- package/src/runtime/cli.test.ts +222 -0
- package/src/runtime/cli.ts +311 -0
- package/src/runtime/index.ts +31 -0
- package/src/runtime/parent-watch.process.test.ts +112 -0
- package/src/runtime/parent-watch.test.ts +148 -0
- package/src/runtime/parent-watch.ts +115 -0
- package/src/sidecar-entry.test.ts +89 -0
- package/src/tauri/generate.test.ts +401 -0
- package/src/tauri/generate.ts +327 -0
- package/src/tauri/icon.test.ts +63 -0
- package/src/tauri/icon.ts +62 -0
- package/src/tauri/main-rs.rustfmt.test.ts +86 -0
- package/src/tauri/main-rs.ts +241 -0
- package/src/tauri/next-steps.test.ts +38 -0
- package/src/tauri/next-steps.ts +30 -0
- package/src/tauri/target-triple.test.ts +84 -0
- package/src/tauri/target-triple.ts +65 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/adapter.ts
CHANGED
|
@@ -17,12 +17,329 @@
|
|
|
17
17
|
* `bun build --compile`. No runtime needed on the target host.
|
|
18
18
|
*/
|
|
19
19
|
import type { EntryGenerationContext, ProviderAdapter } from '@pikku/deploy'
|
|
20
|
-
import { nodeBuiltinExternals } from '@pikku/deploy'
|
|
20
|
+
import { nodeBuiltinExternals, SERVER_READY_MARKER } from '@pikku/deploy'
|
|
21
21
|
|
|
22
22
|
export type StandaloneRuntime = 'node' | 'bun'
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Directory the built frontend is copied to, both inside the unit and beside
|
|
26
|
+
* the shipped bundle. The node entry resolves it relative to itself at runtime,
|
|
27
|
+
* so the two have to agree.
|
|
28
|
+
*/
|
|
29
|
+
export const STANDALONE_FRONTEND_DIR = 'frontend'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Module the bun entry imports its embedded assets from. It stays out of the
|
|
33
|
+
* esbuild bundle — esbuild rejects the `with { type: 'file' }` attribute the
|
|
34
|
+
* manifest is built on — and is resolved by `bun build --compile` instead.
|
|
35
|
+
*/
|
|
36
|
+
export const STANDALONE_FRONTEND_MANIFEST = './frontend-assets.gen.js'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Lines every standalone entry ends with, whatever the runtime.
|
|
40
|
+
*
|
|
41
|
+
* The ready line is the handshake a parent process — `pikku dev --spawn`, or
|
|
42
|
+
* the desktop shell that runs this binary as a sidecar — blocks on. It carries
|
|
43
|
+
* `server.port` rather than the requested port because a shell passes `PORT=0`:
|
|
44
|
+
* picking a free port in the parent and handing it down races anything else
|
|
45
|
+
* that binds it in between, so the server binds first and reports back.
|
|
46
|
+
*/
|
|
47
|
+
const sidecarHandshakeLines = (): string[] => [
|
|
48
|
+
` watchParentProcess()`,
|
|
49
|
+
` console.log(\`${SERVER_READY_MARKER} on http://\${hostname}:\${server.port}\`)`,
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The runtime helpers the entry imports. The database ones are left out of a
|
|
54
|
+
* build with no database, so the bundle carries no migrator it can never run.
|
|
55
|
+
*/
|
|
56
|
+
const runtimeImport = (ctx: EntryGenerationContext): string => {
|
|
57
|
+
const names = ['watchParentProcess', 'parseStandaloneCommand']
|
|
58
|
+
if (ctx.db) names.push('runStandaloneCommand', 'resolveMigrationsDir')
|
|
59
|
+
return `import { ${names.join(', ')} } from '@pikku/deploy-standalone/runtime'`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Environment variable naming the directory the SQLite file lives in.
|
|
64
|
+
*
|
|
65
|
+
* The database has to outlive a release. A deploy that swaps the artifact
|
|
66
|
+
* directory would take the database with it if the file sat beside the bundle,
|
|
67
|
+
* so the path comes from the environment and points somewhere the operator
|
|
68
|
+
* keeps stable across releases, rather than being derived from the bundle's own
|
|
69
|
+
* location the way the frontend directory is.
|
|
70
|
+
*/
|
|
71
|
+
const DATA_DIR_VAR = 'PIKKU_DATA_DIR'
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Full override for the database file, for when it must match a path something
|
|
75
|
+
* else already decided — notably `pikku db migrate`, which has to open the same
|
|
76
|
+
* file this opens or the app runs against an unmigrated database.
|
|
77
|
+
*/
|
|
78
|
+
const DATABASE_FILE_VAR = 'PIKKU_DATABASE_FILE'
|
|
79
|
+
|
|
80
|
+
const DEFAULT_DATABASE_FILENAME = 'pikku.db'
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The dialect factory each runtime opens SQLite with. bun cannot use the node
|
|
84
|
+
* one — `bun:sqlite` is a different driver, and the node build reaches for
|
|
85
|
+
* `node:sqlite`, which a compiled bun binary does not carry.
|
|
86
|
+
*/
|
|
87
|
+
const SQLITE_FACTORY = {
|
|
88
|
+
node: {
|
|
89
|
+
specifier: '@pikku/kysely-node-sqlite',
|
|
90
|
+
fn: 'createNodeSqliteKysely',
|
|
91
|
+
},
|
|
92
|
+
bun: { specifier: '@pikku/kysely-bun-sqlite', fn: 'createBunSqliteKysely' },
|
|
93
|
+
} as const
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The environment variable a Postgres build reads its connection string from.
|
|
97
|
+
*
|
|
98
|
+
* The same name every other pikku host uses, so an artifact dropped onto a
|
|
99
|
+
* machine that already runs a pikku app needs no new variable.
|
|
100
|
+
*/
|
|
101
|
+
const DATABASE_URL_VAR = 'DATABASE_URL'
|
|
102
|
+
|
|
103
|
+
/** Imports the coercion plugin, for an app that generated a map. */
|
|
104
|
+
const coercionImportLines = (coercionImportPath: string): string[] => [
|
|
105
|
+
`import { createCoercionPlugin } from '@pikku/kysely'`,
|
|
106
|
+
`import { coercionMap as __pikkuCoercionMap } from '${coercionImportPath}'`,
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
/** Imports a database-backed entry needs on top of the common set. */
|
|
110
|
+
const dbImportLines = (
|
|
111
|
+
runtime: 'node' | 'bun',
|
|
112
|
+
db: NonNullable<EntryGenerationContext['db']>
|
|
113
|
+
): string[] => [
|
|
114
|
+
...(db.engine === 'sqlite'
|
|
115
|
+
? [
|
|
116
|
+
`import { ${SQLITE_FACTORY[runtime].fn} } from '${SQLITE_FACTORY[runtime].specifier}'`,
|
|
117
|
+
`import { mkdirSync as __pikkuMkdirSync } from 'node:fs'`,
|
|
118
|
+
]
|
|
119
|
+
: [`import { PikkuKysely } from '@pikku/kysely-postgres'`]),
|
|
120
|
+
...(db.coercionImportPath ? coercionImportLines(db.coercionImportPath) : []),
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Opens the database before services are built, so `createSingletonServices`
|
|
125
|
+
* receives `kysely` exactly as a hosted runtime would hand it over.
|
|
126
|
+
*
|
|
127
|
+
* Creating the directory rather than requiring it is deliberate: the artifact
|
|
128
|
+
* is expected to start on a machine where nothing has run yet, and a missing
|
|
129
|
+
* parent directory is the difference between a first boot that works and one
|
|
130
|
+
* that needs a documented mkdir nobody reads.
|
|
131
|
+
*/
|
|
132
|
+
const dbSetupLines = (
|
|
133
|
+
runtime: 'node' | 'bun',
|
|
134
|
+
db: NonNullable<EntryGenerationContext['db']>
|
|
135
|
+
): string[] => {
|
|
136
|
+
const plugins = db.coercionImportPath
|
|
137
|
+
? `[createCoercionPlugin({ map: __pikkuCoercionMap })]`
|
|
138
|
+
: `[]`
|
|
139
|
+
|
|
140
|
+
if (db.engine === 'sqlite') {
|
|
141
|
+
return [
|
|
142
|
+
` const __pikkuDbFile = process.env.${DATABASE_FILE_VAR}`,
|
|
143
|
+
` ? process.env.${DATABASE_FILE_VAR}`,
|
|
144
|
+
` : __pikkuJoin(__pikkuRequireDataDir(), '${DEFAULT_DATABASE_FILENAME}')`,
|
|
145
|
+
` __pikkuMkdirSync(__pikkuDirname(__pikkuDbFile), { recursive: true })`,
|
|
146
|
+
` const kysely = ${SQLITE_FACTORY[runtime].fn}({`,
|
|
147
|
+
` filename: __pikkuDbFile,`,
|
|
148
|
+
` plugins: ${plugins},`,
|
|
149
|
+
` })`,
|
|
150
|
+
]
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return [
|
|
154
|
+
` const __pikkuDbUrl = process.env.${DATABASE_URL_VAR}`,
|
|
155
|
+
` if (!__pikkuDbUrl) {`,
|
|
156
|
+
` throw new Error(`,
|
|
157
|
+
` 'This build connects to Postgres, so it needs ${DATABASE_URL_VAR} set to the database it should open.'`,
|
|
158
|
+
` )`,
|
|
159
|
+
` }`,
|
|
160
|
+
` const __pikkuPg = new PikkuKysely(logger, __pikkuDbUrl)`,
|
|
161
|
+
` await __pikkuPg.init()`,
|
|
162
|
+
...(db.coercionImportPath
|
|
163
|
+
? [
|
|
164
|
+
` const kysely = __pikkuPg.kysely.withPlugin(`,
|
|
165
|
+
` createCoercionPlugin({ map: __pikkuCoercionMap })`,
|
|
166
|
+
` )`,
|
|
167
|
+
]
|
|
168
|
+
: [` const kysely = __pikkuPg.kysely`]),
|
|
169
|
+
]
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Where the migrations sit relative to the running artifact.
|
|
174
|
+
*
|
|
175
|
+
* A node bundle reads them from its own directory. A compiled bun binary has no
|
|
176
|
+
* directory — `import.meta.url` points inside the embedded filesystem — so it
|
|
177
|
+
* resolves them beside the executable, which is where an operator unpacking an
|
|
178
|
+
* artifact puts them.
|
|
179
|
+
*/
|
|
180
|
+
const bundleDirExpression = (runtime: 'node' | 'bun'): string =>
|
|
181
|
+
runtime === 'node'
|
|
182
|
+
? `__pikkuDirname(__pikkuFileURLToPath(import.meta.url))`
|
|
183
|
+
: `__pikkuDirname(process.execPath)`
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The command line, parsed before anything is opened.
|
|
187
|
+
*
|
|
188
|
+
* `version` and `help` have to answer without a database, a config factory or a
|
|
189
|
+
* port, because the machine asking may be one where none of the three work yet
|
|
190
|
+
* — which is exactly when someone runs them.
|
|
191
|
+
*/
|
|
192
|
+
const commandParseLines = (ctx: EntryGenerationContext): string[] => [
|
|
193
|
+
`const __pikkuCommand = parseStandaloneCommand(process.argv.slice(2), {`,
|
|
194
|
+
` version: '${(ctx.version ?? 'unknown').replace(/'/g, "\\'")}',`,
|
|
195
|
+
` hasDb: ${Boolean(ctx.db)},`,
|
|
196
|
+
...(ctx.db ? [` engine: '${ctx.db.engine}',`] : []),
|
|
197
|
+
`})`,
|
|
198
|
+
`if (__pikkuCommand.kind === 'exit') process.exit(__pikkuCommand.code)`,
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Runs a non-serve command against the database the app itself just opened, and
|
|
203
|
+
* stops before a port is bound.
|
|
204
|
+
*
|
|
205
|
+
* Reusing the app's own connection is the point: a command that resolved its
|
|
206
|
+
* own would be free to migrate a different database than the next `serve`
|
|
207
|
+
* reads, and the two would only disagree once in production.
|
|
208
|
+
*/
|
|
209
|
+
const commandDispatchLines = (
|
|
210
|
+
runtime: 'node' | 'bun',
|
|
211
|
+
ctx: EntryGenerationContext
|
|
212
|
+
): string[] => {
|
|
213
|
+
if (!ctx.db) {
|
|
214
|
+
return [` if (__pikkuCommand.kind !== 'serve') process.exit(0)`, ``]
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const dir = `__pikkuJoin(${bundleDirExpression(runtime)}, 'db', '${ctx.db.engine}')`
|
|
218
|
+
const handle =
|
|
219
|
+
ctx.db.engine === 'sqlite'
|
|
220
|
+
? `databaseFile: __pikkuDbFile,`
|
|
221
|
+
: `sql: __pikkuPg.sql,`
|
|
222
|
+
|
|
223
|
+
return [
|
|
224
|
+
` const __pikkuDbCommandTarget = {`,
|
|
225
|
+
` engine: '${ctx.db.engine}',`,
|
|
226
|
+
` migrationsDir: resolveMigrationsDir(${dir}),`,
|
|
227
|
+
` ${handle}`,
|
|
228
|
+
` }`,
|
|
229
|
+
` if ((await runStandaloneCommand(__pikkuCommand, __pikkuDbCommandTarget)) === 'done') {`,
|
|
230
|
+
...(ctx.db.engine === 'postgres' ? [` await __pikkuPg.close()`] : []),
|
|
231
|
+
` return`,
|
|
232
|
+
` }`,
|
|
233
|
+
``,
|
|
234
|
+
]
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Imports the app's own lifecycle module, when it declares one. */
|
|
238
|
+
const lifecycleImportLines = (
|
|
239
|
+
lifecycle: NonNullable<EntryGenerationContext['lifecycle']>
|
|
240
|
+
): string[] => [
|
|
241
|
+
`import { ${lifecycle.variable} as __pikkuLifecycle } from '${lifecycle.importPath}'`,
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Runs the app's start hooks around the port opening, the same order and the
|
|
246
|
+
* same services `pikku dev` gives them.
|
|
247
|
+
*
|
|
248
|
+
* `beforeStart` runs after `init` so a hook can rely on everything the server
|
|
249
|
+
* resolved, and before `start` so work that must finish before the first
|
|
250
|
+
* request — a seeded admin account, a schema probe — is finished when one
|
|
251
|
+
* arrives.
|
|
252
|
+
*/
|
|
253
|
+
const lifecycleStartLines = (): string[] => [
|
|
254
|
+
` await __pikkuLifecycle?.beforeStart?.(singletonServices)`,
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
const lifecycleAfterStartLines = (): string[] => [
|
|
258
|
+
` await __pikkuLifecycle?.afterStart?.(singletonServices)`,
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Hands the stop hooks to the signal handler that owns the shutdown.
|
|
263
|
+
*
|
|
264
|
+
* The connection pool is closed in `afterStop`, once the app's own hook and the
|
|
265
|
+
* server have both finished with it — a pool closed any earlier takes the
|
|
266
|
+
* queries they are still allowed to make down with it. SQLite needs no
|
|
267
|
+
* counterpart: the process exiting releases the file.
|
|
268
|
+
*/
|
|
269
|
+
const shutdownHooksArg = (ctx: EntryGenerationContext): string => {
|
|
270
|
+
const before: string[] = []
|
|
271
|
+
const after: string[] = []
|
|
272
|
+
|
|
273
|
+
if (ctx.lifecycle) {
|
|
274
|
+
before.push(`await __pikkuLifecycle?.beforeStop?.(singletonServices)`)
|
|
275
|
+
after.push(`await __pikkuLifecycle?.afterStop?.(singletonServices)`)
|
|
276
|
+
}
|
|
277
|
+
if (ctx.db?.engine === 'postgres') {
|
|
278
|
+
after.push(`await __pikkuPg.close()`)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (before.length === 0 && after.length === 0) return ''
|
|
282
|
+
|
|
283
|
+
const hooks: string[] = []
|
|
284
|
+
if (before.length > 0) {
|
|
285
|
+
hooks.push(`beforeStop: async () => { ${before.join('; ')} }`)
|
|
286
|
+
}
|
|
287
|
+
if (after.length > 0) {
|
|
288
|
+
hooks.push(`afterStop: async () => { ${after.join('; ')} }`)
|
|
289
|
+
}
|
|
290
|
+
return `{ ${hooks.join(', ')} }`
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Fails with the variable's name rather than whatever SQLite says about a path
|
|
295
|
+
* of `undefined/pikku.db`, which is the error an operator would otherwise have
|
|
296
|
+
* to work backwards from.
|
|
297
|
+
*/
|
|
298
|
+
const dataDirHelperLines = (): string[] => [
|
|
299
|
+
`function __pikkuRequireDataDir() {`,
|
|
300
|
+
` const dir = process.env.${DATA_DIR_VAR}`,
|
|
301
|
+
` if (!dir) {`,
|
|
302
|
+
` throw new Error(`,
|
|
303
|
+
` 'This build bundles a SQLite database, so it needs somewhere to keep it. Set ${DATA_DIR_VAR} to a writable directory that survives a release swap, or set ${DATABASE_FILE_VAR} to the database file itself.'`,
|
|
304
|
+
` )`,
|
|
305
|
+
` }`,
|
|
306
|
+
` return dir`,
|
|
307
|
+
`}`,
|
|
308
|
+
]
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* `rustc -vV`, or nothing when no toolchain is installed. The triple then falls
|
|
312
|
+
* back to the Node platform pair, which is right for every ordinary host — the
|
|
313
|
+
* cases rustc knows better about (musl, Rosetta) are the ones where a Rust
|
|
314
|
+
* toolchain is present anyway.
|
|
315
|
+
*/
|
|
316
|
+
const rustcHostOutput = async (): Promise<string | undefined> => {
|
|
317
|
+
try {
|
|
318
|
+
const { execFileSync } = await import('node:child_process')
|
|
319
|
+
return execFileSync('rustc', ['-vV'], { encoding: 'utf-8', stdio: 'pipe' })
|
|
320
|
+
} catch {
|
|
321
|
+
return undefined
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
24
325
|
export interface StandaloneProviderAdapterOptions {
|
|
25
326
|
runtime?: StandaloneRuntime
|
|
327
|
+
/**
|
|
328
|
+
* Generate a desktop shell (Tauri) around the compiled binary. Requires the
|
|
329
|
+
* `bun` runtime — the shell ships the binary as a sidecar, and only that
|
|
330
|
+
* runtime produces one. A shell pointed at {@link desktopUrl} ships no binary
|
|
331
|
+
* and so has no such requirement.
|
|
332
|
+
*/
|
|
333
|
+
desktop?: boolean
|
|
334
|
+
/** Project root. The shell crate is written to `<projectDir>/src-tauri`. */
|
|
335
|
+
projectDir?: string
|
|
336
|
+
/** Bundle identifier for the shell. Derived from the app name when absent. */
|
|
337
|
+
desktopIdentifier?: string
|
|
338
|
+
/**
|
|
339
|
+
* An already-deployed server for the shell to open, instead of bundling one.
|
|
340
|
+
* The window is a webview onto that origin and nothing else is shipped.
|
|
341
|
+
*/
|
|
342
|
+
desktopUrl?: string
|
|
26
343
|
}
|
|
27
344
|
|
|
28
345
|
export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
@@ -30,9 +347,17 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
30
347
|
readonly deployDirName = 'standalone'
|
|
31
348
|
readonly singleUnit = true
|
|
32
349
|
readonly runtime: StandaloneRuntime
|
|
350
|
+
readonly desktop: boolean
|
|
351
|
+
readonly projectDir?: string
|
|
352
|
+
readonly desktopIdentifier?: string
|
|
353
|
+
readonly desktopUrl?: string
|
|
33
354
|
|
|
34
355
|
constructor(options: StandaloneProviderAdapterOptions = {}) {
|
|
35
356
|
this.runtime = options.runtime ?? 'node'
|
|
357
|
+
this.desktop = options.desktop ?? Boolean(options.desktopUrl)
|
|
358
|
+
this.projectDir = options.projectDir
|
|
359
|
+
this.desktopIdentifier = options.desktopIdentifier
|
|
360
|
+
this.desktopUrl = options.desktopUrl
|
|
36
361
|
}
|
|
37
362
|
|
|
38
363
|
generateEntrySource(ctx: EntryGenerationContext): string {
|
|
@@ -53,6 +378,15 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
53
378
|
`import { PikkuNodeHTTPServer } from '@pikku/node-http-server'`,
|
|
54
379
|
`import { DEFAULT_WS_MAX_PAYLOAD, pikkuWebsocketHandler } from '@pikku/ws'`,
|
|
55
380
|
`import { WebSocketServer } from 'ws'`,
|
|
381
|
+
runtimeImport(ctx),
|
|
382
|
+
...(ctx.frontend || ctx.db
|
|
383
|
+
? [
|
|
384
|
+
`import { dirname as __pikkuDirname, join as __pikkuJoin } from 'node:path'`,
|
|
385
|
+
`import { fileURLToPath as __pikkuFileURLToPath } from 'node:url'`,
|
|
386
|
+
]
|
|
387
|
+
: []),
|
|
388
|
+
...(ctx.db ? dbImportLines('node', ctx.db) : []),
|
|
389
|
+
...(ctx.lifecycle ? lifecycleImportLines(ctx.lifecycle) : []),
|
|
56
390
|
``,
|
|
57
391
|
ctx.configImport,
|
|
58
392
|
ctx.servicesImport,
|
|
@@ -64,6 +398,8 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
64
398
|
`const port = parseInt(process.env.PORT || '3000', 10)`,
|
|
65
399
|
`const hostname = process.env.HOST || '0.0.0.0'`,
|
|
66
400
|
``,
|
|
401
|
+
...commandParseLines(ctx),
|
|
402
|
+
``,
|
|
67
403
|
`async function main() {`,
|
|
68
404
|
` const config = await ${ctx.configVar}()`,
|
|
69
405
|
` const schedulerService = new InMemorySchedulerService()`,
|
|
@@ -73,8 +409,11 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
73
409
|
` const eventHub = new LocalEventHubService()`,
|
|
74
410
|
` workflowService.wireQueueWorkers()`,
|
|
75
411
|
` wireAgentScorerQueueWorkers()`,
|
|
412
|
+
...(ctx.db ? dbSetupLines('node', ctx.db) : []),
|
|
413
|
+
...commandDispatchLines('node', ctx),
|
|
76
414
|
` const singletonServices = await ${ctx.servicesVar}(config, {`,
|
|
77
415
|
` logger,`,
|
|
416
|
+
...(ctx.db ? [` kysely,`] : []),
|
|
78
417
|
` schedulerService,`,
|
|
79
418
|
` queueService,`,
|
|
80
419
|
` workflowService,`,
|
|
@@ -84,9 +423,21 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
84
423
|
` })`,
|
|
85
424
|
` pikkuState(null, 'package', 'singletonServices', singletonServices)`,
|
|
86
425
|
``,
|
|
426
|
+
...(ctx.frontend
|
|
427
|
+
? [
|
|
428
|
+
// Resolved from the running bundle rather than baked in at build
|
|
429
|
+
// time, so the distributable stays movable.
|
|
430
|
+
` const staticMounts = [{`,
|
|
431
|
+
` urlPrefix: '${ctx.frontend.urlPrefix}',`,
|
|
432
|
+
` directory: __pikkuJoin(__pikkuDirname(__pikkuFileURLToPath(import.meta.url)), '${STANDALONE_FRONTEND_DIR}'),`,
|
|
433
|
+
` spaFallback: ${ctx.frontend.spaFallback},`,
|
|
434
|
+
` }]`,
|
|
435
|
+
``,
|
|
436
|
+
]
|
|
437
|
+
: []),
|
|
87
438
|
` const wss = new WebSocketServer({ noServer: true, maxPayload: DEFAULT_WS_MAX_PAYLOAD })`,
|
|
88
439
|
` const server = new PikkuNodeHTTPServer(`,
|
|
89
|
-
` { ...config, port, hostname },`,
|
|
440
|
+
` { ...config, port, hostname${ctx.frontend ? ', staticMounts' : ''} },`,
|
|
90
441
|
` logger,`,
|
|
91
442
|
` {`,
|
|
92
443
|
` ${ctx.mcpServerOption}configureServer: (httpServer) => {`,
|
|
@@ -97,8 +448,11 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
97
448
|
` await server.init()`,
|
|
98
449
|
` await schedulerService.start()`,
|
|
99
450
|
` await triggerService.start()`,
|
|
100
|
-
|
|
451
|
+
...(ctx.lifecycle ? lifecycleStartLines() : []),
|
|
452
|
+
` server.enableExitOnSignals(${shutdownHooksArg(ctx)})`,
|
|
101
453
|
` await server.start()`,
|
|
454
|
+
...(ctx.lifecycle ? lifecycleAfterStartLines() : []),
|
|
455
|
+
...sidecarHandshakeLines(),
|
|
102
456
|
`}`,
|
|
103
457
|
``,
|
|
104
458
|
`main().catch((err) => {`,
|
|
@@ -106,6 +460,7 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
106
460
|
` process.exit(1)`,
|
|
107
461
|
`})`,
|
|
108
462
|
``,
|
|
463
|
+
...(ctx.db?.engine === 'sqlite' ? [...dataDirHelperLines(), ``] : []),
|
|
109
464
|
].join('\n')
|
|
110
465
|
}
|
|
111
466
|
|
|
@@ -113,10 +468,21 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
113
468
|
return [
|
|
114
469
|
`// Generated standalone entry (bun runtime) — all functions in one process`,
|
|
115
470
|
`import { ConsoleLogger, InMemoryQueueService, InMemoryTriggerService, InMemoryWorkflowService } from '@pikku/core/services'`,
|
|
471
|
+
runtimeImport(ctx),
|
|
116
472
|
`import { pikkuState } from '@pikku/core/state'`,
|
|
117
473
|
`import { wireAgentScorerQueueWorkers } from '@pikku/core/agent-scorer'`,
|
|
118
474
|
`import { InMemorySchedulerService } from '@pikku/schedule'`,
|
|
119
475
|
`import { PikkuBunServer, BunEventHubService } from '@pikku/bun-server'`,
|
|
476
|
+
...(ctx.frontend
|
|
477
|
+
? [`import { frontendAssets } from '${STANDALONE_FRONTEND_MANIFEST}'`]
|
|
478
|
+
: []),
|
|
479
|
+
...(ctx.db
|
|
480
|
+
? [
|
|
481
|
+
`import { dirname as __pikkuDirname, join as __pikkuJoin } from 'node:path'`,
|
|
482
|
+
]
|
|
483
|
+
: []),
|
|
484
|
+
...(ctx.db ? dbImportLines('bun', ctx.db) : []),
|
|
485
|
+
...(ctx.lifecycle ? lifecycleImportLines(ctx.lifecycle) : []),
|
|
120
486
|
``,
|
|
121
487
|
ctx.configImport,
|
|
122
488
|
ctx.servicesImport,
|
|
@@ -128,6 +494,8 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
128
494
|
`const port = parseInt(process.env.PORT || '3000', 10)`,
|
|
129
495
|
`const hostname = process.env.HOST || '0.0.0.0'`,
|
|
130
496
|
``,
|
|
497
|
+
...commandParseLines(ctx),
|
|
498
|
+
``,
|
|
131
499
|
`async function main() {`,
|
|
132
500
|
` const config = await ${ctx.configVar}()`,
|
|
133
501
|
` const schedulerService = new InMemorySchedulerService()`,
|
|
@@ -137,8 +505,11 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
137
505
|
` const eventHub = new BunEventHubService()`,
|
|
138
506
|
` workflowService.wireQueueWorkers()`,
|
|
139
507
|
` wireAgentScorerQueueWorkers()`,
|
|
508
|
+
...(ctx.db ? dbSetupLines('bun', ctx.db) : []),
|
|
509
|
+
...commandDispatchLines('bun', ctx),
|
|
140
510
|
` const singletonServices = await ${ctx.servicesVar}(config, {`,
|
|
141
511
|
` logger,`,
|
|
512
|
+
...(ctx.db ? [` kysely,`] : []),
|
|
142
513
|
` schedulerService,`,
|
|
143
514
|
` queueService,`,
|
|
144
515
|
` workflowService,`,
|
|
@@ -148,12 +519,28 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
148
519
|
` })`,
|
|
149
520
|
` pikkuState(null, 'package', 'singletonServices', singletonServices)`,
|
|
150
521
|
``,
|
|
151
|
-
|
|
522
|
+
...(ctx.frontend
|
|
523
|
+
? [
|
|
524
|
+
// A compiled binary has no directory to read: every file was
|
|
525
|
+
// embedded, and the map is the only way back to it.
|
|
526
|
+
` const staticMounts = [{`,
|
|
527
|
+
` urlPrefix: '${ctx.frontend.urlPrefix}',`,
|
|
528
|
+
` directory: '',`,
|
|
529
|
+
` spaFallback: ${ctx.frontend.spaFallback},`,
|
|
530
|
+
` assets: frontendAssets,`,
|
|
531
|
+
` }]`,
|
|
532
|
+
``,
|
|
533
|
+
]
|
|
534
|
+
: []),
|
|
535
|
+
` const server = new PikkuBunServer({ ...config, port, hostname${ctx.frontend ? ', staticMounts' : ''} }, logger, { ${ctx.mcpServerOption}eventHub })`,
|
|
152
536
|
` await server.init()`,
|
|
153
537
|
` await schedulerService.start()`,
|
|
154
538
|
` await triggerService.start()`,
|
|
155
|
-
|
|
539
|
+
...(ctx.lifecycle ? lifecycleStartLines() : []),
|
|
540
|
+
` server.enableExitOnSignals(${shutdownHooksArg(ctx)})`,
|
|
156
541
|
` await server.start()`,
|
|
542
|
+
...(ctx.lifecycle ? lifecycleAfterStartLines() : []),
|
|
543
|
+
...sidecarHandshakeLines(),
|
|
157
544
|
`}`,
|
|
158
545
|
``,
|
|
159
546
|
`main().catch((err) => {`,
|
|
@@ -161,6 +548,7 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
161
548
|
` process.exit(1)`,
|
|
162
549
|
`})`,
|
|
163
550
|
``,
|
|
551
|
+
...(ctx.db?.engine === 'sqlite' ? [...dataDirHelperLines(), ``] : []),
|
|
164
552
|
].join('\n')
|
|
165
553
|
}
|
|
166
554
|
|
|
@@ -182,10 +570,26 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
182
570
|
// Bun-native builtins are provided by the runtime and resolved by
|
|
183
571
|
// `bun build --compile` — leave them as imports rather than inlining.
|
|
184
572
|
externals.push('bun', 'bun:*', 'bun:sqlite', 'bun:ffi')
|
|
573
|
+
externals.push(STANDALONE_FRONTEND_MANIFEST)
|
|
185
574
|
}
|
|
186
575
|
return externals
|
|
187
576
|
}
|
|
188
577
|
|
|
578
|
+
/**
|
|
579
|
+
* The SQLite driver this runtime cannot load.
|
|
580
|
+
*
|
|
581
|
+
* `loadSqliteRuntime` picks its driver by looking for `globalThis.Bun`, so a
|
|
582
|
+
* node process never runs the bun branch — but esbuild still follows the
|
|
583
|
+
* import, and `bun:sqlite` sits at the top of that module as a static import
|
|
584
|
+
* it cannot resolve. Left in, the bundle fails to build; marked external, it
|
|
585
|
+
* becomes a top-level import node fails to load. Stubbing removes the branch
|
|
586
|
+
* that was already dead.
|
|
587
|
+
*/
|
|
588
|
+
getStubModules(): string[] {
|
|
589
|
+
if (this.runtime === 'bun') return []
|
|
590
|
+
return ['sqlite-runtime-bun']
|
|
591
|
+
}
|
|
592
|
+
|
|
189
593
|
getPlatform(): 'node' {
|
|
190
594
|
return 'node'
|
|
191
595
|
}
|
|
@@ -196,8 +600,37 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
196
600
|
onProgress?: (step: string, detail: string) => void
|
|
197
601
|
}) {
|
|
198
602
|
const { buildDir, logger } = options
|
|
603
|
+
|
|
604
|
+
// Checked before anything expensive runs: a `--desktop` deploy that cannot
|
|
605
|
+
// produce a shell should say so now, not after a bun compile.
|
|
606
|
+
if (this.desktop) {
|
|
607
|
+
if (!this.desktopUrl && this.runtime !== 'bun') {
|
|
608
|
+
return {
|
|
609
|
+
success: false,
|
|
610
|
+
errors: [
|
|
611
|
+
{
|
|
612
|
+
step: 'desktop',
|
|
613
|
+
error: `A desktop shell ships the server as a sidecar binary, which only the bun runtime produces. Re-run with --runtime bun (got '${this.runtime}').`,
|
|
614
|
+
},
|
|
615
|
+
],
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
if (!this.projectDir) {
|
|
619
|
+
return {
|
|
620
|
+
success: false,
|
|
621
|
+
errors: [
|
|
622
|
+
{
|
|
623
|
+
step: 'desktop',
|
|
624
|
+
error:
|
|
625
|
+
'No project directory was supplied, so there is nowhere to write src-tauri/.',
|
|
626
|
+
},
|
|
627
|
+
],
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
199
632
|
const { join, dirname } = await import('node:path')
|
|
200
|
-
const { readdir, writeFile, copyFile, mkdir } =
|
|
633
|
+
const { cp, readdir, writeFile, copyFile, mkdir } =
|
|
201
634
|
await import('node:fs/promises')
|
|
202
635
|
const { existsSync } = await import('node:fs')
|
|
203
636
|
|
|
@@ -230,6 +663,22 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
230
663
|
}
|
|
231
664
|
logger.info(`Bundle: ${join(outDir, 'bundle.js')}`)
|
|
232
665
|
|
|
666
|
+
// --- 2a. Frontend, when the build produced one ---
|
|
667
|
+
// Both runtimes need it here rather than only in the build directory: node
|
|
668
|
+
// resolves the mount relative to the shipped bundle, and `bun build
|
|
669
|
+
// --compile` follows the manifest import out of the copy it is given.
|
|
670
|
+
const frontendDir = join(unitDir, STANDALONE_FRONTEND_DIR)
|
|
671
|
+
if (existsSync(frontendDir)) {
|
|
672
|
+
await cp(frontendDir, join(outDir, STANDALONE_FRONTEND_DIR), {
|
|
673
|
+
recursive: true,
|
|
674
|
+
})
|
|
675
|
+
const manifestName = STANDALONE_FRONTEND_MANIFEST.replace('./', '')
|
|
676
|
+
if (existsSync(join(unitDir, manifestName))) {
|
|
677
|
+
await copyFile(join(unitDir, manifestName), join(outDir, manifestName))
|
|
678
|
+
}
|
|
679
|
+
logger.info(`Frontend: ${join(outDir, STANDALONE_FRONTEND_DIR)}`)
|
|
680
|
+
}
|
|
681
|
+
|
|
233
682
|
// --- 2b. bun runtime: compile the bundle into a self-contained binary ---
|
|
234
683
|
if (this.runtime === 'bun') {
|
|
235
684
|
const { execFileSync } = await import('node:child_process')
|
|
@@ -261,6 +710,58 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
261
710
|
}
|
|
262
711
|
}
|
|
263
712
|
|
|
713
|
+
// --- 2c. desktop: wrap the server in a shell, or point one at a remote ---
|
|
714
|
+
let targetTriple: string | undefined
|
|
715
|
+
if (this.desktop && this.projectDir) {
|
|
716
|
+
const { generateTauriShell, tauriBundleIdentifier } =
|
|
717
|
+
await import('./tauri/generate.js')
|
|
718
|
+
const { hostTargetTriple } = await import('./tauri/target-triple.js')
|
|
719
|
+
const { renderTauriNextSteps } = await import('./tauri/next-steps.js')
|
|
720
|
+
try {
|
|
721
|
+
const rustcVersionVerbose = await rustcHostOutput()
|
|
722
|
+
targetTriple = hostTargetTriple({ rustcVersionVerbose })
|
|
723
|
+
const shell = await generateTauriShell({
|
|
724
|
+
projectDir: this.projectDir,
|
|
725
|
+
appName,
|
|
726
|
+
identifier: this.desktopIdentifier ?? tauriBundleIdentifier(appName),
|
|
727
|
+
targetTriple,
|
|
728
|
+
...(this.desktopUrl
|
|
729
|
+
? { remoteUrl: this.desktopUrl }
|
|
730
|
+
: { binaryPath: join(outDir, appName) }),
|
|
731
|
+
})
|
|
732
|
+
logger.info(`Desktop shell: ${shell.dir} (${shell.targetTriple})`)
|
|
733
|
+
if (shell.written.length) {
|
|
734
|
+
logger.info(` wrote ${shell.written.join(', ')}`)
|
|
735
|
+
}
|
|
736
|
+
if (shell.preserved.length) {
|
|
737
|
+
logger.info(
|
|
738
|
+
` kept your edits, not regenerated: ${shell.preserved.join(', ')}`
|
|
739
|
+
)
|
|
740
|
+
}
|
|
741
|
+
if (shell.sidecar) {
|
|
742
|
+
logger.info(` sidecar: binaries/${shell.sidecar.fileName}`)
|
|
743
|
+
} else {
|
|
744
|
+
logger.info(` window opens: ${this.desktopUrl}`)
|
|
745
|
+
}
|
|
746
|
+
for (const line of renderTauriNextSteps({
|
|
747
|
+
shellDir: shell.dir,
|
|
748
|
+
hasRust: rustcVersionVerbose !== undefined,
|
|
749
|
+
})) {
|
|
750
|
+
logger.info(line)
|
|
751
|
+
}
|
|
752
|
+
} catch (e: unknown) {
|
|
753
|
+
return {
|
|
754
|
+
success: false,
|
|
755
|
+
errors: [
|
|
756
|
+
{
|
|
757
|
+
step: 'desktop',
|
|
758
|
+
error: e instanceof Error ? e.message : String(e),
|
|
759
|
+
},
|
|
760
|
+
],
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
264
765
|
// --- 3. config/ — empty template with .env example ---
|
|
265
766
|
const configDir = join(outDir, 'config')
|
|
266
767
|
await mkdir(configDir, { recursive: true })
|
|
@@ -292,6 +793,7 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
292
793
|
workersDeployed: [appName],
|
|
293
794
|
resourcesCreated: [],
|
|
294
795
|
errors: [],
|
|
796
|
+
targetTriple,
|
|
295
797
|
}
|
|
296
798
|
}
|
|
297
799
|
}
|