@goliapkg/sentori-next 1.0.0 → 1.1.0

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/lib/index.d.ts CHANGED
@@ -4,4 +4,6 @@ export { resolveConfig } from './config.js';
4
4
  export type { SentoriNextConfig } from './config.js';
5
5
  export type { RequestErrorContext, RequestErrorRequest } from './server.js';
6
6
  export { SentoriErrorBoundary, SentoriProvider, useCaptureError, useSentori, } from '@goliapkg/sentori-react';
7
+ export { sentoriPush } from './push.js';
8
+ export type { SentoriPushConfig, SentoriPushClient } from './push.js';
7
9
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AACxC,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AACxD,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAE3C,YAAY,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;AACpD,YAAY,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAA;AAE3E,OAAO,EACL,oBAAoB,EACpB,eAAe,EACf,eAAe,EACf,UAAU,GACX,MAAM,yBAAyB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AACxC,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AACxD,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAE3C,YAAY,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;AACpD,YAAY,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAA;AAE3E,OAAO,EACL,oBAAoB,EACpB,eAAe,EACf,eAAe,EACf,UAAU,GACX,MAAM,yBAAyB,CAAA;AAOhC,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAA;AACvC,YAAY,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAA"}
package/lib/index.js CHANGED
@@ -11,4 +11,10 @@ export { clientInit } from './client.js';
11
11
  export { serverInit, onRequestError } from './server.js';
12
12
  export { resolveConfig } from './config.js';
13
13
  export { SentoriErrorBoundary, SentoriProvider, useCaptureError, useSentori, } from '@goliapkg/sentori-react';
14
+ // v2.8 — server-side Push helper. Re-export from the dedicated
15
+ // `/push` subpath so server-only code can `import { sentoriPush }
16
+ // from '@goliapkg/sentori-next/push'` and avoid pulling the rest of
17
+ // the surface. The top-level re-export here keeps `import { ... }
18
+ // from '@goliapkg/sentori-next'` working for the common case.
19
+ export { sentoriPush } from './push.js';
14
20
  //# sourceMappingURL=index.js.map
