@crombolo/server 0.9.1 → 0.11.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/src/server.ts CHANGED
@@ -1,13 +1,18 @@
1
1
  /**
2
2
  * The Crombolo server.
3
3
  *
4
- * Route order matters and mirrors docs/01-architecture.md: static assets, the
4
+ * Route order matters and mirrors docs/architecture.md: static assets, the
5
5
  * non-contract security and branding endpoints, the contract-first Management
6
6
  * API, the SPA shells, then the front-end site.
7
7
  */
8
8
 
9
9
  import { join } from 'node:path'
10
- import { createManagementApiRouter, hasSection, type Principal } from '@crombolo/api-management'
10
+ import {
11
+ type ClientAddress,
12
+ createManagementApiRouter,
13
+ hasSection,
14
+ type Principal,
15
+ } from '@crombolo/api-management'
11
16
  import {
12
17
  AuthService,
13
18
  AuthStore,
@@ -41,11 +46,13 @@ import {
41
46
  } from '@crombolo/core'
42
47
  import {
43
48
  appendCacheInstruction,
49
+ createTablesCapability,
44
50
  type Db,
45
51
  FormEntryRepository,
46
52
  FormWorkflowRepository,
47
53
  RedirectRepository,
48
54
  readReport,
55
+ TransferRunRepository,
49
56
  type WritesPaused,
50
57
  } from '@crombolo/data'
51
58
  import {
@@ -63,9 +70,13 @@ import {
63
70
  import type { SyncReport } from '@crombolo/schema'
64
71
  import type { Server } from 'bun'
65
72
  import { createNodeLookup, loadAccess } from './access.ts'
73
+ import { createAuditHost, createAuditSink, pluginAuditPlan, recordAudit } from './adapters/audit.ts'
74
+ import { readCreatedBundlePackage, writeCreatedBundlePackage } from './adapters/bundles.ts'
66
75
  import { createPublishedContentSource, loadDraftNode } from './adapters/documents.ts'
67
76
  import { createSchemaProposals } from './adapters/schema-proposals.ts'
68
- import { createBundleRedirects, createServerBundles } from './bundles.ts'
77
+ import { installBundleContent } from './bundle-install.ts'
78
+ import { bundleChains, bundleTablesReport } from './bundle-tables.ts'
79
+ import { createBundleRedirects, createServerBundles, validateServerBundles } from './bundles.ts'
69
80
  import { type CromboloConfig, loadConfig, type ServerRole, VERSION } from './config.ts'
70
81
  import { attachDatabase, bootstrapDatabase } from './database.ts'
71
82
  import { createDiagnostics } from './diagnostics.ts'
@@ -198,7 +209,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
198
209
  }
199
210
  }
200
211
  const domainLog = logger('domains')
201
- // Extensions are the site's own npm dependencies (`docs/17-bundles.md`).
212
+ // Extensions are the site's own npm dependencies (`docs/bundles.md`).
202
213
  // Uncached in development so an author editing one sees a reload pick it up.
203
214
  const extensions = createExtensionRegistry(config.siteDir, { cache: !config.development })
204
215
  const marketplace = createMarketplace({
@@ -207,6 +218,26 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
207
218
  marketplaceUrl: config.marketplaceUrl,
208
219
  extensions,
209
220
  siteDir: config.siteDir,
221
+ wired: () => bundles?.mounted.map((bundle) => bundle.id) ?? [],
222
+ contentApplied: async (packageName) =>
223
+ (await new TransferRunRepository(db).appliedForPackage(packageName))?.packageVersion ?? null,
224
+ applyContentOnInstall: config.applyBundleContentOnInstall,
225
+ applyContent: async (packageName) => {
226
+ const result = await installBundleContent(db, config, { bundle: packageName })
227
+ if (!result.runId) {
228
+ const reasons = [
229
+ ...result.problems,
230
+ ...result.check.outstanding.map((finding) => finding.message),
231
+ ]
232
+ return {
233
+ applied: false,
234
+ message: `Its content was not applied: ${reasons.join('; ') || 'the check refused it'}. Resolve that, then run \`crombolo bundle install ${packageName}\`.`,
235
+ }
236
+ }
237
+ // The content landed under a cache this process had already constructed.
238
+ cache.invalidate()
239
+ return { applied: true, runId: result.runId, message: 'Its content was applied.' }
240
+ },
210
241
  })
211
242
  const paths = createBackOfficePaths({ backOfficePath: config.backOfficePath })
212
243
  // Two questions, not one: what this process answers, and what it is
@@ -214,8 +245,17 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
214
245
  // `all` and `web` serve the public site.
215
246
  const servesBackOffice = config.role !== 'web'
216
247
  const owns = config.role !== 'web'
248
+ // Bundles are checked before the database is touched, because a bundle that
249
+ // owns tables is migrated at boot, and a bad declaration must not get that far.
250
+ const bundlePackages = servesBackOffice ? extensions.bundles().bundles : []
251
+ if (servesBackOffice) {
252
+ const problems = validateServerBundles(config.bundles, bundlePackages, VERSION)
253
+ if (problems.length > 0)
254
+ throw new Error(`The configured bundles cannot be mounted:\n- ${problems.join('\n- ')}`)
255
+ }
256
+ const chains = servesBackOffice ? bundleChains(config.bundles, bundlePackages) : []
217
257
  const { db, seededAdminPassword } = owns
218
- ? await bootstrapDatabase(config)
258
+ ? await bootstrapDatabase(config, chains)
219
259
  : await attachDatabase(config)
220
260
  // A configured schema store is copied down before anything reads `schema/`, and
221
261
  // becomes the schema directory for this boot. Everything after here — loading,
@@ -236,10 +276,30 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
236
276
  const auth = new AuthService(new AuthStore(db))
237
277
  const cookies = { siteName: config.siteName, securePrefix: config.secureCookies }
238
278
  const names = cookieNames(cookies)
279
+ // The socket's peer for each request in flight, which only `Bun.serve` knows;
280
+ // a request that came some other way (a test, the assistant) has none.
281
+ const peers = new WeakMap<Request, string>()
282
+ const clientAddress = (request: Request): ClientAddress => ({
283
+ ipAddress: peers.get(request) ?? null,
284
+ forwardedFor: request.headers.get('x-forwarded-for'),
285
+ })
239
286
  const authRoutes = createAuthRoutes({
240
287
  backOfficePath: paths.backOfficePath,
241
288
  service: auth,
242
289
  cookies,
290
+ onAudit: (event, request) =>
291
+ recordAudit(db, {
292
+ category: 'sign-in',
293
+ action: event.action,
294
+ source: 'request',
295
+ userKey: event.user?.key ?? null,
296
+ userName: event.user?.userName ?? event.login ?? null,
297
+ entityType: event.user ? 'User' : null,
298
+ entityKey: event.user?.key ?? null,
299
+ entityName: event.user?.userName ?? null,
300
+ detail: event.detail ?? null,
301
+ ...clientAddress(request),
302
+ }),
243
303
  })
244
304
 
245
305
  /**
@@ -351,6 +411,12 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
351
411
  const bundles = servesBackOffice
352
412
  ? createServerBundles({
353
413
  bundles: config.bundles,
414
+ packages: bundlePackages,
415
+ coreVersion: VERSION,
416
+ tables: (bundle) => {
417
+ const chain = chains.find((candidate) => candidate.id === bundle.id)
418
+ return chain ? createTablesCapability(db, chain.compiled.schema) : undefined
419
+ },
354
420
  pluginApiPath: `${paths.pluginPath}/api`,
355
421
  host: {
356
422
  redirects: createBundleRedirects({ db, cache }),
@@ -495,6 +561,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
495
561
  schema,
496
562
  mediaFiles,
497
563
  email,
564
+ onSearchQueued: () => jobs.kickSearchIndexing(),
498
565
  // This node looks again at once; the others are told to. The write has
499
566
  // already landed wherever `componentsDir` points, so there is nothing to
500
567
  // distribute — only the fact that it happened.
@@ -517,11 +584,15 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
517
584
  authenticate,
518
585
  nodes: createNodeLookup(db),
519
586
  allowLocalLogin: config.allowLocalLogin,
587
+ audit: createAuditSink(db),
588
+ clientAddress,
520
589
  onNotImplemented: config.development
521
590
  ? (operation, request) => notImplemented.record(operation, request)
522
591
  : undefined,
523
592
  onServerEvent: (event) => {
524
593
  events.broadcast('notify', [event])
594
+ // Saves, deletes and moves all announce themselves; whatever they queued gets indexed now.
595
+ jobs.kickSearchIndexing()
525
596
  // An open preview of the document reloads when it changes.
526
597
  if (event.eventSource === 'Umbraco:CMS:Document' && event.eventType !== 'Deleted')
527
598
  previewHub.broadcast('refreshed', [event.key])
@@ -566,6 +637,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
566
637
  nodeState: schema.nodeState,
567
638
  nodeId: schema.nodeId,
568
639
  versionCleanup: config.versionCleanup,
640
+ auditRetentionDays: config.auditRetentionDays,
569
641
  mediaFiles,
570
642
  // The workflow queue drains here rather than in the request: a submission is
571
643
  // stored first, and an e-mail that cannot be sent yet must not cost it.
@@ -592,7 +664,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
592
664
  nodeId: schema.nodeId,
593
665
  role: config.role,
594
666
  })
595
- // App_Plugins is gone (`docs/17-bundles.md`). Saying so beats a site whose
667
+ // App_Plugins is gone (`docs/bundles.md`). Saying so beats a site whose
596
668
  // extensions silently stopped loading after an upgrade; remove at 1.0.
597
669
  warnAboutAppPlugins(config.siteDir, log)
598
670
  noticeAboutEmail(emailStatus, logger('email'), { development: config.development })
@@ -640,6 +712,9 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
640
712
 
641
713
  // Imported only when configured, like the assistant: an unconfigured site has no
642
714
  // git route and no credentials anywhere near it.
715
+ const auditPath = `${paths.pluginPath}/api/audit`
716
+ const auditHost = createAuditHost(db, config.auditRetentionDays)
717
+
643
718
  const gitPath = `${paths.pluginPath}/api/git`
644
719
  const git =
645
720
  config.git && servesBackOffice
@@ -674,7 +749,10 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
674
749
  // there — they are not routes at all, and 404 like any other unknown path.
675
750
  if (servesBackOffice) {
676
751
  const response = await serveBackOffice(request, url)
677
- if (response) return response
752
+ if (response) {
753
+ if (response.ok) await auditPluginWrite(request, pathname)
754
+ return response
755
+ }
678
756
  }
679
757
 
680
758
  if (pathname.startsWith(`${MEMBER_ROUTE_PREFIX}/`)) {
@@ -709,6 +787,27 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
709
787
  return renderSite(request, pathname, url.host)
710
788
  }
711
789
 
790
+ /**
791
+ * The audit entry for a successful write to one of Crombolo's own endpoints,
792
+ * which the contract router does not see. The assistant's are left out: what it
793
+ * changes goes through the router, and is recorded there. docs/audit-log.md.
794
+ */
795
+ async function auditPluginWrite(request: Request, pathname: string): Promise<void> {
796
+ const prefix = `${paths.pluginPath}/api/`
797
+ if (request.method === 'GET' || request.method === 'HEAD') return
798
+ if (!pathname.startsWith(prefix) || pathname.startsWith(`${assistantPath}/`)) return
799
+ const principal = await authenticate(request)
800
+ if (!principal) return
801
+ const plan = pluginAuditPlan(request.method, pathname.slice(prefix.length))
802
+ await recordAudit(db, {
803
+ ...plan,
804
+ source: 'request',
805
+ userKey: principal.id,
806
+ userName: principal.name,
807
+ ...clientAddress(request),
808
+ })
809
+ }
810
+
712
811
  /**
713
812
  * The backoffice, the Management API and everything that serves an editor.
714
813
  *
@@ -847,24 +946,44 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
847
946
  return Response.json({
848
947
  url: marketplace.url(),
849
948
  keyword: config.packageKeyword,
949
+ appliesContent: config.applyBundleContentOnInstall,
850
950
  items: await marketplace.search(url.searchParams.get('q') ?? undefined),
851
951
  })
852
952
  }
853
953
 
954
+ // A created bundle's npm name and version: not in Umbraco's definition, so
955
+ // not in the contract, and set from the Created view beside it.
956
+ const created = /^created\/([0-9a-f-]{36})\/package$/i.exec(action)
957
+ if (created) {
958
+ const id = created[1] as string
959
+ if (request.method === 'GET') {
960
+ const found = await readCreatedBundlePackage(db, id)
961
+ return found ? Response.json(found) : new Response('Not Found', { status: 404 })
962
+ }
963
+ if (request.method === 'PUT') {
964
+ const body = (await request.json().catch(() => ({}))) as Record<string, unknown>
965
+ const outcome = await writeCreatedBundlePackage(db, id, body)
966
+ return Response.json(outcome, { status: outcome.ok ? 200 : 400 })
967
+ }
968
+ }
969
+
854
970
  if (action === 'installed' && request.method === 'GET') {
855
- return Response.json({ items: marketplace.installed() })
971
+ return Response.json({ items: await marketplace.installed() })
856
972
  }
857
973
 
858
974
  if ((action === 'install' || action === 'uninstall') && request.method === 'POST') {
859
975
  const body = (await request.json().catch(() => ({}))) as {
860
976
  name?: unknown
861
977
  version?: unknown
978
+ applyContent?: unknown
862
979
  }
863
980
  const name = typeof body.name === 'string' ? body.name : ''
864
981
  const version = typeof body.version === 'string' ? body.version : undefined
865
982
  const outcome =
866
983
  action === 'install'
867
- ? await marketplace.install(name, version)
984
+ ? await marketplace.install(name, version, {
985
+ applyContent: body.applyContent !== false,
986
+ })
868
987
  : await marketplace.uninstall(name)
869
988
  log.info('{user} {action} the package {name}: {outcome}', {
870
989
  user: principal.email,
@@ -901,7 +1020,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
901
1020
  /**
902
1021
  * Non-contract: forms and their entries. There is no form operation in the
903
1022
  * vendored contract — Umbraco Forms ships its own API and its own client,
904
- * and neither is here — so this is ours (`docs/18-forms.md`).
1023
+ * and neither is here — so this is ours (`docs/forms.md`).
905
1024
  *
906
1025
  * Definitions are read from `schema/` per request rather than cached: the
907
1026
  * files are the truth, the backoffice may have just rewritten one, and a
@@ -924,7 +1043,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
924
1043
  const byKey = formsByKey(definitions)
925
1044
  // Submissions are personal data, so reading them asks for more than a
926
1045
  // session. Four verbs, because designing a form and reading what people
927
- // sent it are different jobs (`docs/18-forms.md`).
1046
+ // sent it are different jobs (`docs/forms.md`).
928
1047
  const may = (verb: string) => principal.isAdmin || principal.permissions.includes(verb)
929
1048
  const formFiles = {
930
1049
  schemaDir: config.schemaDir,
@@ -1106,6 +1225,16 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
1106
1225
  // and no explanation. The findings name documents and the properties that are
1107
1226
  // unfilled: that is the Settings dashboard's material, and the dashboard's own
1108
1227
  // manifest is already `Umb.Condition.SectionAlias` on Settings.
1228
+ // Non-contract, Settings: the audit log dashboard's reads. Settings, as the
1229
+ // dashboard is: it names who signed in from where, and every change made.
1230
+ if (pathname === auditPath || pathname.startsWith(`${auditPath}/`)) {
1231
+ const principal = await authenticate(request)
1232
+ if (!principal) return new Response('Unauthorized', { status: 401 })
1233
+ if (!hasSection(principal, 'settings')) return new Response('Forbidden', { status: 403 })
1234
+ const response = await auditHost.handle(request, pathname.slice(auditPath.length))
1235
+ if (response) return response
1236
+ }
1237
+
1109
1238
  if (pathname === `${paths.pluginPath}/api/change-report`) {
1110
1239
  const principal = await authenticate(request)
1111
1240
  if (!principal) return new Response('Unauthorized', { status: 401 })
@@ -1122,7 +1251,10 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
1122
1251
  source[f.kind] = (source[f.kind] ?? 0) + 1
1123
1252
  bySource[f.source] = source
1124
1253
  }
1125
- return Response.json({ health: health(), counts, bySource, findings })
1254
+ // A bundle's tables are part of what an upgrade changes, so the dashboard
1255
+ // that shows an upgrade's findings shows their steps too.
1256
+ const bundles = await bundleTablesReport(db, chains)
1257
+ return Response.json({ health: health(), counts, bySource, findings, bundles })
1126
1258
  }
1127
1259
 
1128
1260
  const staticMatch = resolveStaticFile(paths, pathname, {
@@ -1542,6 +1674,8 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
1542
1674
  port: config.port,
1543
1675
  maxRequestBodySize: config.maxRequestBodyBytes,
1544
1676
  fetch: diagnostics.wrap(async (request, bunServer: Server<HubSocketData>) => {
1677
+ const peer = bunServer.requestIP(request)?.address
1678
+ if (peer) peers.set(request, peer)
1545
1679
  const pathname = new URL(request.url).pathname
1546
1680
  if (pathname === hubPath || pathname === previewHubPath) {
1547
1681
  const principal = await authenticate(request)
package/src/status.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  TransferRunRepository,
28
28
  } from '@crombolo/data'
29
29
  import { loadSchemaDirectory } from '@crombolo/schema'
30
+ import { BundleConfigurationError, bundleChainsFor, pendingBundleSteps } from './bundle-tables.ts'
30
31
  import { type CromboloConfig, describeDatabase, VERSION } from './config.ts'
31
32
  import { type EmailAvailability, emailAvailability, resolveEmailPort } from './email.ts'
32
33
  import { assertViewRuntime } from './view-runtime.ts'
@@ -60,6 +61,12 @@ export interface SiteStatus {
60
61
  pending: boolean
61
62
  }
62
63
  framework: { pending: string[] }
64
+ /**
65
+ * Each wired bundle that owns tables, by id: its chain's state and the steps
66
+ * still to run. Only a node that mounts bundles applies them, so this is what
67
+ * such a node would refuse to boot over in production.
68
+ */
69
+ bundles: Record<string, { name: string; state: string | null; pending: string[] }>
63
70
  content: { documents: number; media: number; elements: number; lastImport?: string }
64
71
  /**
65
72
  * Open findings by kind. `auto` ones are reported, not counted against the
@@ -140,6 +147,7 @@ export async function siteStatus(config: CromboloConfig): Promise<SiteStatus> {
140
147
  pending: false,
141
148
  },
142
149
  framework: { pending: [] },
150
+ bundles: {},
143
151
  content: { documents: 0, media: 0, elements: 0 },
144
152
  findings: { blocking: 0, person: 0, auto: 0, resolved: 0 },
145
153
  versions: { crombolo: VERSION, backoffice: backofficeVersion(), bun: Bun.version },
@@ -189,6 +197,27 @@ export async function siteStatus(config: CromboloConfig): Promise<SiteStatus> {
189
197
  `${status.framework.pending.length} framework migration(s) pending: run \`crombolo upgrade\``,
190
198
  )
191
199
 
200
+ try {
201
+ const chains = bundleChainsFor(config)
202
+ for (const chain of chains)
203
+ status.bundles[chain.id] = { name: chain.name, state: null, pending: [] }
204
+ for (const bundle of await pendingBundleSteps(db, chains)) {
205
+ status.bundles[bundle.id] = {
206
+ name: bundle.name,
207
+ state: bundle.state,
208
+ pending: bundle.pending.map((step) => step.name),
209
+ }
210
+ if (bundle.pending.length > 0 && !config.development)
211
+ status.problems.push(
212
+ `the bundle "${bundle.id}" has ${bundle.pending.length} step(s) pending: run \`crombolo upgrade\``,
213
+ )
214
+ }
215
+ } catch (error) {
216
+ if (error instanceof BundleConfigurationError)
217
+ for (const problem of error.problems) status.problems.push(problem)
218
+ else status.problems.push((error as Error).message)
219
+ }
220
+
192
221
  const state = await currentSchemaState(db)
193
222
  status.schema.database = { version: state.version, revision: state.revision }
194
223
  if (loaded) {
package/src/tar.ts ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * A minimal npm package tarball writer: a gzipped ustar archive with every entry
3
+ * under `package/`, which is the layout `npm pack` produces and `bun add
4
+ * ./file.tgz` and `bun publish` read (`docs/bundles.md`).
5
+ *
6
+ * No dependency: the format is 512-byte headers and padded file bodies, and
7
+ * `Bun.gzipSync` does the compression. Timestamps are fixed, as npm fixes them,
8
+ * so the same selection packs to the same bytes.
9
+ */
10
+
11
+ export interface TarEntry {
12
+ /** The path inside the package, `/`-separated and never absolute. */
13
+ path: string
14
+ bytes: Uint8Array
15
+ }
16
+
17
+ const BLOCK = 512
18
+
19
+ /** 1985-10-26T08:15:00Z, the mtime npm writes into every packed file. */
20
+ const NPM_MTIME = 499162500
21
+
22
+ const encoder = new TextEncoder()
23
+
24
+ /** An octal field, NUL-terminated and zero-padded to its width. */
25
+ function octal(value: number, width: number): Uint8Array {
26
+ return encoder.encode(`${value.toString(8).padStart(width - 1, '0')}\0`)
27
+ }
28
+
29
+ /**
30
+ * Splits a path across ustar's `prefix` (155 bytes) and `name` (100 bytes) at a
31
+ * slash, which is how a path longer than 100 bytes is written without an
32
+ * extension header.
33
+ */
34
+ function splitPath(path: string): { name: string; prefix: string } {
35
+ if (encoder.encode(path).length <= 100) return { name: path, prefix: '' }
36
+ for (let i = path.lastIndexOf('/'); i > 0; i = path.lastIndexOf('/', i - 1)) {
37
+ const prefix = path.slice(0, i)
38
+ const name = path.slice(i + 1)
39
+ if (encoder.encode(prefix).length <= 155 && encoder.encode(name).length <= 100)
40
+ return { name, prefix }
41
+ }
42
+ throw new Error(`${path} is too long to pack`)
43
+ }
44
+
45
+ function header(path: string, size: number): Uint8Array {
46
+ const block = new Uint8Array(BLOCK)
47
+ const put = (bytes: Uint8Array, offset: number) => block.set(bytes, offset)
48
+ const { name, prefix } = splitPath(path)
49
+ put(encoder.encode(name), 0)
50
+ put(octal(0o644, 8), 100)
51
+ put(octal(0, 8), 108)
52
+ put(octal(0, 8), 116)
53
+ put(octal(size, 12), 124)
54
+ put(octal(NPM_MTIME, 12), 136)
55
+ // The checksum is computed with its own field read as spaces.
56
+ put(encoder.encode(' '), 148)
57
+ put(encoder.encode('0'), 156)
58
+ put(encoder.encode('ustar\0'), 257)
59
+ put(encoder.encode('00'), 263)
60
+ put(encoder.encode(prefix), 345)
61
+ const sum = block.reduce((total, byte) => total + byte, 0)
62
+ put(encoder.encode(`${sum.toString(8).padStart(6, '0')}\0 `), 148)
63
+ return block
64
+ }
65
+
66
+ /** The entries as an npm package tarball, each under `package/`. */
67
+ export function writeNpmTarball(entries: readonly TarEntry[]): Uint8Array<ArrayBuffer> {
68
+ const parts: Uint8Array[] = []
69
+ for (const entry of entries) {
70
+ if (entry.path.startsWith('/') || entry.path.split('/').includes('..'))
71
+ throw new Error(`${entry.path} is not a path inside the package`)
72
+ parts.push(header(`package/${entry.path}`, entry.bytes.length), entry.bytes)
73
+ const pad = (BLOCK - (entry.bytes.length % BLOCK)) % BLOCK
74
+ if (pad > 0) parts.push(new Uint8Array(pad))
75
+ }
76
+ // Two empty blocks end the archive.
77
+ parts.push(new Uint8Array(BLOCK * 2))
78
+ const tar = new Uint8Array(parts.reduce((total, part) => total + part.length, 0))
79
+ let offset = 0
80
+ for (const part of parts) {
81
+ tar.set(part, offset)
82
+ offset += part.length
83
+ }
84
+ return Bun.gzipSync(tar) as Uint8Array<ArrayBuffer>
85
+ }
package/src/zip.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * A minimal zip writer, because a created package is a zip and nothing else
3
- * here needs one (`docs/17-bundles.md`).
3
+ * here needs one (`docs/bundles.md`).
4
4
  *
5
5
  * No dependency: `node:zlib` provides `deflateRawSync` and `crc32`, which is
6
6
  * everything the format needs. Entries are written in the order given, each