@pikku/core 0.12.139 → 0.12.141

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 CHANGED
@@ -1,3 +1,17 @@
1
+ ## 0.12.141
2
+
3
+ ### Patch Changes
4
+
5
+ - b04e444: Fix CLI commands that come from an addon package. Auth and permissions set on such a command are now merged into the addon's own function instead of replacing it, and the command's input is shaped by the addon's own schema (for example a comma list becomes an array). The inspector now also resolves `...refCLI('addon:contract')`, spreads and `defineCLICommands` identifiers inside a command's `subcommands`.
6
+ - d3d8a98: Graceful shutdown ignores repeat signals. A second SIGTERM or SIGINT arriving while `beforeStop` and the service teardown are still running no longer falls through to the default handler and kills the process mid-cleanup. `@pikku/core/lifecycle` is a new entry point holding `onShutdownSignals`, `stopSingletonServices` and `pikkuServerLifecycle` (the latter two stay re-exported from `utils`), which the server runtimes now share.
7
+ - 658da50: `SecretService` gains an optional `setEncryptedSecret(key, sealed)` for stores that hold a sealing key, so a browser can seal a secret and the caller's process never sees the plaintext.
8
+
9
+ ## 0.12.140
10
+
11
+ ### Patch Changes
12
+
13
+ - f396538: Event-stream responses pass through `applyWebResponse` without being read, so an SSE route can proxy another stream. HTTP responses gain `onClose`, which `executeRoute` uses to tell an SSE route's event hub when the client leaves. `pikku all` wires the console's `/meta/stream` route to `console:streamMetaChanges`.
14
+
1
15
  ## 0.12.139
2
16
 
3
17
  ### Patch Changes
@@ -0,0 +1,6 @@
1
+ import type { CoreSingletonServices, ServerLifecycle } from './types/core.types.js';
2
+ /** Stops addon package services first, then the parent singleton services. */
3
+ export declare const stopSingletonServices: () => Promise<void>;
4
+ /** Wrap server lifecycle hooks so the inspector can discover them. */
5
+ export declare const pikkuServerLifecycle: <SS extends CoreSingletonServices = CoreSingletonServices>(lifecycle: ServerLifecycle<SS>) => ServerLifecycle<SS>;
6
+ export declare const onShutdownSignals: (run: (signal: NodeJS.Signals) => void | Promise<void>, signals?: NodeJS.Signals[]) => (() => void);
@@ -0,0 +1,58 @@
1
+ import { getSingletonServices, getAllPackageStates } from './pikku-state.js';
2
+ const stopService = async (logger, name, service) => {
3
+ const stop = service?.stop;
4
+ if (stop) {
5
+ logger.info(`Stopping singleton service: ${name}`);
6
+ try {
7
+ await stop.call(service);
8
+ }
9
+ catch (e) {
10
+ logger.error(`Error stopping service ${name}:`, e);
11
+ }
12
+ }
13
+ };
14
+ /** Stops addon package services first, then the parent singleton services. */
15
+ export const stopSingletonServices = async () => {
16
+ const singletonServices = getSingletonServices();
17
+ const logger = singletonServices.logger;
18
+ const stateMap = getAllPackageStates();
19
+ if (stateMap.size > 0) {
20
+ for (const [packageName, packageState] of stateMap) {
21
+ if (packageName === '__main__')
22
+ continue;
23
+ const packageServices = packageState.package?.singletonServices;
24
+ if (packageServices) {
25
+ logger.info(`Stopping singleton services for package: ${packageName}`);
26
+ for (const [name, service] of Object.entries(packageServices)) {
27
+ await stopService(logger, `${packageName}/${name}`, service);
28
+ }
29
+ packageState.package.singletonServices = null;
30
+ }
31
+ }
32
+ }
33
+ for (const [name, service] of Object.entries(singletonServices)) {
34
+ await stopService(logger, name, service);
35
+ }
36
+ };
37
+ /** Wrap server lifecycle hooks so the inspector can discover them. */
38
+ export const pikkuServerLifecycle = (lifecycle) => lifecycle;
39
+ export const onShutdownSignals = (run, signals = ['SIGINT', 'SIGTERM']) => {
40
+ const proc = typeof process !== 'undefined' ? process : undefined;
41
+ if (!proc?.on)
42
+ return () => { };
43
+ let stopping = false;
44
+ const handlers = signals.map((signal) => {
45
+ const handler = () => {
46
+ if (stopping)
47
+ return;
48
+ stopping = true;
49
+ void run(signal);
50
+ };
51
+ proc.on(signal, handler);
52
+ return [signal, handler];
53
+ });
54
+ return () => {
55
+ for (const [signal, handler] of handlers)
56
+ proc.off(signal, handler);
57
+ };
58
+ };
@@ -8,6 +8,7 @@ export declare class ScopedSecretService implements SecretService {
8
8
  getSecret<T = string>(key: string): Promise<SecretValue<T>>;
9
9
  hasSecret(key: string): Promise<boolean>;
10
10
  setSecret(_key: string, _value: unknown): Promise<void>;
11
+ setEncryptedSecret(_key: string, _sealed: string): Promise<void>;
11
12
  deleteSecret(_key: string): Promise<void>;
12
13
  getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<SecretValues<T>>>;
13
14
  }
