feat: add local sharing and containerized web/server layout

This commit is contained in:
lofyer
2026-10-01 12:01:44 +08:00
parent 599a0a6645
commit c9d5e63276
51 changed files with 2212 additions and 233 deletions
-75
View File
@@ -1,75 +0,0 @@
import { serveStatic } from '@hono/node-server/serve-static'
import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { secureHeaders } from 'hono/secure-headers'
import { prototypeSnapshot } from '../shared/prototype-data.js'
const app = new Hono()
app.use('*', logger())
app.use(
'*',
secureHeaders({
contentSecurityPolicy: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:'],
connectSrc: ["'self'"]
},
crossOriginEmbedderPolicy: false
})
)
app.get('/api/v1/public/health', (context) =>
context.json({
status: 'ok',
service: 'shareserver',
version: '0.1.0-prototype.1'
})
)
app.get('/api/v1/public/ready', (context) =>
context.json({
status: 'ready',
mode: 'interactive-prototype',
productionReady: false
})
)
app.get('/api/v1/web/prototype/snapshot', (context) =>
context.json({
...prototypeSnapshot,
instance: {
...prototypeSnapshot.instance,
generatedAt: new Date().toISOString()
}
})
)
app.post('/api/v1/web/prototype/actions', async (context) => {
const body = await context.req.json<{ action?: string; targetId?: string }>().catch(() => null)
if (!body?.action || !body.targetId) {
return context.json(
{
code: 'INVALID_PROTOTYPE_ACTION',
message: '缺少原型操作或目标对象。',
requestId: crypto.randomUUID()
},
400
)
}
return context.json({
accepted: true,
action: body.action,
targetId: body.targetId,
prototypeOnly: true,
requestId: crypto.randomUUID()
})
})
app.use('*', serveStatic({ root: './dist' }))
app.get('*', serveStatic({ path: './dist/index.html' }))
export { app }
+101
View File
@@ -0,0 +1,101 @@
import { serveStatic } from '@hono/node-server/serve-static'
import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { secureHeaders } from 'hono/secure-headers'
import { prototypeSnapshot } from '../../shared/prototype-data.js'
import { sharingRoutes } from './sharing-routes.js'
import { SharingStore } from './sharing-store.js'
export function createApp(store = new SharingStore()) {
const app = new Hono()
app.use('*', logger())
app.use(
'*',
secureHeaders({
contentSecurityPolicy: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:'],
connectSrc: ["'self'"]
},
crossOriginEmbedderPolicy: false
})
)
app.get('/api/v1/public/health', (context) =>
context.json({
status: 'ok',
service: 'shareserver',
version: '0.1.0-prototype.1'
})
)
app.get('/api/v1/public/ready', (context) =>
context.json({
status: 'ready',
mode: 'interactive-prototype',
productionReady: false,
sharing: {
mode: 'local-metadata-only',
authentication: 'none',
persistence: 'local-json',
writerScope: 'single-process-single-store-per-data-directory',
sourceMode: 'descriptive-only-no-content-sync',
permissionsEnforced: false,
networkBoundary: 'trusted-local-test-service',
storageChecked: false
}
})
)
app.get('/api/v1/web/prototype/snapshot', (context) =>
context.json({
...prototypeSnapshot,
instance: {
...prototypeSnapshot.instance,
generatedAt: new Date().toISOString()
}
})
)
app.post('/api/v1/web/prototype/actions', async (context) => {
const body = await context.req.json<{ action?: string; targetId?: string }>().catch(() => null)
if (!body?.action || !body.targetId) {
return context.json(
{
code: 'INVALID_PROTOTYPE_ACTION',
message: '缺少原型操作或目标对象。',
requestId: crypto.randomUUID()
},
400
)
}
return context.json({
accepted: true,
action: body.action,
targetId: body.targetId,
prototypeOnly: true,
requestId: crypto.randomUUID()
})
})
app.route('/api/v1/sharing', sharingRoutes(store))
app.all('/api/*', (context) => context.json({
code: 'NOT_FOUND', message: 'API route not found.', requestId: crypto.randomUUID()
}, 404))
app.all('/api', (context) => context.json({
code: 'NOT_FOUND', message: 'API route not found.', requestId: crypto.randomUUID()
}, 404))
app.use('*', serveStatic({ root: './dist/web' }))
app.get('*', serveStatic({ path: './dist/web/index.html' }))
return app
}
const app = createApp()
export { app }
+42
View File
@@ -0,0 +1,42 @@
import { randomUUID } from 'node:crypto'
import { Hono, type Context } from 'hono'
import { bodyLimit } from 'hono/body-limit'
import { SharingError, SharingStore, validateDevice, validateId, validatePublication } from './sharing-store.js'
async function body(context: Context): Promise<unknown> {
if (context.req.header('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') {
throw new SharingError(415, 'UNSUPPORTED_MEDIA_TYPE', 'Use application/json.')
}
try {
return await context.req.json()
} catch {
throw new SharingError(400, 'INVALID_JSON', 'Request body must be valid JSON.')
}
}
export function sharingRoutes(store: SharingStore) {
const routes = new Hono()
routes.onError((error, context) => {
if (error instanceof SharingError) {
return context.json({ code: error.code, message: error.message, requestId: randomUUID() }, error.status)
}
return context.json({ code: 'STORAGE_UNAVAILABLE', message: 'Sharing metadata storage is unavailable.', requestId: randomUUID() }, 503)
})
routes.use('*', bodyLimit({
maxSize: 32 * 1024,
onError: (context) => context.json({ code: 'PAYLOAD_TOO_LARGE', message: 'Request body exceeds 32768 bytes.', requestId: randomUUID() }, 413)
}))
routes.post('/devices/register', async (context) => context.json(await store.register(validateDevice(await body(context)))))
routes.get('/devices', async (context) => context.json({ devices: await store.devices() }))
routes.get('/publications', async (context) => {
const queries = context.req.queries()
if (Object.keys(queries).some((key) => key !== 'deviceId') || (queries.deviceId && queries.deviceId.length !== 1)) {
throw new SharingError(400, 'INVALID_INPUT', 'Only one optional deviceId query parameter is supported.')
}
const deviceId = queries.deviceId ? validateId(queries.deviceId[0], 'deviceId') : undefined
return context.json({ publications: await store.publications(deviceId) })
})
routes.post('/publications', async (context) => context.json(await store.publish(validatePublication(await body(context)))))
routes.post('/publications/:id/revoke', async (context) => context.json(await store.revoke(validateId(context.req.param('id')))))
return routes
}
+219
View File
@@ -0,0 +1,219 @@
import { randomUUID } from 'node:crypto'
import { mkdir, open, readFile, rename, rm } from 'node:fs/promises'
import { resolve } from 'node:path'
import type { Device, DeviceInput, Publication, PublicationInput } from '../../shared/sharing.js'
export class SharingError extends Error {
constructor(public status: 400 | 404 | 409 | 413 | 415, public code: string, message: string) {
super(message)
}
}
function invalid(message: string): never {
throw new SharingError(400, 'INVALID_INPUT', message)
}
function object(value: unknown, keys: string[]): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) invalid('Expected a JSON object.')
const record = value as Record<string, unknown>
if (Object.keys(record).length !== keys.length || keys.some((key) => !Object.hasOwn(record, key))) {
invalid(`Expected exactly these fields: ${keys.join(', ')}.`)
}
return record
}
function text(value: unknown, field: string, max: number, allowEmpty = false): string {
if (typeof value !== 'string' || value.length > max || (!allowEmpty && !value.trim()) ||
Array.from(value).some((character) => {
const code = character.charCodeAt(0)
return code === 127 || (code < 32 && ![9, 10, 13].includes(code))
})) {
invalid(`${field} must be ${allowEmpty ? '0' : '1'}-${max} characters without control characters.`)
}
return value
}
export function validateId(value: unknown, field = 'id'): string {
if (typeof value !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value)) {
invalid(`${field} must be 1-128 ASCII letters, digits, dots, underscores or hyphens, starting with a letter or digit.`)
}
return value
}
export function validateDevice(value: unknown): DeviceInput {
const input = object(value, ['id', 'name', 'platform', 'appVersion'])
return {
id: validateId(input.id),
name: text(input.name, 'name', 200),
platform: text(input.platform, 'platform', 64),
appVersion: text(input.appVersion, 'appVersion', 64)
}
}
export function validatePublication(value: unknown): PublicationInput {
const input = object(value, ['id', 'deviceId', 'name', 'kind', 'description', 'sourceMode', 'permissions'])
if (input.kind !== 'capability' && input.kind !== 'knowledge') invalid('Invalid kind.')
if (input.sourceMode !== 'device' && input.sourceMode !== 'server') invalid('Invalid sourceMode.')
const permissions = object(input.permissions, ['search', 'read', 'download'])
if (typeof permissions.search !== 'boolean' || typeof permissions.read !== 'boolean' ||
typeof permissions.download !== 'boolean') invalid('Permissions must be booleans.')
return {
id: validateId(input.id),
deviceId: validateId(input.deviceId, 'deviceId'),
name: text(input.name, 'name', 200),
kind: input.kind,
description: text(input.description, 'description', 4000, true),
sourceMode: input.sourceMode,
permissions: { search: permissions.search, read: permissions.read, download: permissions.download }
}
}
interface State {
version: 1
devices: Device[]
publications: Publication[]
}
function timestamp(value: unknown): string {
if (typeof value !== 'string' || !Number.isFinite(Date.parse(value)) ||
new Date(value).toISOString() !== value) throw new Error('Invalid stored timestamp.')
return value
}
function parseState(value: unknown): State {
const state = object(value, ['version', 'devices', 'publications'])
if (state.version !== 1 || !Array.isArray(state.devices) || !Array.isArray(state.publications)) {
throw new Error('Invalid metadata file.')
}
const devices = state.devices.map((value) => {
const { registeredAt, updatedAt, ...input } = object(value, ['id', 'name', 'platform', 'appVersion', 'registeredAt', 'updatedAt'])
return { ...validateDevice(input), registeredAt: timestamp(registeredAt), updatedAt: timestamp(updatedAt) }
})
const publications = state.publications.map((value): Publication => {
const { status, createdAt, updatedAt, ...input } = object(value,
['id', 'deviceId', 'name', 'kind', 'description', 'sourceMode', 'permissions', 'status', 'createdAt', 'updatedAt'])
if (status !== 'published' && status !== 'revoked') throw new Error('Invalid stored status.')
return { ...validatePublication(input), status, createdAt: timestamp(createdAt), updatedAt: timestamp(updatedAt) }
})
const ids = new Set(devices.map((device) => device.id))
if (ids.size !== devices.length || new Set(publications.map((item) => item.id)).size !== publications.length ||
publications.some((item) => !ids.has(item.deviceId))) throw new Error('Invalid metadata references.')
return { version: 1, devices, publications }
}
// One store per data directory in one process. The queue covers reads and mutations.
export class SharingStore {
private state?: State
private queue: Promise<unknown> = Promise.resolve()
private readonly directory: string
private readonly filename: string
constructor(directory = process.env.SHARESERVER_DATA_DIR ?? './data') {
this.directory = resolve(directory)
this.filename = resolve(this.directory, 'sharing.json')
}
private run<T>(operation: () => Promise<T>): Promise<T> {
const result = this.queue.then(operation)
this.queue = result.catch(() => undefined)
return result
}
private async load(): Promise<State> {
if (!this.state) {
try {
this.state = parseState(JSON.parse(await readFile(this.filename, 'utf8')))
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
// Stored corruption is a service failure, never a client validation error.
throw new Error('Unable to load sharing metadata.', { cause: error })
}
this.state = { version: 1, devices: [], publications: [] }
}
}
return this.state
}
private async save(state: State): Promise<void> {
await mkdir(this.directory, { recursive: true })
const temporary = `${this.filename}.${randomUUID()}.tmp`
try {
const file = await open(temporary, 'wx', 0o600)
try {
await file.writeFile(JSON.stringify(state, null, 2) + '\n', 'utf8')
await file.sync()
} finally {
await file.close()
}
await rename(temporary, this.filename)
this.state = state
} finally {
await rm(temporary, { force: true })
}
}
devices(): Promise<Device[]> {
return this.run(async () => structuredClone((await this.load()).devices))
}
publications(deviceId?: string): Promise<Publication[]> {
return this.run(async () => structuredClone((await this.load()).publications.filter(
(item) => deviceId === undefined || item.deviceId === deviceId
)))
}
register(input: DeviceInput): Promise<Device> {
return this.run(async () => {
const state = structuredClone(await this.load())
const index = state.devices.findIndex((item) => item.id === input.id)
const existing = state.devices[index]
if (existing && existing.name === input.name && existing.platform === input.platform &&
existing.appVersion === input.appVersion) return existing
const now = new Date().toISOString()
const device = { ...input, registeredAt: existing?.registeredAt ?? now, updatedAt: now }
if (index < 0) state.devices.push(device)
else state.devices[index] = device
await this.save(state)
return structuredClone(device)
})
}
publish(input: PublicationInput): Promise<Publication> {
return this.run(async () => {
const state = structuredClone(await this.load())
const existing = state.publications.find((item) => item.id === input.id)
if (existing) {
const original: PublicationInput = {
id: existing.id, deviceId: existing.deviceId, name: existing.name, kind: existing.kind,
description: existing.description, sourceMode: existing.sourceMode, permissions: existing.permissions
}
if (JSON.stringify(original) !== JSON.stringify(input)) {
throw new SharingError(409, 'PUBLICATION_ID_CONFLICT', 'Publication ID already has different metadata.')
}
// Retrying creation must never republish a revoked record.
return existing
}
if (!state.devices.some((item) => item.id === input.deviceId)) {
throw new SharingError(404, 'DEVICE_NOT_FOUND', 'Register the device before publishing.')
}
const now = new Date().toISOString()
const publication: Publication = { ...input, status: 'published', createdAt: now, updatedAt: now }
state.publications.push(publication)
await this.save(state)
return structuredClone(publication)
})
}
revoke(id: string): Promise<Publication> {
return this.run(async () => {
const state = structuredClone(await this.load())
const publication = state.publications.find((item) => item.id === id)
if (!publication) throw new SharingError(404, 'PUBLICATION_NOT_FOUND', 'Publication does not exist.')
if (publication.status === 'revoked') return publication
publication.status = 'revoked'
publication.updatedAt = new Date().toISOString()
await this.save(state)
return structuredClone(publication)
})
}
}
+264
View File
@@ -0,0 +1,264 @@
// @vitest-environment node
import { serve } from '@hono/node-server'
import { spawn } from 'node:child_process'
import { once } from 'node:events'
import { mkdtemp, readFile, readdir, rm, writeFile, mkdir } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createApp } from './app.js'
import { SharingStore } from './sharing-store.js'
const device = { id: 'desktop-1', name: 'Local desktop', platform: 'win32', appVersion: '1.0.0' }
const publication = {
id: 'pub-1', deviceId: device.id, name: 'Local knowledge', kind: 'knowledge',
description: 'Metadata only', sourceMode: 'server', permissions: { search: true, read: true, download: false }
}
const prefix = '/api/v1/sharing'
const post = (value: unknown): RequestInit => ({
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value)
})
describe('persistent sharing API', () => {
let directory: string
let app: ReturnType<typeof createApp>
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'shareserver-test-'))
app = createApp(new SharingStore(directory))
})
afterEach(async () => {
await rm(directory, { recursive: true, force: true })
})
it('upserts devices and preserves registration time and exact retry timestamps', async () => {
const first = await app.request(`${prefix}/devices/register`, post(device))
expect(first.status).toBe(200)
const original = await first.json()
expect(original).toEqual({ ...device, registeredAt: expect.any(String), updatedAt: expect.any(String) })
expect(new Date(original.registeredAt).toISOString()).toBe(original.registeredAt)
const retry = await app.request(`${prefix}/devices/register`, post(device))
expect(await retry.json()).toEqual(original)
const update = await app.request(`${prefix}/devices/register`, post({ ...device, name: 'Renamed', appVersion: '2' }))
const updated = await update.json()
expect(updated).toMatchObject({ name: 'Renamed', appVersion: '2', registeredAt: original.registeredAt })
expect(updated.updatedAt >= original.updatedAt).toBe(true)
expect(await (await app.request(`${prefix}/devices`)).json()).toEqual({ devices: [updated] })
})
it('handles publication retries, conflicts, filtering and idempotent revocation across restarts', async () => {
await app.request(`${prefix}/devices/register`, post(device))
const created = await app.request(`${prefix}/publications`, post(publication))
expect(created.status).toBe(200)
const original = await created.json()
expect(original).toEqual({ ...publication, status: 'published', createdAt: expect.any(String), updatedAt: expect.any(String) })
// JSON key order is not part of the retry contract.
const retry = await app.request(`${prefix}/publications`, post({
...publication, permissions: { download: false, read: true, search: true }
}))
expect(await retry.json()).toEqual(original)
for (const change of [{ name: 'Changed' }, { deviceId: 'other' }, { permissions: { search: false, read: true, download: false } }]) {
const conflict = await app.request(`${prefix}/publications`, post({ ...publication, ...change }))
expect(conflict.status).toBe(409)
expect(await conflict.json()).toMatchObject({ code: 'PUBLICATION_ID_CONFLICT' })
}
app = createApp(new SharingStore(directory))
expect(await (await app.request(`${prefix}/publications?deviceId=${device.id}`)).json()).toEqual({ publications: [original] })
expect(await (await app.request(`${prefix}/publications?deviceId=other`)).json()).toEqual({ publications: [] })
const revoked = await (await app.request(`${prefix}/publications/pub-1/revoke`, { method: 'POST' })).json()
expect(revoked).toMatchObject({ status: 'revoked', createdAt: original.createdAt })
app = createApp(new SharingStore(directory))
expect(await (await app.request(`${prefix}/publications/pub-1/revoke`, { method: 'POST' })).json()).toEqual(revoked)
expect(await (await app.request(`${prefix}/publications`, post(publication))).json()).toEqual(revoked)
expect(await (await app.request(`${prefix}/publications`)).json()).toEqual({ publications: [revoked] })
expect(await readdir(directory)).toEqual(['sharing.json'])
})
it('serializes concurrent mutations without losing records, including conflicting ID retries', async () => {
const results = await Promise.all(Array.from({ length: 16 }, (_, index) =>
app.request(`${prefix}/devices/register`, post({ ...device, id: `device-${index}` }))))
expect(results.every((response) => response.status === 200)).toBe(true)
const publishes = await Promise.all(['A', 'B'].map((name) => app.request(`${prefix}/publications`,
post({ ...publication, deviceId: 'device-0', name }))))
expect(publishes.map((response) => response.status).sort()).toEqual([200, 409])
const restarted = createApp(new SharingStore(directory))
const devices = await (await restarted.request(`${prefix}/devices`)).json()
expect(devices.devices).toHaveLength(16)
const publications = await (await restarted.request(`${prefix}/publications`)).json()
expect(publications.publications).toHaveLength(1)
})
it('returns explicit missing-resource errors', async () => {
for (const [path, init, code] of [
[`${prefix}/publications`, post(publication), 'DEVICE_NOT_FOUND'],
[`${prefix}/publications/missing/revoke`, { method: 'POST' }, 'PUBLICATION_NOT_FOUND']
] as const) {
const response = await app.request(path, init)
expect(response.status).toBe(404)
expect(await response.json()).toMatchObject({ code, requestId: expect.any(String) })
}
})
it.each([
null, [], {}, { ...device, id: '../escape' }, { ...device, id: 'x'.repeat(129) },
{ ...device, name: ' ' }, { ...device, name: 'x'.repeat(201) }, { ...device, name: 'bad\u0000name' },
{ ...device, platform: 123 }, { ...device, appVersion: 'x'.repeat(65) }, { ...device, token: 'not-accepted' }
])('rejects invalid device input %j', async (input) => {
const response = await app.request(`${prefix}/devices/register`, post(input))
expect(response.status).toBe(400)
expect(await response.json()).toMatchObject({ code: 'INVALID_INPUT' })
expect(await readdir(directory)).toEqual([])
})
it.each([
{ ...publication, kind: 'file' }, { ...publication, sourceMode: 'cloud' },
{ ...publication, description: 'x'.repeat(4001) }, { ...publication, deviceId: '' },
{ ...publication, permissions: { search: 'true', read: true, download: false } },
{ ...publication, permissions: { search: true, read: true } },
{ ...publication, permissions: { ...publication.permissions, token: 'not-accepted' } },
{ ...publication, status: 'published' }
])('rejects invalid publication input %j', async (input) => {
const response = await app.request(`${prefix}/publications`, post(input))
expect(response.status).toBe(400)
expect(await response.json()).toMatchObject({ code: 'INVALID_INPUT' })
})
it('bounds requests and rejects malformed JSON, media types, and invalid filters', async () => {
for (const [init, status, code] of [
[{ method: 'POST', body: '{}' }, 415, 'UNSUPPORTED_MEDIA_TYPE'],
[{ ...post({}), body: '{' }, 400, 'INVALID_JSON'],
[post({ ...device, name: 'x'.repeat(32768) }), 413, 'PAYLOAD_TOO_LARGE']
] as const) {
const response = await app.request(`${prefix}/devices/register`, init)
expect(response.status).toBe(status)
expect(await response.json()).toMatchObject({ code })
}
for (const query of ['deviceId=', 'deviceId=a&deviceId=b', 'deviceId=..%2Fsecret', 'token=secret']) {
const response = await app.request(`${prefix}/publications?${query}`)
expect(response.status).toBe(400)
}
})
it('does not overwrite corrupt storage or expose filesystem details', async () => {
await writeFile(join(directory, 'sharing.json'), '{broken')
const response = await app.request(`${prefix}/devices/register`, post(device))
expect(response.status).toBe(503)
expect(await response.json()).toMatchObject({ code: 'STORAGE_UNAVAILABLE' })
expect(await readFile(join(directory, 'sharing.json'), 'utf8')).toBe('{broken')
await writeFile(join(directory, 'sharing.json'), JSON.stringify({ version: 1, devices: [], publications: [{ ...publication }] }))
expect((await app.request(`${prefix}/devices`)).status).toBe(503)
})
it('does not commit failed writes in memory and recovers its mutation queue', async () => {
// A directory at the final file path forces the atomic rename to fail.
expect((await app.request(`${prefix}/devices`)).status).toBe(200)
await mkdir(join(directory, 'sharing.json'))
expect((await app.request(`${prefix}/devices/register`, post(device))).status).toBe(503)
expect(await (await app.request(`${prefix}/devices`)).json()).toEqual({ devices: [] })
expect(await readdir(directory)).toEqual(['sharing.json'])
await rm(join(directory, 'sharing.json'), { recursive: true })
expect((await app.request(`${prefix}/devices/register`, post(device))).status).toBe(200)
})
it('uses JSON 404s for unknown API paths and unsupported methods', async () => {
for (const path of ['/api', '/api/missing', `${prefix}/missing`, `${prefix}/devices/register`]) {
const response = await app.request(path)
expect(response.status).toBe(404)
expect(response.headers.get('content-type')).toContain('application/json')
expect(await response.json()).toMatchObject({ code: 'NOT_FOUND' })
}
})
it('serves real HTTP and reloads persisted state after listener replacement', async () => {
async function listen(current: ReturnType<typeof createApp>) {
const server = serve({ fetch: current.fetch, hostname: '127.0.0.1', port: 0 })
await once(server, 'listening')
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing listener address')
return {
url: `http://127.0.0.1:${address.port}`,
close: () => new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
}
}
let listener = await listen(app)
try {
const registered = await fetch(`${listener.url}${prefix}/devices/register`, post(device))
expect(registered.status).toBe(200)
await registered.json()
const published = await fetch(`${listener.url}${prefix}/publications`, post(publication))
expect(published.status).toBe(200)
const record = await published.json()
await listener.close()
listener = await listen(createApp(new SharingStore(directory)))
const listed = await fetch(`${listener.url}${prefix}/publications?deviceId=${device.id}`)
expect(await listed.json()).toEqual({ publications: [record] })
const revoked = await fetch(`${listener.url}${prefix}/publications/pub-1/revoke`, { method: 'POST' })
expect(await revoked.json()).toMatchObject({ status: 'revoked' })
const ready = await fetch(`${listener.url}/api/v1/public/ready`)
expect(await ready.json()).toMatchObject({ productionReady: false, sharing: {
authentication: 'none', sourceMode: 'descriptive-only-no-content-sync', permissionsEnforced: false
} })
const missing = await fetch(`${listener.url}/api/unknown`)
expect(missing.status).toBe(404)
expect(await missing.json()).toMatchObject({ code: 'NOT_FOUND' })
} finally {
await listener.close()
}
})
it('persists metadata across actual server process restarts using SHARESERVER_DATA_DIR', async () => {
async function start() {
const child = spawn(process.execPath, ['--import', 'tsx', fileURLToPath(new URL('./index.ts', import.meta.url))], {
env: { ...process.env, SHARESERVER_DATA_DIR: directory, SHARESERVER_HOST: '127.0.0.1', SHARESERVER_PORT: '0' },
stdio: ['ignore', 'pipe', 'pipe']
})
const exited = once(child, 'exit')
const stop = async () => {
if (child.exitCode === null && child.signalCode === null) child.kill()
await exited
}
try {
const url = await new Promise<string>((resolve, reject) => {
let output = ''
const timer = setTimeout(() => reject(new Error('Server startup timed out')), 10000)
child.once('error', (error) => { clearTimeout(timer); reject(error) })
child.once('exit', () => { clearTimeout(timer); reject(new Error(`Server exited during startup: ${output}`)) })
child.stderr.on('data', (chunk) => { output += chunk.toString() })
child.stdout.on('data', (chunk) => {
output += chunk.toString()
const address = output.match(/listening on (http:\/\/127\.0\.0\.1:\d+)/)
if (address) { clearTimeout(timer); resolve(address[1]) }
})
})
return { url, stop }
} catch (error) {
await stop()
throw error
}
}
const first = await start()
let original: unknown
try {
const registered = await fetch(`${first.url}${prefix}/devices/register`, post(device))
expect(registered.status).toBe(200)
await registered.json()
const response = await fetch(`${first.url}${prefix}/publications`, post(publication))
expect(response.status).toBe(200)
original = await response.json()
} finally {
await first.stop()
}
const second = await start()
try {
const response = await fetch(`${second.url}${prefix}/publications`)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ publications: [original] })
const retry = await fetch(`${second.url}${prefix}/publications`, post(publication))
expect(await retry.json()).toEqual(original)
} finally {
await second.stop()
}
}, 30000)
})
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"outDir": "../dist/server",
"rootDir": "..",
"types": ["node"]
},
"include": ["src", "../shared"],
"exclude": ["src/**/*.test.ts"]
}