package/lib/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,gEAAgE;AAChE,+DAA+D;AAC/D,EAAE;AACF,wEAAwE;AACxE,yEAAyE;AACzE,6EAA6E;AAC7E,EAAE;AACF,mEAAmE;AACnE,8DAA8D;AAE9D,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AACxC,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AACxD,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAK3C,OAAO,EACL,oBAAoB,EACpB,eAAe,EACf,eAAe,EACf,UAAU,GACX,MAAM,yBAAyB,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,gEAAgE;AAChE,+DAA+D;AAC/D,EAAE;AACF,wEAAwE;AACxE,yEAAyE;AACzE,6EAA6E;AAC7E,EAAE;AACF,mEAAmE;AACnE,8DAA8D;AAE9D,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AACxC,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AACxD,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAK3C,OAAO,EACL,oBAAoB,EACpB,eAAe,EACf,eAAe,EACf,UAAU,GACX,MAAM,yBAAyB,CAAA;AAEhC,+DAA+D;AAC/D,kEAAkE;AAClE,oEAAoE;AACpE,kEAAkE;AAClE,8DAA8D;AAC9D,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAA"}
package/lib/push.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { PushMessage, PushReceipt, PushTicket } from '@goliapkg/sentori-core';
2
+ export type SentoriPushConfig = {
3
+ ingestUrl: string;
4
+ token: string;
5
+ fetch?: typeof fetch;
6
+ };
7
+ export type SentoriPushClient = {
8
+ send(msg: PushMessage): Promise<PushTicket>;
9
+ sendBatch(msgs: PushMessage[]): Promise<PushTicket[]>;
10
+ getReceipt(sendId: string): Promise<PushReceipt>;
11
+ isSentoriPushToken(value: unknown): value is string;
12
+ };
13
+ export declare function sentoriPush(cfg: SentoriPushConfig): SentoriPushClient;
14
+ //# sourceMappingURL=push.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"push.d.ts","sourceRoot":"","sources":["../src/push.ts"],"names":[],"mappings":"AA4BA,OAAO,KAAK,EACV,WAAW,EACX,WAAW,EACX,UAAU,EACX,MAAM,wBAAwB,CAAA;AAE/B,MAAM,MAAM,iBAAiB,GAAG;IAG9B,SAAS,EAAE,MAAM,CAAA;IAIjB,KAAK,EAAE,MAAM,CAAA;IAIb,KAAK,CAAC,EAAE,OAAO,KAAK,CAAA;CACrB,CAAA;AAED,MAAM,MAAM,iBAAiB,GAAG;IAI9B,IAAI,CAAC,GAAG,EAAE,WAAW,GAAG,OAAO,CAAC,UAAU,CAAC,CAAA;IAM3C,SAAS,CAAC,IAAI,EAAE,WAAW,EAAE,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC,CAAA;IAErD,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAAA;IAEhD,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAAA;CACpD,CAAA;AAID,wBAAgB,WAAW,CAAC,GAAG,EAAE,iBAAiB,GAAG,iBAAiB,CAgErE"}
package/lib/push.js ADDED
@@ -0,0 +1,90 @@
1
+ // v2.8 — server-side Push helper for Next.js apps.
2
+ //
3
+ // Use from API routes, Server Actions, or any Node / Edge runtime
4
+ // piece that needs to send a push. The helper wraps `/v1/push/send`
5
+ // + `/v1/push/receipts/{id}` with the same wire shape the SDK
6
+ // matrix shares (see `@goliapkg/sentori-core`'s `PushMessage` type).
7
+ //
8
+ // Edge-safe: pure `fetch`, no Node-only imports. Works under
9
+ // `runtime: 'edge'` for App Router + middleware contexts.
10
+ //
11
+ // Example (App Router server action):
12
+ // 'use server'
13
+ // import { sentoriPush } from '@goliapkg/sentori-next/push'
14
+ //
15
+ // const push = sentoriPush({
16
+ // ingestUrl: process.env.SENTORI_INGEST_URL!,
17
+ // token: process.env.SENTORI_ADMIN_TOKEN!,
18
+ // })
19
+ //
20
+ // export async function notifyComment(iptHandle: string, comment: string) {
21
+ // await push.send({
22
+ // to: iptHandle,
23
+ // title: 'New comment',
24
+ // body: comment.slice(0, 80),
25
+ // data: { kind: 'comment' },
26
+ // })
27
+ // }
28
+ const MAX_CONCURRENT_BATCH = 8;
29
+ export function sentoriPush(cfg) {
30
+ const fetchImpl = cfg.fetch ?? globalThis.fetch;
31
+ if (!fetchImpl) {
32
+ throw new Error('sentoriPush: no fetch implementation available');
33
+ }
34
+ const base = cfg.ingestUrl.replace(/\/+$/, '');
35
+ async function send(msg) {
36
+ const res = await fetchImpl(`${base}/v1/push/send`, {
37
+ method: 'POST',
38
+ headers: {
39
+ authorization: `Bearer ${cfg.token}`,
40
+ 'content-type': 'application/json',
41
+ },
42
+ body: JSON.stringify(msg),
43
+ });
44
+ if (!res.ok) {
45
+ const detail = await res.text().catch(() => '');
46
+ throw new Error(`/v1/push/send HTTP ${res.status}: ${detail.slice(0, 200)}`);
47
+ }
48
+ const body = (await res.json());
49
+ if (!body.tickets || body.tickets.length === 0) {
50
+ throw new Error('server returned no tickets');
51
+ }
52
+ return body.tickets[0];
53
+ }
54
+ async function sendBatch(msgs) {
55
+ // Pool of workers — each picks the next message off `queue`.
56
+ const queue = msgs.slice();
57
+ const results = new Array(msgs.length);
58
+ let nextSlot = 0;
59
+ const workers = [];
60
+ const worker = async () => {
61
+ while (true) {
62
+ const idx = nextSlot++;
63
+ const msg = queue[idx];
64
+ if (!msg)
65
+ return;
66
+ results[idx] = await send(msg);
67
+ }
68
+ };
69
+ for (let i = 0; i < Math.min(MAX_CONCURRENT_BATCH, msgs.length); i++) {
70
+ workers.push(worker());
71
+ }
72
+ await Promise.all(workers);
73
+ return results;
74
+ }
75
+ async function getReceipt(sendId) {
76
+ const res = await fetchImpl(`${base}/v1/push/receipts/${encodeURIComponent(sendId)}`, {
77
+ headers: { authorization: `Bearer ${cfg.token}` },
78
+ });
79
+ if (!res.ok) {
80
+ const detail = await res.text().catch(() => '');
81
+ throw new Error(`/v1/push/receipts HTTP ${res.status}: ${detail.slice(0, 200)}`);
82
+ }
83
+ return (await res.json());
84
+ }
85
+ function isSentoriPushToken(value) {
86
+ return typeof value === 'string' && /^ipt_[0-9a-fA-F]+$/.test(value);
87
+ }
88
+ return { send, sendBatch, getReceipt, isSentoriPushToken };
89
+ }
90
+ //# sourceMappingURL=push.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"push.js","sourceRoot":"","sources":["../src/push.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goliapkg/sentori-next",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Next.js adapter for Sentori — instrumentation.ts hooks, App Router error boundary, navigation tracing, env-driven provider built on @goliapkg/sentori-react.",
5
5
  "license": "Apache-2.0 OR MIT",
