@pikku/deploy-standalone 0.12.13 → 0.12.19
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 +181 -0
- package/dist/adapter.d.ts +26 -1
- package/dist/adapter.js +332 -7
- package/dist/index.d.ts +1 -0
- package/dist/runtime/cli.d.ts +75 -0
- package/dist/runtime/cli.js +193 -0
- package/dist/runtime/index.d.ts +2 -0
- package/dist/runtime/index.js +1 -0
- package/package.json +4 -3
- package/src/adapter.test.ts +539 -0
- package/src/adapter.ts +395 -8
- package/src/contributors.test.ts +96 -0
- package/src/index.ts +1 -0
- package/src/runtime/cli.test.ts +222 -0
- package/src/runtime/cli.ts +311 -0
- package/src/runtime/index.ts +18 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/adapter.ts
CHANGED
|
@@ -16,8 +16,23 @@
|
|
|
16
16
|
* compiles the bundle into a single self-contained executable via
|
|
17
17
|
* `bun build --compile`. No runtime needed on the target host.
|
|
18
18
|
*/
|
|
19
|
-
import type {
|
|
20
|
-
|
|
19
|
+
import type {
|
|
20
|
+
BindingSource,
|
|
21
|
+
ContributorPlatform,
|
|
22
|
+
EntryGenerationContext,
|
|
23
|
+
PlatformServiceContributor,
|
|
24
|
+
ProviderAdapter,
|
|
25
|
+
} from '@pikku/deploy'
|
|
26
|
+
import {
|
|
27
|
+
assertContributorsSupported,
|
|
28
|
+
collectContributorImports,
|
|
29
|
+
collectContributorLines,
|
|
30
|
+
dedupeContributors,
|
|
31
|
+
nodeBuiltinExternals,
|
|
32
|
+
SERVER_READY_MARKER,
|
|
33
|
+
} from '@pikku/deploy'
|
|
34
|
+
|
|
35
|
+
export const STANDALONE_BINDING_SOURCES: readonly BindingSource[] = ['env']
|
|
21
36
|
|
|
22
37
|
export type StandaloneRuntime = 'node' | 'bun'
|
|
23
38
|
|
|
@@ -49,7 +64,263 @@ const sidecarHandshakeLines = (): string[] => [
|
|
|
49
64
|
` console.log(\`${SERVER_READY_MARKER} on http://\${hostname}:\${server.port}\`)`,
|
|
50
65
|
]
|
|
51
66
|
|
|
52
|
-
|
|
67
|
+
/**
|
|
68
|
+
* The runtime helpers the entry imports. The database ones are left out of a
|
|
69
|
+
* build with no database, so the bundle carries no migrator it can never run.
|
|
70
|
+
*/
|
|
71
|
+
const runtimeImport = (ctx: EntryGenerationContext): string => {
|
|
72
|
+
const names = ['watchParentProcess', 'parseStandaloneCommand']
|
|
73
|
+
if (ctx.db) names.push('runStandaloneCommand', 'resolveMigrationsDir')
|
|
74
|
+
return `import { ${names.join(', ')} } from '@pikku/deploy-standalone/runtime'`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Environment variable naming the directory the SQLite file lives in.
|
|
79
|
+
*
|
|
80
|
+
* The database has to outlive a release. A deploy that swaps the artifact
|
|
81
|
+
* directory would take the database with it if the file sat beside the bundle,
|
|
82
|
+
* so the path comes from the environment and points somewhere the operator
|
|
83
|
+
* keeps stable across releases, rather than being derived from the bundle's own
|
|
84
|
+
* location the way the frontend directory is.
|
|
85
|
+
*/
|
|
86
|
+
const DATA_DIR_VAR = 'PIKKU_DATA_DIR'
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Full override for the database file, for when it must match a path something
|
|
90
|
+
* else already decided — notably `pikku db migrate`, which has to open the same
|
|
91
|
+
* file this opens or the app runs against an unmigrated database.
|
|
92
|
+
*/
|
|
93
|
+
const DATABASE_FILE_VAR = 'PIKKU_DATABASE_FILE'
|
|
94
|
+
|
|
95
|
+
const DEFAULT_DATABASE_FILENAME = 'pikku.db'
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The dialect factory each runtime opens SQLite with. bun cannot use the node
|
|
99
|
+
* one — `bun:sqlite` is a different driver, and the node build reaches for
|
|
100
|
+
* `node:sqlite`, which a compiled bun binary does not carry.
|
|
101
|
+
*/
|
|
102
|
+
const SQLITE_FACTORY = {
|
|
103
|
+
node: {
|
|
104
|
+
specifier: '@pikku/kysely-node-sqlite',
|
|
105
|
+
fn: 'createNodeSqliteKysely',
|
|
106
|
+
},
|
|
107
|
+
bun: { specifier: '@pikku/kysely-bun-sqlite', fn: 'createBunSqliteKysely' },
|
|
108
|
+
} as const
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The environment variable a Postgres build reads its connection string from.
|
|
112
|
+
*
|
|
113
|
+
* The same name every other pikku host uses, so an artifact dropped onto a
|
|
114
|
+
* machine that already runs a pikku app needs no new variable.
|
|
115
|
+
*/
|
|
116
|
+
const DATABASE_URL_VAR = 'DATABASE_URL'
|
|
117
|
+
|
|
118
|
+
/** Imports the coercion plugin, for an app that generated a map. */
|
|
119
|
+
const coercionImportLines = (coercionImportPath: string): string[] => [
|
|
120
|
+
`import { createCoercionPlugin } from '@pikku/kysely'`,
|
|
121
|
+
`import { coercionMap as __pikkuCoercionMap } from '${coercionImportPath}'`,
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
/** Imports a database-backed entry needs on top of the common set. */
|
|
125
|
+
const dbImportLines = (
|
|
126
|
+
runtime: 'node' | 'bun',
|
|
127
|
+
db: NonNullable<EntryGenerationContext['db']>
|
|
128
|
+
): string[] => [
|
|
129
|
+
...(db.engine === 'sqlite'
|
|
130
|
+
? [
|
|
131
|
+
`import { ${SQLITE_FACTORY[runtime].fn} } from '${SQLITE_FACTORY[runtime].specifier}'`,
|
|
132
|
+
`import { mkdirSync as __pikkuMkdirSync } from 'node:fs'`,
|
|
133
|
+
]
|
|
134
|
+
: [`import { PikkuKysely } from '@pikku/kysely-postgres'`]),
|
|
135
|
+
...(db.coercionImportPath ? coercionImportLines(db.coercionImportPath) : []),
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Opens the database before services are built, so `createSingletonServices`
|
|
140
|
+
* receives `kysely` exactly as a hosted runtime would hand it over.
|
|
141
|
+
*
|
|
142
|
+
* Creating the directory rather than requiring it is deliberate: the artifact
|
|
143
|
+
* is expected to start on a machine where nothing has run yet, and a missing
|
|
144
|
+
* parent directory is the difference between a first boot that works and one
|
|
145
|
+
* that needs a documented mkdir nobody reads.
|
|
146
|
+
*/
|
|
147
|
+
const dbSetupLines = (
|
|
148
|
+
runtime: 'node' | 'bun',
|
|
149
|
+
db: NonNullable<EntryGenerationContext['db']>
|
|
150
|
+
): string[] => {
|
|
151
|
+
const plugins = db.coercionImportPath
|
|
152
|
+
? `[createCoercionPlugin({ map: __pikkuCoercionMap })]`
|
|
153
|
+
: `[]`
|
|
154
|
+
|
|
155
|
+
if (db.engine === 'sqlite') {
|
|
156
|
+
return [
|
|
157
|
+
` const __pikkuDbFile = process.env.${DATABASE_FILE_VAR}`,
|
|
158
|
+
` ? process.env.${DATABASE_FILE_VAR}`,
|
|
159
|
+
` : __pikkuJoin(__pikkuRequireDataDir(), '${DEFAULT_DATABASE_FILENAME}')`,
|
|
160
|
+
` __pikkuMkdirSync(__pikkuDirname(__pikkuDbFile), { recursive: true })`,
|
|
161
|
+
` const kysely = ${SQLITE_FACTORY[runtime].fn}({`,
|
|
162
|
+
` filename: __pikkuDbFile,`,
|
|
163
|
+
` plugins: ${plugins},`,
|
|
164
|
+
` })`,
|
|
165
|
+
]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return [
|
|
169
|
+
` const __pikkuDbUrl = process.env.${DATABASE_URL_VAR}`,
|
|
170
|
+
` if (!__pikkuDbUrl) {`,
|
|
171
|
+
` throw new Error(`,
|
|
172
|
+
` 'This build connects to Postgres, so it needs ${DATABASE_URL_VAR} set to the database it should open.'`,
|
|
173
|
+
` )`,
|
|
174
|
+
` }`,
|
|
175
|
+
` const __pikkuPg = new PikkuKysely(logger, __pikkuDbUrl)`,
|
|
176
|
+
` await __pikkuPg.init()`,
|
|
177
|
+
...(db.coercionImportPath
|
|
178
|
+
? [
|
|
179
|
+
` const kysely = __pikkuPg.kysely.withPlugin(`,
|
|
180
|
+
` createCoercionPlugin({ map: __pikkuCoercionMap })`,
|
|
181
|
+
` )`,
|
|
182
|
+
]
|
|
183
|
+
: [` const kysely = __pikkuPg.kysely`]),
|
|
184
|
+
]
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Where the migrations sit relative to the running artifact.
|
|
189
|
+
*
|
|
190
|
+
* A node bundle reads them from its own directory. A compiled bun binary has no
|
|
191
|
+
* directory — `import.meta.url` points inside the embedded filesystem — so it
|
|
192
|
+
* resolves them beside the executable, which is where an operator unpacking an
|
|
193
|
+
* artifact puts them.
|
|
194
|
+
*/
|
|
195
|
+
const bundleDirExpression = (runtime: 'node' | 'bun'): string =>
|
|
196
|
+
runtime === 'node'
|
|
197
|
+
? `__pikkuDirname(__pikkuFileURLToPath(import.meta.url))`
|
|
198
|
+
: `__pikkuDirname(process.execPath)`
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The command line, parsed before anything is opened.
|
|
202
|
+
*
|
|
203
|
+
* `version` and `help` have to answer without a database, a config factory or a
|
|
204
|
+
* port, because the machine asking may be one where none of the three work yet
|
|
205
|
+
* — which is exactly when someone runs them.
|
|
206
|
+
*/
|
|
207
|
+
const commandParseLines = (ctx: EntryGenerationContext): string[] => [
|
|
208
|
+
`const __pikkuCommand = parseStandaloneCommand(process.argv.slice(2), {`,
|
|
209
|
+
` version: '${(ctx.version ?? 'unknown').replace(/'/g, "\\'")}',`,
|
|
210
|
+
` hasDb: ${Boolean(ctx.db)},`,
|
|
211
|
+
...(ctx.db ? [` engine: '${ctx.db.engine}',`] : []),
|
|
212
|
+
`})`,
|
|
213
|
+
`if (__pikkuCommand.kind === 'exit') process.exit(__pikkuCommand.code)`,
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Runs a non-serve command against the database the app itself just opened, and
|
|
218
|
+
* stops before a port is bound.
|
|
219
|
+
*
|
|
220
|
+
* Reusing the app's own connection is the point: a command that resolved its
|
|
221
|
+
* own would be free to migrate a different database than the next `serve`
|
|
222
|
+
* reads, and the two would only disagree once in production.
|
|
223
|
+
*/
|
|
224
|
+
const commandDispatchLines = (
|
|
225
|
+
runtime: 'node' | 'bun',
|
|
226
|
+
ctx: EntryGenerationContext
|
|
227
|
+
): string[] => {
|
|
228
|
+
if (!ctx.db) {
|
|
229
|
+
return [` if (__pikkuCommand.kind !== 'serve') process.exit(0)`, ``]
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const dir = `__pikkuJoin(${bundleDirExpression(runtime)}, 'db', '${ctx.db.engine}')`
|
|
233
|
+
const handle =
|
|
234
|
+
ctx.db.engine === 'sqlite'
|
|
235
|
+
? `databaseFile: __pikkuDbFile,`
|
|
236
|
+
: `sql: __pikkuPg.sql,`
|
|
237
|
+
|
|
238
|
+
return [
|
|
239
|
+
` const __pikkuDbCommandTarget = {`,
|
|
240
|
+
` engine: '${ctx.db.engine}',`,
|
|
241
|
+
` migrationsDir: resolveMigrationsDir(${dir}),`,
|
|
242
|
+
` ${handle}`,
|
|
243
|
+
` }`,
|
|
244
|
+
` if ((await runStandaloneCommand(__pikkuCommand, __pikkuDbCommandTarget)) === 'done') {`,
|
|
245
|
+
...(ctx.db.engine === 'postgres' ? [` await __pikkuPg.close()`] : []),
|
|
246
|
+
` return`,
|
|
247
|
+
` }`,
|
|
248
|
+
``,
|
|
249
|
+
]
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Imports the app's own lifecycle module, when it declares one. */
|
|
253
|
+
const lifecycleImportLines = (
|
|
254
|
+
lifecycle: NonNullable<EntryGenerationContext['lifecycle']>
|
|
255
|
+
): string[] => [
|
|
256
|
+
`import { ${lifecycle.variable} as __pikkuLifecycle } from '${lifecycle.importPath}'`,
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Runs the app's start hooks around the port opening, the same order and the
|
|
261
|
+
* same services `pikku dev` gives them.
|
|
262
|
+
*
|
|
263
|
+
* `beforeStart` runs after `init` so a hook can rely on everything the server
|
|
264
|
+
* resolved, and before `start` so work that must finish before the first
|
|
265
|
+
* request — a seeded admin account, a schema probe — is finished when one
|
|
266
|
+
* arrives.
|
|
267
|
+
*/
|
|
268
|
+
const lifecycleStartLines = (): string[] => [
|
|
269
|
+
` await __pikkuLifecycle?.beforeStart?.(singletonServices)`,
|
|
270
|
+
]
|
|
271
|
+
|
|
272
|
+
const lifecycleAfterStartLines = (): string[] => [
|
|
273
|
+
` await __pikkuLifecycle?.afterStart?.(singletonServices)`,
|
|
274
|
+
]
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Hands the stop hooks to the signal handler that owns the shutdown.
|
|
278
|
+
*
|
|
279
|
+
* The connection pool is closed in `afterStop`, once the app's own hook and the
|
|
280
|
+
* server have both finished with it — a pool closed any earlier takes the
|
|
281
|
+
* queries they are still allowed to make down with it. SQLite needs no
|
|
282
|
+
* counterpart: the process exiting releases the file.
|
|
283
|
+
*/
|
|
284
|
+
const shutdownHooksArg = (ctx: EntryGenerationContext): string => {
|
|
285
|
+
const before: string[] = []
|
|
286
|
+
const after: string[] = []
|
|
287
|
+
|
|
288
|
+
if (ctx.lifecycle) {
|
|
289
|
+
before.push(`await __pikkuLifecycle?.beforeStop?.(singletonServices)`)
|
|
290
|
+
after.push(`await __pikkuLifecycle?.afterStop?.(singletonServices)`)
|
|
291
|
+
}
|
|
292
|
+
if (ctx.db?.engine === 'postgres') {
|
|
293
|
+
after.push(`await __pikkuPg.close()`)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (before.length === 0 && after.length === 0) return ''
|
|
297
|
+
|
|
298
|
+
const hooks: string[] = []
|
|
299
|
+
if (before.length > 0) {
|
|
300
|
+
hooks.push(`beforeStop: async () => { ${before.join('; ')} }`)
|
|
301
|
+
}
|
|
302
|
+
if (after.length > 0) {
|
|
303
|
+
hooks.push(`afterStop: async () => { ${after.join('; ')} }`)
|
|
304
|
+
}
|
|
305
|
+
return `{ ${hooks.join(', ')} }`
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Fails with the variable's name rather than whatever SQLite says about a path
|
|
310
|
+
* of `undefined/pikku.db`, which is the error an operator would otherwise have
|
|
311
|
+
* to work backwards from.
|
|
312
|
+
*/
|
|
313
|
+
const dataDirHelperLines = (): string[] => [
|
|
314
|
+
`function __pikkuRequireDataDir() {`,
|
|
315
|
+
` const dir = process.env.${DATA_DIR_VAR}`,
|
|
316
|
+
` if (!dir) {`,
|
|
317
|
+
` throw new Error(`,
|
|
318
|
+
` '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.'`,
|
|
319
|
+
` )`,
|
|
320
|
+
` }`,
|
|
321
|
+
` return dir`,
|
|
322
|
+
`}`,
|
|
323
|
+
]
|
|
53
324
|
|
|
54
325
|
/**
|
|
55
326
|
* `rustc -vV`, or nothing when no toolchain is installed. The triple then falls
|
|
@@ -84,6 +355,21 @@ export interface StandaloneProviderAdapterOptions {
|
|
|
84
355
|
* The window is a webview onto that origin and nothing else is shipped.
|
|
85
356
|
*/
|
|
86
357
|
desktopUrl?: string
|
|
358
|
+
contributors?: PlatformServiceContributor[]
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const contributorPlatform = (
|
|
362
|
+
ctx: EntryGenerationContext
|
|
363
|
+
): ContributorPlatform => {
|
|
364
|
+
const services = ctx.unit.services ?? []
|
|
365
|
+
return {
|
|
366
|
+
serviceNames: services.map((s) => s.sourceServiceName),
|
|
367
|
+
needsQueue: services.some((s) => s.capability === 'queue'),
|
|
368
|
+
needsWorkflow: services.some((s) => s.capability === 'workflow-state'),
|
|
369
|
+
needsAgent: services.some(
|
|
370
|
+
(s) => s.capability === 'ai-storage' || s.capability === 'ai-model'
|
|
371
|
+
),
|
|
372
|
+
}
|
|
87
373
|
}
|
|
88
374
|
|
|
89
375
|
export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
@@ -95,6 +381,7 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
95
381
|
readonly projectDir?: string
|
|
96
382
|
readonly desktopIdentifier?: string
|
|
97
383
|
readonly desktopUrl?: string
|
|
384
|
+
readonly contributors: PlatformServiceContributor[]
|
|
98
385
|
|
|
99
386
|
constructor(options: StandaloneProviderAdapterOptions = {}) {
|
|
100
387
|
this.runtime = options.runtime ?? 'node'
|
|
@@ -102,6 +389,48 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
102
389
|
this.projectDir = options.projectDir
|
|
103
390
|
this.desktopIdentifier = options.desktopIdentifier
|
|
104
391
|
this.desktopUrl = options.desktopUrl
|
|
392
|
+
this.contributors = dedupeContributors(options.contributors)
|
|
393
|
+
assertContributorsSupported(
|
|
394
|
+
this.contributors,
|
|
395
|
+
STANDALONE_BINDING_SOURCES,
|
|
396
|
+
this.name
|
|
397
|
+
)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
private contributorImportLines(ctx: EntryGenerationContext): string[] {
|
|
401
|
+
if (this.contributors.length === 0) return []
|
|
402
|
+
return collectContributorImports(
|
|
403
|
+
this.contributors,
|
|
404
|
+
contributorPlatform(ctx)
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private platformServicesBlock(ctx: EntryGenerationContext): string[] {
|
|
409
|
+
if (this.contributors.length === 0) return []
|
|
410
|
+
return [
|
|
411
|
+
`const createPlatformServices = async (env: Record<string, string | undefined>): Promise<${ctx.servicesType}> => {`,
|
|
412
|
+
` const services: ${ctx.servicesType} = {}`,
|
|
413
|
+
...collectContributorLines(this.contributors, {
|
|
414
|
+
ctx,
|
|
415
|
+
platform: contributorPlatform(ctx),
|
|
416
|
+
isGateway: false,
|
|
417
|
+
}),
|
|
418
|
+
` return services`,
|
|
419
|
+
`}`,
|
|
420
|
+
``,
|
|
421
|
+
]
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private platformServicesCallLines(): string[] {
|
|
425
|
+
if (this.contributors.length === 0) return []
|
|
426
|
+
return [
|
|
427
|
+
` const platformServices = await createPlatformServices(process.env as Record<string, string | undefined>)`,
|
|
428
|
+
]
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private platformServicesSpreadLines(): string[] {
|
|
432
|
+
if (this.contributors.length === 0) return []
|
|
433
|
+
return [` ...platformServices,`]
|
|
105
434
|
}
|
|
106
435
|
|
|
107
436
|
generateEntrySource(ctx: EntryGenerationContext): string {
|
|
@@ -122,13 +451,16 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
122
451
|
`import { PikkuNodeHTTPServer } from '@pikku/node-http-server'`,
|
|
123
452
|
`import { DEFAULT_WS_MAX_PAYLOAD, pikkuWebsocketHandler } from '@pikku/ws'`,
|
|
124
453
|
`import { WebSocketServer } from 'ws'`,
|
|
125
|
-
|
|
126
|
-
...(ctx.frontend
|
|
454
|
+
runtimeImport(ctx),
|
|
455
|
+
...(ctx.frontend || ctx.db
|
|
127
456
|
? [
|
|
128
457
|
`import { dirname as __pikkuDirname, join as __pikkuJoin } from 'node:path'`,
|
|
129
458
|
`import { fileURLToPath as __pikkuFileURLToPath } from 'node:url'`,
|
|
130
459
|
]
|
|
131
460
|
: []),
|
|
461
|
+
...(ctx.db ? dbImportLines('node', ctx.db) : []),
|
|
462
|
+
...(ctx.lifecycle ? lifecycleImportLines(ctx.lifecycle) : []),
|
|
463
|
+
...this.contributorImportLines(ctx),
|
|
132
464
|
``,
|
|
133
465
|
ctx.configImport,
|
|
134
466
|
ctx.servicesImport,
|
|
@@ -140,8 +472,12 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
140
472
|
`const port = parseInt(process.env.PORT || '3000', 10)`,
|
|
141
473
|
`const hostname = process.env.HOST || '0.0.0.0'`,
|
|
142
474
|
``,
|
|
475
|
+
...commandParseLines(ctx),
|
|
476
|
+
``,
|
|
477
|
+
...this.platformServicesBlock(ctx),
|
|
143
478
|
`async function main() {`,
|
|
144
479
|
` const config = await ${ctx.configVar}()`,
|
|
480
|
+
...this.platformServicesCallLines(),
|
|
145
481
|
` const schedulerService = new InMemorySchedulerService()`,
|
|
146
482
|
` const queueService = new InMemoryQueueService()`,
|
|
147
483
|
` const workflowService = new InMemoryWorkflowService()`,
|
|
@@ -149,14 +485,18 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
149
485
|
` const eventHub = new LocalEventHubService()`,
|
|
150
486
|
` workflowService.wireQueueWorkers()`,
|
|
151
487
|
` wireAgentScorerQueueWorkers()`,
|
|
488
|
+
...(ctx.db ? dbSetupLines('node', ctx.db) : []),
|
|
489
|
+
...commandDispatchLines('node', ctx),
|
|
152
490
|
` const singletonServices = await ${ctx.servicesVar}(config, {`,
|
|
153
491
|
` logger,`,
|
|
492
|
+
...(ctx.db ? [` kysely,`] : []),
|
|
154
493
|
` schedulerService,`,
|
|
155
494
|
` queueService,`,
|
|
156
495
|
` workflowService,`,
|
|
157
496
|
` workflowRunService: workflowService,`,
|
|
158
497
|
` triggerService,`,
|
|
159
498
|
` eventHub,`,
|
|
499
|
+
...this.platformServicesSpreadLines(),
|
|
160
500
|
` })`,
|
|
161
501
|
` pikkuState(null, 'package', 'singletonServices', singletonServices)`,
|
|
162
502
|
``,
|
|
@@ -185,8 +525,10 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
185
525
|
` await server.init()`,
|
|
186
526
|
` await schedulerService.start()`,
|
|
187
527
|
` await triggerService.start()`,
|
|
188
|
-
|
|
528
|
+
...(ctx.lifecycle ? lifecycleStartLines() : []),
|
|
529
|
+
` server.enableExitOnSignals(${shutdownHooksArg(ctx)})`,
|
|
189
530
|
` await server.start()`,
|
|
531
|
+
...(ctx.lifecycle ? lifecycleAfterStartLines() : []),
|
|
190
532
|
...sidecarHandshakeLines(),
|
|
191
533
|
`}`,
|
|
192
534
|
``,
|
|
@@ -195,6 +537,7 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
195
537
|
` process.exit(1)`,
|
|
196
538
|
`})`,
|
|
197
539
|
``,
|
|
540
|
+
...(ctx.db?.engine === 'sqlite' ? [...dataDirHelperLines(), ``] : []),
|
|
198
541
|
].join('\n')
|
|
199
542
|
}
|
|
200
543
|
|
|
@@ -202,7 +545,7 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
202
545
|
return [
|
|
203
546
|
`// Generated standalone entry (bun runtime) — all functions in one process`,
|
|
204
547
|
`import { ConsoleLogger, InMemoryQueueService, InMemoryTriggerService, InMemoryWorkflowService } from '@pikku/core/services'`,
|
|
205
|
-
|
|
548
|
+
runtimeImport(ctx),
|
|
206
549
|
`import { pikkuState } from '@pikku/core/state'`,
|
|
207
550
|
`import { wireAgentScorerQueueWorkers } from '@pikku/core/agent-scorer'`,
|
|
208
551
|
`import { InMemorySchedulerService } from '@pikku/schedule'`,
|
|
@@ -210,6 +553,14 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
210
553
|
...(ctx.frontend
|
|
211
554
|
? [`import { frontendAssets } from '${STANDALONE_FRONTEND_MANIFEST}'`]
|
|
212
555
|
: []),
|
|
556
|
+
...(ctx.db
|
|
557
|
+
? [
|
|
558
|
+
`import { dirname as __pikkuDirname, join as __pikkuJoin } from 'node:path'`,
|
|
559
|
+
]
|
|
560
|
+
: []),
|
|
561
|
+
...(ctx.db ? dbImportLines('bun', ctx.db) : []),
|
|
562
|
+
...(ctx.lifecycle ? lifecycleImportLines(ctx.lifecycle) : []),
|
|
563
|
+
...this.contributorImportLines(ctx),
|
|
213
564
|
``,
|
|
214
565
|
ctx.configImport,
|
|
215
566
|
ctx.servicesImport,
|
|
@@ -221,8 +572,12 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
221
572
|
`const port = parseInt(process.env.PORT || '3000', 10)`,
|
|
222
573
|
`const hostname = process.env.HOST || '0.0.0.0'`,
|
|
223
574
|
``,
|
|
575
|
+
...commandParseLines(ctx),
|
|
576
|
+
``,
|
|
577
|
+
...this.platformServicesBlock(ctx),
|
|
224
578
|
`async function main() {`,
|
|
225
579
|
` const config = await ${ctx.configVar}()`,
|
|
580
|
+
...this.platformServicesCallLines(),
|
|
226
581
|
` const schedulerService = new InMemorySchedulerService()`,
|
|
227
582
|
` const queueService = new InMemoryQueueService()`,
|
|
228
583
|
` const workflowService = new InMemoryWorkflowService()`,
|
|
@@ -230,14 +585,18 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
230
585
|
` const eventHub = new BunEventHubService()`,
|
|
231
586
|
` workflowService.wireQueueWorkers()`,
|
|
232
587
|
` wireAgentScorerQueueWorkers()`,
|
|
588
|
+
...(ctx.db ? dbSetupLines('bun', ctx.db) : []),
|
|
589
|
+
...commandDispatchLines('bun', ctx),
|
|
233
590
|
` const singletonServices = await ${ctx.servicesVar}(config, {`,
|
|
234
591
|
` logger,`,
|
|
592
|
+
...(ctx.db ? [` kysely,`] : []),
|
|
235
593
|
` schedulerService,`,
|
|
236
594
|
` queueService,`,
|
|
237
595
|
` workflowService,`,
|
|
238
596
|
` workflowRunService: workflowService,`,
|
|
239
597
|
` triggerService,`,
|
|
240
598
|
` eventHub,`,
|
|
599
|
+
...this.platformServicesSpreadLines(),
|
|
241
600
|
` })`,
|
|
242
601
|
` pikkuState(null, 'package', 'singletonServices', singletonServices)`,
|
|
243
602
|
``,
|
|
@@ -258,8 +617,10 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
258
617
|
` await server.init()`,
|
|
259
618
|
` await schedulerService.start()`,
|
|
260
619
|
` await triggerService.start()`,
|
|
261
|
-
|
|
620
|
+
...(ctx.lifecycle ? lifecycleStartLines() : []),
|
|
621
|
+
` server.enableExitOnSignals(${shutdownHooksArg(ctx)})`,
|
|
262
622
|
` await server.start()`,
|
|
623
|
+
...(ctx.lifecycle ? lifecycleAfterStartLines() : []),
|
|
263
624
|
...sidecarHandshakeLines(),
|
|
264
625
|
`}`,
|
|
265
626
|
``,
|
|
@@ -268,6 +629,7 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
268
629
|
` process.exit(1)`,
|
|
269
630
|
`})`,
|
|
270
631
|
``,
|
|
632
|
+
...(ctx.db?.engine === 'sqlite' ? [...dataDirHelperLines(), ``] : []),
|
|
271
633
|
].join('\n')
|
|
272
634
|
}
|
|
273
635
|
|
|
@@ -294,6 +656,31 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
|
|
|
294
656
|
return externals
|
|
295
657
|
}
|
|
296
658
|
|
|
659
|
+
/**
|
|
660
|
+
* The SQLite driver this runtime cannot load.
|
|
661
|
+
*
|
|
662
|
+
* `loadSqliteRuntime` picks its driver by looking for `globalThis.Bun`, so a
|
|
663
|
+
* node process never runs the bun branch — but esbuild still follows the
|
|
664
|
+
* import, and `bun:sqlite` sits at the top of that module as a static import
|
|
665
|
+
* it cannot resolve. Left in, the bundle fails to build; marked external, it
|
|
666
|
+
* becomes a top-level import node fails to load. Stubbing removes the branch
|
|
667
|
+
* that was already dead.
|
|
668
|
+
*/
|
|
669
|
+
getStubModules(): string[] {
|
|
670
|
+
if (this.runtime === 'bun') return []
|
|
671
|
+
return ['sqlite-runtime-bun']
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* The bun bundle is not the artifact that runs — `bun build --compile` turns
|
|
676
|
+
* it into the binary — so esbuild must not rename anything bun will rename
|
|
677
|
+
* again. See `getMangleIdentifiers` on the adapter interface for the boot
|
|
678
|
+
* failure the two passes produce together.
|
|
679
|
+
*/
|
|
680
|
+
getMangleIdentifiers(): boolean {
|
|
681
|
+
return this.runtime !== 'bun'
|
|
682
|
+
}
|
|
683
|
+
|
|
297
684
|
getPlatform(): 'node' {
|
|
298
685
|
return 'node'
|
|
299
686
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import type { PlatformServiceContributor } from '@pikku/deploy'
|
|
4
|
+
|
|
5
|
+
import { StandaloneProviderAdapter } from './adapter.js'
|
|
6
|
+
|
|
7
|
+
const ctx = {
|
|
8
|
+
unit: {
|
|
9
|
+
name: 'app',
|
|
10
|
+
role: 'function',
|
|
11
|
+
services: [{ capability: 'kv', sourceServiceName: 'browser' }],
|
|
12
|
+
},
|
|
13
|
+
unitDir: '/build/app',
|
|
14
|
+
bootstrapPath: './.pikku/pikku-bootstrap.gen.js',
|
|
15
|
+
configImport: `import { createConfig } from './config.js'`,
|
|
16
|
+
configVar: 'createConfig',
|
|
17
|
+
servicesImport: `import { createSingletonServices } from './services.js'`,
|
|
18
|
+
servicesVar: 'createSingletonServices',
|
|
19
|
+
singletonServicesImport: '',
|
|
20
|
+
servicesType: 'Record<string, unknown>',
|
|
21
|
+
mcpImport: '',
|
|
22
|
+
mcpServerOption: '',
|
|
23
|
+
} as never
|
|
24
|
+
|
|
25
|
+
const kysely: PlatformServiceContributor = {
|
|
26
|
+
name: 'kysely',
|
|
27
|
+
imports: [`import { Kysely } from 'kysely'`],
|
|
28
|
+
emit: () => [` if (env.DATABASE_URL) services.kysely = new Kysely({})`],
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const browser: PlatformServiceContributor = {
|
|
32
|
+
name: 'browser',
|
|
33
|
+
imports: (platform) =>
|
|
34
|
+
platform.serviceNames.includes('browser')
|
|
35
|
+
? [`import { Browser } from './browser.js'`]
|
|
36
|
+
: [],
|
|
37
|
+
emit: () => [` services.browser = new Browser(env, logger)`],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const queueBinding: PlatformServiceContributor = {
|
|
41
|
+
name: 'queue-binding',
|
|
42
|
+
requires: ['cloudflare'],
|
|
43
|
+
emit: () => [` services.queue = env.QUEUE`],
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const runtime of ['node', 'bun'] as const) {
|
|
47
|
+
describe(`StandaloneProviderAdapter contributors (${runtime})`, () => {
|
|
48
|
+
test('without contributors the entry has no platform services block', () => {
|
|
49
|
+
const source = new StandaloneProviderAdapter({
|
|
50
|
+
runtime,
|
|
51
|
+
}).generateEntrySource(ctx)
|
|
52
|
+
|
|
53
|
+
assert.doesNotMatch(source, /createPlatformServices/)
|
|
54
|
+
assert.doesNotMatch(source, /platformServices/)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('contributor imports, lines and the spread are all emitted', () => {
|
|
58
|
+
const source = new StandaloneProviderAdapter({
|
|
59
|
+
runtime,
|
|
60
|
+
contributors: [kysely, browser],
|
|
61
|
+
}).generateEntrySource(ctx)
|
|
62
|
+
|
|
63
|
+
assert.match(source, /import { Kysely } from 'kysely'/)
|
|
64
|
+
assert.match(source, /import { Browser } from '\.\/browser\.js'/)
|
|
65
|
+
assert.match(
|
|
66
|
+
source,
|
|
67
|
+
/const createPlatformServices = async \(env: Record<string, string \| undefined>\): Promise<Record<string, unknown>> => \{/
|
|
68
|
+
)
|
|
69
|
+
assert.match(
|
|
70
|
+
source,
|
|
71
|
+
/if \(env\.DATABASE_URL\) services\.kysely = new Kysely\(\{\}\)/
|
|
72
|
+
)
|
|
73
|
+
assert.match(source, /services\.browser = new Browser\(env, logger\)/)
|
|
74
|
+
assert.match(
|
|
75
|
+
source,
|
|
76
|
+
/const platformServices = await createPlatformServices\(process\.env as Record<string, string \| undefined>\)/
|
|
77
|
+
)
|
|
78
|
+
assert.match(
|
|
79
|
+
source,
|
|
80
|
+
/eventHub,\n \.\.\.platformServices,\n \}\)/,
|
|
81
|
+
'contributed services must be spread last so they override the defaults'
|
|
82
|
+
)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('a contributor that needs cloudflare bindings is refused up front', () => {
|
|
86
|
+
assert.throws(
|
|
87
|
+
() =>
|
|
88
|
+
new StandaloneProviderAdapter({
|
|
89
|
+
runtime,
|
|
90
|
+
contributors: [kysely, queueBinding],
|
|
91
|
+
}),
|
|
92
|
+
/standalone adapter only provides env bindings; unsupported contributors: queue-binding \(requires cloudflare\)/
|
|
93
|
+
)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
|
|
18
18
|
export { StandaloneProviderAdapter }
|
|
19
19
|
export type { StandaloneProviderAdapterOptions } from './adapter.js'
|
|
20
|
+
export type { PlatformServiceContributor } from '@pikku/deploy'
|
|
20
21
|
|
|
21
22
|
export const createAdapter = (options?: StandaloneProviderAdapterOptions) =>
|
|
22
23
|
new StandaloneProviderAdapter(options)
|