@hanzo/base 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,24 +1,47 @@
1
1
  /**
2
- * @hanzoai/base/compat -- PocketBase-compatible client re-exports.
2
+ * @hanzo/base/compat -- drop-in for legacy client imports.
3
3
  *
4
- * Provides a drop-in replacement for the `pocketbase` npm package
5
- * so that existing code can migrate to `@hanzoai/base` without changes.
4
+ * Re-exports every symbol consumers used to import from the legacy
5
+ * client. Switching the specifier (and only the specifier) carries
6
+ * existing code over with no further changes:
6
7
  *
7
- * Usage:
8
- * import Base, { LocalAuthStore, isTokenExpired } from '@hanzoai/base/compat'
8
+ * - import Base, { LocalAuthStore } from '@hanzo/base/compat'
9
+ *
10
+ * Everything here is implemented natively in @hanzo/base — no
11
+ * upstream package dependency.
9
12
  */
10
13
 
11
14
  export {
12
15
  default,
13
- default as PocketBase,
14
16
  default as Base,
17
+ BaseClient,
18
+ MemoryAuthStore,
15
19
  LocalAuthStore,
16
20
  AsyncAuthStore,
17
- BaseAuthStore,
18
- isTokenExpired,
19
21
  ClientResponseError,
22
+ isTokenExpired,
23
+ getTokenPayload,
20
24
  cookieParse,
21
25
  cookieSerialize,
22
- getTokenPayload,
23
26
  normalizeUnknownQueryParams,
24
- } from 'pocketbase'
27
+ } from '../core/index.js'
28
+
29
+ export type {
30
+ AuthStore,
31
+ AuthChangeCallback,
32
+ ClientConfig,
33
+ ListOptions,
34
+ ListResult,
35
+ BaseAuthStore,
36
+ BaseRecord,
37
+ RecordModel,
38
+ CollectionField,
39
+ CollectionModel,
40
+ RecordQueryOptions,
41
+ RecordFullListOptions,
42
+ FileOptions,
43
+ AuthResponse,
44
+ OAuth2Options,
45
+ ClientResponseErrorData,
46
+ CookieSerializeOptions,
47
+ } from '../core/index.js'
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Auth-store implementations beyond the in-memory default.
3
+ *
4
+ * Native `LocalAuthStore` / `AsyncAuthStore` implementations so the
5
+ * SDK has zero upstream client dependency. The shape matches the
6
+ * legacy client's auth stores so existing apps migrating to
7
+ * `@hanzo/base` keep working.
8
+ */
9
+
10
+ import type { AuthStore, AuthChangeCallback } from './client.js'
11
+ import type { BaseRecord } from './state.js'
12
+
13
+ interface PersistedAuth {
14
+ token: string
15
+ record: BaseRecord | null
16
+ }
17
+
18
+ /**
19
+ * Type alias matching the upstream interface name. New code should use
20
+ * `AuthStore`.
21
+ */
22
+ export type BaseAuthStore = AuthStore
23
+
24
+ /**
25
+ * Synchronous localStorage-backed auth store. Suitable for browser SPAs.
26
+ * Falls back to in-memory storage when `window.localStorage` is absent
27
+ * (SSR, sandboxed iframes, etc.).
28
+ */
29
+ export class LocalAuthStore implements AuthStore {
30
+ private _storageKey: string
31
+ private _listeners = new Set<AuthChangeCallback>()
32
+ private _memToken = ''
33
+ private _memRecord: BaseRecord | null = null
34
+
35
+ constructor(storageKey: string = 'base_auth') {
36
+ this._storageKey = storageKey
37
+ }
38
+
39
+ private get _storage(): Storage | null {
40
+ try {
41
+ if (typeof globalThis !== 'undefined' && 'localStorage' in globalThis) {
42
+ return (globalThis as { localStorage?: Storage }).localStorage ?? null
43
+ }
44
+ } catch {
45
+ // Access can throw in some sandboxed contexts; fall through.
46
+ }
47
+ return null
48
+ }
49
+
50
+ private _read(): PersistedAuth {
51
+ const storage = this._storage
52
+ if (!storage) return { token: this._memToken, record: this._memRecord }
53
+ try {
54
+ const raw = storage.getItem(this._storageKey)
55
+ if (!raw) return { token: '', record: null }
56
+ const parsed = JSON.parse(raw) as Partial<PersistedAuth>
57
+ return { token: parsed.token ?? '', record: parsed.record ?? null }
58
+ } catch {
59
+ return { token: '', record: null }
60
+ }
61
+ }
62
+
63
+ private _write(value: PersistedAuth): void {
64
+ const storage = this._storage
65
+ if (!storage) {
66
+ this._memToken = value.token
67
+ this._memRecord = value.record
68
+ return
69
+ }
70
+ try {
71
+ if (!value.token) {
72
+ storage.removeItem(this._storageKey)
73
+ } else {
74
+ storage.setItem(this._storageKey, JSON.stringify(value))
75
+ }
76
+ } catch {
77
+ // Quota / serialization errors fall back to memory.
78
+ this._memToken = value.token
79
+ this._memRecord = value.record
80
+ }
81
+ }
82
+
83
+ get token(): string {
84
+ return this._read().token
85
+ }
86
+
87
+ get record(): BaseRecord | null {
88
+ return this._read().record
89
+ }
90
+
91
+ get isValid(): boolean {
92
+ const token = this.token
93
+ if (!token) return false
94
+ try {
95
+ const parts = token.split('.')
96
+ if (parts.length !== 3) return false
97
+ const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')))
98
+ if (typeof payload.exp === 'number') return payload.exp > Date.now() / 1000
99
+ return true
100
+ } catch {
101
+ return false
102
+ }
103
+ }
104
+
105
+ save(token: string, record: BaseRecord | null): void {
106
+ this._write({ token, record })
107
+ this._notify(token, record)
108
+ }
109
+
110
+ clear(): void {
111
+ this._write({ token: '', record: null })
112
+ this._notify('', null)
113
+ }
114
+
115
+ onChange(callback: AuthChangeCallback): () => void {
116
+ this._listeners.add(callback)
117
+ return () => {
118
+ this._listeners.delete(callback)
119
+ }
120
+ }
121
+
122
+ private _notify(token: string, record: BaseRecord | null): void {
123
+ for (const cb of this._listeners) {
124
+ try {
125
+ cb(token, record)
126
+ } catch {
127
+ // listener errors must not break notification
128
+ }
129
+ }
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Async auth store — wraps any async storage backend (cookies via
135
+ * fetch, encrypted SecureStore on mobile, KV namespace on the edge,
136
+ * etc.). Reads and writes are buffered through an in-memory cache so
137
+ * the `token`/`record` accessors stay synchronous (matching upstream).
138
+ *
139
+ * Pass a `save` function that persists the serialized payload to your
140
+ * backend, and an `initial` value loaded synchronously at app boot.
141
+ */
142
+ export class AsyncAuthStore implements AuthStore {
143
+ private _token = ''
144
+ private _record: BaseRecord | null = null
145
+ private _listeners = new Set<AuthChangeCallback>()
146
+ private readonly _save: (serialized: string) => Promise<void> | void
147
+ private readonly _clear?: () => Promise<void> | void
148
+
149
+ constructor(config: {
150
+ save: (serialized: string) => Promise<void> | void
151
+ initial?: string | null
152
+ clear?: () => Promise<void> | void
153
+ }) {
154
+ this._save = config.save
155
+ this._clear = config.clear
156
+ if (config.initial) {
157
+ try {
158
+ const parsed = JSON.parse(config.initial) as Partial<PersistedAuth>
159
+ this._token = parsed.token ?? ''
160
+ this._record = parsed.record ?? null
161
+ } catch {
162
+ // ignore malformed initial value
163
+ }
164
+ }
165
+ }
166
+
167
+ get token(): string {
168
+ return this._token
169
+ }
170
+
171
+ get record(): BaseRecord | null {
172
+ return this._record
173
+ }
174
+
175
+ get isValid(): boolean {
176
+ if (!this._token) return false
177
+ try {
178
+ const parts = this._token.split('.')
179
+ if (parts.length !== 3) return false
180
+ const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')))
181
+ if (typeof payload.exp === 'number') return payload.exp > Date.now() / 1000
182
+ return true
183
+ } catch {
184
+ return false
185
+ }
186
+ }
187
+
188
+ save(token: string, record: BaseRecord | null): void {
189
+ this._token = token
190
+ this._record = record
191
+ void this._save(JSON.stringify({ token, record }))
192
+ this._notify()
193
+ }
194
+
195
+ clear(): void {
196
+ this._token = ''
197
+ this._record = null
198
+ if (this._clear) {
199
+ void this._clear()
200
+ } else {
201
+ void this._save('')
202
+ }
203
+ this._notify()
204
+ }
205
+
206
+ onChange(callback: AuthChangeCallback): () => void {
207
+ this._listeners.add(callback)
208
+ return () => {
209
+ this._listeners.delete(callback)
210
+ }
211
+ }
212
+
213
+ private _notify(): void {
214
+ for (const cb of this._listeners) {
215
+ try {
216
+ cb(this._token, this._record)
217
+ } catch {
218
+ // listener errors must not break notification
219
+ }
220
+ }
221
+ }
222
+ }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Two API surfaces:
5
5
  *
6
- * 1. PocketBase-compatible: client.collection('posts').getList(...)
6
+ * 1. Base-compatible: client.collection('posts').getList(...)
7
7
  * 2. Direct (convenience): client.list('posts', { filter: '...' })
8
8
  *
9
9
  * Both share the same QueryStore, RealtimeService, and AuthStore.
@@ -105,7 +105,7 @@ export class FileService {
105
105
 
106
106
  /**
107
107
  * Build a full URL to a record file.
108
- * Compatible with PocketBase's pb.files.getURL().
108
+ * Compatible with Base's files.getURL().
109
109
  */
110
110
  getURL(record: BaseRecord, filename: string, options?: FileOptions): string {
111
111
  if (!filename || !record.id) return ''
@@ -113,7 +113,7 @@ export class FileService {
113
113
  const collectionId = (record.collectionId ?? record.collectionName ?? '') as string
114
114
  const parts = [
115
115
  this._baseUrl,
116
- 'api',
116
+ 'v1',
117
117
  'files',
118
118
  encodeURIComponent(collectionId),
119
119
  encodeURIComponent(record.id),
@@ -200,7 +200,7 @@ export class BaseClient {
200
200
  })
201
201
  }
202
202
 
203
- // ---- PocketBase-compatible collection() API -----------------------------
203
+ // ---- Base-compatible collection() API ------------------------------------
204
204
 
205
205
  /** Get or create a CollectionService for the given name/id. */
206
206
  collection(nameOrId: string): CollectionService {
@@ -269,7 +269,7 @@ export class BaseClient {
269
269
  if (options?.perPage) params.set('perPage', String(options.perPage))
270
270
 
271
271
  const qs = params.toString()
272
- const path = `/api/collections/${encodeURIComponent(collection)}/records${qs ? '?' + qs : ''}`
272
+ const path = `/v1/collections/${encodeURIComponent(collection)}/records${qs ? '?' + qs : ''}`
273
273
  const result = await this._request<ListResult>('GET', path)
274
274
 
275
275
  this.store.setQuery(collection, options?.filter ?? '', result.items)
@@ -281,26 +281,26 @@ export class BaseClient {
281
281
  if (options?.expand) params.set('expand', options.expand)
282
282
  if (options?.fields) params.set('fields', options.fields)
283
283
  const qs = params.toString()
284
- const path = `/api/collections/${encodeURIComponent(collection)}/records/${encodeURIComponent(id)}${qs ? '?' + qs : ''}`
284
+ const path = `/v1/collections/${encodeURIComponent(collection)}/records/${encodeURIComponent(id)}${qs ? '?' + qs : ''}`
285
285
  return this._request<BaseRecord>('GET', path)
286
286
  }
287
287
 
288
288
  async create(collection: string, data: Record<string, unknown>): Promise<BaseRecord> {
289
- const path = `/api/collections/${encodeURIComponent(collection)}/records`
289
+ const path = `/v1/collections/${encodeURIComponent(collection)}/records`
290
290
  const record = await this._request<BaseRecord>('POST', path, data)
291
291
  this.store.applyServerUpdate(collection, 'create', record)
292
292
  return record
293
293
  }
294
294
 
295
295
  async update(collection: string, id: string, data: Record<string, unknown>): Promise<BaseRecord> {
296
- const path = `/api/collections/${encodeURIComponent(collection)}/records/${encodeURIComponent(id)}`
296
+ const path = `/v1/collections/${encodeURIComponent(collection)}/records/${encodeURIComponent(id)}`
297
297
  const record = await this._request<BaseRecord>('PATCH', path, data)
298
298
  this.store.applyServerUpdate(collection, 'update', record)
299
299
  return record
300
300
  }
301
301
 
302
302
  async delete(collection: string, id: string): Promise<void> {
303
- const path = `/api/collections/${encodeURIComponent(collection)}/records/${encodeURIComponent(id)}`
303
+ const path = `/v1/collections/${encodeURIComponent(collection)}/records/${encodeURIComponent(id)}`
304
304
  await this._request<void>('DELETE', path)
305
305
  this.store.applyServerUpdate(collection, 'delete', { id } as BaseRecord)
306
306
  }
@@ -312,7 +312,7 @@ export class BaseClient {
312
312
  identity: string,
313
313
  password: string,
314
314
  ): Promise<{ token: string; record: BaseRecord }> {
315
- const path = `/api/collections/${encodeURIComponent(collection)}/auth-with-password`
315
+ const path = `/v1/collections/${encodeURIComponent(collection)}/auth-with-password`
316
316
  const result = await this._request<{ token: string; record: BaseRecord }>('POST', path, {
317
317
  identity,
318
318
  password,
@@ -329,7 +329,7 @@ export class BaseClient {
329
329
  }
330
330
 
331
331
  async refreshAuth(collection: string): Promise<{ token: string; record: BaseRecord }> {
332
- const path = `/api/collections/${encodeURIComponent(collection)}/auth-refresh`
332
+ const path = `/v1/collections/${encodeURIComponent(collection)}/auth-refresh`
333
333
  const result = await this._request<{ token: string; record: BaseRecord }>('POST', path)
334
334
  this.authStore.save(result.token, result.record)
335
335
  return result
@@ -391,7 +391,7 @@ export class BaseClient {
391
391
  // ---- Health check -------------------------------------------------------
392
392
 
393
393
  async health(): Promise<{ code: number; message: string }> {
394
- return this.send('/api/health')
394
+ return this.send('/v1/health')
395
395
  }
396
396
 
397
397
  // ---- Realtime convenience -----------------------------------------------
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * CollectionService -- typed CRUD + auth + realtime for a single collection.
3
3
  *
4
- * API-compatible with PocketBase JS SDK's RecordService, extended with
4
+ * API-compatible with the upstream RecordService interface, extended with
5
5
  * reactive features (subscribe/unsubscribe, optimistic writes).
6
6
  */
7
7
 
@@ -379,7 +379,7 @@ export class CollectionService {
379
379
  // ---- Internal -----------------------------------------------------------
380
380
 
381
381
  private _collectionPath(): string {
382
- return `/api/collections/${encodeURIComponent(this.collectionIdOrName)}`
382
+ return `/v1/collections/${encodeURIComponent(this.collectionIdOrName)}`
383
383
  }
384
384
 
385
385
  private _applyOptions(params: URLSearchParams, options?: RecordQueryOptions): void {
package/src/core/index.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  /**
2
- * @hanzoai/base -- Core entry point.
2
+ * @hanzo/base -- Core entry point.
3
3
  *
4
- * Re-exports the client, collection, store, state, and realtime modules.
4
+ * Exposes the full Base client surface natively. No upstream package
5
+ * dependency — every type and helper the SDK needs lives here.
5
6
  */
6
7
 
8
+ import { BaseClient } from './client.js'
9
+
7
10
  // Client
8
11
  export { BaseClient, BaseClientError, MemoryAuthStore, FileService } from './client.js'
9
12
  export type { AuthStore, AuthChangeCallback, ClientConfig, ListOptions, ListResult } from './client.js'
@@ -35,3 +38,25 @@ export type {
35
38
  RealtimeCallback,
36
39
  ConnectionCallback,
37
40
  } from './realtime.js'
41
+
42
+ // Schema types — admin UI consumers
43
+ export type { CollectionField, CollectionModel, RecordModel } from './types.js'
44
+
45
+ // Auth stores — beyond the in-memory default
46
+ export { LocalAuthStore, AsyncAuthStore } from './auth-stores.js'
47
+ export type { BaseAuthStore } from './auth-stores.js'
48
+
49
+ // Token + cookie helpers
50
+ export {
51
+ getTokenPayload,
52
+ isTokenExpired,
53
+ cookieParse,
54
+ cookieSerialize,
55
+ normalizeUnknownQueryParams,
56
+ } from './tokens.js'
57
+ export type { CookieSerializeOptions } from './tokens.js'
58
+
59
+ // Default export — matches the upstream client default. Consumers can
60
+ // `import Base from '@hanzo/base'` and continue calling `new Base(url)`
61
+ // exactly as they did against the upstream package.
62
+ export default BaseClient
@@ -157,10 +157,10 @@ export class RealtimeService {
157
157
  this._intentionalDisconnect = false
158
158
  this._setState('connecting')
159
159
 
160
- const url = `${this._baseUrl}/api/realtime`
160
+ const url = `${this._baseUrl}/v1/realtime`
161
161
  this._eventSource = new EventSource(url)
162
162
 
163
- this._eventSource.addEventListener('PB_CONNECT', (e: MessageEvent) => {
163
+ this._eventSource.addEventListener('CONNECT', (e: MessageEvent) => {
164
164
  try {
165
165
  const data = JSON.parse(e.data) as { clientId: string }
166
166
  this._clientId = data.clientId
@@ -240,7 +240,7 @@ export class RealtimeService {
240
240
 
241
241
  const token = this._getToken()
242
242
  try {
243
- await fetch(`${this._baseUrl}/api/realtime`, {
243
+ await fetch(`${this._baseUrl}/v1/realtime`, {
244
244
  method: 'POST',
245
245
  headers: {
246
246
  'Content-Type': 'application/json',
@@ -0,0 +1,139 @@
1
+ /**
2
+ * JWT + cookie helpers — small utilities the compat layer used to
3
+ * re-export from the upstream client. Implemented natively so the SDK
4
+ * has zero upstream dependency.
5
+ */
6
+
7
+ /**
8
+ * Decode the payload of a JWT without verifying its signature.
9
+ * Returns `null` for malformed tokens. Safe for browser + Node use
10
+ * (relies only on global `atob`).
11
+ */
12
+ export function getTokenPayload<T = Record<string, unknown>>(token: string): T | null {
13
+ if (!token) return null
14
+ const parts = token.split('.')
15
+ if (parts.length !== 3) return null
16
+ try {
17
+ const padded = parts[1].replace(/-/g, '+').replace(/_/g, '/')
18
+ return JSON.parse(atob(padded)) as T
19
+ } catch {
20
+ return null
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Check whether a JWT's `exp` claim has passed.
26
+ * `expirationThreshold` (seconds) is subtracted from `exp` to expire
27
+ * tokens early — set this when you want to refresh before the actual
28
+ * expiration. Tokens without an `exp` claim are treated as
29
+ * non-expiring.
30
+ */
31
+ export function isTokenExpired(token: string, expirationThreshold: number = 0): boolean {
32
+ const payload = getTokenPayload<{ exp?: number }>(token)
33
+ if (!payload || typeof payload.exp !== 'number') return true
34
+ return payload.exp - expirationThreshold <= Date.now() / 1000
35
+ }
36
+
37
+ /**
38
+ * Cookie parsing — extracts a name→value map from a Set-Cookie or
39
+ * Cookie header value. Decodes URI-encoded values. Mirrors the
40
+ * `cookie` npm package's signature so it's drop-in for the upstream
41
+ * client's `cookieParse`.
42
+ */
43
+ export function cookieParse(input: string): Record<string, string> {
44
+ const out: Record<string, string> = {}
45
+ if (!input) return out
46
+ for (const segment of input.split(/;\s*/)) {
47
+ if (!segment) continue
48
+ const eq = segment.indexOf('=')
49
+ if (eq < 0) continue
50
+ const key = segment.slice(0, eq).trim()
51
+ let value = segment.slice(eq + 1).trim()
52
+ if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1)
53
+ try {
54
+ out[key] = decodeURIComponent(value)
55
+ } catch {
56
+ out[key] = value
57
+ }
58
+ }
59
+ return out
60
+ }
61
+
62
+ export interface CookieSerializeOptions {
63
+ encode?: (value: string) => string
64
+ maxAge?: number
65
+ domain?: string
66
+ path?: string
67
+ expires?: Date
68
+ httpOnly?: boolean
69
+ secure?: boolean
70
+ sameSite?: 'strict' | 'lax' | 'none' | boolean
71
+ priority?: 'low' | 'medium' | 'high'
72
+ }
73
+
74
+ /**
75
+ * Cookie serialization — builds a `Set-Cookie` header value.
76
+ * `encode` defaults to `encodeURIComponent`. Throws if the name or
77
+ * encoded value contain invalid characters.
78
+ */
79
+ export function cookieSerialize(
80
+ name: string,
81
+ value: string,
82
+ options: CookieSerializeOptions = {},
83
+ ): string {
84
+ if (!/^[\w!#$%&'*+\-.^`|~]+$/.test(name)) {
85
+ throw new TypeError(`cookieSerialize: invalid cookie name ${JSON.stringify(name)}`)
86
+ }
87
+ const encode = options.encode ?? encodeURIComponent
88
+ const encoded = encode(value)
89
+ if (encoded && !/^[\w!#$%&'()*+\-./:<=>?@[\]^`{|}~]*$/.test(encoded)) {
90
+ throw new TypeError(`cookieSerialize: invalid cookie value for ${name}`)
91
+ }
92
+ const parts = [`${name}=${encoded}`]
93
+ if (typeof options.maxAge === 'number' && Number.isFinite(options.maxAge)) {
94
+ parts.push(`Max-Age=${Math.floor(options.maxAge)}`)
95
+ }
96
+ if (options.domain) parts.push(`Domain=${options.domain}`)
97
+ if (options.path) parts.push(`Path=${options.path}`)
98
+ if (options.expires) parts.push(`Expires=${options.expires.toUTCString()}`)
99
+ if (options.httpOnly) parts.push('HttpOnly')
100
+ if (options.secure) parts.push('Secure')
101
+ if (options.sameSite !== undefined && options.sameSite !== false) {
102
+ const ss = options.sameSite
103
+ const value =
104
+ ss === true
105
+ ? 'Strict'
106
+ : `${ss.charAt(0).toUpperCase()}${ss.slice(1)}`
107
+ parts.push(`SameSite=${value}`)
108
+ }
109
+ if (options.priority) {
110
+ parts.push(`Priority=${options.priority.charAt(0).toUpperCase() + options.priority.slice(1)}`)
111
+ }
112
+ return parts.join('; ')
113
+ }
114
+
115
+ /**
116
+ * Normalize a query-param record so values are always strings (or
117
+ * arrays of strings). Mirrors the upstream `normalizeUnknownQueryParams`
118
+ * helper used by the auto-encoding URL builder. Nullish entries are
119
+ * dropped; non-primitive entries are JSON-stringified.
120
+ */
121
+ export function normalizeUnknownQueryParams(
122
+ params: Record<string, unknown> | null | undefined,
123
+ ): Record<string, string | string[]> {
124
+ const out: Record<string, string | string[]> = {}
125
+ if (!params) return out
126
+ for (const [key, raw] of Object.entries(params)) {
127
+ if (raw === undefined || raw === null) continue
128
+ if (Array.isArray(raw)) {
129
+ out[key] = raw.map((v) => (typeof v === 'string' ? v : JSON.stringify(v)))
130
+ } else if (typeof raw === 'string') {
131
+ out[key] = raw
132
+ } else if (typeof raw === 'number' || typeof raw === 'boolean') {
133
+ out[key] = String(raw)
134
+ } else {
135
+ out[key] = JSON.stringify(raw)
136
+ }
137
+ }
138
+ return out
139
+ }