6
6
  "author": "GOLIA K.K. <takagi@golia.jp> (https://golia.jp)",
@@ -43,6 +43,10 @@
43
43
  "./app-router": {
44
44
  "types": "./lib/app-router.d.ts",
45
45
  "default": "./lib/app-router.js"
46
+ },
47
+ "./push": {
48
+ "types": "./lib/push.d.ts",
49
+ "default": "./lib/push.js"
46
50
  }
47
51
  },
48
52
  "files": [
@@ -61,9 +65,9 @@
61
65
  "react": ">=18"
62
66
  },
63
67
  "dependencies": {
64
- "@goliapkg/sentori-core": "^1.0.0",
65
- "@goliapkg/sentori-javascript": "^1.0.0",
66
- "@goliapkg/sentori-react": "^1.0.0"
68
+ "@goliapkg/sentori-core": "^1.3.0",
69
+ "@goliapkg/sentori-javascript": "^1.3.0",
70
+ "@goliapkg/sentori-react": "^1.1.0"
67
71
  },
68
72
  "devDependencies": {
69
73
  "@types/bun": "latest",
package/src/index.ts CHANGED
@@ -21,3 +21,11 @@ export {
21
21
  useCaptureError,
22
22
  useSentori,
23
23
  } from '@goliapkg/sentori-react'
24
+
25
+ // v2.8 — server-side Push helper. Re-export from the dedicated
26
+ // `/push` subpath so server-only code can `import { sentoriPush }
27
+ // from '@goliapkg/sentori-next/push'` and avoid pulling the rest of
28
+ // the surface. The top-level re-export here keeps `import { ... }
29
+ // from '@goliapkg/sentori-next'` working for the common case.
30
+ export { sentoriPush } from './push.js'
31
+ export type { SentoriPushConfig, SentoriPushClient } from './push.js'
package/src/push.ts ADDED
@@ -0,0 +1,132 @@
1
+ // v2.8 — server-side Push helper for Next.js apps.
2
+ //
3
+ // Use from API routes, Server Actions, or any Node / Edge runtime
4
+ // piece that needs to send a push. The helper wraps `/v1/push/send`
5
+ // + `/v1/push/receipts/{id}` with the same wire shape the SDK
6
+ // matrix shares (see `@goliapkg/sentori-core`'s `PushMessage` type).
7
+ //
8
+ // Edge-safe: pure `fetch`, no Node-only imports. Works under
9
+ // `runtime: 'edge'` for App Router + middleware contexts.
10
+ //
11
+ // Example (App Router server action):
12
+ // 'use server'
13
+ // import { sentoriPush } from '@goliapkg/sentori-next/push'
14
+ //
15
+ // const push = sentoriPush({
16
+ // ingestUrl: process.env.SENTORI_INGEST_URL!,
17
+ // token: process.env.SENTORI_ADMIN_TOKEN!,
18
+ // })
19
+ //
20
+ // export async function notifyComment(iptHandle: string, comment: string) {
21
+ // await push.send({
22
+ // to: iptHandle,
23
+ // title: 'New comment',
24
+ // body: comment.slice(0, 80),
25
+ // data: { kind: 'comment' },
26
+ // })
27
+ // }
28
+
29
+ import type {
30
+ PushMessage,
31
+ PushReceipt,
32
+ PushTicket,
33
+ } from '@goliapkg/sentori-core'
34
+
35
+ export type SentoriPushConfig = {
36
+ /// Base URL of the Sentori ingest host. e.g. `https://ingest.sentori.golia.jp`.
37
+ /// Typically read from `process.env.SENTORI_INGEST_URL`.
38
+ ingestUrl: string
39
+ /// Admin Bearer token. The `/v1/push/send` route requires an
40
+ /// admin-scope token (the same kind that posts events).
41
+ /// Typically read from `process.env.SENTORI_ADMIN_TOKEN`.
42
+ token: string
43
+ /// Override the global fetch implementation. Defaults to
44
+ /// `globalThis.fetch`. Useful for unit tests + environments that
45
+ /// inject a fetch polyfill.
46
+ fetch?: typeof fetch
47
+ }
48
+
49
+ export type SentoriPushClient = {
50
+ /// Send one push. Returns the queued ticket (or the existing one
51
+ /// if the call carries an idempotency key that matched an earlier
52
+ /// send).
53
+ send(msg: PushMessage): Promise<PushTicket>
54
+ /// Send a batch — equivalent to N parallel `send` calls but uses
55
+ /// a single HTTP request when the message's `to` is an array. If
56
+ /// you pass an array of `PushMessage`s, this fans out to N
57
+ /// requests (one per message); concurrency-capped at 8 to avoid
58
+ /// flooding the Sentori dispatcher on big jobs.
59
+ sendBatch(msgs: PushMessage[]): Promise<PushTicket[]>
60
+ /// Fetch the latest status of a send by id.
61
+ getReceipt(sendId: string): Promise<PushReceipt>
62
+ /// `true` if `value` is a Sentori push handle (`ipt_...`).
63
+ isSentoriPushToken(value: unknown): value is string
64
+ }
65
+
66
+ const MAX_CONCURRENT_BATCH = 8
67
+
68
+ export function sentoriPush(cfg: SentoriPushConfig): SentoriPushClient {
69
+ const fetchImpl = cfg.fetch ?? globalThis.fetch
70
+ if (!fetchImpl) {
71
+ throw new Error('sentoriPush: no fetch implementation available')
72
+ }
73
+ const base = cfg.ingestUrl.replace(/\/+$/, '')
74
+
75
+ async function send(msg: PushMessage): Promise<PushTicket> {
76
+ const res = await fetchImpl(`${base}/v1/push/send`, {
77
+ method: 'POST',
78
+ headers: {
79
+ authorization: `Bearer ${cfg.token}`,
80
+ 'content-type': 'application/json',
81
+ },
82
+ body: JSON.stringify(msg),
83
+ })
84
+ if (!res.ok) {
85
+ const detail = await res.text().catch(() => '')
86
+ throw new Error(`/v1/push/send HTTP ${res.status}: ${detail.slice(0, 200)}`)
87
+ }
88
+ const body = (await res.json()) as { tickets?: PushTicket[] }
89
+ if (!body.tickets || body.tickets.length === 0) {
90
+ throw new Error('server returned no tickets')
91
+ }
92
+ return body.tickets[0]!
93
+ }
94
+
95
+ async function sendBatch(msgs: PushMessage[]): Promise<PushTicket[]> {
96
+ // Pool of workers — each picks the next message off `queue`.
97
+ const queue = msgs.slice()
98
+ const results: PushTicket[] = new Array(msgs.length)
99
+ let nextSlot = 0
100
+ const workers: Promise<void>[] = []
101
+ const worker = async (): Promise<void> => {
102
+ while (true) {
103
+ const idx = nextSlot++
104
+ const msg = queue[idx]
105
+ if (!msg) return
106
+ results[idx] = await send(msg)
107
+ }
108
+ }
109
+ for (let i = 0; i < Math.min(MAX_CONCURRENT_BATCH, msgs.length); i++) {
110
+ workers.push(worker())
111
+ }
112
+ await Promise.all(workers)
113
+ return results
114
+ }
115
+
116
+ async function getReceipt(sendId: string): Promise<PushReceipt> {
117
+ const res = await fetchImpl(`${base}/v1/push/receipts/${encodeURIComponent(sendId)}`, {
118
+ headers: { authorization: `Bearer ${cfg.token}` },
119
+ })
120
+ if (!res.ok) {
121
+ const detail = await res.text().catch(() => '')
122
+ throw new Error(`/v1/push/receipts HTTP ${res.status}: ${detail.slice(0, 200)}`)
123
+ }
124
+ return (await res.json()) as PushReceipt
125
+ }
126
+
127
+ function isSentoriPushToken(value: unknown): value is string {
128
+ return typeof value === 'string' && /^ipt_[0-9a-fA-F]+$/.test(value)
129
+ }
130
+
131
+ return { send, sendBatch, getReceipt, isSentoriPushToken }
132
+ }