@@ -21,6 +21,9 @@ export class ScopedSecretService {
21
21
  async setSecret(_key, _value) {
22
22
  throw new Error('setSecret is not allowed in scoped secret service');
23
23
  }
24
+ async setEncryptedSecret(_key, _sealed) {
25
+ throw new Error('setEncryptedSecret is not allowed in scoped secret service');
26
+ }
24
27
  async deleteSecret(_key) {
25
28
  throw new Error('deleteSecret is not allowed in scoped secret service');
26
29
  }
@@ -13,6 +13,12 @@ export interface SecretService {
13
13
  /** Answers for any key, including a disallowed one — it must not throw. */
14
14
  hasSecret(key: string): Promise<boolean>;
15
15
  setSecret(key: string, value: unknown): Promise<void>;
16
+ /**
17
+ * Stores a value sealed to this service's public key, so the plaintext never
18
+ * reaches the caller's process. `sealed` is the serialized envelope, opaque
19
+ * here. Optional: only stores that hold a sealing key implement it.
20
+ */
21
+ setEncryptedSecret?(key: string, sealed: string): Promise<void>;
16
22
  deleteSecret(key: string): Promise<void>;
17
23
  /**
18
24
  * Missing keys are omitted rather than throwing, hence `Partial<T>`: callers
@@ -41,6 +41,7 @@ export declare class TypedSecretService<TMap = Record<string, unknown>> implemen
41
41
  getSecret<T = string>(key: string): Promise<SecretValue<T>>;
42
42
  hasSecret(key: string): Promise<boolean>;
43
43
  setSecret<K extends string>(key: K, value: K extends keyof TMap ? TMap[K] : unknown): Promise<void>;
44
+ setEncryptedSecret(key: string, sealed: string): Promise<void>;
44
45
  deleteSecret(key: string): Promise<void>;
45
46
  getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<SecretValues<T>>>;
46
47
  getAllStatus(): Promise<CredentialStatus[]>;
@@ -33,6 +33,13 @@ export class TypedSecretService {
33
33
  await this.secrets.setSecret(key, value);
34
34
  this.cache.delete(key);
35
35
  }
36
+ async setEncryptedSecret(key, sealed) {
37
+ if (!this.secrets.setEncryptedSecret) {
38
+ throw new Error('This secret service does not accept sealed secrets');
39
+ }
40
+ await this.secrets.setEncryptedSecret(key, sealed);
41
+ this.cache.delete(key);
42
+ }
36
43
  async deleteSecret(key) {
37
44
  await this.secrets.deleteSecret(key);
38
45
  this.cache.delete(key);
@@ -48,6 +48,15 @@ export const defineSecretServiceTests = (name, secretService) => {
48
48
  await service.deleteSecret('to-delete');
49
49
  assert.strictEqual(await service.hasSecret('to-delete'), false);
50
50
  });
51
+ test('setEncryptedSecret stores a sealed value without opening it', async (t) => {
52
+ const service = await factory({ key: kek });
53
+ if (!service.setEncryptedSecret) {
54
+ t.skip('store does not accept sealed secrets');
55
+ return;
56
+ }
57
+ await service.setEncryptedSecret('sealed-key', 'opaque-envelope');
58
+ assert.strictEqual(await service.hasSecret('sealed-key'), true);
59
+ });
51
60
  test('rotateKEK re-wraps all secrets', async () => {
52
61
  const newKEK = 'new-key-encryption-key-rotated!';
53
62
  const oldService = await factory({ key: kek });
package/dist/utils.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Logger } from './services/logger.js';
2
- import type { CoreSingletonServices, ServerLifecycle, WireServices } from './types/core.types.js';
2
+ import type { WireServices } from './types/core.types.js';
3
3
  export declare const closeWireServices: (logger: Logger, wireServices: WireServices) => Promise<void>;
4
4
  export declare const createWeakUID: () => string;
5
5
  export declare const isSerializable: (data: any) => boolean;
@@ -12,10 +12,7 @@ export declare const getTagGroups: <T>(tagGroups: Record<string, T>, tag: string
12
12
  */
13
13
  export declare const parseJson: <T = any>(input: string | Uint8Array) => T;
14
14
  export declare const freezeDedupe: <T>(arr?: readonly T[] | T[] | undefined) => readonly T[];
15
- /** Stops addon package services first, then the parent singleton services. */
16
- export declare const stopSingletonServices: () => Promise<void>;
17
- /** Wrap server lifecycle hooks so the inspector can discover them. */
18
- export declare const pikkuServerLifecycle: <SS extends CoreSingletonServices = CoreSingletonServices>(lifecycle: ServerLifecycle<SS>) => ServerLifecycle<SS>;
15
+ export { stopSingletonServices, pikkuServerLifecycle } from './lifecycle.js';
19
16
  export type MakeRequired<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;
20
17
  export type JSONPrimitive = string | number | boolean | null | undefined;
21
18
  export type JSONValue = JSONPrimitive | JSONValue[] | {
package/dist/utils.js CHANGED
@@ -1,4 +1,3 @@
1
- import { getSingletonServices, getAllPackageStates } from './pikku-state.js';
2
1
  import { BadRequestError } from './errors/errors.js';
3
2
  export const closeWireServices = async (logger, wireServices) => {
4
3
  await Promise.all(Object.values(wireServices).map(async (service) => {
@@ -77,42 +76,6 @@ export const freezeDedupe = (arr) => {
77
76
  }
78
77
  return Object.freeze(out);
79
78
  };
80
- const stopService = async (logger, name, service) => {
81
- const stop = service?.stop;
82
- if (stop) {
83
- logger.info(`Stopping singleton service: ${name}`);
84
- try {
85
- await stop.call(service);
86
- }
87
- catch (e) {
88
- logger.error(`Error stopping service ${name}:`, e);
89
- }
90
- }
91
- };
92
- /** Stops addon package services first, then the parent singleton services. */
93
- export const stopSingletonServices = async () => {
94
- const singletonServices = getSingletonServices();
95
- const logger = singletonServices.logger;
96
- const stateMap = getAllPackageStates();
97
- if (stateMap.size > 0) {
98
- for (const [packageName, packageState] of stateMap) {
99
- if (packageName === '__main__')
100
- continue;
101
- const packageServices = packageState.package?.singletonServices;
102
- if (packageServices) {
103
- logger.info(`Stopping singleton services for package: ${packageName}`);
104
- for (const [name, service] of Object.entries(packageServices)) {
105
- await stopService(logger, `${packageName}/${name}`, service);
106
- }
107
- packageState.package.singletonServices = null;
108
- }
109
- }
110
- }
111
- for (const [name, service] of Object.entries(singletonServices)) {
112
- await stopService(logger, name, service);
113
- }
114
- };
115
- /** Wrap server lifecycle hooks so the inspector can discover them. */
116
- export const pikkuServerLifecycle = (lifecycle) => lifecycle;
79
+ export { stopSingletonServices, pikkuServerLifecycle } from './lifecycle.js';
117
80
  export { getDurationInMilliseconds, getRelativeTimeOffset, getRelativeTimeOffsetFromNow, parseDurationString, } from './time-utils.js';
118
81
  export { formatVersionedId, isVersionedId, parseVersionedId, } from './version.js';
@@ -95,11 +95,26 @@ function registerCLICommands(commands, path = [], inheritedOptions = {}, program
95
95
  const unwrapped = unwrapFunc(command);
96
96
  const commandAuth = typeof command === 'object' ? command.auth : undefined;
97
97
  const commandPermissions = typeof command === 'object' ? command.permissions : undefined;
98
- addFunction(funcName, {
99
- ...unwrapped,
100
- auth: commandAuth ?? unwrapped.auth,
101
- permissions: commandPermissions ?? unwrapped.permissions,
102
- }, currentMeta?.packageName);
98
+ if (currentMeta?.packageName && !funcName.includes(':')) {
99
+ if (commandAuth !== undefined || commandPermissions !== undefined) {
100
+ const registered = pikkuState(currentMeta.packageName, 'function', 'functions');
101
+ const existing = registered.get(funcName);
102
+ if (existing) {
103
+ registered.set(funcName, {
104
+ ...existing,
105
+ auth: commandAuth ?? existing.auth,
106
+ permissions: commandPermissions ?? existing.permissions,
107
+ });
108
+ }
109
+ }
110
+ }
111
+ if (!currentMeta?.packageName || funcName.includes(':')) {
112
+ addFunction(funcName, {
113
+ ...unwrapped,
114
+ auth: commandAuth ?? unwrapped.auth,
115
+ permissions: commandPermissions ?? unwrapped.permissions,
116
+ }, currentMeta?.packageName);
117
+ }
103
118
  if (typeof command === 'object' && command.render) {
104
119
  if (programs[program]) {
105
120
  programs[program].renderers[commandId] = command.render;
@@ -110,11 +125,11 @@ function registerCLICommands(commands, path = [], inheritedOptions = {}, program
110
125
  }
111
126
  }
112
127
  }
113
- function pluckCLIData(mergedData, funcName, availableOptions) {
114
- const funcMeta = pikkuState(null, 'function', 'meta')[funcName];
128
+ function pluckCLIData(mergedData, funcName, availableOptions, packageName) {
129
+ const funcMeta = pikkuState(packageName ?? null, 'function', 'meta')[funcName];
115
130
  const schemaName = funcMeta?.inputSchemaName;
116
131
  const schema = schemaName
117
- ? pikkuState(funcMeta?.packageName ?? null, 'misc', 'schemas').get(schemaName)
132
+ ? pikkuState(packageName ?? funcMeta?.packageName ?? null, 'misc', 'schemas').get(schemaName)
118
133
  : null;
119
134
  if (schema && schema.properties) {
120
135
  const result = {};
@@ -172,7 +187,7 @@ export async function runCLICommand({ program, commandPath, data, singletonServi
172
187
  }
173
188
  const commandId = commandPath.join('.');
174
189
  const availableOptions = programData?.commandOptions?.[commandId] || {};
175
- const pluckedData = () => pluckCLIData(data, funcName, availableOptions);
190
+ const pluckedData = () => pluckCLIData(data, funcName, availableOptions, currentCommand.packageName);
176
191
  const renderer = programData?.renderers[commandId] || programData?.defaultRenderer;
177
192
  let cliState;
178
193
  const channel = {
@@ -164,6 +164,10 @@ const executeRoute = async (services, matchedRoute, http, options) => {
164
164
  await originalClose();
165
165
  };
166
166
  }
167
+ response.onClose?.(() => {
168
+ channel.state = 'closed';
169
+ singletonServices.eventHub?.onChannelClosed(channelId);
170
+ });
167
171
  }
168
172
  const wire = {
169
173
  traceId: requestId,
@@ -185,6 +185,7 @@ export interface PikkuHTTPResponse<Out = unknown> {
185
185
  send?(data: string | ArrayBuffer | ArrayBufferView): this;
186
186
  redirect(location: string, status?: number): this;
187
187
  close?: () => void;
188
+ onClose?: (callback: () => void) => void;
188
189
  setMode?: (mode: 'stream') => void;
189
190
  flushHeaders?: () => void;
190
191
  }
@@ -14,6 +14,7 @@ export declare class PikkuFetchHTTPResponse implements PikkuHTTPResponse {
14
14
  send(data: any): this;
15
15
  redirect(location: string, status?: number): this;
16
16
  close(): this;
17
+ onClose(callback: () => void): void;
17
18
  toResponse(args?: Record<string, any>): Response;
18
19
  private createStream;
19
20
  }
@@ -8,6 +8,7 @@ export class PikkuFetchHTTPResponse {
8
8
  #send = null;
9
9
  #close = null;
10
10
  #cancelStream = null;
11
+ #onClose = [];
11
12
  setMode(mode) {
12
13
  this.#responseMode = 'stream';
13
14
  if (mode === 'stream') {
@@ -95,6 +96,15 @@ export class PikkuFetchHTTPResponse {
95
96
  }
96
97
  return this;
97
98
  }
99
+ onClose(callback) {
100
+ this.#onClose.push(callback);
101
+ }
102
+ #closed() {
103
+ const callbacks = this.#onClose;
104
+ this.#onClose = [];
105
+ for (const callback of callbacks)
106
+ callback();
107
+ }
98
108
  toResponse(args) {
99
109
  const cookieHeader = Array.from(this.#cookies.entries()).map(([name, { value, flags }]) => stringifySetCookie({ name, value, ...flags }, flags));
100
110
  cookieHeader.forEach((cookie) => this.#headers.append('Set-Cookie', cookie));
@@ -119,9 +129,11 @@ export class PikkuFetchHTTPResponse {
119
129
  return;
120
130
  closed = true;
121
131
  controller.close();
132
+ this.#closed();
122
133
  };
123
134
  this.#cancelStream = () => {
124
135
  closed = true;
136
+ this.#closed();
125
137
  };
126
138
  this.#send = send;
127
139
  this.#close = close;
@@ -1,3 +1,4 @@
1
+ import { PikkuFetchHTTPResponse } from './pikku-fetch-http-response.js';
1
2
  export function toWebRequest(req, baseUrl) {
2
3
  const proto = req.header('x-forwarded-proto') ?? 'http';
3
4
  const host = req.header('x-forwarded-host') ?? req.header('host') ?? 'localhost';
@@ -120,6 +121,12 @@ export async function applyWebResponse(res, webResponse) {
120
121
  if (setCookieValues.length > 0) {
121
122
  res.header('Set-Cookie', setCookieValues);
122
123
  }
124
+ if (webResponse.body &&
125
+ res instanceof PikkuFetchHTTPResponse &&
126
+ webResponse.headers.get('content-type')?.startsWith('text/event-stream')) {
127
+ res.send(webResponse.body);
128
+ return;
129
+ }
123
130
  const body = await readBody(webResponse);
124
131
  if (body !== null) {
125
132
  if (res.send) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.139",
3
+ "version": "0.12.141",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/pikkujs/pikku.git",
@@ -87,6 +87,7 @@
87
87
  "./state": "./dist/pikku-state.js",
88
88
  "./classification": "./dist/classification/index.js",
89
89
  "./utils": "./dist/utils.js",
90
+ "./lifecycle": "./dist/lifecycle.js",
90
91
  "./schema": "./dist/schema.js",
91
92
  "./testing": "./dist/testing/index.js",
92
93
  "./dev": "./dist/dev/hot-reload.js"
@@ -621,6 +621,11 @@
621
621
  "pikkuServerLifecycle",
622
622
  "stopSingletonServices"
623
623
  ],
624
+ "./lifecycle": [
625
+ "onShutdownSignals",
626
+ "pikkuServerLifecycle",
627
+ "stopSingletonServices"
628
+ ],
624
629
  "./schema": [
625
630
  "addSchema",
626
631
  "applyDefaultsFromSchema",